p716~p757, 分两次, 716~733, 733~757.
摘要
本章后半部分主要讨论了线程并发下的安全问题. 如何保证多线程下能程序能"正确"的执行.
线程内存模型
一组并发线程运行在一个进程的上下文中.
独享的: 线程ID, 栈, 栈指针, 程序计数器, 条件码, 通用目的寄存器值.
共享的: 用户虚拟地址空间(包括 代码, 读/写数据, 堆以及所有的共享库代码和数据区域), 打开文件的集合.
变量映射到内存
- 全局变量, 在虚拟内存的读/写区域, 所有线程共享一个实例.
- 本地自动变量, 定义在函数内部但没有static属性的变量. 运行时, 每个线程的栈都包含它自己的所有本地自动变量的实例.
- 本地静态变量, 定义在函数内部有static属性的变量, 全局共享一个实例.
信号量
大神Edsger Dijkstra提出了"信号量"(semaphore)的特殊变量来解决并发的问题.
信号量s是具有非负整数值的全局变量, 只能由两种特殊的操作来处理, 分别为P操作和V操作.
- P(s): if(s > 0) s = s -1, return s; if(s = 0) wait;
- V(s); s = s +1; 唤醒等待s的众多线程中的一个去执行P操作.
#include <semaphore.h>
int sem_init(sem_t *sem, 0, unsigned int value);
int sem_wait(sem_t *s); /* P(s) */
int sem_post(sem_t *s); /* V(s) */
使用信号量的示例代码
volatile long cnt = 0;
sem_t mutex;
sem_init(&mutex, 0, 1);
for (int i=0; i< niters; i++){
sem_wait(&mutext);
cnt++;
sem_post(&mutext);
}
使用多线程完成任务
使用多线程执行一个任务时, 注意将任务拆分为独立的子任务, 让各个线程独立执行, 然后将各自的结果合并为一个最终结果. 尽量少用P, V操作, 因为P, V操作开销较大, 导致程序运行缓慢.
线程安全
4种线程不安全类型:
- 不保护共享变量的函数. 在对共享变量操作前后没有P, V操作导致, 变量执行不对.
解决办法: 加上P, V可以很容易的解决这个问题, 但会导致程序速度减慢. - 保持跨越多个调用的状态的函数.
解决办法: 重写该函数, 不使用static数据, 依靠调用者传递参数. - 返回指向静态变量的指针的函数.
解决办法1: 重写函数, 使得调用者传递存放结果的变量的地址.
解决办法2: 使用加锁-复制(lock-and-copy)技术, 加锁, 复制到私有地址, 解锁. - 调用线程不安全函数的函数.
解决办法: 如果调用的函数g是第2种情况, 则调用方f也是不安全的, 除了修改g没有别的办法. 如果是1, 3情况, 则可以通过加锁的方式解决问题.
可重入性
线程安全函数中有一种函数, 叫做可重入函数(reentrant function). 当被多个线程调用时, 不会引用任何共享数据. 这个函数内部没有引用共享数据, 所以不需要加锁, 因而效率更高.
让我联想到"纯函数"的概念, 纯函数有2个条件
- 不依赖外部数据, 只依赖传入的参数
- 不产生可观察的副作用(包括I/O, 修改传入参数, 读取/修改共享变量等)
这样看, 纯函数肯定是可重入的. 类似的概念还有"接口的幂等性", "无状态服务".
日常开发当中, 应该尽量多写一些纯函数, 这样修改和重构才会更加安全.
有些编程新手写的代码中, 混入了很多共享变量, 多次函数中都对共享变量进行了修改, 后来运行结果莫名其妙, 还找了半天找不到问题的原因. 看代码的人也看不懂原作者的意图, 真是害人不浅.