临界区常见的同步方式为互斥量与条件变量。
互斥量
互斥量(Mutex)从本质上说是一把锁,在访问共享资源前对互斥量进行加锁,在访问完成后释放锁。当我们对互斥量进行加锁之后,任何其他试图再次对互斥量加锁的线程都会被阻塞直到当前线程释放该互斥锁。如果释放互斥量时有一个以上的线程阻塞,那么所有之前尝试对互斥量加锁的线程都会变成可运行状态,当第一个变为可运行的线程对互斥量加锁后,其他线程只能再次阻塞。在这种方式下,每次只有一个线程可以执行临界区内的代码。
互斥锁
互斥锁是sleep-waiting类型的锁。例如在一个双核的机器上有两个线程(线程 A 和线程 B ),它们分别运行在Core0 和 Core1上。假设线程 A 想要通过互斥锁操作得到一个临界区的锁,而此时这个锁正被线程 B 所持有,那么线程 A 就会被阻塞 。互斥锁得不到锁,线程 A 会进入休眠状态,Core0 会在此时进行上下文切换将线程 A 置于等待队列中,此时 Core0 就可以运行其他的任务(例如线程 C )而不必进行忙等待。
自旋锁
自旋锁属于busy-waiting类型的锁,如果线程 A 使用自旋锁操作去请求锁,那么线程 A 就会一直在 Core0 上进行忙等待,不停的进行锁请求重复检查这个锁,直到得到这个锁为止。可以想象,当一个处理器处于自旋状态时,它做不了任何有用的工作,因此自旋锁对于单处理器不可抢占内核没有意义。自旋锁主要用在临界区持锁时间非常短且 CPU 资源不紧张的情况下,而互斥锁用于临界区持锁时间比较长的操作。
读写锁
读写锁是一种特殊的自旋锁,它把对共享资源的访问者划分成读者和写者,读者只能对共享资源进行读访问,写者则需要对共享资源进行写操作。这种锁相对于自旋锁而言,能提高并发性,因为在多处理器系统中,它允许同时有多个读者来访问共享资源,最大可能的读者数为实际的逻辑 CPU 数。而写者是排他性的,一个读写锁同时只能有一个写者或多个读者(与CPU数相关),也不能同时既有读者又有写者。
死锁
如果一个并发任务中含有多个临界区,例如线程 A 持有独占锁 a,并尝试去获取独占锁 b, 同时线程 B 持有独占锁 b,并尝试获取独占锁 a ,此时就会发生 A、B 两个线程由于互相持有对方需要的锁,而发生死锁现象,线程 A 和 B 也会无限阻塞下去。
解决死锁的常用方式一般有两个,一个为固定锁定顺序,先锁定 a 再锁定 b,这样做虽然可以有效避免死锁,但并不是所有情况下可以实现。另一种方式为试锁定-回退,当线程尝试获取第二个独占锁失败时,主动释放第一个已经加锁的资源。
条件变量
条件变量是多线程程序中实现"等待—唤醒"逻辑常用的方法。条件变是利用线程间共享的全局变量进行同步的一种机制,主要包括两个动作:一个线程等待"条件变量的条件成立"而挂起;另一个线程使“条件成立”。例如线程 A 需要等某个条件成立才能继续往下执行,如果这个条件不成立,线程 A 会调用系统调用函数pthread_cond_wait把自己放到对应的等待线程列表中。当线程 B 在执行过程中使这个条件成立时,唤醒线程 A 继续执行。
条件变量是一种通知机制,当条件成立时,通过系统调用pthread_cond_singal单发或pthread_cond_broadcast广播唤醒其他线程执行任务。但 POSIX 为了简化实现,允许pthread_cond_singal可以唤醒不止一个线程。
条件变量通常与互斥锁一起使用。这是为了应对线程 A 在调用pthread_cond_wait但还没有进入 wait cond 的状态时,线程 B 就对其进行唤醒的情况。 如果不使用互斥锁,这个唤醒信号就会丢失。加了锁时,在线程 A 进入等待状态后再解锁,线程 B 在这之后才能进行唤醒操作。
那为什么有互斥锁,还需要条件变量?
因为:互斥锁和条件变量所解决的,是不同的问题,不同的场景。
详解
互斥锁解决的是在 shared memory space 模型下,多个线程对同一个全局变量的访问的竞争问题。由于写操作的非原子性(从内存中读进寄存器,修改,如果其他线程完成了对这个变量的修改,则旧的修改就被覆盖,等等问题),必须保证同一时间只有一个线程在进行写操作。这就涉及到了互斥锁,将临界区的操作锁起来,保证只有一个线程在进行操作。多个线程在等待同一把锁的时候,按照 FIFO 组织队列,当锁被释放时,队头线程获得锁(由操作系统管理,具体不表)。没有获得锁的线程继续被 block,换言之,它们是因为没有获得锁而被 block。
假如我们没有“条件变量”这个概念,如果一个线程要等待某个“自定义的条件”满足而继续执行,而这个条件只能由另一个线程来满足,比如 T1不断给一个全局变量 x +1, T2检测到x 大于100时,将x 置0。
如果我们没有条件变量,只通过互斥锁实现。这种情况下,就算 lock 空闲,thread2需要不断重复<加锁,判断,解锁>这个流程,会给系统带来不必要的开销。有没有一种办法让 thread2先被 block,等条件满足的时候再唤醒 thread2?这样 thread2 就不用不断进行重复的加解锁操作了(类似轮询和监听的情况)?这就要用到条件变量了。
需要注意的是,条件变量需要配合互斥锁来使用:
为什么要与pthread_mutex 一起使用呢? 这是为了应对 线程1在调用pthread_cond_wait()但线程1还没有进入wait cond的状态的时候,此时线程2调用了 cond_singal 的情况。 如果不用mutex锁的话,这个cond_singal就丢失了。加了锁的情况是,线程2必须等到 mutex 被释放(也就是 pthread_cod_wait() 释放锁并进入wait_cond状态 ,此时线程2上锁) 的时候才能调用cond_singal。
简而言之就是,在thread 1 call pthread_cond_wait() 的时刻到 thread 1真正进入 wait 状态时,是存在着时间差的。如果在这段时间差内 thread2 调用了 pthread_cond_signal() 那这个 signal 信号就丢失了。给 wait 加锁可以防止同时有另一个线程在 signal。