当一个程序涉及到多个线程的时候,线程之间的同步和互斥就显得尤其重要。为了避免一个线程中的敏感数据被另一个线程修改,程序中往往需要显式的使用线程同步机制来保证线程运行环境的安全。当前的编程库环境(例如pthread库)就为多线程的同步和互斥提供了多种不同的机制,包括互斥锁,读写锁,自旋锁,条件变量和信号量等等。本文简要记载使用不同的同步机制时应该注意的问题。
互斥锁
互斥锁指的就是不同的锁之间相互排斥。对于某个临界区而言,任何时间都只能有一个互斥锁的锁住该临界区。当该互斥锁未释放之前,任何其他企图对该临界区进行加锁访问的线程都会被挂起而阻塞。等到该临界区上的锁释放的时候,其他进程才能被唤醒来访问该临界区。
在使用互斥锁的时候需要注意以下几个问题:
- 但临界区非常下的时候,一般不适合使用互斥锁。原因是互斥锁一旦加锁失败往往会将线程挂起阻塞,而线程的上下文切换也是需要开销的。如果临界区比较小并且需要频繁加锁,那么使用自旋锁更合适一些。(当然网上有资料表明pthread中的互斥锁mutex在加锁失败之后会自旋一段时间,然后再阻塞,基本代替了自旋锁的作用。但由于本文只讨论不同锁的特性,不依赖于具体的实现,因此还是加上这条)。
- 使用互斥锁的时候及其容易引起死锁。如果加锁顺序和释放锁的顺序不当,互斥锁是比较容易引起死锁的。解决死锁问题一方面可以通过在程序编写中小心注意而避免,另一方面可以通过非阻塞的申请锁的方式而避免。非阻塞申请锁一般在申请失败后就直接返回而不阻塞,避免了因为阻塞而引起的死锁。
- 任何同步的场景都可以使用互斥锁等价的实现,但是仅仅使用互斥锁有时候严重影响性能,比如下文要提到的读写锁就可以提高临界区访问的并发性。
读写锁
在上文中已经提到,尽管互斥锁会极大地简化线程并发中所涉及的到的临界区访问的控制逻辑,在某些场合,其可能会极大地影响到线程并发的效率,例如在多线程并发读写临界区,并且读多写少得场景下。
直观来看,允许多个read-only的线程并发的访问数据并不会影响程序的运行逻辑,因为他们不会对读取的数据进行任何修改,而要写数据的线程与其他的线程则需要进行绝对的并发控制。读写锁就是在这种背景下产生的。读写锁一般分为读锁和写锁,其互斥逻辑如下:1)同一个临界区可以同时加上多个读锁;2)同一个临界区有且只能有一个写锁;3)读锁和写锁不能共存与一个临界区。
在了解这些特性之后可以看到,当一个线程已经给某个临界区加上读锁之后,后续的读线程仍然能够给此临界区加上读锁进行访问。只有当临界区上的所有锁释放之后,写线程才能成功的给临界区加上写锁。这种逻辑会带来一个直观的问题,就是写线程的饥饿:一旦不同读线程持续不断的给临界区加锁,那么写线程将永远没有机会进入临界区。因此,在使用读写锁的同时需要给予写线程比较高的优先级,从而防止写线程的饥饿。
自旋锁
自旋锁可以理解成为“空转锁”。相比于互斥锁而言,自旋锁一旦加锁失败,其线程并不会被调度挂起进入阻塞状态,而是阻塞在一个空循环上。这种性质决定了自旋锁节省了线程切换的开销,却浪费了CPU时间。(阿里的笔试题就考到了这个问题)
在使用自旋锁的时候需要注意的是,加锁的临界区要尽量的小。如果临界区很长需要花费很多时间,那么大部分的阻塞在自旋上的线程会大量的消耗掉CPU的时间,那么使用自旋锁显然就是不合适的。此外Linux内核在进行临界区处理的时候大量的使用了自旋锁。尽管在真实的编程环境下自旋锁使用的不多,但其仍然是线程同步机制中一种重要的方式。
条件变量
在多线程的环境下我们往往有如下的需求:当A线程完成某件事情之前B线程需要阻塞,当A线程完成该事件之后通知B线程之后,B线程才能被唤醒继续执行。显然这种场景可以使用互斥锁或者信号量来实现,但是一种逻辑上更自然的做法就是使用条件变量。
条件变量一般都有wait()
和signal()
两个函数,其中wait()
函数在未接受到任何信号的嘶吼将会阻塞当前线程,而signal()
函数负责唤醒等待在当前条件变量上的线程。注意signal函数具体唤醒多少线程并没有一个特定的限制,其依赖于具体的实现而定。
不同于互斥锁,自旋锁和信号量,等待在某个条件变量上的线程将完全依赖于signal()
函数发出的信号而唤醒。一旦发出的信号丢失,该阻塞进程将再也没有机会被唤醒了。因此条件变量一般都需要配合互斥锁或者信号量使用,一般情况下等待线程的逻辑如下:
mutex_lock.lock();
...
if(conditions are not satisfied)
cond_var.wait(&mutex_lock); //wait之后,mutex_lock会被自动释放
...
mutex_lock.unlock();
而发送信号的线程如下:
mutex_lock.lock();
...
cond_var.signal(&mutex_lock);
...
mutex_lock.unlock();
再次提醒一下,条件变量一定要配合互斥锁使用。不管是wait函数还是signal函数,都最好在加锁之后的区域调用,否则很可能会产生意想不到的问题。
信号量
信号量是Dijkstra在1965年提出的一种同步的方案。这种方案使用一种特殊的被称作信号量的整形结构来记录某一临界区操作的次数。信号量的实现中一般具有两种操作,分别是P操作和V操作。对一个信号量进行P操作时,首先检查其值是否大于0,如果大于0,则将其值减一之后返回进行后续操作,如果值小于等于0,则该进程将进行阻塞。P操作可以由以下逻辑表示:
function P(semaphore &s) {
s.value--;
if(s.value < 0){
wait(s.list)
}
}
执行V操作时,首先将信号量的值加一,然后检查信号量的值是否大于0,如果大于0,则直接返回执行其他的操作,否则唤醒一个等待在该信号量上的进程。
function V(semaphore &s) {
s.value++;
if(s.value <= 0){
wake(s.list);
}
}
在上述操作中,P和V操作都是原子的。这意味着P函数或者V函数中的语句要么都不执行,要么都执行。在单核操作系统中,这种原子性可以通过关中断而保证,而在多核系统中,可以通过锁总线操作而保证。另外需要注意的是,虽然信号量可以针对进程使用,但是在具体应用中,由于线程之间共享数据更加方便,使用信号量进行线程同步的场景更多。
事实上,信号量可以同时用来进行互斥和同步的操作,通过信号量赋予不同的初始值,可以使用信号量模拟互斥锁的行为。