🐱作者:一只大喵咪1201
🐱专栏:《Linux学习》
🔥格言:你只管努力,剩下的交给时间!
互斥 | 线程安全
- 🥩线程不安全
- 🍚线程不安全的原因
- 🥩线程互斥
- 🍚加锁
- 🍚锁的本质
- 🥩锁的封装
- 🥩可重入VS线程安全
- 🥩死锁
- 🍚死锁的必要条件
- 🥩总结
🥩线程不安全
从一个例子入手:
- 全局变量tickets表示票的数量。
- 主线程中创建了4个新线程,代表4个用户去抢票。
- 每个新线程在抢到票后将票数减一,并且打印出票的数量。
- 当票被抢完以后,线程退出,不再进行抢票。
运行以后,发现出现了负数票,这不合理,票抢完就应该停止了,包括我们的代码逻辑都是这样写的,但是此时就出现了这种情况。
- 上面现象的原因是发生了线程不安全问题。
如何产生线程不安全现象:
上面现象故意弄出来的,涉及到了线程调度,利用了线程调度的特性造出了一个这样的现象。要想出现上面的现象,就需要:
- 尽可能让多个线程交叉执行。
- 多个线程交叉执行的本质:就是让调度器尽可能的频繁发生线程调度与切换。
虽然看起来是多个线程在同时运行,但这是由于CPU运行速度太快导致的,实际上,CPU是一个线程一个线程执行的。现在就是要让CPU频繁调度,不停的切换线程,一个线程还没有执行完就再执行下一个,每个线程都执行一点,这样交叉执行。
当一个线程进行延时的时候,CPU并不会等它,而是会将它放在等待队列里,然后去执行另一个线程,等延时线程醒来以后才会接着执行。
- 线程在时间片到来,更高优先级线程到来,线程等待的时候会发生线程切换。
- 线程是在从内核态转换成用户态的时候检测是否达到线程切换的条件的。
- 线程检测是否切换是以内核态的身份去检测的,执行的是3~4G内核空间中的代码,本质上是操作系统在检测。
产生线程不安全现象的原因:
假设tickets已经只剩一张了,即全局变量tickets = 1。
主线程创建好4个新线程以后,4个新线程便开始执行了,在执行到延时的时候,新线程就会被放在等待队列里。
看CPU及内核:
if判断的本质逻辑:
- 从内存中读取数据到CPU寄存器。
- 进行判断。
- 在线程user1执行到if判断时,CPU从内存中将tickets变量中的数据1拿到了CPU的寄存器ebx中。
- CPU进行判断后,发现符合大于0的条件。
当线程user1符合条件继续向下执行延时代码时,CPU将线程user1切走了,换上了user2。
- 在线程user1被切走的时候,它的上下文数据也会被切走。
- 所以ebx寄存器中的1也会跟着user1的PCB被切走。
user2被调度时仍然重复user1的过程,执行延时被切走,再换上user3,以此类推,直到user4被切走。
- 四个线程都拿到了tickets=1,所以符合条件,都能向下执行。
- 当user4被挂起后,user1差不多就该醒来了。
user1唤醒以后接着被切走的位置继续执行:
执行tickets - - 的本质:
- 从内存中读取数据到CPU的寄存器
- 更改数据
- 写回数据到内存中
虽然C/C++代码只有一条语句,但是汇编后至少有3条语句。
user1执行tickets–以后,抢票成功了,并且将抢票后的tickets=0写回到了内存中。
此时user2醒来了,同样接着它被切走的位置继续执行,此时user2回来后认为tickets=1,所以就向下执行了:
当执行tickets减减时,仍然需要三步:
- 从内存中读取tickets=0到CPU寄存器ebx中。
- 修改值,从0变成-1。
- 将-1写回内存中。
当user2执行完后,user3和user4醒来同样继续向下执行,重复上面的过程,仍然对tickets减一,所以导致结果不合理。
🍚线程不安全的原因
只存在两个线程,对全局变量tickets仅作减减操作:
- 线程A先被CPU调度,进行减减操作。
- 从内存中将tickets=1000取到寄存器ebx中。
- 进行减减操作,tickets变成了999。
- 在执行第三步写回数据之前,线程A被切走了。
线程A切走的同时,它的上下文,也就是tickets=999也被切走了。
- 线程B此时被调度,线程A在等待队列。
- 线程B先从内存中读取tickets = 1000到寄存器ebx中。
- 进行减减操作。
- 将减减后的值写回到内存中。
- 线程B将减减操作完整的执行了很多遍,直到tickets=200时才被切下去。
线程B被切走以后,线程A又接着被调度。
- 线程A接着被切走的位置开始执行,也就是执行减减的第三步操作—写回。
- 线程A被调度后,先恢复上下文,将被切走时的tickets=999恢复到了ebx寄存器中。
- 然后执行第三步,将tickets=999写回到了内存中。
线程B辛辛苦苦将tickets从1000减到了200,线程A重新被调度后,直接将tickets又从200写回到了999。
上面这种现象被叫做数据不一致问题。
- 导致数据不一致问题的原因:共享资源没有被保护,多线程对该资源进行了交叉访问。
而解决数据不一致问题的办法就是对共享资源加锁。
🥩线程互斥
基本概念:
- 临界资源:多个执行流进行安全访问的共享资源。
上面现象中的tickets很显然就不是临界资源,因为多线程对它的访问并不安全,存在数据不一致问题。
- 临界区:多个执行流中,访问临界资源的代码。
假设上面例子中的是临界资源,那么每个线程都存在一部分临界区,就是对tickets进行判断,打印,减减部分的代码。多个线程中的这部分代码属于临界区。
- 互斥:让多个线程串行访问共享资源,任何时候只有一个执行流在访问共享资源。
上面例子中如果多个线程能够串行访问tickets,而不是交叉访问,也不会产生数据不一致问题。而让共享资源变成临界资源就是为了实现互斥,也就是让多个线程串行访问原本的共享资源。
- 原子性:对一个资源进行访问的时候,要么不做,要么就做完。
在C/C++中的减减和加加操作,看似是一句代码,但是对应着三条汇编指令,上面例子中,线程A在执行第三步之前被切走了,导致减减操作没有完成,这种行为就不具有原子性,因为对共享资源的操作没有做完。
- 对一个资源进行操作,如果只用一条汇编就能完成,那么就具有原子性,反正就不是原子的。
这是当前的一种理解,这种理解只能算原子性中的一个子集,是为了方便表述。
🍚加锁
要想解决多线程的数据不一致问题,就需要做到以下几点:
- 代码必须要有互斥行为,当一个线程进入临界区执行代码时,不允许其他线程进入该临界区。
- 如果有多个线程同时请求执行临界区代码,并且临界区没有线程在执行代码,那么只允许一个线程进入该临界区。
- 如果线程不在临界区中执行代码,那么该线程不能阻止其他线程进入临界区。
要做到上面三点,只需要一把锁就可以,持有锁的线程才能进入临界区中执行代码,并且其他线程无法进入该临界区。
- 锁:就是互斥量,也叫互斥锁。
加锁可以让共享资源临界资源化,从而保护共享资源的安全,让多个线程串行访问共享资源。
和锁相关的系统调用:
pthread_mutex_t lock;
和创建线程一样,锁也需要创建,POSIX提供了锁的变量类型,如上面代码所示,其中mutext是互斥量的意思。
初始化锁:
int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);
- 形参1:创建的互斥锁指针
- 形参2:直到锁的属性,一般情况下设为nullptr
- 返回值:初始化成功返回0,失败返回错误码
- 作用:将创建的锁初始化。
销毁锁:
int pthread_mutex_destroy(pthread_mutex_t *mutex);
- 形参:创建的互斥锁指针
- 返回值:销毁成功返回0,失败返回错误码
- 作用:当锁使用完后,必须进行销毁
全局或者静态锁初始化:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
如果锁是全局的或者被static修饰的静态锁,只需要使用上面语句初始化锁即可。
加锁:
int pthread_mutex_lock(pthread_mutex_t *mutex);
- 形参:创建的互斥锁指针
- 返回值:加锁成功返回0,失败返回错误码
- 作用:给临界区加锁,让多线程串行访问临界资源
解锁:
int pthread_mutex_unlock(pthread_mutex_t *mutex);
- 形参:创建的互斥锁指针
- 返回值:解锁成功返回0,失败返回错误码
- 作用:解锁,让多线程恢复并发执行
锁其实起一个区间划分的作用,在加锁和解锁之间的代码就是临界区,多个执行流只能串行执行临界区代码,从而保护公共资源,使之成为临界资源。
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(lock);
//临界区
//...
pthread_mutex_unlock(lock);
加锁和解锁两句代码圈定了临界区的范围。
现在将抢票代码加上锁,看看是否还会出现多线程数据不一致问题:
在主线程中创建一个互斥锁,并且初始化,在所有新线程等待成功后将锁释放。
- 但是此时的锁是存在于主线程的栈结构中,需要让所有新线程看到这把锁。
在线程数据结构体中再增加一个锁指针,此时所有线程就都能看到这把锁了。
在新线程中对临界区加锁和解锁,让所有线程串行执行临界区中代码。
- 解锁不能放在else的代码块后面,防止break出循环,但是没有解锁。
- 在else的break前也要有解锁,防止if条件不满足直接跳出循环没有解锁。
此时抢票的结果是正常了,最终抢到1结束,符合我们的预期。
- 抢票的速度比以前慢了好多。
- 加锁和解锁的过程是多个线程串行执行的,并且临界区的代码也是串行执行的,所以速度就变慢了。
- 只有user3在抢票,其他线程没有抢。
- 锁只规定了互斥访问,并没有规定必须让谁先执行。
- 锁是让多个执行流进行竞争的结果。
只有user3在执行,说明user3的竞争能力强,别的线程抢不过它。因为现在的抢票逻辑是抢到票以后立马释放然后就又立马申请锁了,所以之前持有锁的线程更加容易再次申请到锁。
实际上,抢票成功后不可能立刻再去抢,还需要做一些工作,比如给用户打印订单等等。
在抢票成功后延时1000微秒,代表线程做的后续工作。
此时就成了多个线程在一起抢票。
- 当一个线程从临界区中出来并且释放锁后,执行后续任务时,其他线程才有能力去竞争锁。
加锁后的代码结构上如上图所示。
- 加锁时,一定要保证临界区的粒度非常小。将那些不是必须放在临界区中的代码放在临界区外。
- 加锁是程序员行为,要加锁就所有线程都加锁,否则就起不到保护共享资源的效果。
🍚锁的本质
如何看待锁?
- 在上面代码中,一个锁必须让所有线程都看到,所以锁本身就是一个共享资源。
既然是共享资源,锁也必须是安全的,那么是谁来保证锁的安全性呢?
- 锁是通过加锁和解锁是原子的来保证自身的安全的。
一个线程,如果申请成功锁,那么它就会继续向下执行,如果暂时申请不成功呢?
如上图代码所示,一个线程连续两次申请一个锁。
此时代码就被阻塞住了,线程和进程都是存在的。
- 一个锁只能被申请一次,只有锁被释放后才能再次申请。
- 当一个线程申请锁暂时失败以后,就会阻塞不动。
又多个线程在执行这部分代码。
- 当一个线程申请锁成功,进入临界区访问临界资源,其他线程要想进入临界区只能阻塞等待,等锁释放。
- 当一个线程申请锁成功,进入临界区访问临界资源,同样是能被切走的,而且该线程是抱着锁走的,其他线程仍然无法申请锁成功。
操作系统内部并不存在锁的概念,所以调度器在调度轻量级进程的时候并不会考虑是否有锁。
所以站在其他线程的角度,锁只有两种状态:
- 申请锁前
- 申请锁后
站在其他线程的角度,看到当前持有锁的过程就是原子的。
加锁解锁的原理:
经过上面的例子,我们认识到一个事实,c/c++中加加和减减的操作并不是原子的,所以会导致多线程数据不一致的问题。
- 而为了能让加锁过程是原子的,在大多数体系结构了,都提供了swap或者xchange汇编指令,通过一条汇编指令来保证加锁的原子性。
加锁解锁的伪代码:
lock:
movb %al, $0
xchange %al, mutex
if(al寄存器的内容 > 0)
{
return 0;
}
else
{
挂起等待;
}
goto lock;
unlock:
movb mutex, $1
唤醒等待mutex的线程;
return 0;
加锁过程中,xchange是原子的,可以保证锁的安全。
假设现在有两个线程,ThreadA和ThreadB:
线程A在执行,线程B在等待,线程A加锁时的第一步就是将0写入到al寄存器中。
- 在执行完第一条汇编后,线程A是可以被切走的,而且在切走的时候会将它的上下文,也就是al中的0带走。
- 这一步的本质就是将0写入到线程A的上下文中。
线程A在执行第二步的时候,直接将内存中mutex中的数据交换到了al寄存器中。
- 在执行完第二步的时候,线程A同样可以被切走,而且是带着上下文走的,也就是会将al中的mutex带走。
- 交换的本质就是将锁交换到线程A的上下文中。
假设现在线程A被切走了,而且带走了它的上下文mutex:
线程B在执行的时候,先第一步给寄存器al写0,然后执行第二步交换锁和al中的值。
- 此时al中的值虽然交换了,但是仍然是0,根据上面的伪代码,if条件不成立,所以将线程B挂起等待了。
此时操作系统就会又将线程A调回来继续执行:
线程A做的第一件事情就是恢复上下文,将锁恢复到al寄存器中。
- 线程A执行下一步时,if条件成了,所以该线程就申请锁成功了。
经过上面过程的描述,我们发现,锁只能被一个线程持有,而且由于xchange汇编只有一条指令,即使申请锁的过程被切走也不怕。
- 一旦一个线程通过xchage拿到了锁,即使它被切走,也是抱着锁走的,其他线程是无法拿到锁的,只有等它将锁释放。
只有持有锁的线程才能执行下去,锁相当于一张入场卷。
这样来看,释放锁的过程其实对原子性的要求并没有那么高,因为释放锁的线程必定是持有锁的线程,不持有锁的线程都不会执行到这里,都在阻塞等待。
- 线程A在解锁时,仅是将内存中存放锁的变量写为1,此时其他线程在xchange以后就可以通过if条件判断,申请锁了。
虽然解锁对原子性的要求不是很必要,但是在设计上还是要设计成原子的,可以看到,解锁也是只通过一条汇编就搞定了。
提示:上图中锁中的变量1仅仅是表示锁存在,并不是真正意义上的数字1。
🥩锁的封装
为了更好的使用C++,像封装线程那样,将加锁也封装成一个小组件,方便我们后面使用。
Mutex.hpp:
#include <iostream>
#include <pthread.h>
class Mutex
{
public:
Mutex(pthread_mutex_t* lock_p = nullptr):_lock_p(lock_p)
{}
void lock()
{
pthread_mutex_lock(_lock_p);//加锁
}
void unlock()
{
pthread_mutex_unlock(_lock_p);//解锁
}
private:
pthread_mutex_t* _lock_p;
};
class LockGuard
{
public:
LockGuard(pthread_mutex_t* lock_p):_mutex(lock_p)
{
_mutex.lock();//构造函数内加锁
}
~LockGuard()
{
_mutex.unlock();//析构函数内解锁
}
private:
Mutex _mutex;
};
只需要创建一个LockGurd对象就可以进行加锁,需要传入锁的地址,在LockGuard对象生命周期结束的时候,会自动释放锁。
- 创建一个全局的锁,就不用使用pthread_mutex_init取初始化,也不用使用pthread_mutex_destroy来销毁锁了,直接使用就行。
在临界区加锁,执行完临界区代码后解锁。
- 将临界区放在一个代码块中,此时LockGuard的生命周期就是这个代码块。
- 创建LockGuard对象时在构造函数中自动加锁,出作用域时析构函数自动解锁。
使用封装的加锁小组将,抢票的结果和我们之前直接用系统调用加锁是一样的。
- 上面这种加锁的风格被称为RAII加锁。
🥩可重入VS线程安全
重入:
- 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。
之前在信号部分就提到过重入,进程在执行一个函数,收到某个信号在处理信号时又调用了这个函数。今天在多线程这里,理解重入更加容易,我们上面写的多线程代码都是重入的。
- 可重入和不可重入:一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
常见可重入情况:
- 不使用全局变量或静态变量。
- 不使用用malloc或者new开辟出的空间。
- 不返回静态或全局数据,所有数据都有函数的调用者提供。
常见不可重入情况:
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的函数。
- 可重入函数体内使用了静态的数据结构。
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
总的来说,一个函数中如果使用了全局数据,或者静态数据,以及堆区上的数据,就是不可重入的,反之就是可重入的。
线程安全:
- 多个线程并发同一段代码时,不会出现不同的结果(数据不一致)。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
互斥锁就是让不安全的线程变安全,也就是前面我们所学习的内容。
常见线程安全情况:
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
- 类或者接口对于线程来说都是原子操作。
- 多个线程之间的切换不会导致该接口的执行结果存在二义性。
多线程共同执行的代码段中,如果有全局变量或者静态变量并且没有保护,那么就是线程不安全的。
常见线程不安全情况:
- 不保护共享变量的函数。
- 函数状态随着被调用,状态发生变化的函数。
- 返回指向静态变量指针的函数。
可重入与线程安全的联系:
多线程是通过调用函数来实现的,所以线程安全和重入就存在一些联系:
- 函数是可重入的,那就是线程安全的,因为没有全局或者静态变量,不会产生数据不一致问题。
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。出发对不可重入函数的全局变量进行保护。
- 如果一个函数中有全局变量并且没有保护,那么这个函数既不是线程安全也不是可重入的。
可重入与线程安全的区别:
可重入和线程安全是不同的两个东西,但是又存在一定的交集。
- 可重入说的是函数。
- 线程安全说的是线程。
- 可重入函数是线程安全函数的一种,因为不存在全局或者静态变量。
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。因为线程安全的情况可能是对全局变量进行了保护(加了锁)。
由于线程可以加锁,所以说线程安全的情况比可重入要多。
🥩死锁
我们前面例子中写的都是只有一把锁的情况,在实际使用中有可能会存在多把锁,此时就可能造成死锁。
- 死锁:一组执行流中的各个执行流均占有不会释放的锁资源,但因互相申请被其他进程所站用不会释放的锁资源而处于的一种永久等待状态。
通俗来说就是一个线程自己持有锁,并且不会释放,但是还要申请其他线程的锁,此时就容易造成死锁。
- 一把锁也是会死锁的,连续申请俩次就是死锁。
在上面演示一个线程暂时申请锁失败而阻塞时,就是死锁。
死锁的逻辑链条:
可以看到,往往解决一个问题就会引出新的问题,然后再区解决新的问题。
🍚死锁的必要条件
死锁的四个必要条件:
- 互斥
这一点不用说,只要用到锁就会互斥。
- 请求与保持
请求就是指一个执行流申请其他锁,保持是指不释放自己已经持有的锁。
- 不剥夺
一个执行流已经持有锁,在不主动释放前不能强行剥夺。
- 环路等待
线程A,B,C都持有一把锁,并且不释放。
- 线程A 申请 线程B持有的锁B
- 线程B 申请 线程C持有的锁C
- 线程C 申请 线程A持有的锁A
此时就构成了环路阻塞等待。
只有符合上面四个条件就会造成死锁。而要破坏死锁只要破坏其中一个条件即可。
避免死锁:
四个必要条件中的第一个无法破坏,因为我们使用的就是锁,锁就具有互斥的性质。只能破坏其他三个条件。
- 避免锁位释放的场景
这是为了破坏请求与保持条件。当一个执行流在申请另一个锁的时候,要先释放已经持有的锁再申请。
- 加锁顺序一致
这是为了避免形参环路等待,只要不构成环路即可。
- 资源一次性分配
临界资源尽量一次性分配好,不要分布在太多的地方加锁,这样的话导致死锁的概率就会增加。
避免死锁的算法:
- 死锁检测算法
- 银行家算法
有想去的小伙伴可以自己去了解一下这两个算法。
采用算法来避免死锁时,就会有一个执行流专门用来监测其他执行流的状态,一旦发现某个执行流长时间没有执行,就释放它所持有的锁。
从解锁的伪代码只能可以看出,解锁是可以由其他线程来完成的,只需要将锁重新赋值到锁的共享资源变量中即可。
🥩总结
互斥锁在实际中能不用就不用,实在没有办法的时候也要尽量少用。