关于c:线程间同步方式详解

45次阅读

共计 7194 个字符,预计需要花费 18 分钟才能阅读完成。

  • 线程间同步形式

    • 引言
    • 互斥锁

      • 探索底层,实现一个锁

        • 测试并加锁(TAS)
        • 比拟并替换(CAS)
        • 另一个问题,过多的自旋?
      • 回到互斥锁
    • 信号量

      • 有名信号量
      • 无名信号量
      • 总结
    • 条件变量

      • 什么是条件变量?
      • 相干函数

        • 1. 初始化
        • 2. 期待条件
        • 3. 告诉条件
      • 用法与思考
    • 实际——读写者锁

文章已收录至我的仓库:Java 学习笔记与收费书籍分享

线程间同步形式

引言

不同线程间对临界区资源的拜访可能会引起常见的并发问题,咱们心愿线程原子式的执行一系列指令,但因为单处理器上的中断,咱们必须想一些其余方法以同步各线程,本文就来介绍一些线程间的同步形式。

互斥锁

互斥锁(又名互斥量),强调的是资源的拜访互斥:互斥锁是用在多线程多任务互斥的,当一个线程占用了某一个资源,那么别的线程就无法访问,直到这个线程 unlock,其余的线程才开始能够利用这个资源。

int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

留神了解 trylock 函数,这与一般的 lock 不一样,一般的 lock 函数在资源被锁住时会被梗塞,直到锁被开释。

trylock 函数是非阻塞调用模式,也就是说如果互斥量没被锁住,trylock 函数将把互斥量加锁,并取得对共享资源的拜访权限; 如果互斥量被锁住了,trylock 函数将不会阻塞期待而间接返回 EBUSY,示意共享资源处于忙状态,这样就能够防止死锁或饿死等一些极其状况产生。

探索底层,实现一个锁

实现一个锁必须须要硬件的反对,因为咱们必须要保障锁也是并发平安的,这就须要硬件反对以保障锁外部是原子实现的。

很容易想到保护一个全局变量 flag,当该变量为 0 时,容许线程加锁,并设置 flag 为 1;否则,线程必须挂起期待,直到 flag 为 0.

typedef struct lock_t {int flag;}lock_t;

void init(lock_t &mutex) {mutex->flag = 0;}

void lock(lock_t &mutex) {while (mutex->flag == 1) {;} // 自旋期待变量为 0 才可进入
    mutex->flag = 1;
}

void unlock(lock_t &mutex) {mutex->flag = 0;}

这是基于软件的初步实现,初始化变量为 0,线程自旋期待变量为 0 才可进入,这看上去仿佛并没有什么故障,然而认真思考,这是有问题的:

当线程恰好通过 while 断定时陷入中断,此时并未设置 flag 为 1,另一个线程闯入,此时 flag 依然为 0,通过 while 断定进入临界区,此时中断,回到原线程,原线程继续执行,也进入临界区,这就造成了同步问题。

在 while 循环中,仅仅设置 mutex->flag == 1 是不够的,只管他是一个原语,咱们必须有更多的代码,同时,当咱们引入更多代码时,咱们必须保障这些代码也是原子的,这就意味着咱们须要硬件的反对。

咱们思考下面代码为什么会失败?起因是当退出 while 循环时,在这一时刻 flag 依然为 0,这就给了其余线程抢入临界区的机会。

解决办法也很直观 —— 在退出 while 时,借助硬件反对保障 flag 被设置为 1。

测试并加锁(TAS)

咱们编写如下函数:

int TestAndSet(int *old_ptr, int new) {
    int old = *old_ptr;
    *old_ptr = new;
    return old;
}

同时从新设置 while 循环:

void lock(lock_t &mutex) {while (TestAndSet(mutex->flag,1) == 1) {;} // 自旋期待变量为 0 才可进入
    mutex->flag = 1;
}

这里,咱们借助硬件,保障 TestAndSet 函数是原子执行的,当初锁能够正确的应用了。当 flag 为 0 时,咱们通过 while 测试时曾经将 flag 设置为 1 了,其余线程曾经无奈进入临界区。

比拟并替换(CAS)

咱们编写如下函数:

int CompareAndSwap(int *ptr, int expected, int new) {
    int actual = *ptr;
    if (actual == expected) {*ptr = new;}
    return actual;
}

同样的,硬件也应该反对 CAS 原语以保障 CAS 外部也是平安的,当初从新设置 while:

void lock(lock_t &mutex) {while (CompareAndSwap(mutex->flag,0,1) == 1) {;} // 自旋期待变量为 0 才可进入
    mutex->flag = 1;
}

当初锁能够正确的应用了,当 flag 为 0 时,咱们通过 while 测试时曾经将 flag 设置为 1 了,其余线程曾经无奈进入临界区。

此外你可能发现 CAS 所须要更多的寄存器,在未来钻研 synchronozation 时,你会发现它的妙处。

另一个问题,过多的自旋?

你可能发现了,只管一个线程未能取得锁,其依然在一直 while 循环以占用 CPU 资源,一个方法就是当线程未能取得锁,进入休眠以开释 CPU 资源(条件变量),当一个线程开释锁时,唤醒一个正在休眠的线程。不过这样也有毛病,进入休眠与唤醒一个锁也是须要工夫的,当一个线程很快就能开释锁时,多等等是比陷入休眠更好的抉择。

Linux 下采纳两阶段锁,第一阶段线程自旋肯定工夫或次数期待锁的开释,当达到肯定工夫或肯定次数时,进入第二阶段,此时线程进入休眠。

回到互斥锁

互斥锁提供了并发平安的根本保障,互斥锁用于保障对临界区资源的平安拜访,但何时须要拜访资源并不是互斥锁应该思考的事件,这可能是条件变量该思考的事件。

如果线程频繁的加锁和解锁,效率是十分低效的,这也是咱们必须要思考到的一个点。

信号量

信号量并不用来传送资源,而是用来爱护共享资源,了解这一点是很重要的,信号量 s 的示意的含意为 同时容许拜访资源的最大线程数量,它是一个全局变量。

在过程中也能够应用信号量,对于信号量的了解过程中与线程中并无太大差别,都是用来爱护资源,对于更多信号量的了解参见这篇文章: JavaLearningNotes/ 过程间通信形式。

来思考一个下面简略的例子:两个线程同时批改而造成谬误,咱们不思考读者而仅仅思考写者过程,在这个例子中共享资源最多容许一个线程批改资源,因而咱们初始化 s 为 1。

开始时,A 率先写入资源,此时 A 调用 P(s),将 s 减一,此时 s = 0,A 进入共享区工作。

此时,线程 B 也想进入共享区批改资源,它调用 P(s)发现此时 s 为 0,于是挂起线程,退出期待队列。

A 工作结束,调用 V(s),它发现 s 为 0 并检测到期待队列不为空,于是它随机唤醒一个期待线程,并将 s 加 1,这里唤醒了 B。

B 被唤醒,继续执行 P 操作,此时 s 不为 0,B 胜利执行将 s 置为 0 并进入工作区。

此时 C 想要进入工作区 ……

能够发现,在无论何时只有一个线程可能访问共享资源,这就是信号量做的事件,他管制进入共享区的最大过程数量,这取决于初始化 s 的值。尔后,在进入共享区之前调用 P 操作,出共享区后调用 V 操作,这就是信号量的思维。

有名信号量

有名信号量以文件的模式存在,即时是不同过程间的线程也能够拜访该信号量,因而能够用于不同过程间的多线程间的互斥与同步。

创立关上有名信号量

sem_t *sem_open(const char *name, int oflag);
sem_t *sem_open(const char *name, int oflag, mode_t mode, unsigned int value);
// 胜利返回信号量指针;失败返回 SEM_FAILED,设置 errno

name 是文件路径名,value 设置为信号量的初始值。

敞开信号量,过程终止时,会调用它

int sem_close(sem_t *sem);    // 胜利返回 0;失败返回 -1,设置 errno

删除信号量,立刻删除信号量名字,当其余过程都敞开它时,销毁它

int sem_unlink(const char *name);

期待信号量,测试信号量的值,如果其值小于或等于 0,那么就期待(阻塞);一旦其值变为大于 0 就将它减 1,并返回

int sem_wait(sem_t *sem);
int sem_trywait(sem_t *sem);
// 胜利返回 0;失败返回 -1,设置 errno

当信号量的值为 0 时,sem_trywait 立刻返回,设置 errno 为 EAGAIN。如果被某个信号中断,sem_wait 会过早地返回,设置 errno 为 EINTR

发出信号量,给它的值加 1,而后唤醒正在期待该信号量的过程或线程

int sem_post(sem_t *sem);

胜利返回 0;失败返回 -1,不会扭转它的值,设置 errno,该函数是异步信号平安的,能够在信号处理程序里调用它

无名信号量

无名信号量存在于过程内的虚拟空间中,对于其余过程是不可见的,因而无名信号量用于一个过程体内各线程间的互斥和同步, 应用如下 API:

(1)sem_init 性能:用于创立一个信号量,并初始化信号量的值。函数原型:

int sem_init (sem_t* sem, int pshared, unsigned int value);

函数传入值:sem: 信号量。pshared: 决定信号量是否在几个过程间共享。因为目前 LINUX 还没有实现过程间共享信息量,所以这个值只能取 0。

(2)其余函数

int sem_wait       (sem_t* sem);
int sem_trywait   (sem_t* sem);
int sem_post       (sem_t* sem);
int sem_getvalue (sem_t* sem);
int sem_destroy   (sem_t* sem);

性能:

sem_wait 和 sem_trywait 相当于 P 操作,它们都能将信号量的值减一,两者的区别在于若信号量的值小于零时,sem_wait 将会阻塞过程,而 sem_trywait 则会立刻返回。

sem_post 相当于 V 操作,它将信号量的值加一,同时收回唤醒的信号给期待的线程。

sem_getvalue 失去信号量的值。

sem_destroy 捣毁信号量。

如果某个基于内存的信号量是在不同过程间同步的,该信号灯必须寄存在共享内存区中,这要只有该共享内存区存在,该信号灯就存在。

总结

无名信号量存在于内存中,有名信号量是存在于磁盘上的,因而无名信号量的速度更快,但只实用于一个独立过程内的各线程;有名信号量能够速度欠缺,但能够使不同过程间的线程同步,这是通过共享内存实现的,共享内存是过程间的一种通信形式。

你可能发现了,当信号量的值 s 为 1 时,信号量的作用于互斥锁的作用是一样的,互斥锁只能容许一个线程进入临界区,而信号量容许更多的线程进入临界区,这取决于信号量的值为多少。

条件变量

什么是条件变量?

在互斥锁中,线程期待 flag 为 0 能力进入临界区;信号量中 P 操作也要期待 s 不为 0 …… 在多线程中,一个线程期待某个条件是很常见的,互斥锁实现一节中,咱们采纳自旋是否有一个更专门、更高效的形式实现条件的期待?

它就是条件变量!条件变量 (condition variable) 是利用线程间共享的全局变量进行同步的一种机制,次要包含两个动作:一个线程期待某个条件为真,而将本人挂起;另一个线程设置条件为真,并告诉期待的线程持续。

因为某个条件是全局变量,因而 条件变量常应用互斥锁以爱护(这是必须的,是被强制要求的)。

条件变量与互斥量一起应用时,容许线程以无竞争的形式期待特定的条件产生。

线程能够应用条件变量来期待某个条件为真,留神了解并不是期待条件变量为真,条件变量 (cond) 是在多线程程序中用来实现 ” 期待 –> 唤醒 ” 逻辑罕用的办法,用于保护一个条件(与是条件变量不同的概念),并不是说期待条件变量为真或为假。条件变量是一个显式的队列,当条件不满足时,线程将本人退出期待队列,同时开释持有的互斥锁;当一个线程扭转条件时,能够唤醒一个或多个期待线程(留神此时条件不肯定为真)。

在条件变量上有两种基本操作:

  • 期待(wait):一个线程处于期待队列中休眠,此时线程不会占用互斥量,当线程被唤醒后,从新取得互斥锁(可能是多个线程竞争),并从新取得互斥量。
  • 告诉(signal/notify):当条件更改时,另一个线程发送告诉以唤醒期待队列中的线程。

相干函数

1. 初始化

条件变量采纳的数据类型是 pthread_cond_t,,在应用之前必须要进行初始化,,这包含两种形式:

动态: 间接设置条件变量 cond 为常量 PTHREAD_COND_INITIALIZER。

动静: pthread_cond_init 函数, 是开释动静条件变量的内存空间之前, 要用 pthread_cond_destroy 对其进行清理。

int pthread_cond_init(pthread_cond_t *restrict cond, pthread_condattr_t *restrict attr);
int pthread_cond_destroy(pthread_cond_t *cond);
// 胜利则返回 0, 出错则返回谬误编号.

留神:条件变量占用的空间并未被开释。

cond:要初始化的条件变量;attr:个别为 NULL。

2. 期待条件

int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restric mutex);
int pthread_cond_timedwait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex, const struct timespec *restrict timeout);
// 胜利则返回 0, 出错则返回谬误编号.

这两个函数别离是阻塞期待和超时期待,梗塞等到进入期待队列休眠直到条件批改而被唤醒;超时期待在休眠肯定工夫后主动醒来。

进入期待时线程开释互斥锁,而在被唤醒时线程从新取得锁。

3. 告诉条件

int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);
// 胜利则返回 0, 出错则返回谬误编号.

这两个函数用于告诉线程条件已被批改,调用这两个函数向线程或条件发送信号。

用法与思考

条件变量用法模板:

pthread_cond_t cond;  // 条件变量
mutex_t mutex;    // 互斥锁
int flag; // 条件

// A 线程
void threadA() {Pthread_mutex_lock(&mutex);  // 爱护临界资源,因为线程会批改全局条件 flag
    while (flag == 1) // 期待某条件成立
        Pthread_cond_wait(&cond, &mutex);  // 不成立则退出队列休眠,并开释锁
    ....dosomthing
    ....change flag   // 条件被批改
    Pthread_cond_signal(&cond); // 发送信号告诉条件被批改
    Pthread_mutex_unlock(&mutex); // 放松信号后尽量疾速开释锁,因为被唤醒的线程会尝试取得锁
}


// B 线程
void threadB() {Pthread_mutex_lock(&mutex);  // 爱护临界资源
    while (flag == 0) // 期待某条件成立
        Pthread_cond_wait(&cond, &mutex);  // 不成立则退出队列休眠,并开释锁
    ....dosomthing
    ....change flag   // 条件被批改
    Pthread_cond_signal(&cond); // 放松信号后尽量疾速开释锁,因为被唤醒的线程会尝试取得锁
    Pthread_mutex_unlock(&mutex);
}

通过下面的一个例子,应该很好了解条件变量与条件的区别,条件变量是一个逻辑,它并不是 while 循环里的 bool 语句,我置信很多初学者都有这么一个误区,即条件变量就是线程须要期待的条件。条件是条件,线程期待条件而不是期待条件变量,条件变量使得线程更高效的期待条件成立,是一组期待 — 唤醒 的逻辑。

留神这里依然要应用 while 循环期待条件,你可能会认为明明曾经上锁了别的线程无奈强入。事实上当线程 A 陷入休眠时会开释锁,而当其被唤醒时,会尝试取得锁,而正在其尝试取得锁时,另一个线程 B 当初尝试取得锁,并且抢到锁进入临界区,而后批改条件,使得线程 A 的条件不再成立,线程 B 返回,此时线程 A 终于取得锁了,并进入临界区,但此时线程 A 的条件基本曾经不成立,他不该进入临界区!

此外,被唤醒也不代表条件成立了,例如上述代码线程 B 批改 flag = 3,并且唤醒线程 A,这里线程 A 的条件基本不合乎,所以必须反复断定条件。互斥锁和条件变量的例子通知咱们:在期待条件时,总是应用 while 而不是 if!

陷入休眠的线程必须开释锁也是有意义的,如果不开释锁,其余线程根本无法批改条件,休眠的线程永远都不会醒过来!

实际——读写者锁

读取锁——共享;写入锁——独占。即:读线程能够加多个,而写线程只能有一个,并且读者和写者不能同时工作。

这种状况下因为容许多个读者共享临界区效率会高效,咱们来思考实现的问题:只容许一个写者工作,那么肯定须要一个互斥量或二值信号量来保护,咱们称为写者锁;因为读者和写者不能同时工作,第一个读者必须尝试获取写者锁,而一旦读者数量大于 1,则后续读者毋庸尝试获取写者锁而可间接进入,留神到这里存在全局读者数量变量,因而读者也须要一个锁以保护全局读者数量,最初一个退出的读者必须负责开释读者锁。

通晓原理,快去本人入手实现一个读写者锁把!

Linux 下通过 pthread_rwlock 函数族实现。

正文完
 0