Linux线程互斥与同步(下)(30)
文章目录
- 前言
- 一、线程安全 && 重入
- 概念
- 二、常见锁概念
- 死锁问题
- 三、线程同步
- 同步相关概念
- 同步相关操作
- 简单同步Demo
- 总结
前言
Hello,现在到同步啦!
一、线程安全 && 重入
概念
线程安全:多线程并发访问同一段代码时,不会出现不同的结果,此时就是线程安全的;但如果在没有加锁保护的情况下访问全局变量或静态变量,导致出现不同的结果,此时线程就是不安全的
重入:同一个函数被多个线程(执行流)调用,当前一个执行流还没有执行完函数时,其他执行流可以进入该函数,这种行为称之为 重入;在发生重入时,函数运行结果不会出现问题,称该函数为 可重入函数,否则称为 不可重入函数
常见线程不安全的情况
- 不保护共享变量,比如全局变量和静态变量
- 函数的状态随着被调用,而导致状态发生变化
- 返回指向静态变量指针的函数
- 调用 线程不安全函数 的函数
常见线程安全的情况
- 每个线程对全局变量或静态变量只有读取权限,而没有写入权限,一般来说都是线程安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致执行结果存在二义性
常见不可重入的情况
- 调用了 malloc / free 函数,因为这些都是 C语言 提供的接口,通过全局链表进行管理
- 调用了标准 I/O 库函数,其中很多实现都是以 不可重入 的方式来使用全局数据结构
- 可重入函数体内使用了静态的数据结构
常见可重入的情况
- 不使用全局变量或静态变量
- 不使用 malloc 或 new 开辟空间
- 不调用不可重入函数
- 不返回全局或静态数据,所有的数据都由函数调用者提供
- 使用本地数据或者通过制作全局数据的本地拷贝来保护全局数据
重入与线程安全的联系
- 如果函数是可重入的,那么函数就是线程安全的;不可重入的函数有可能引发线程安全问题
- 如果一个函数中使用了全局数据,那么这个函数既不是线程安全的,也不是可重入的
重入与线程安全的区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,反过来可重入函数一定是线程安全的
- 如果对于临界资源的访问加上锁,则这个函数是线程安全的;但如果这个重入函数中没有被释放会引发死锁,因此是不可被重入的
一句话总结:是否可重入只是函数的一种特征,没有好坏之分,但线程不安全是需要规避的
二、常见锁概念
死锁问题
死锁:指在一组进程中的各个线程均占有不会释放的资源,但因相互申请被其他线程所占用不会释放的资源处于一种永久等待状态
只有一把锁会造成死锁吗?
答案是 会的,如果线程 thread_A 申请锁资源,访问完临界资源后没有释放,会导致 线程 thread_B 无法申请到锁资源,同时线程 thread_A 自己也申请不到锁资源了,不就是 死锁 吗
死锁 产生的四个必要条件:
- 互斥:一个资源每次只能被一个执行流使用
- 请求与保持:一个执行流因请求资源而阻塞时,对已获得的资源保持不释放
- 环路等待:若干执行流之间形成一种首尾相接的循环等待资源关系
- 不剥夺条件:不能强行剥夺其他线程的资源
具体有哪些方法呢?
方法一:不加锁
不加锁的本质是不保证 互斥,即破坏条件1
方法二:尝试主动释放锁
比如进入 临界区 访问 临界资源,需要两把锁,thread_A 和 thread_B 各自持有一把锁,并且都在尝试申请第二把锁,但如果此时 thread_A 放弃申请,主动把锁释放,这样就能打破 死锁 的局面,主打的就是一个牺牲自己
可以借助 pthread_mutex_trylock 函数实现这种方案
#include <pthread.h>int pthread_mutex_trylock(pthread_mutex_t *mutex);
这个函数就是尝试申请锁,如果长时间申请不到锁,就会把自己当前持有的锁释放,然后放弃加锁,给其他想要加锁的线程一个机会
方法三:按照顺序申请锁
按照顺序申请锁 -> 按照顺序释放锁 -> 就不会出现环路等待的情况
方法四:控制线程统一释放锁
首先要明白:锁不一定要由申请锁的线程释放,其他线程也可以释放锁
这是由释放锁的机制决定的,直接向 mutex 赋值而非交换,意味着其他线程也能解锁
比如在下面这个程序中,主线程就释放了次线程申请的锁,打破了 死锁 的局面
#include <iostream>
#include <pthread.h>
#include <unistd.h>using namespace std;// 全局互斥锁,无需手动初始化和销毁
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;void* threadRoutine(void* args)
{cout << "我是次线程,我开始运行了" << endl;// 申请锁pthread_mutex_lock(&mtx);cout << "我是次线程,我申请到了一把锁" << endl;// 在不释放锁的情况下,再次申请锁,陷入 死锁 状态pthread_mutex_lock(&mtx);cout << "我是次线程,我又再次申请到了一把锁" << endl;pthread_mutex_unlock(&mtx);return nullptr;
}int main()
{pthread_t t;pthread_create(&t, nullptr, threadRoutine, nullptr);// 等待次线程先跑sleep(3);// 主线程帮忙释放锁pthread_mutex_unlock(&mtx);cout << "我是主线程,我已经帮次线程释放了一把锁" << endl;// 等待次线程后续动作sleep(3);pthread_join(t, nullptr);cout << "线程等待成功" << endl;return 0;
}
最终程序运行后,可以看到 主线程 成功帮次线程释放了锁资源
因此,我们可以设计一个 控制线程,专门掌管所有的锁资源,如果识别到发生了 死锁 问题,就释放所有的锁,让线程重新竞争
注意: 规定只有申请锁的人才能释放锁,规定可以不遵守,但最好遵守
死锁 一般比较少见,因为这是因代码编写失误而引发的问题
常见的避免 死锁 问题的算法:死锁检测算法、银行家算法
三、线程同步
同步相关概念
同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免 饥饿问题
至于该如何正确理解 饥饿问题,需要再次请张三出场
话说张三在早上 6:00 抢到了自习室的钥匙,并开开心心的进入了自习室自习
此时自习室外人声鼎沸,显然有很多人都在等待张三交出钥匙,但张三不急,慢悠悠的自习到了中午 12:00,此时张三有些饿了,想出去吃个饭,吃饭就意味着张三需要把钥匙归还(这是规定)
张三刚把钥匙放到门上,扭头就发现了大批的同学正在等待钥匙,张三心想:要是我就这样把钥匙归还了,那等我吃完饭回来岂不是也需要等待
于是法外狂徒张三决定放弃吃饭,强忍着饥饿再次拿起钥匙进入了自习室自习;刚进入自习室没几分钟,肚子就饿的咕咕叫,于是张三就又想出去吃饭,刚出门归还了钥匙,扭头看见大批同学就感觉很亏,一咬牙就又拿起钥匙进入了自习室,就这样张三反复横跳,直到下午 6:00 都还没吃上午饭,不仅自己没吃上午饭、没好好自习,还导致其他同学无法自习!
张三错了吗?张三没错,十分符合自习室的规定,只是 不合理
因为张三这种不合理的行为,导致 自习室 资源被浪费了,在外等待的同学也失去了自习,陷入 饥饿状态,活生生被张三 “饿惨了”
为此校方更新了 自习室 的规则:
- 所有自习完的同学在归还钥匙之后,不能立即再次申请
- 在外面等待钥匙的同学必须排队,遵守规则
规则更新之后,就不会出现这种 饥饿问题 了,所以解决 饥饿问题 的关键是:在安全的规则下,使多线程访问资源具有一定的顺序性
即通过 线程同步 解决 饥饿问题
原生线程库 中提供了 条件变量 这种方式来实现 线程同步
逻辑链:通过条件变量 -> 实现线程同步 -> 解决饥饿问题
条件变量:当一个线程互斥的访问某个变量时,它可能发现在其他线程改变状态之前,什么也做不了
比如当一个线程访问队列时,发现队列为空,它只能等待,直到其他线程往队列中添加数据,此时就可以考虑使用 条件变量
条件变量的本质就是 衡量访问资源的状态
竞态条件:因为时序问题而导致程序出现异常
可以把 条件变量 看作一个结构体,其中包含一个 队列 结构,用来存储正在排队等候的线程信息,当条件满足时,就会取 队头 线程进行操作,操作完成后重新进入 队尾
队列是保证顺序性的重要工具
同步相关操作
条件变量创建与销毁
作为出自 原生线程库 的 条件变量,使用接口与 互斥锁 风格差不多,比如 条件变量 的类型为 pthread_cond_t,同样在创建后需要初始化
#include <pthread.h>pthread_cond_t cond; // 定义一个条件变量
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);
参数1 pthread_cond_t* 表示想要初始化的条件变量
参数2 const pthread_condattr_t* 表示初始化时的相关属性,设置为 nullptr 表示使用默认属性
返回值:成功返回 0,失败返回 error number
7
条件变量 在使用结束后需要销毁
#include <pthread.h>int pthread_cond_destroy(pthread_cond_t* cond);
pthread_cond_t* 表示想要销毁的条件变量
返回值:成功返回 0,失败返回 error number
注:同互斥锁一样,条件变量支持静态分配,即在创建全局条件变量时,定义为PTHREAD_COND_INITIALIZER,表示自动初始化、自动销毁
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
注意: 这种定义方式只支持全局条件变量
条件等待
原生线程库 中提供了 pthread_cond_wait 函数用于等待
#include <pthread.h>int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
参数1 pthread_cond_t* 想要加入等待的条件变量
参数2 pthread_mutex_t* 互斥锁,用于辅助条件变量
返回值:成功返回 0,失败返回 error number
参数2值得详细说一说,首先要明白 条件变量是需要配合互斥锁使用的,需要在获取 [锁资源] 之后,在通过条件变量判断条件是否满足
传递互斥锁的理由:
- 条件变量也是临界资源,需要保护
- 当条件不满足时(没有被唤醒),当前持有锁的线程就会被挂起,其他线程还在等待锁资源呢,为了避免死锁问题,条件变量需要具备自动释放锁的能力
当某个线程被唤醒时,条件变量释放锁,该线程会获取锁资源,并进入 条件等待 状态
唤醒线程
条件变量 中的线程是需要被唤醒的,否则它也不知道何时对 队头线程 进行判断,可以使用 pthread_cond_signal 函数进行唤醒
#include <pthread.h>int pthread_cond_signal(pthread_cond_t *cond);
pthread_cond_t 表示想要从哪个条件变量中唤醒线程
返回值:成功返回 0,失败返回 error number
注意: 使用 pthread_cond_signal 一次只会唤醒一个线程,即队头线程
如果想唤醒全部线程,可以使用 pthread_cond_broadcast
#include <pthread.h>int pthread_cond_broadcast(pthread_cond_t *cond);
参数和返回值含义与前者一致,broadcast 就是广播的意思,也就是挨个通知该 条件变量 中的所有线程访问 临界资源
简单同步Demo
接下来简单使用一下 线程同步 相关接口
目标:创建 5 个次线程,等待条件满足,主线程负责唤醒
这里演示 单个唤醒 与 广播 两种方式,先来看看 单个唤醒 相关代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>using namespace std;// 互斥锁和条件变量都定义为自动初始化和释放
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;const int num = 5; // 创建五个线程void* Active(void* args)
{const char* name = static_cast<const char*>(args);while(true){// 加锁pthread_mutex_lock(&mtx);// 等待条件满足pthread_cond_wait(&cond, &mtx);cout << "\t线程 " << name << " 正在运行" << endl;// 解锁pthread_mutex_unlock(&mtx);}delete[] name;return nullptr;
}int main()
{pthread_t pt[num];for(int i = 0; i < num; i++){char* name = new char[32];snprintf(name, 32, "thread-%d", i);pthread_create(pt + i, nullptr, Active, name);}// 等待所有次线程就位sleep(3);// 主线程唤醒次线程while(true){cout << "Main thread wake up Other thread!" << endl;pthread_cond_signal(&cond); // 单个唤醒sleep(1);}for(int i = 0; i < num; i++)pthread_join(pt[i], nullptr);return 0;
}
可以看到,在 单个唤醒 模式下,一次只会有一个线程苏醒,并且得益于 条件变量,线程苏醒的顺序都是一样的
可以将唤醒方式换成 广播
// ......
pthread_cond_broadcast(&cond); // 广播
// ......
互斥锁 + 条件变量 可以实现 生产者消费者模型,至于具体如何实现呢?
总结
我们下一篇将继续揭晓!