线程同步,“同”字从字面上容易理解为一起动作,其实不然,“同”字应是指协同、协助、互相配合。同步(synchronous)就是协同步调,按预定的先后次序进行运行。如:你说完,我再说。
在多线程编程里,当两个或两个以上的线程共享某些资源或需要相互配合来完成某些工作时,就必须通过线程同步来协调各个线程运行的次序,否则会出现线程安全问题。比如在线程A和B配合工作时,A执行到一定程度时要依靠B的某个结果,于是停下来,示意B运行;B依言执行,再将结果给A;A再继续操作。或者当线程A和B共享一个资源时,如果同一时间读写这个资源,就会发生资源竞争的问题,这时就只能允许某个时间点只有一个线程占有资源,另外一个线程等待,这也是线程同步。
线程的最大特点是资源的共享性,但资源共享中的同步问题是多线程编程的难点。解决线程同步问题,是要用到同步原语,这些同步原语有互斥锁(mutex),条件变量(condition variable)、读写锁(reader-writer lock)和信号量(Semaphone)。
对于线程同步的编程问题,陈硕大神在他自己的书《Linux多线程服务端编程》,总结了多线程编程的经验,在这里,顺便贴出来,做下笔记。
一、并发编程基本模型
message passing和shared memory。
二、线程同步的四项原则
*尽量最低限度地共享对象,减少需要同步的场合。如果确实需要,优先考虑共享 immutable 对象。实在不行才暴露可修改的对象,并同步措施来充分保护它。在考虑多线程程序的设计,第一先想想不用同步是否能做得到,如不用同步,那就避免线程同步问题,减少出错概率。
*使用高级的并发编程构件,如TaskQueue、Producer-Consumer Queue、CountDownLatch等等。
*不得已必须使用底层同步原语(primitives)时,只用非递归的互斥器和条件变量,慎用读写锁,不要用信号量。
*除了使用 atomic 整数之外,不自己编写 lock-free 代码,也不要用“内核级”同步原语。不凭空猜测“哪种做法性能会更好”,比如 spin lock vs. mutex
三、互斥器的使用
互斥器用于保护临界区,任何一个时刻最多只能有一个线程在此mutex划出的临界区内活动,单独使用mutex时,主要是为了保护共享资源。编程原则:
*用RAII手法封装mutex创建,销毁,加锁,解锁这四个操作。
*只用非递归的mutex(不可重入的mutex)。因为少用一个计数器,比递归的mutex略快一点,但主要还是为了设计意图,不是为了性能考虑。在同一个线程中多次对非递归mutex加入会立刻死锁,能帮助我们及早发现问题。
*不手工调用lock()和unlock()函数,一切交给栈上的Guard对象构造函数和析构函数负责。
*每次构造Guard对象,思考一路上(调用栈上)已经持有的锁,防止因加锁顺序不同而导致死锁。
次要原则:
* 不使用跨进程的mutex,进程间通信只用TCP sockets。
* 加锁、解锁都在同一个线程,线程a不能去unlock线程b已经锁住的mutex。
* RAII保证解锁与不重复解锁。
* 必要的时候可以考虑PTHREAD_MUTEX_ERRORCHECK来排错。
note: Linux的Pthreads mutex 采用 futex 实现,不必每次加锁、解锁都陷入内核。
四、条件变量的使用
对于 wait() 端:
* 必须与 mutex 一起使用,该布尔表达式的读写需受此 mutex 的保护。
* 在 mutex 已上锁的情况下才能调用 wait()。
* 把判断布尔表达式和 wait() 放在 while 循环中。
note:必须使用while循环来等待条件变量,不是使用if语句,原因是spurious wakeup。
对于 signal/broadcast 端:
* 不一定要在 mutex 已上锁的情况下调用 signal(理论上)。
* 在 signal 之前一般要修改布尔表达式。
* 修改布尔表达式通常需要用 mutex 保护(至少用作 full memory barrier)。
* broadcast 通常用于表明状态变化,signal 通常用于表示资源可用。
虚假唤醒(spurious wakeup),Linux 中 futex 慢速系统调用被信号打断返回 -1,wait 返回了。
note:互斥器和条件变量构成了多线程编程的全部必备同步源于,用它们即可完成任何多线程同步任务,二者不可相互替代。
五、不用读写锁和信号量
读写锁:
* 正确性上:易发生在持有read lock时候修改了共享数据,这种错误的后果跟无保护并发读写共享数据时一样的。
* 性能上:读写锁不见得比普通mutex更高效,如果临界区很小,锁竞争不激烈,mutex往往更快。
* 通常reader lock是可重入的,writer lock是不可重入的。但为了防止writer饥饿,writer通常会阻塞后来的reader lock,因此reader lock在重入的时候可能死锁。
不用信号量:
* 使用条件变量配合互斥器可以完全替代信号量,而且不易出错。
* 信号量增加了程序设计的负担和出错的可能。
对多线程初学者,陈硕给出些建议。
1.循序渐进。先精通mutex和conditon这两个同步原语的用法,先学会正确的、安全的多线程程序,再在必要时考虑其它高级手段提高性能,如果确实提高性能的话。千万不要连mutex都还没学会、用好,一上来就考虑lock-free设计。
2.不要畏难而退。多线程编程是一项重要的编程技能,不能因为难本能排斥。掌握多线程编程,才能更理智的选择用还是不用多线程,因为你能预计多线程实现的难度与收益,在一开始作出正确的选择。掌握同步原语和它们的适用场合是多线程的基本功。
3.学习多线程程序设计,远远不是看看教程了解API怎么用那么简单,这最多“主要是为了读懂别人的代码,如果自己要写这类代码,必须专门花时间严肃、认真、系统地学习,严禁半桶水上阵”。操作系统教材是必读的,如《操作系统设计语实现》、《现代操作系统》、《操作系统概念》,任选一本,至少要完整地学习一本经典教材的相关章节,了解各种同步原语、临界区、竞态条件、死锁、典型的IPC问题等等,防止闭门造车。