多线程系统编程
对象生命周期管理
线程安全定义
- 多个线程同时访问,行为正确,无论操作系统如何调度
- 调用端代码无须额外同步操作
基本原则:凡非共享的对象彼此独立,只被一个线程用到;而const共享对象的read-only是线程安全的
对象构造与销毁
构造唯一要求:构造时不要泄露this指针
构造期间还没有完成初始化,别的线程可能访问到这个半成品。最后一行也不行,如果是个基类呢,基类构造虽然完成,但派生类还有构造函数呢。
二段式构造——构造函数+initialize()可能会是个好的选择
销毁比较麻烦:
对象本身的mutex并不能保护临界区,因为析构时mutex也会被销毁。例如析构时如果用mutex保护,加了锁,另一个线程访问另一个成员函数,因为同样访问临界区然后也会加锁,这时析构函数完成,锁没了!
所以mutex不能保护析构
同时读写两个对象,可能会发生死锁,例如同样访问a,b,线程A先锁a,线程b先锁b,那就死锁了。
如果一定要锁住相同类型多个对象,需要保证始终按相同顺序加锁,例如通过mutex的地址顺序
拥抱智能指针吧!?
C++里可能出现的内存问题:
- 缓冲区溢出
- 悬垂指针/野指针
- 重复释放
- 内存泄漏
- 不配对的new[]/delete
- 内存碎片
智能指针(shared_ptr, weak_ptr, unique_ptr
)可以解决前5个问题
但需注意:
一般来说,智能指针应该是栈元素。
智能指针所管理的对象是线程安全的,但智能指针本身并不是,所以访问智能指针时需要加锁
需注意可能的意外延长对象生命周期,存储智能指针时应该是weak_ptr, 而不是shared_ptr,另外需注意bind()时可能的拷贝
智能指针拷贝开销更高一些,建议一个线程只在最外层函数有一个实体shared_ptr,之后通过引用传参
在无须共享对象时,使用unique_ptr似乎是个好选择(独占式)
多线程编程应当最低限度地共享数据,这才是根本,其次考虑不可修改的数据,再次才暴露可修改数据,并提供同步机制
线程同步
只使用非递归互斥器(不可重入)和条件变量,慎用读写锁,不用信号量
正确比快更重要,应当首先追求正确,再去考虑性能
永远不要用sleep来“同步”
不推荐使用读写锁和信号量
读者-编者问题
读写锁不一定比普通mutex快,因为要更新reader数目
追求高性能:read-copy-update方式(RCU方式),无锁
copy-on-other-reading:作者在2.8节的方式,利用shared_ptr的计数和mutex实现读写锁功能,当有读者时,复制一份,然后将副本和旧数据交换,在真实数据上修改,不影响其余读者读取数据。这个过程读者临界区只需要获得数据就行了,临界区很小。
信号量并没有互斥锁+条件变量灵活好用,而且信号量本身也有一个计数,同我们的数据计数重复了。
编程模型
进程拥有独立的地址空间,可以把每个进程比喻为一个人,每个人都有自己的记忆,但不知道别人的记忆。然后就可以思考:容错、扩容、负载均衡、退休等等场景
单线程模型:“non-blocking IO + IO mutiplexing”基于事件驱动的编程模型要求回调函数必须是非阻塞的,容易割裂业务逻辑,不容易理解和维护(现代语言的协程是一种应对思路)
多线程模型:
one loop per thread + 每个请求创建一个/线程池 + 阻塞式/非阻塞式IOmutiplexing
领导者/跟随者模式
推荐模式:one loop per thread + 线程池 + nonblocking mutiplexing
进程间通信作者只推荐TCP,不建议共享内存。TCP代码复杂度低,支持场景多(比如跨设备),而共享内存需要a填好一块内存让b来读,算是“停等”,二者结合起来比较麻烦,复杂度高。
我觉得只有在写少读多的场景下共享内容才有优势。
适用场合
模式:
- 单进程单线程
- 单进程多线程
- 多进程单线程
a. 把模式1的进程运行多份
b. 主进程+worker进程 - 多进程多线程
单线程场合:
- 需要fork,最好单线程,多线程fork会很麻烦(linux fork只会fork当前线程)。目前唯一需要这样的场合只有守护进程相关(看门狗进程,如集群中运行在计算节点上的负责启动job的守护进程)
- 限制CPU占用率。非关键任务不应该使用全部资源,否则有可能造成关键任务资源不够造成响应慢
多线程场合:
概括:提高响应速度,让IO和计算重叠
- 前提是有多个CPU核心可用
- 有共享数据且可修改,相比于模式3,这时候用多线程可降低内存消耗及通信开销
- 优先级,有些事件优先级较高,应及时响应
- 响应时间(latency延迟)平均,相比于模式1
- 划分功能
线程划分:
- IO线程
- 计算线程
- 第三方库线程,如logging或者数据库连接
线程池大小设计:
阻抗匹配原则:密集计算所占事件比重*线程数=CPU核心数
比重小于0.2不再适用(可能是因为线程切换开销?)
多线程系统编程
思维转变:线程随时会切换出去(被抢占),事件发生顺序不再有全局统一的先后关系。
基本线程原语:线程、互斥锁、条件变量
c/c++系统库:
- glibc库函数大部分是线程安全的
- 线程安全是不能组合的,两个线程安全的操作合在一起就不一定了
- C++中的流式操作不是线程安全的,因为等同于多个函数调用(同理,如果一个对象重载了操作符,那么连加连等之类的也不是线程安全)
- posix规定的pthread_t并不一定是个整形,只是glibc中是unsigned long,实际会当做一个struct看待。同样pthread_t在时间上看并不具有唯一性,前一个线程结束后再创建的线程可以跟之前线程pthread_t相同
- 作者建议使用gettid()的返回值作为线程ID
线程管理:
- “线程是稀缺资源”,这里的线程指的是真实的由操作系统调配的线程
- 统一管理线程,程序初始阶段创建全部工作线程,运行期间不再创建和销毁
- 线程销毁唯一的正确方式是自然退出
- 其他方式比如异常、自杀pthread_exit、他杀pthread_cancel等
- 安全退出很难实现,exit(3)(析构全局对象和函数静态对象)可能会导致死锁
善用__thread
- 修饰POD类型,可修饰全局变量和静态变量,每个线程复制一份,互不影响,访问效率高
- 这个“每个线程复制一份”,指的是在新线程中该变量会重新初始化,不受之前线程赋值的影响
- 不能修饰class类型,因为无法自动调用构造函数和析构函数
多线程与IO:
- 大部分情况下,一个文件描述符应当只由一个线程操作(磁盘文件和UDP可例外)
- 串话问题:A在使用fd8,然后B关闭了fd8,又重新创建了fd8,A从fd8获得了意外的数据,并且妨碍了新的fd8的通信。
- 使用RAII管理connect,同一个fd只由一个connect对象操作
其他:
-不要使用signal,也不要使用基于signal的定时函数,不处理异常信号(除了SIGPIPE)。
- 传统方法是在信号处理函数中写入pipe,然后由IO事件处理框架统一处理
- 现代方式可使用signalfd,避免使用signal handler
- 创建文件描述符可直接指定O_NONBLOCK和FD_CLOEXEC,分别表示非阻塞和exec时关闭描述符,防止泄露。
日志
两个意思,一个是诊断日志供人阅读,一个是交易日志供回滚状态,本章指诊断日志
Log Everything All The Time
在日志角度考虑,将日志处理分为前端和后端,前端负责写入缓冲区,后端负责将日志刷到磁盘。
功能:
- 多种级别,运行时可调
- 多个目的地(分布式系统只有本地文件,因为网络日志不可靠)
- 格式可配置
- 一条日志应当只有一行,这样方便搜索和处理
- 时间戳-微妙
- 线程id
- 日志级别
- 原文件名和行号
- 过滤器
- rolling
- 例如写满1GB换下一个文件
- 例如定时换下一个文件
性能:
前端不阻塞正常执行流程,性能损失要小
后端吞吐量要足够大
双缓冲技术
性能不能仅靠感觉,比如有时直接拷贝数据比传递指针还要快
问题
- P15-如果智能指针时某对象的数据成员,模版参数T又是imcomplete类型,那么该对象的析构函数需显示定义,不能用默认的
- 一个POD类型需要满足什么条件?