线程定义
线程的定义在维基百科和各种教材书中都能找到,这里再简单描述一下:操作系统中能够被调度的最小单位,有自己的context、stack以及thread-local存储,但与同一进程中的其他线程共享进程资源。如果从另外一个角度来思考:比如把C语言的指针作为机器地址的抽象的话,那么线程可以认为是机器流水线或者“虚拟处理器”的抽象。只是流水线之间的耦合并没有线程来的那么紧密,原因在于线程之间还是共享了很多东西,编码时则需要线程安全。
线程安全
很多场合,线程安全总是和可重入混在一起。需要引起注意的是,这两个并不是同一个概念。可重入(别名 异步信号安全)是一个更严格的要求,首先它要求线程安全,其次当发生信号中断并执行完处理例程后继续执行仍然保证正确方可。使用mutex的函数可能是线程安全的,但不是可重入的。二者大致关系如下图,可重入函数只是线程安全函数的一部分。
(面积比例不代表函数数量比例)
不过无论是线程安全或者可重入,二者都具备传递性:若当前函数调用了非线程安全函数,则当前函数一定不是线程安全的。
线程同步与线程安全的实现机制
最好就是不共享任何的资源,从而避免竞争(race condition),比如比较两个参数大小的函数
inline int max(int a, int b){
return a > b ? a : b;
}
如果一定要共享,那么可以通过互斥锁来实现。但在共享前,需要识别的是是否仅仅是伪共享,比如errno。业务逻辑如果允许每个线程一份copy,而且无须同步的话,则可通过将资源thread-local化来实现。
POSIX提供pthread_key_create
,pthread_get(set)specific
接口来实现,而新的C++11更是将thread_local设置成了关键字更好的进行支持。
互斥锁
在操作系统提供的编程接口层面,开发者可用的同步手段很多,其中以互斥锁应用最为广泛(信号量是一种将资源数目从1泛化到n的互斥锁)。当线程进入临界区前获得锁,只有获得了锁的线程才可能继续执行,当退出临界区后归还锁。如果锁被占用,则线程进入阻塞状态。但是要预防错误的加锁顺序或者持有锁但不释放的场景,否则会造成程序死锁、异常,陷入不易复现的bug。通过合理的利用RAII机制来保证锁的获取与释放是多线程编程的一种最佳实践,以至于这也进入了C++11的新标准,比如std::unique_lock
。
即使编码时非常小心,已经注意到了加锁顺序、锁申请必然释放等规则,但仍然有可能存在问题。
还记的发生在火星上的灾难吧:优先级翻转。现代操作系统通过优先级继承较好的解决了这个问题,但程序员需要注意自己代码所运行的平台是否有这个机制,然后正确的设置线程属性方可。
此外,如果所有优先级都调到一个数量级,那么还需要注意lock convoy问题。
发生lock convoy的场景犹如2人迎面通过一独木桥,2人相遇后均主动放弃退回,然后再次上桥相遇。注意,这不是死锁。我们的业务软件需要避免这种设计。类似的问题比如当初accept的惊群问题以及当在条件变量中唤醒所有的等待线程时也会瞬时触发单回合的类似场景。
条件变量
条件变量也是同步的一种手段,由一把锁(mutex)和一个condition组成。它可以使线程阻塞在某一条件上,比如queue.not_empty()
。当条件满足时,线程唤醒。需要注意是要小心虚假唤醒,即当wait返回后,需要再次判断条件是否满足。C++11中的wait接口有了第二个参数,允许传入predicate,借用lambda可以省掉几行代码,极大简化了编码。比如
_cv_ready.wait(lock,[=]{return _tail != _head;})
当队列头不等于队列尾的条件满足时(不为空),线程唤醒并再次检查条件。
读写锁
允许读锁并发,写-写,写读互斥。但尴尬的是,这是一个充满诱惑的坑:由于语义复杂性,其内部实现效率要比普通mutex慢很多,可能这也是C++11并没有将读写锁纳入std的原因吧。因此当且仅当在多读一写的情况下,并且读锁临界区非常大时,比如要做IO,才适合用读写锁。不过针对不同的场景,可以借鉴这种思路,比如实现一种用于监控统计功能的“写读锁”,即写远远大于读的频率,等到运维人员读某个统计时才合并所有的写。这样可以有效降低cache bouncing。
spinlock
spinlock类似互斥锁,但等待锁期间不会被切换,而是一直空转。因此比较耗费cpu资源,尤其当持有锁的线程因时间片到期被换出时。因此使用spinlock的场景,仅适用实时、短小且不会主动切换的场景(比如持有锁期间肯定不包含IO之类的操作)。实际上mutex的实现中自带了部分spinlock,所以用户态下除非必要,尽量选用互斥锁,否则容易适得其反。
原子操作
原子操作可类比数据库ACID的atomic。很多公司的平台部门在做基础库时会使用asm汇编实现原子操作。C++11则直接提供了std::atomic供程序员使用。和前面的介绍相同,本文重点介绍使用陷阱:编译器和CPU在生成或执行指令时可能打乱某些看似无关的代码(指令)。但实际上在多线程环境下存在依赖关系,如下例,假设thread_1/2运行在同一进程的不同线程,则可能打印a值为0。
atomic<int> a;
atomic<int> b;
int thread_1(){
int t = 1;
a = t;
b = 2;
}
int thread_2{
while(b != 2);
cout << a << endl;
}
因此C++11中大部分atomic都需要指定当前场景所需的memory order来提高性能, 更多memory order相关的参考这里。
除此之外,一个初学者常犯的错误,比如a,b 两个变量都是atomic属性,但if(a > b)
这种写法可不是线程安全的,毕竟a > b其实是多个指令并不是原子的。众多的lock-free实现也是基于atomic的,但同时也有诸多问题需要注意,比如ABA problem。所以,在使用atomic的时候,尽量选择使用在简单变量的读写共享上,即始终明确临界区的概念中所谓的锁,并不是锁定一个资源,而是锁定对该资源的访问操作。
性能相关
当通过互斥或条件变量来实现线程同步,不可避免的会发生主动的线程切换。而不恰当的切换则会影响到系统的吞吐和效率,这里仅从同步原语出发针对多线程编程总结一下。
显然,多线程加锁的消耗取决于竞争的激烈程度以及上下文切换的开销。因此缩小临界区,将不用锁的尤其那些重量级的不用锁的部分移到临界区外是提高性能的不二法则。而pthread_mutex_t
实现基于Linux的futex,当临界区足够小时,一次pthread_mutex_lock
消耗很非常小。
此外,通过对不同的业务逻辑采用恰当的线程模型,也能够避免竞争的激烈程度。
线程模型
对于不同的业务逻辑,多线程的设计存在不同的模型,注意,这里的模型不是指操作系统的1:1 、N:1、N:M线程模型,而是如何使用线程来应对不同的业务场景。对此可以大致分为运算密集型、IO密集型。
众所周知,对于多线程编程通常采用线程池技术来降低线程创建和退出的消耗,但如何使用这些线程呢,一般来说,策略分为
- work group
- pipeline
work-group模型
在工作组模型中,请求(或称数据)是被一组线程处理的,如下图:
根据操作的不同,可分为MIMD和SIMD两种。
- 如果每个工作线程从共享队列中获取工作请求并处理,由于队列中的操作和数据不尽相同——类似MIMD(多指令多数据)。
- 如果工作组的线程,每个负责处理数据或请求的一部分(例如,某列或行),所有工作线程在不同的数据上执行相同的操作——类似SIMD(单指令多数据)。
在实际编程中,leader-follower pattern为大家所熟知。其工作流程为:线程池初始化后,存在唯一的leader线程,等待工作请求的到来,请求到达后leader读取请求并开始处理,将自己身份变成follower,同时从线程池中选出下一个线程作为新leader,当这次请求完成结果送回后该线程把自己重新送回到线程池中。
pipe-line模型
在流水线方式中,数据流串行的被一组线程顺序处理。每个线程依次在数据上执行自身特定的操作,并将执行结果传递给流水线中的下一个线程。编程中常见的即:生产者和消费者。
这里需要注意个平均分配到线程工作量不应差别太大,否则很容易在后续优化中会碰到串行化方面的难题:Amdahl's Law.
(图片来自wiki)
即,即使将某个部分效率大幅提升,但总的吞吐仍然维持在意想不到的小幅度提高。
总结
本文主要介绍了在多线程编程中的诸多注意事项和常见多线程模型,希望读者在实战中能够避开已知的坑,在这里祝大家线程安全!