目录
- 1.术语
- 2.买票案例
- 3.互斥锁的本质
- 4.可重入与线程安全
- 5.死锁
- 6.总结
前言
进程间通信告诉我们,两个进程要想进行通信,就必须先看到一份临界资源。而对于临界资源的操作,管道是自带同步与互斥机制的。
假若我们对于一份临界资源不加某种限制的话,两个线程同时对其进行操作时,就可能会发生数据不一致的问题。
来举个例子:
今有一全局变量tickets,作为临界资源;有2个线程对于tickets进行操作:tickets++,假设tickets初始为0
thread1 执行tickets++时,会发生以下的事件:
- tickets从内存拷贝到cpu寄存器
- cpu++ tickets
- cpu寄存器写回内存
但是我们知道,线程每次将数据放入cpu寄存器并进行运算的时候,都会有一个时间片,如果时间片到了,那么寄存器会保存上下文,该线程进入等待队列,等待下一次的载入寄存器。
当我们的thread1完成了++之后,此时时间片恰好到了,寄存器里的值(1)没有写回到内存,而此时,thread2来了,thread2看到的内存中的值并不是++后的(1)而是++前的(0),所以,thread2从0加到1,并把1写回到内存。 此时thread2时间片到了;thread1来了,thread1恢复上下文,并把1写回到内存。
最终,thread1和thread2虽然各++了一次,但是内存中的tickets值却是1
归根结底,造成这一切的罪魁祸首是thread1 执行cnt++还没结束,就因为时间片到了被切出去了,然后thread2对于临界资源cnt进行了修改
线程切换的时机:内核态返回用户态(比如进行系统调用就会发生内核态用户态的切换)
我们必须引入互斥量,对于临界资源的操作进行一定的限制
1.术语
- 临界资源:多线程执行流共享的资源就叫做临界资源
- 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
- 原子性:不会被任何调度机制打断的操作,该操作只有两态:
互斥量mutex
- 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
- 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
- 多个线程并发的操作共享变量,会带来一些问题。
2.买票案例
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#define NUM 5
using namespace std;
int tickets = 1000;
void* routine(void* args) {
while(true) {
if(tickets > 0) {
usleep(1000);
printf("thread NO. 0x%x get a ticket, %d left\n", pthread_self(), tickets--);
usleep(1000);
}
else {
printf("0x%x qiut... ticket: %d\n", pthread_self(), tickets);
break;
}
}
return nullptr;
}
int main()
{
pthread_t tids[NUM];
for(int i = 0; i < NUM; i++) {
pthread_create(tids+i, nullptr, routine, nullptr);
}
for(int i = 0; i < NUM; i++) {
pthread_join(tids[i], nullptr);
}
return 0;
}
五个线程不加锁同时抢票,就会发生同时访问临界资源造成操作不一致的问题,票会被抢到负数
所以说,我们要让抢票这一过程变成原子的
做到原子需要满足:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区
我们直接加锁🔒!
接口: 创建🔒
pthread_mutex_t lock;
初始化🔒
pthread_mutex_init(&lock, nullptr);
加🔒
pthread_mutex_lock(&lock);
解🔒
pthread_mutex_unlock(&lock);
销毁🔒
pthread_mutex_destroy(&lock);
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#define NUM 5
using namespace std;
int tickets = 1000;
pthread_mutex_t lock;
struct arg{
int x;
};
void* routine(void* args) {
while(true) {
pthread_mutex_lock(&lock);
if(tickets > 0) {
usleep(1000);
printf("thread NO. %d 0x%x get a ticket, %d left\n", ((arg*)args)->x, pthread_self(), tickets--);
usleep(1000);
pthread_mutex_unlock(&lock);
}
else {
printf("0x%x qiut... ticket: %d\n", pthread_self(), tickets);
pthread_mutex_unlock(&lock);
break;
}
}
return nullptr;
}
int main()
{
pthread_mutex_init(&lock, nullptr);
arg* arg1 = new arg;
arg1->x = 1;
pthread_t tids[NUM];
for(int i = 0; i < NUM; i++) {
pthread_create(tids+i, nullptr, routine, arg1);
}
for(int i = 0; i < NUM; i++) {
pthread_join(tids[i], nullptr);
}
pthread_mutex_destroy(&lock);
return 0;
}
加锁带来的是我们对于临界资源的操作变成原子的:
3.互斥锁的本质
我们有没有想过,锁既然能被所有线程看到,那么是不是就是说锁本身就是一个临界资源,那么既然如此,也就是说,申请锁/释放锁的操作本身就是原子的。那这是如何实现的呢?
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。lock和unlock的汇编伪代码:
%al是一个寄存器的值,也就是上下文数据,是线程私有的 mutex是一个内存中的一个存储空间,空间里的值是1 exchange是一条汇编指令,保证加锁的原子性
整个过程中,值为1的mutex只有一份,拿到1的线程才拿到了锁🔒 一条exchange汇编就能完成内存和寄存器的数据的交换 mutex_lock是原子的
我们来看一下如果申请锁的时候分别在1、2、3步线程切出去会怎么样:
- 把0赋给al寄存器:这步切出去没什么影响,寄存器一开始保存的就是随机值
- 交换内存单元中锁(值为1)与寄存器(值为0)的值:由于该步一条exchange汇编指令就能完成,交换完之后,代表锁的1就到了寄存器里,锁里装了0。假如切出去了,其他线程也到了这一步想来申请锁,执行了exchange指令,没关系,你执行好了,反正1已经被上一个线程换走了,你换到的还是0,所以if判断不成立,挂起等待
- 在if判断的时候切出去,更没什么影响了,由于1只能被一个线程拿到,所以只有一个线程能执行if成功后的语句。其他线程此时只能因为if判断失败等着被别人解锁后唤醒,唤醒后竞争锁。
4.可重入与线程安全
- 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
- 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
常见不可重入的情况
调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构 可重入函数体内使用了静态的数据结构
可重入与线程安全联系
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的
可重入与线程安全区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的
极端一点,如果捕捉了进程的信号,信号处理函数改为申请锁,那么你发送信号时若锁还未释放,信号处理函数又申请了锁,就会死锁
5.死锁
死锁定义
- 死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态
死锁的四个必要条件
- 互斥:一个资源每次只能被一个执行流使用
- 请求与保持:一个执行流申请对方资源但对方不给资源
- 不剥夺:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
- 循环等待:若干执行流之间形成一种头尾相接的循环等待资源的关系
避免死锁
- 破坏死锁的四个必要条件
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配
银行家算法
当线程向系统申请资源时,系统必须判断如果分配了资源,会不会导致资源不安全的状态;会,就不分配;否则就分配
检查状态是否安全的方法是看是否有足够的资源满足一个距最大需求最近的进程 如果可以,则认为这些资源是可以收回的,然后检查下一个距最大需求最近的进程,如此反复下去。如果所有资源最终都被收回,则该状态是安全的,最初的申请可以满足
6.总结
对于临界区进行保护,所有的执行线程都必须遵守这个规则
lock->访问临界区->unlock
加锁的前提是所有的线程必须先看到同一把锁,锁本身就是临界资源->锁本身要保证自身安全
申请锁的过程,不能有中间状态,也就是两态,lock->原子性
一次保证只有一个线程进入临界区访问临界资源,就叫做互斥!
|