pthread 多线程机制
前言
多线程模型和异步模型是有差别的,异步模型一般是语言层面提供的 async/await 之类的语言机制,它基于协程(一个可多次重入的函数)的概念。多线程模型不是语言层面的,而是操作系统提供的机制。多线程才能有效利用 cpu 多核,实现所谓的并行执行。
因此,不管语言是怎么封装多线程,它归根结底就是操作系统底层的机制。pthread 是 linux 系统的实现。
多线程下的程序困境
单线程下的程序是原子性,可以将一段程序理解为必然的结果,但是在多线程下却不一样。多线程对某个数据同时操作是,读写顺序是随机的(甚至在指令级也是非原子性的),这就让我们编写程序一直以来依赖的假设不成立,如果我们不确定顺序,怎么能书写正确的逻辑?这是不可能做到的。所以归根结底,多线程的编程方式:
- 分割任务,使任务之间没有重叠,最后等待各个部分完成
- 重叠部分,串行化。也就是实际上让多线程转化为单线程,从而有序化,可编程化
也就是说多线程编程并不神奇,也没有量子化,它的重点是在隔离分割,让任务和任务之间实际并没有什么干涉。而对于确实存在干涉的局部,进行单线程化,从而将多线程的不确定性消除。
也就是归根结底,是如何提高任务之间的并行性,让任务之间最大的隔离开来,如果发生干涉,必然会失去多线程,那么就算你能正确的书写同步代码,又有什么意义?(指无法提高并行度)所以要提高并行度,就要理解哪些任务是可以并行的。比如:
- 线程独立的变量:栈,线程局部变量,常量。反例就是:全局变量。比较暧昧的是函数参数,对象等。
- 事务角度宏观划分哪些任务可同时执行,哪些任务必须顺序执行。
背景知识
- 互斥算法:peterson 算法描述(不基于原子操作):
- 有 A 和 B 两人上厕所
- A 举旗 a,然后写厕所门标志 b ,当 B 没有举旗或标志为 a,进入厕所
- 同时,B 举旗 b,写标志 a(覆盖),当 A 没有举旗或标志为 b,进入厕所
- 否则等待
- 出厕所时把旗子放下
- 互斥算法满足:
- 互斥访问:永远只有一个人在厕所
- 空闲让进:当厕所空时,有想进的人总是能进
- 有限等待:不能无限阻塞其中一个人,当A出厕所后,如果B已经准备好,应该由B进入厕所(此时 A 无限步骤也最多只能写入标志 b,所以它无法阻止 B)
- 自旋锁(spin lock):基于原子操作
- xchg :原子指令,交换值
-
lock(){ while( xchg(&a, 1) ); }
:获得锁
-
unlock(){ xchg(&a, 0); }
: 释放锁
- CAS(比较然后写入)机制:
- 读取(并标记,如果被其他线程写入,该标志改变)
- 操作
- 写入(比较标记,失败重做1~3)
- 互斥锁(mutex lock):对自旋锁改进,在失败时休眠线程(依赖系统调用)
- 生成者-消费者模型(produce-consume):解决 90% 的线程同步问题
- 对生产者做限制:比如控制队列长度
- 消费者依赖生产者 : 1-2 实现了同步协调
- 条件变量(cond): 当线程任务条件没有达成时,进入休眠状态,由另外线程唤醒
- mutex_lock(&lk)
- while(n == 0) cond_wait(&cv, &lk) : 当不符合条件,释放锁,进入休眠
- 苏醒时自动获得锁,判断条件,直到条件满足时(n != 0),执行任务内容
- cond_signal(&cv) : 唤醒关联该条件变量的某个休眠中的线程
- cond_broadcast(&cv): 唤醒其余线程
- mutex_unlock(&lk)
- 信号量(PV元语):特殊的条件变量,因为条件逻辑固定所以不如条件变量灵活
- S = 1 : 类似互斥锁,> 1 类似条件变量
- P(s):S > 0 : s-- 并执行任务,否则休眠,即类似 s >0 的条件变量
- V(s): S >= 0 : s++ 否则唤醒睡眠线程
- 解决死锁
- 自主式:同时获取计算任务所需要的全部资源,而非部分
- 集中式:使用资源管理程序,如生产者-消费者模型
- 协程(可切换的函数):持有执行状态,且能暂停时退出(yield)进入时恢复(resume),从而实现类似线程的异步效果。协程本质是函数的扩展(类似闭包),所以并不真正具备多线程能力,它依赖调度器的具体实现来解决阻塞问题。
- async : 创建异步调用(执行状态机)
- yield : 暂停,并返回中间值
- resume : 恢复(一般交由专门的管理程序,即调度器)
- await : 等待另一个异步调用完成,然后 wake
- 有栈协程:由任务函数外创建通用状态机,提供专用栈来保存状态
- 无栈协程:由任务函数根据自身构造一个专用状态机。无须栈但通用性稍差
- epoll:IO 多路复用,解决 IO 读写的阻塞问题。
- FD:文件描述符
- select : 将判断一组文件描述符是否有数据的任务转接给内核态,该函数阻塞直到有数据
- poll: 改进监察的数据结构,更容易编程
- epoll:改进监察的数据结构,更高效率
- epoll_create: 创建描述表
- epoll_ctl: 设置表内容
- epoll_wait: 监察
- io_uring :异步 IO
基本用法
基础线程
- 开始线程:
pthread_create(pthread_t *线程id, const pthread_attr_t *线程属性, void *(*入口函数)(void *), void *入口函数参数)
- 退出线程:void pthread_exit(void* 返回值)
- 使用退出线程并不会结束子线程
- 连接线程:int pthread_join(pthread_t 线程id, void **返回值)
- 即等待该子线程结束
- 分离线程:int pthread_detach(pthread_t 线程id)
- 分离子线程,让其自生自灭
#include <stdio.h>
#include <pthread.h> // 1. 多线程库
void *hello(char* name){ // 2. 线程启动的函数
printf("hello %s!\n", name);
return "hello";
}
int main(void){
pthread_t thread; // 3. 线程 id
void *result = (char[100]){0}; // 5. 缓冲区
pthread_create(&thread, NULL, hello, "jim"); // 4. 创建
pthread_join(thread, &result); // 6. 主线程等待子线程结束(同步)
printf("result:%s \n", result); // 7. 查看返回值
printf("hello world!\n");
return 0;
}
互斥量
数据冲突成因:
- 读操作可以共享
- 多少个读(者)都没关系,因为数据不会改变
- 写操作应该唯一
- 写入会异步改变(另外一处)读取的结果,如导致多次读结果不一致
- 双写数据会变得乱七八糟
- 所以常规逻辑下,书写者(对一个对象)应该是唯一的独占的
- 同步代码是有严格顺序的,所以可以认为同一时间只有一个操作,不管是读取还是写入。当然理论上,谁也不能阻止程序员写出乱七八糟的读写逻辑,但可能性比较低,因为这种方式符合人类直觉。
- 异步代码是无序的,所以如果对同一个对象有一个写入者 + 另一个读者以上,可以认为他们总是同时进行,这样的数据结果是不可预料的,不确定的,这种逻辑几乎可以确定为 bug,除非客户需要乱码。
- 因此,对共享数据(处于异步操作中的),必须让其同步化,序列化,保证操作逻辑是明确的。
- 这里面的数据并不一定是单指数据,也可能广义上理解为一系列逻辑上的有序操作,应该让其同步化。简而言之,就是让某一段代码,由异步变同步。
- 这其实有难度的,你需要分析:
- 确定是否共享的数据
- 本地数据是没问题的,因为不同线程使用不同的堆栈,要针对从外部获取的数据
- 调用的子函数是否支持异步,是经过异步优化的没,否则它可能本身就依赖一些数据读写,产生逻辑错误,如果不支持那么就要整体同步化
- 可能对子函数的特点要有所了解,无状态的函数是可以轻易异步化的
- 闭包问题也不大
- 依赖外部状态的函数比较危险
- 对象、数据结构(类上)
- 确定业务是否存在逻辑次序
- 同步技术本身的复杂性
同步化:
- join: 上面的 join 操作就有这个效果,但颗粒比较大,控制整个线程,得等它结束,往往我们需要细化的,两个线程交替运行,中间一段保持同步。
- mutex: 互斥量,某段代码加锁,保持一次只有一个线程通过
- pthread_mutex_init(pthread_mutex_t *互斥锁, 选项)
- pthread_mutex_lock(锁)
- pthread_mutex_unlock(锁)
- pthread_mutex_destroy(锁)
- cond:条件变量,暂停线程并等待别的线程唤醒,配合锁使用。对于需要某种条件才能执行的代码(这种条件会被另一个线程改变),可以提高执行性能,因为不需要轮询条件是否满足
- pthread_cond_signal(pthread_cond_t *条件变量)
- pthread_cond_wait(条件变量,锁)
- 解决死锁:当持有 a 又申请 b,而持有 b 的线程又在申请 a 时,就发生死锁
- 只用一个锁取代 ab,当一个业务需要 ab 资源,那么它就应该一次性同时申请 ab,而不是分开成 ab 两个锁,这是上策
- 对锁排序,比如资源申请顺序都保持 a 永远在 b 前
- 使用条件变量,主动在不符合条件的情况下释放锁
// 对单个变量的锁
// 线程 ab 对 A 变量进行读写,那么它实际 r/w 同时发生
// a 读出来,做计算,然后写入。
// 结果 b 早就在期间写入一个新值,所以就丢失了 b 写入的数据
// 解决问题的关键是 A 只能被一个线程拥有,这叫变量的同步
// a 获得 A 的所有权,然后计算,然后写入。
// b 这时候如果想读,不好意思,请等待
// 这就是对单个变量的锁的应用逻辑,也就是 A 只能被某个线程独占
// 程序角度就是:锁-读-处理-写-解锁
// 注意要包裹整体逻辑,而不能 锁-读-解锁-锁-写-解锁 这样设计
// 解锁之后,b 就可以写入,a 早前读取的数据就失去意义了
// 对多个变量的锁
// 对多个变量运用单个锁是安全的
// 程序逻辑: 锁-读A-写A-读B-写B-解锁
// 就是同步范围有点大,导致 AB 的处理都只能在单线程
// 对单种顺序的锁
// 当我们希望一段逻辑有序(同步)时,比如打印 a 再打印 b
// 但多线程下 ab 都可能发生,如 aababbba
// 可以将其理解为:对多个变量使用单个锁
// 程序逻辑: 锁-a-b-解锁
// 对多种顺序的锁
// 要么 ab 要么 ba
// ab 或 ba 可以理解为多个变量
// 对多个变量使用单个锁,就能产生互斥(同步)的效果
// 程序逻辑: 锁-ab-解锁-锁-ba-解锁
// 两个锁(避免死锁的方法)
// 任意对象单个锁无疑是最强的同步,但是灵活性(并行性)受到约束
// 这就要求我们对对象进行分组来加不同的锁
// 比如每个变量分别配不同的锁,这就杜绝了数据竞争的问题
线程局部数据
- 线程独有数据是安全的,又是函数外部范围可共享的数据,只属于单个线程所有。
int pthread_key_create(pthread_key_t *线程私有变量, void(* 清理函数)(void *))
- int pthread_setspecific(线程私有变量, const void* 设置值)
- int pthread_key_delete(线程私有变量)
参考
- pthread介绍:https://www.cnblogs.com/yuanqiangfei/p/15268698.html
- c语言pthread库_线程库:https://ispacesoft.com/57898.html
- 条件变量为什么要和互斥锁一起使用:https://blog.csdn.net/yizhiniu_xuyw/article/details/109635912
- 南京大学2022操作系统(蒋炎岩):https://www.bilibili.com/video/BV13u411X72Q
- 【并发】IO多路复用select/poll/epoll介绍:https://www.bilibili.com/video/BV1qJ411w7du
- 操作系统层面聊聊BIO,NIO和AIO (epoll):https://www.cnblogs.com/twoheads/p/10712094.html