C++线程知识简介
目的
假设我们使用的是多核 CPU,且无法避免使用多线程。站在产品的稳定性和性能优化的角度,对线程知识进行简介。
范围
为了在多线程的编程环境中,更好发挥多核CPU的性能,并对多线程相关的缺陷加以了解并进行规避。着重于讲并行
而非并发
的情况。
并行(Parallel
)与并发(Concurrent
):
- 并行: 单线程。
- 并发: 多线程。
并发:
任务1 | 任务2 |
---|---|
执行语句1 | |
执行语句2 | |
执行语句3 | |
执行语句4 |
并行:
任务1 | 任务2 |
---|---|
执行语句1 | 执行语句2 |
热身
- 一个数据如果一个时刻是只读的,那么在这个时刻该数据是线程安全的。
- 一个数据被多个线程同时读写,那么该数据是线程不安全的。
- 在同一个线程中,对一个普通互斥量加锁两次,会发生死锁。
int
、unsigned
、char
、double
等基本类型,均为线程不安全的。- 互斥量的创建、加锁和解锁操作本身,并不耗时。
场景
线程间共享数据
场景举例:
- 通信线程之间同步消息序列号。
- ATS线程写入列车信息,其他线程读取。
- C++ STL 互斥量版本
1 | std::mutex mtx; |
- C++ STL 读写锁版本
1 | std::shared_mutex mutex; |
写入线程 | 读取线程1 | 读取线程2 |
---|---|---|
readerThread() | readerThread() | |
writerThread() |
- C++ STL 原子变量版本
1 | std::atomic<int> i; |
- Windows API 版本
1 | HANDLE hMutex; |
- Windows 临界区版本
1 | CRITICAL_SECTION cs; |
注: EnterCriticalSection
和 WaitForSingleObject
的区别:
WaitForSingleObject
因为涉及到 用户态和内核态的切换,更慢。WaitForSingleObject
可以用于进程间的同步,而EnterCriticalSection
不能。WaitForSingleObject
可以达到超时等待的效果,而EnterCriticalSection
会一直等待。- 在同一线程中多次调用
WaitForSingleObject
和EnterCriticalSection
都不会产生死锁。
- pthread 版本
1 | pthread_mutex_t mutex; |
- 注:
pthread_mutex_t
默认是不可被同一线程加锁两次的,即不可重入。如果想要可以重入则需要设置属性。
1 | pthread_mutexattr_t mutex_attr; |
- gos 库版本
1 | HANDLE mutex_ = gos_mutex_init(); |
注:
- gos 库版本的互斥量,在
window
下是可重入的, 在linux
下是不可重入的。
后台运行周期性任务
1 | class NTPClient |
- C++ STL 标准库版本
1 | NTPClient client; |
- pthread 版本:
1 | pthread_attr_t stAttr; |
- windows 版本:
1 | /// 1. Windows API: |
为什么选择 _beginthreadex
而不是 CreateThread
?
_beginthreadex
为每个线程分配自己的tiddata
内存结构, 其中保存了 C 语言中的全局变量, 如 errno
。
参考资料: windows 多线程: CreateThread、_beginthread、_beginthreadex、AfxBeginThread 的区别
- gos 库版本
1 | class NTPClientThread: public GThread |
后台运行耗时任务(一次性任务)
场景举例:
- 耗时函数放到后台运行,结果想要获取时再主动获取。
- 两个执行时间非常长的函数,并行执行可节省时间。
- 等待打印机打印的同时,继续执行其他任务。
实现思路:
- 启动一个线程,运行一个函数,函数运行结束,线程退出。
- 业务线程: 创建后台线程。
- 后台线程: 运行函数,函数结束后退出。
- 业务线程: 等待后台线程结束后,取得函数结果。
- C++ STL 标准库版本
1 | int i = 0; |
- C++ STL 异步版本
1 | int i = 0; |
- Windows API 版本
1 | int i = 0; |
- pthread 版本
1 | int i = 0; |
线程主动停止与资源释放
线程正在运行时,对线程进行销毁(free, delete),可能会访问到已经被释放的内存,导致程序崩溃。
因此,线程停止时需要等待线程结束后再释放资源。
对于 joinable
的线程,需要调用 join
函数等待线程结束后再释放资源。
但对于 detach
的线程,如何知晓线程函数执行完毕。
场景举例: 视频播放线程的主动停止。
1 | class ThreadPlayAndRec : public GThread |
解决办法: 设置结束标识位来判断。
1 | VOID ThreadPlayAndRec::Free() |
温州S2项目对 GThread 的改动:
1 | ThreadPlayAndRec* p = new ThreadPlayAndRec(); |
线程唤醒(线程池)
场景举例: 线程池中的线程,在任务队列出现任务时,唤醒一个线程进行处理。
- Linux 信号量举例
1 | /// 初始化 |
生产者线程 | 业务线程1 | 业务线程2 |
---|---|---|
初始化 sem_init() | ||
等待唤醒 sem_wait() | ||
等待唤醒 sem_wait() | ||
消息入列 sem_post() | ||
被唤醒后, 取出消息并处理 | ||
回收资源 sem_destroy() |
- pthread 条件变量版本
1 | pthread_cond_t cond = PTHREAD_COND_INITIALIZER; |
- C++ STL 风格代码举例
1 | void producers() |
生产者线程 | 业务线程1 | 业务线程2 |
---|---|---|
条件变量阻塞等待(wait() ) |
||
条件变量阻塞等待(wait() ) |
||
消息入列 | ||
唤醒线程(notify_one() ) |
||
被唤醒(条件变量停止阻塞) | ||
获取消息并执行业务 |
线程安全中所涉及的问题
死锁
- 情况1:
线程1 | 线程2 |
---|---|
获取互斥量1 | 获取互斥量2 |
获取互斥量2 | 获取互斥量1 |
解决办法:
- 一次只获取一个互斥量。
1 |
|
- 使用 STL 的语法
1 | { |
- 情况2:
线程1 |
---|
获取互斥量1 |
获取互斥量1 |
解决办法:
- 使用带有可重入属性的互斥量。
1 | /// STL |
- 情况3:
线程1 | 线程2 |
---|---|
等待线程2 join | 等待线程1 join |
解决办法:
- 在同一线程创建其他线程,也在同一线程进行 join。
ABA问题
线程1 | 线程2 |
---|---|
查询余额, 并存储进变量 i | 查询余额, 并存储进变量 i |
if(i >= 50) | if(i >= 50) |
i = i - 50; | i = i - 50; |
更新余额为 50 | |
更新余额为 50 |
解决办法:
- 串行执行
1 | | 线程1 | 线程2 | |
初始化单例
C++11之前以下全局变量,线程不安全。
1 | ThreadPlayAndRec g_ThreadPlayAndRec; ///< 全局变量,在程序启动时线程不安全 |
- 错误的做法1:
1 | ThreadPlayAndRec* p = NULL; |
线程1 | 线程2 |
---|---|
if(!p) | if(!p) |
new ThreadPlayAndRec(); | new ThreadPlayAndRec(); |
p 被赋值 | p 被赋值 |
线程1 | 线程2 |
---|---|
if(!p) | |
new ThreadPlayAndRec(); | if(!p) |
p 被赋值 | new ThreadPlayAndRec(); |
p 被赋值 |
- 错误的做法2:
1 | ThreadPlayAndRec* p = NULL; |
正常流程:
线程1 | 线程2 |
---|---|
if(!p) 第一次检查 | if(!p) 第一次检查 |
尝试获取互斥量 | 尝试获取互斥量 |
获取互斥量成功 | |
if(!p) 再次判断是否为空 | |
new ThreadPlayAndRec(); | |
p 被赋值 | |
释放互斥量 | |
获取互斥量成功 | |
if(!p) 再次判断是否为空 | |
p 不为空,流程结束 |
错误流程:
线程1 | 线程2 |
---|---|
if(!p) 第一次检查 | |
尝试获取互斥量 | |
获取互斥量成功 | |
if(!p) 再次判断是否为空 | |
new ThreadPlayAndRec(); | |
p 被赋值 | if(!p) 第一次检查 |
最后一步产生了读写竞争。
- 解决办法:
- 在 C++11 标准下使用 全局/静态变量。
- 使用 C++11 中提供的
std::call_once
保证初始化函数只被调用一次。
1 | std::once_flag flag; |
- 使用互斥量
1 | ThreadPlayAndRec* p = NULL; |
- 在 C++11 之前,使用
Linux API
或者Windows API
函数。
linux:
1 | pthread_once_t once_control = PTHREAD_ONCE_INIT; |
windows:
1 | EnterCriticalSection(&cs); |
不管, 成本最低也是最适合我们的方法。
使用
gos::singleton
, 其实现思路是 C++11 之前使用 double-check lock 的方法, C++11 之后使用std::call_once
。
多个线程同时开始
测试线程需要被测试函数同时并行执行。
- C++ STL 条件变量实现
1 | std::mutex mutex_; |
隐藏的多线程问题
场景1: 在通信线程,直接使用修改界面的语句。
注: 界面有自己的线程调度,如果在其他线程中直接修改界面,可能会导致界面崩溃。场景2: 在回调函数中使用共享变量(全局、静态或类成员变量)。
注: 回调函数一定在其他线程中执行,如果在回调函数中使用共享变量,可能会导致线程不安全。