定义

  • 多个线程同时访问时,其表现出争取的行为。
  • 无论操作系统如何调度这些线程,无论这些线程的执行顺序如何交织。
  • 调用端代码无须额外的同步或其他协调动作。

可重入 vs 不可重入

可重入函数也可以这样理解,重入即表示重复进入,首先它意味着这个函数可以被中断,其次意味着它除了使用自己栈上的变量以外不依赖于任何环境(包括static),这样的函数就是purecode(纯代码)可重入,可以允许有多个该函数的副本在运行,由于它们使用的是分离的栈,所以不会互相干扰。如果确实需要访问全局变量(包括static),一定要注意实施互斥手段。可重入函数在并行运行环境中非常重要,但是一般要为访问全局变量付出一些性能代价。

而不可重入的函数由于使用了一些系统资源,比如全局变量区,中断向量表等,所以它如果被中断的话,可能会出现问题,这类函数是不能运行在多任务环境下的。

对象创建很简单

对象构造要做到线程安全,唯一的要求是在构造期间不要泄露this指针,即

  • 不要在构造函数中注册任何回调;
  • 也不要在构造函数中把this传给跨线程的对象;
  • 即便在构造函数的最后一行也不行。

销毁太难

  • mutex 作为成员函数是不能保护析构的。

对象池来调度也可能存在问题

  • 对象池的线程安全,如何安全地、完整地把对象放回池子里,防止出现“部分放回”的竞态?
  • 全局共享数据引发的lock contention,这个集中化的对象池会不会把多线程并发的操作串行化?
  • 如果共享对象的类型不止一种,那么重复实现对象池还是使用类模板?
  • 会不会造成内存泄漏与分片?因为对象池内存只增不减,而且多个对象池不能共享内存。

Observer的实现也非常麻烦。不知道对象死了没。

万能的解决方案

引入一层间接性,用对象来管理共享资源,亦即body/handle惯用技法。—>智能指针

C++可能出现的内存问题及解决方案

  • 缓冲区溢出:用std::vector/std::string或自己编写的buffer class来管理缓冲区,自动记住用缓冲区的长度,并通过成员函数而不是裸指针来修改缓冲区。
  • 空悬指针:用智能指针。
  • 重复释放:同上。
  • 内存泄漏:同上。
  • 不配对的new[]/delete:将new[]统一换成vector。
  • 内存碎片问题:不好解决。

再论shared_ptr的线程安全

shared_ptr的线程安全级别和内建类型、标准库容器、std::string一样,即:

  • 一个shared_ptr对象实体可被多线程同时读取;
  • 两个shared_ptr对象实体可被两个线程同时写入,“析构”算写操作;
  • 如果要从多个线程读写同一个shared_ptr对象,那么需要加锁。

不要使用读写锁和信号量

读写锁

  • 从正确性方面来说,一种典型的易犯错误是在持有readlock的时候修改了共享数据。(这个通常发生在后期维护阶段)
  • 从性能方面来说,读写锁不见得比普通的mutex更高效。因为readerlock总归是要更新reader的数量的。
  • readerlock可能允许提升为writerlock,也可能不允许。
  • 通常readerlock是可重入的,但是writerlock是不可重入的。但为了防止writer饥饿,writerlock通常会阻塞后来的readerlock,因此readerlock在重入的时候可能死锁。另外在追求低延迟读取的场所也不适合读写锁。

信号量(semaphore)

一方面信号量完全可以被条件变量和互斥器替代,而且更不容易出错;另一方面。信号量有自己的计数值,而通常我们数据结构中也会存一个,这样就得时刻保持一致,增加了出错风险。

归纳总结

  • 线程同步的四项原则:尽量用高层同步设施(线程池、队列、倒计时);
  • 使用普通互斥器和条件变量完成剩余的同步任务,采用RAII惯用手法再包装一下。

用普通mutex替换读写锁的一个例子

场景:一个多线程的C++程序,24h x 5.5d运行。有几个工作线程ThreadWorker{0, 1, 2, 3},处理客户发过来的交易请求;另外有一个背景线程ThreadBackground,不定期更新程序内部的参考数据。这些线程都跟一个hash表打交道,工作线程只读,背景线程读写,必然要用到一些同步机制,防止数据损坏。这里的示例代码用std::map代替hash表,意思是一样的:

alt

Map的key是用户名,value是一个vector,里边存的是不同stock的最小交易间隔,vector已经排好序,可以用二分查找。

我们的系统要求工作线程的延迟尽可能小,可以容忍背景线程的延迟略大。一天之内,背景线程对数据更新的次数屈指可数,最多一小时一次,更新的数据来自于网络,所以对更新的及时性不敏感。Map的数据量也不大,大约一千多条数据。

最简单的同步办法是用读写锁:工作线程加读锁,背景线程加写锁。但是读写锁的开销比普通mutex要大,而且是写锁优先,会阻塞后面的读锁。如果工作线程能用最普通的非重入mutex实现同步,就不必用读写锁,这能降低工作线程延迟。我们借助shared_ptr做到了这一点

alt

CustomerData::query()就用前面说的引用计数加1的办法,用局部MapPtr data变量来持有Map,防止并发修改。

alt

关键看CustomerData::update()怎么写。既然要更新数据,那肯定得加锁,如果这时候其他线程正在读,那么不能在原来的数据上修改,得创建一个副本,在副本上修改,修改完了再替换。如果没有用户在读,那么就能直接修改,节约一次Map拷贝。

alt

注意其中用了shared_ptr::unique()来判断是不是有人在读,如果有人在读,那么我们不能直接修改,因为query()并没有全程加锁,只在getData()内部有锁。shared_ptr::swap()把data_替换为新副本,而且我们还在锁里,不会有别的线程来读,可以放心地更新。如果别的reader线程已经刚刚通过getData()拿到了MapPtr,它会读到稍旧的数据。这不是问题,因为数据更新来自网络,如果网络稍有延迟,反正reader线程也会读到旧的数据。

如果每次都更新全部数据,而且始终是在同一个线程更新数据,临界区还可以进一步缩小。

alt

据我们测试,大多数情况下更新都是在原来数据上进行的,拷贝的比例还不到1%,很高效。更准确地说,这不是copy-on-write,而是copy-on-other-reading。

我们将来可能会采用无锁数据结构,不过目前这个实现已经非常好,可以满足我们的要求。

本节介绍的做法与read-copy-update颇有相似之处,但理解起来要容易得多。