工具
脚本语言
- Linux shell
POSIX 多进程
- pthreads
POSIX 进程创建和销毁
- 进程通过
fork()
创建- 执行
fork()
的进程,称之为:父进程- 父进程可以通过
wait()
等待子进程执行完毕- 每一此调用
wait()
只能等待一个子线程- 循环
- 保存信号量
- 防止意外错过子进程通知
- 每一此调用
- 父进程可以通过
fork()
会返回两次- 父进程
- 成功创建子进程,则返回 子进程PID
- 失败则返回 负数(错误码)
- 子进程
- 返回 0
- 可以通过返回值来判断是哪一个进程
- 父进程
- 执行
- 使用
kill()
销毁 - 用
exit()
自行结束退出 - 父进程和子进程并不共享内存
- 操作的变量不是同一个
- 最细粒度的并行化需要共享内存
- 线程
- 共享内存式的并行化可要比 fork-join 式的并行化复杂的多
POSIX 线程的创建和撤销
- 创建
- 进程需要调用
pthread_create()
来创建一个线程- 参数:
- 指向
pthread_t
结构的指针- 用于存放将要创建线程的线程 ID 号
- 指向
pthread_attr_t
结构的指针- 可选
- 新线程将要调用的函数
- 顶层函数
- 传递给第3步函数的参数
- 指向
- 参数:
- 进程需要调用
- 结束
- 执行完成
- 顶层函数返回
- 调用
pthread_exit()
退出
- 执行完成
- 父进程
- 调用
pthread_join()
等待线程结果- 对 fork-join 中的 wait()的模仿
- 一直阻塞到 tid 变量指定的线程返回
- 调用
- 线程之间共享内存
- 变量
- 数据竞争
POSIX 锁
POSIX 规范允许程序员使用“POSIX 锁”来避免数据竞争
POSIX 锁包括:
- 操作类型为
pthread_mutex_t
的锁- 该锁的静态声明和初始化由
PTHREAD_MUTEX_INITIALIZER
完成pthread_mutex_init()
- 动态分配并初始化
pthread_mutex_lock()
- 获取一个指定的锁
pthread_mutex_unlock()
- 释放一个指定的锁
- 互相排斥的加锁/解锁
- 一次只能有一个线程在一个特定时刻“持有”一把特定的锁
- 该锁的静态声明和初始化由
POSIX 读写锁
pthread_rwlock_t
类型
-
也可以由
PTHREAD_RWLOCK_INITILIZER
静态初始化 -
或者由
pthread_rwlock_init()
原语动态初始化 -
pthread_rwlock_rdlock()
原语获取pthread_rwlock_t
的读锁 -
pthread_rwlock_wrlock()
获取它的写锁 -
pthread_rwlock_unlock()
原语负责释放锁 -
在任意时刻只能有一个线程持有给定
pthread_rwlock_t
的写锁- 但同时可以有多个线程持有给定
pthread_rwlock_t
的读锁- 至少在没有线程持有写锁时是如此
- 但同时可以有多个线程持有给定
-
读写锁是专门为大多数读的情况设计的
在这种情况中,读写锁可以提供比互斥锁大得多的扩展性,因为互斥锁从定义上已经限制了任意时刻只能有一个线程持有锁, 而读写锁允许任意多数目的读者线程同时持有读锁
-
读写锁优化
- 如果数据从不被改变,那么在访问它时,可以不持有任何锁。
- 如果数据修改频率一点也不频繁,您可以使用检查点,终止所有线程,修改数据,然后在检查点重新启动线程。
- 另一个方法是为每一个线程保留一个排它锁。这样,某个线程要读取数据时,获取它自己的锁,要写数据时,获取所有线程的锁
- 但是随着线程数量的增加,写者的开销变大了
- 读写锁在临界区最小时开销最大
- 最好能有其他手段来保护极其短小的临界区
原子操作
- 这些操作都返回参数的原值
__sync_fetch_and_add()
__sync_fetch_and_sub()
__sync_fetch_and_or()
__sync_fetch_and_and()
__sync_fetch_and_xor()
__sync_fetch_and_nand()
- 比较并交换
- 当变量的原值与指定的 oldval 相等时,这两个原语自动将 newval 写给指定变量
__sync_bool_compare_and_swap()
- bool 版本的原语在操作成功时返回 1,操作失败时返回 0
__sync_val_compare_and_swap()
- val 版本的原语在变量的原值等于指定的 oldval 时,返回变量的原值,表明操作执行成功
- 内存屏障
__sync_synchronize()
- 限制编译器和 CPU 对指令乱序执行的优化
Lixux内核中类似POSIX的操作
gcc 的__sync_族原语都是提供 memory-ordering 的语义,这激励许多程序 员实现自己的函数, 来满足许多不需要 memory ordering 语义的场景
选择
永远记住,进程内的通信和消息传递总是比共享内存的多线程执行要好
计数
原子自增的性能随着 CPU 和线程个数的增加而下降
统计计数器
- pre-thread 计数器,读取时互斥合并
- 考量写/读的侧重场景
近似上限计数器
- 资源平均分配给多个线程
- 资源隔离
- 数据结构是否统一
- 但在很多情况下,不能将计数器问题完全分治
- 部分分治
- 每个线程只需操纵自己的私有状态
- 计数还是可以在进程间传递
- 每线程计数器
- 全局计数器
- 关键:如何将仍在运行的线程的统计值加入全局的合计值,而不是等待这些线程退出
- 部分分治
- 读取时,加锁合计
精确上限计数器
如何同时保持两个数值的原子操作
- 两个变量合并成一个变量
- 32位:高16位表示计数;低16位表示上限
原子化操作也是需要跨CPU,也是需要额外开销代价的!
基于信号量的上限计数器
Signal-Theft
构建状态机
graph LR
idle((IDLE))
style idle fill:#1F0,stroke:#333,stroke-width:4px;
req((REQ))
style req fill:#f90,stroke:#333,stroke-width:4px;
ready((READY))
style ready fill:#1F0,stroke:#333,stroke-width:4px;
ack((ACK))
idle -- need flush --> req
idle -- no count --> ready
ready -- flushed --> idle
req -- lcounting --> ready
req -- counting --> ack
ack -- done counting --> ready
这样的结构,在越复杂的CPU架构中,可能会优于原子化操作;但如果硬件简单,原子化操作同步开销小,则不一定!
并行计数器
为了防止计数器总是在零附近变动 方法:
- 为计数器增加一个很大的偏差值(如:10亿)
- 确保计数器的值远离零,让计数器可以有效工作
当计数器需要精确控制,有时还需要阻止后续访问:
- 在更新计数器时使用读写锁的读锁
- 读取计数器时使用同一把读写锁的写锁
注意:
- C语言的
++
操作符不能在多线程代码中保证函数的可靠性 - 对单个变量的原子操作性能不好,可扩展性也差
分割,用线程还是进程做分割
- 多进程各自处理自己的数据是很好的方法
- 但是多线程共享并行处理的优势:
- 只对性能最关键的应用部分才必须分割
- 分割的部分通常是应用的一小部分
- 虽然与寄存器指针相比,缓存缺失是非常慢的,但比起进程间通信原语要快的多,比TCP/IP网络通信更是快的多
- 共享内存多处理器不算贵
- 只对性能最关键的应用部分才必须分割