第1章 线程安全的对象生命期管理
对象的创建很简单
- 对象构造要做到线程安全,唯一的要求是在构造期间不要泄露this指针(别的线程有可能访问这个半成品对象)。
- 二段式构造——构造函数+initialize()——有时会是好办法。
销毁太难
- 作为数据成员的mutex不能保护析构。另外,对于基类对象,调用到基类析构函数的时候,派生类对象的那部分已经析构了,那么基类对象拥有的MutexLock不能保护整个析构过程。
- 只有当别的线程都访问不到这个对象时,析构才是安全的。
- 一个函数如果要锁住相同类型的多个对象,为了保证始终按相同的顺序加锁,我们可以比较mutex对象的地址,始终先加锁地址较小的mutex。
线程安全的Observer有多难
- 对象的关系主要有三种:组合、聚合、关联。
- 组合:对象的生命期由其唯一的拥有者控制,拥有者析构的时候会把对象也析构掉。
- 聚合:我有一个东西是从别人那里借来的。
- 关联:对象a用到了另一个对象b,调用了后者的成员函数。
- 如果对象x注册了任何非静态成员函数回调,那么必然在某处持有了指向x的指针,这就暴露在race condition之下。
- 直接使用shared_ptr会形成循环引用,直接造成资源泄露。
- shared_ptr/weak_ptr的计数在主流平台上是原子操作,没有用锁,性能不俗。
- 垃圾回收的原理:所有人都用不到的东西一定是垃圾。
- 内存碎片:
- 内存碎片:进程不能完全使用分给它的固定内存区域。
- 外存碎片:未分配的连续内存区域大小。
再论shared_ptr的线程安全
- 它的引用计数本身是安全且无锁的,但对象的读写不是。
- 要在多个线程中同时访问同一个shared_ptr,正确的做法是用mutex保护。
- 用一个指向同一对象的栈上shared_ptr local copy:缩短了临界区长度。
shared_ptr技术与陷阱
- bind会把实参拷贝一份,延长对象的生命期。
- 一个线程只需要在最外层函数有一个实体对象,之后都可以用const reference来使用这个shared_ptr。
- 对象的析构是同步的,对象会在同一个线程析构,这个线程不一定是对象诞生的线程。可以用一个单独的线程来专门做析构,通过一个BlockingQueue<shared_ptr>把对象的析构都转移到那个专用线程。
- 避免循环引用:owner持有指向child的shared_ptr,child持有指向owner的shared_ptr。
对象池
- stocks的大小只增不减:利用shared_ptr的定制析构功能。
- shared_from_this():将this指针变身位shared_ptr。
弱回调
- 利用weak_ptr,在回调的时候先尝试提升位shared_ptr(lock),如果提升成功,说明接受回调的对象还健在,那么就执行回调。
- 没有垃圾回收的并发编程是困难的。
第2章 线程同步精要
互斥器
- 用RAII手法封装mutex:保证锁的生效期间等于一个作用域,不会因异常而忘记解锁。(java synchronized)
- 由于Guard对象是栈上对象,看函数调用栈就能分析用锁的情况,非常便利。
- 进程间通信只用TCP socket。
只使用非递归的mutex
- 区别:同一个线程可以重复对可重入锁加锁,,但是不能重复对不可重入锁加锁。
- 优越性:把程序的逻辑错误暴露出来。
- Linux的Pthreads mutex不必每次加锁、解锁都陷入系统调用,效率不错。在多CPU系统上,如果不能立刻拿到锁,它会先spin以小段时间,如果还不能拿到锁,才挂起当前线程。
false sharing(转载)
- 伪共享的非标准定义为:缓存系统中是以缓存行(cache line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享。
- MESI协议保证缓存的相干性和内存的相干性。
- 远程写时,同时处理RFO请求以及设置I的过程将给写操作带来很大的性能消耗。
spurious wakeup
- 虚假唤醒:pthread_cond_signal可能唤醒多个线程,必须用while循环来等待条件变量。
不要用读写锁和信号量
- 一种易犯的错误是在持有read lock的时候修改了共享数据。
- 无论如何read lock加锁的开销不会比mutex lock小。如果临界区很小,锁竞争不激烈,那么mutex往往会更快。
- 写锁优先,会阻塞后面的读锁(可以用shared_ptr实现copy-on-write)。
static_assert
- 静态断言,用来做编译期间的断言,不会造成任何运行期性能损失。
- assert应该捕捉不应该发生的非法情况。不能用assert检查返回值,因为在realise build里面是空语句。
sleep(3)不是同步原语
- 只能常出现在测试代码中。
- 在用户态做轮询(polling)是低效的。
借shared_ptr实现copy-on-write
- 对于write端,如果发现引用计数为1,可以安全地修改共享对象;不为1,拷贝一份(read端此时读的是旧的对象)。
- 对于read端,在读之前把引用计数加1,读完之后减1,保证在读的期间引用大于1。
第3章 多线程服务器的使用场合与常用编程模型
推荐模式
- one loop per thread + thread pool。
进程间通信只用TCP
- 可以跨主机,具有伸缩性。
- TCP port由一个进程独占,且操作系统会自动回收。即使程序意外退出,也不会给系统留下垃圾。
- 跨语言,服务器和客户端不必使用同一种语言。
- 可记录、可重现(tcpdump)。
- 容易定位服务之间的依赖关系。
必须用单线程的场合
- 只有单线程程序能fork。
- 单线程程序能限制程序的CPU占用率,防止非关键任务耗尽CPU资源。
- Even loop是非抢占式的,这个缺点可以用多线程来克服。
- 计算/IO密集型,多线程都没有什么绝对意义上的性能优势(Web服务器、subset sum)。
- 任何一方早早地先到达瓶颈,多线程程序都没啥优势。
适用多线程程序的场景
- 多线程不能提高绝对性能,但能提高平均响应性能(IO和计算相互重叠)。
- 有多个CPU可用。
- 线程间有共享数据。
- 共享的数据是可以修改的。
- 提供非均质的服务。
round-robin
- 轮询调度算法,是一种无状态调度。
多线程能提高吞吐量吗
- 如果用thread per request的模型,每个客户请求用一个线程去处理,那么当并发请求数大于某个临界区时,吞吐量反而会下降。
如何让IO和计算相互重叠
- 基本思路是:把IO操作通过BlockingQueue交给别的线程去做,自己不必等待。
第4章 C++多线程系统编程精要
Linux上的线程标识
-
pthread_t:进程内唯一的,不同进程内可能相同。pthread的值很容易重复。
-
pid_t:全局唯一,不同进程内也不相同。而且是采用递增轮回法分配,短时间内启动多个线程也会具有不同的线程id。
-
建议使用gettid系统调用的返回值作为线程id。
-
Current::tid使用__thread变量来缓存gettid的返回值。避免子进程fork后看到缓存,用pthread_atfork()注册一个回调来清空缓存。
线程的创建与销毁的守则
- 程序不应该在未提前告知的情况下创建自己的“背景线程”。这样程序可以统筹线程的数目和用途,避免低优先级的任务独占某个线程。
- 尽量用相同的方式创建线程。容易做一些统一的簿记工作。
- 在进入main()函数之前不应该启动线程。避免影响全局对象的安全构造(C++保证在进入main()之前完成全局对象的构造)。
- 程序中线程的创建最好在初始化阶段全部完成。
- 任何从外部强行终止线程的做法和想法都是错的。
pthread_cancel与C++
- 线程不是执行到此函数就立刻终止,而是该函数会抛出异常。这样可以有机会执行stack unwind。
exit在C++中不是线程安全的
- exit会析构全局对象和static对象,不析构局部对象(不存在栈空间回收的问题)。因此可能造成死锁,析构函数的竟态条件。
- 可以考虑用_exit系统调用。它不会试图析构全局对象,但是也不会执行其他任何清理工作。
善用__thread与关键字
- __thread是GCC内置的线程局部存储设施,比pthread_key_t快很多,存取效率可与全局变量相比。
- __thread无法自动调用构造函数和析构函数,只能用于修饰全局变量和静态变量,初始化只能用编译期常量。
多线程与IO
- 操作文件描述符的系统调用本身是线程安全的。
- socket读写的特点是不保证完整性。
- 多个线程分别read或write同一个磁盘上的多个文件也不见得能提速。因为每块磁盘都有一个操作队列,多个线程的读写请求到了内核是排队执行的。只有在内核缓存了大部分数据的情况下,多线程读这些热数据才可能比单线程快。
- 每个文件描述符只由一个线程操作,从而轻松解决消息收发的顺序性问题,也避免了关闭文件描述符的各种race condition。
- 对于磁盘文件,在必要的时候多个线程可以同时调用pread/pwrite(相当于先调用lseek)来读写同一个文件。
- 对于UDP,由于协议本身保证消息的原子性,可以多个线程同时读写同一个UDP文件描述符。
用RAII包装文件描述符
- POSIX标准要求每次新打开文件的时候必须使用当前最小可用的文件描述符号码,这种分配方式可能导致串话。因此不应该stdout或stderr,正确的做法是把stdout或stderr重定向到磁盘文件。
- 用全局表来避免串话通常意味着每次读写都要对全局表加锁。
- RAII:用Socket对象包装文件描述符,所有对此文件描述符的读写都通过此对象进行,在对象的析构函数里关闭文件描述符。
- 为了防止访问失效的对象或者发生网络串话,muduo使用shared_ptr来管理TcpConnection的生命期。
RAII与fork()
- 用RAII手法管理子进程未继承的资源时(定时器、内存锁、文件锁等等),fork出来的子进程不一定正常工作。
多线程与fork()
-
Linux的fork()只克隆当前线程的thread of control,不克隆其他线程。fork()后,其他线程可能正好位于临界区之内,持有了某把锁,而它突然死亡,再也没有机会去解锁了。
因此子进程不能调用:malloc、任何可能分配和释放内存的函数、printf系列函数、任何Pthreads函数。
-
唯一安全的做法是在fork()之后立即调用exec()执行另一个程序。
多线程与signal
- 在signal handler中只能调用可重入函数,不是每个线程安全的函数都是可重入的。
- 在signal handler中不能调用任何Pthreads函数。
- 如果signal handler中需要修改全局数据,那么被修改的变量必须是sig_atomic_t类型的。因为编译器有可能假定这个变量不会被他处修改,从而优化了内存访问。
- 使用signal的第一原则是不要使用signal。
- 也不要使用基于signal实现的定时函数。
- 不主动处理各种异常信号。
- 在没有别的替代方法的情况下,使用signalfd统一事件源。
第5章 高效的多线程日志
功能需求
-
调整日志的输出级别不需要重新编译,也不需要重启进程。
-
应该避免往网络文件系统(例如NFS)上写日志,这等于掩耳盗铃。
-
以本地文件为日志的destination,日志文件的滚动是必需的。
-
万一程序崩溃,那么最后若干条日志往往就丢失了。
muduo日志库用两个办法来应对这一点:其一是定期将缓冲区内的日志消息flush到硬盘;其二是每条内存中的日志消息都带有cookie,其值为某个函数的地址,这样通过在core dump文件中查找cookie就能找到尚未来得及写入磁盘的消息。
多线程异步日志
-
一个多线程程序的每个进程最好只写一个日志文件。
-
“异步日志”:用一个背景线程负责收集日志消息,并写入日志文件,其他业务线程只管往这个“日志线程”发送日志消息。(防止阻塞)
-
双缓冲技术:在新建日志消息的时候不必等待磁盘文件操作,也避免每条新消息都触发后端日志线程(相当于于批处理)。
-
nextBuffer_可以减少前端临界区分配内存的概率,缩短前端临界区长度。
-
这四个缓冲在程序启动的时候会全部填充为0,这样可以避免程序热身时page fault引发性能不稳定。
-
page fault(转载):
- major page fault也称为hard page fault, 指需要访问的内存不在虚拟地址空间,也不在物理内存中,需要从慢速设备载入。从swap回到物理内存也是hard page fault。
- minor page fault也称为soft page fault, 指需要访问的内存不在虚拟地址空间,但是在物理内存中,只需要MMU建立物理内存和虚拟地址空间的映射关系即可。 (通常是多个进程访问同一个共享内存中的数据,可能某些进程还没有建立起映射关系,所以访问时会出现soft page fault)
- invalid fault也称为segment fault, 指进程需要访问的内存地址不在它的虚拟地址空间范围内,属于越界访问,内核会报segment fault错误。
-
日志消息堆积:对于异步日志来说,生产速度高于消费速度,会造成数据在内存中堆积。解决办法是直接丢掉多余的日志buffer。
其他方案
- 用多个桶子,前端写日志的时候再按线程id哈希到不同的bucket中,以减少contention。
- muduo日志库不允许指定路径:在启动脚本(shell脚本)里改变当前路径。
- 通过sysctl设置参数,让每次core dump都产生不同的文件。