POSIX 多线程程序设计-线程同步精要

本章节摘录自陈硕 《Linux多线程服务端编程:使用muduoC++网络库》 第二章关于线程同步笔记。

线程同步四项原则

线程同步的四项原则,按重要性排列:

  • 首要原则是尽量最低限度地共享对象,减少需要同步的场合。一个对象能不暴露给别的线程就不要暴露;如果要暴露,优先考虑 immutable 对象;实在不行才暴露可修改的对象,并用同步措施来充分保护它。
  • 其次是使用高级的并发编程构件,如 TaskQueueProducer-ConsumerQueueCountDownLatch 等等。
  • 最后不得已必须使用底层同步原语(primitives)时,只用非递归的互斥器和条件变量,慎用读写锁,不要用信号量。
  • 除了使用 atomic 整数之外,不自己编写 lock-free 代码,也不要用“内核级”同步原语。不凭空猜测“哪种做法性能会更好”,比如spin lock vs mutex

互斥器(mutex)

互斥器(mutex)是使用得最多的同步原语,粗略地说,它保护了临界区,任何一个时刻最多只能有一个线程在此 mutex 划出的临界区内活动。单独使用 mutex 时,我们主要为了保护共享数据。

主要原则:

  • 在C++中,应该使用用RAII手法封装mutex的创建、销毁、加锁、解锁这四个操作。Java里的 synchronized 语句和C#的 using 语句也有类似的效果,即保证锁的生效期间等于一个作用域(scope),不会因异常而忘记解锁。
  • 只用非递归的 mutex(即不可重入的mutex)。
  • 不手工调用 lock()unlock() 函数,
  • 在每次构造 Guard 对象的时候,思考一路上(调用栈上)已经持有的锁,防止因加锁顺序不同而导致死锁(deadlock)。

次要原则:

  • 不使用跨进程的 mutex,进程间通信只用 TCPsockets。
  • 加锁、解锁在同一个线程,线程a不能去 unlock 线程b已经锁住的 mutex(RAII自动保证)。 别忘了解锁(RAII自动保证)。
  • 不重复解锁(RAII自动保证)。
  • 必要的时候可以考虑用 PTHREAD_MUTEX_ERRORCHECK 来排错。

条件变量

互斥器(mutex)是加锁原语,用来排他性地访问共享数据,它不是等待原语。在使用 mutex 的时候,我们一般都会期望加锁不要阻塞,总是能立刻拿到锁。然后尽快访问数据,用完之后尽快解锁,这样才能不影响并发性和性能。

如果需要等待某个条件成立,我们应该使用条件变量(conditionvariable)。条件变量顾名思义是一个或多个线程等待某个布尔表达式为真,即等待别的线程“唤醒”它。条件变量只有一种正确使用的方式,几乎不可能用错。

对于wait端:

  • 必须与 mutex 一起使用,该布尔表达式的读写需受此mutex保护。
  • mutex 已上锁的时候才能调用 wait()
  • 把判断布尔条件和 wait() 放到 while 循环中。

对于 signal/broadcast 端:

  • 不一定要在 mutex 已上锁的情况下调用 signal(理论上)。
  • signal 之前一般要修改布尔表达式。
  • 修改布尔表达式通常要用 mutex 保护(至少用作fullmemorybarrier)。
  • 注意区分 signalbroadcastbroadcast 通常用于表明状态变化,signal 通常用于表示资源。

条件变量是非常底层的同步原语,很少直接使用,一般都是用它来实现高层的同步措施,如 BlockingQueue<T>CountDownLatch

倒计时(CountDownLatch)是一种常用且易用的同步手段。它主要有两种用途:

  • 主线程发起多个子线程,等这些子线程各自都完成一定的任务之后,主线程才继续执行。通常用于主线程等待多个子线程完成初始化。
  • 主线程发起多个子线程,子线程都等待主线程,主线程完成其他一些任务之后通知所有子线程开始执行。通常用于多个子线程等待主线程发出“起跑”命令。

不要使用读写锁和信号量

读写锁(Readers-Writerlock,简写为rwlock)是个看上去很美的抽象,它明确区分了 read 和 write 两种行为。

初学者常干的一件事情是,一见到某个共享数据结构频繁读而很少写,就把 mutex 替换为 rwlock。甚至首选 rwlock 来保护共享状态,这不见得是正确的。

  • 从正确性方面来说,一种典型的易犯错误是在持有 readlock 的时候修改了共享数据。这通常发生在程序的维护阶段,为了新增功能,程序员不小心在原来 readlock 保护的函数中调用了会修改状态的函数。这种错误的后果跟无保护并发读写共享数据是一样的。
  • 从性能方面来说,读写锁不见得比普通 mutex 更高效。无论如何 readerlock 加锁的开销不会比 mutexlock 小,因为它要更新当前 reader 的数目。如果临界区很小,锁竞争不激烈,那么 mutex 往往会更快。
  • readerlock 可能允许提升为 writerlock,也可能不允许提升。如果处理不好容易导致程序崩溃和死锁。
  • 通常 readerlock 是可重入的,writerlock 是不可重入的。但是为了防止 writer 饥饿,writerlock通常会阻塞后来的readerlock,因此 readerlock 在重入的时候可能死锁。另外,在追求低延迟读取的场合也不适用读写锁。

对于信号量(Semaphore),陈硕认为信号量不是必备的同步原语,因为条件变量配合互斥器可以完全替代其功能,而且更不易用错。
信号量的另一个问题在于它有自己的计数值,而通常我们自己的数据结构也有长度值,这就造成了同样的信息存了两份,需要时刻保持一致,这增加了程序员的负担和出错的可能。

归纳与总结

作者认为,应该先把程序写正确(并尽量保持清晰和简单),然后再考虑性能优化,如果确实还有必要优化的话。这在多线程下仍然成立。让一个正确的程序变快,远比“让一个快的程序变正确”容易得多。

“效率”并不是我的主要考虑点,我提倡正确加锁而不是自己编写 lock-free算法(使用原子整数除外),更不要想当然地自己发明同步设施。在没有实测数据支持的情况下,妄谈哪种做法效率更高是靠不住的,不能听信传言或凭感觉“优化”。很多人误认为用锁会让程序变慢,其实真正影响性能的不是锁,而是锁争用(lockcontention)。