线程互斥
一些概念
- 临界资源:多线程之间共享的资源就是临界资源。通常为一些全局的变量。
- 临界区:访问或者修改临界资源的代码就是临界区。
- 互斥:任何时刻,保证只有一个执行流访问临界资源。
- 原子性:不受调度机制打断的操作。操作要么完成,要么就是未完成,一步到位。
锁的背景
编写一个简单的多线程抢票功能
设置有1万张票,4个线程分别抢票
tiket就是一个全局变量,可以被每一个线程访问修改。
在票0时刻,抢票时候应该停止
而我们编写的多线程抢票缺导致抢到负数票
解释概念
临界资源:全局的票就是临界资源,被线程共享。
临界区:访问全局变量票的代码
原子性:实际上 tikets-- 操作不是一步就完成,会受到调度算法的影响。
为什么会产生负数?
假设有俩个线程 A 和 B
线程A 和 B对一个全局变量g_val=10都做++操作
一个变量做++ 要分三步:先把变量从内存读到CPU; 在CPU里对变量+1 ;将加后的值写到内存中
假设现在线程A的时间片,线程A将内存中10读到CPU寄存器中,并且对10+1=11,再将11写入内存中。
第二轮 还是A的时间片,线程A读到内存中的11到寄存器中,这时候线程A的时间片到了 ,线程A将上下文数据保存到exc寄存器中(保存着11)并且将调度切换到B。
线程B读取内存中的11到寄存器中,对11+1=12,在写入内存。线程B的时间片比较充足,一直将变量从11加到100,此时线程B时间片结束,切换到线程A。
A将在exc寄存器中保存的11加载到CPU寄存器,然后+1=12,再把12拷贝会内存,覆盖已有的100.
此时就说明 +1 操作不是原子的(不是一步完成,会被调度原因中断)
而对于我们上述的例子,其实更为简单
假设目前票数是1,4个线程并行访问,进行if条件的判断,4个线程都判断成功,进入if函数的内部
这时候4个线程的访问由于延迟等,变成串行。
即线程A对变量1进行-- ,将1减到 0
线程B 在上一步已经通过if条件的判断,这时就从内存中把0加载到内存,进行-- 减到-1 ,再拷贝到内存
线程C 和B类似,也不必进行判断,就将-1 再继续-- 内存中就是-2
这种并行判断 ,串行访问修改,就造成票数出现负数!
为了解决这种情况,必须对临界资源的访问加上限制,必须只能是一个执行流对其访问。
这就是互斥!目的就是为了保护从并并访问,变成串行访问。
互斥锁mutex
- 大部分时候,线程拥有独立的栈区,这些变量不会受到其它线程的影响,是安全的。
- 但是对于全局变量,是线程共享的,为了满足线程的交互。
- 但是线对于共享资源(临界资源)的访问是不安全的,需要被保护!很多时候只允许临界资源被一个执行流访问。
为了解决抢票出现负数(临界资源的保护)
- 临界区需要有互斥行为(有且同时只有一个执行流访问临界区)
- 如果线程不执行临界区的资源,那么必须允许其它线程进入。
就好比有一间自习室,这间自习室只允许一个人进入学习。
那么A进入后,就给自习室上.了一把锁,其它人见到锁后,就不能进入。
如果A离开自习室,就必须放回锁,允许其他人进入。
在Linux中的锁叫做互斥量
锁的接口
创建锁
锁的申请,可以是一把全局锁,也可以是局部锁。
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
全局锁
pthread_mutex_t mutex
初始化为PTHREAD_MUTEX_INITIALIZER
后续不需要对锁destroy
局部锁
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数说明:
int pthread_mutex_lock(pthread_mutex_t *mutex);
- mutex为需要初始化的锁
- attr是锁的属性,一般为NULL
成功返回0,失败返回-1
锁的销毁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
- 全局的锁不会要显示的去销毁
- 销毁的锁,必须不能是一把未解除的锁
- 已经销毁的锁,必须保证在后续不会再被上锁。
加锁和解锁
对临界区加锁,同样是可以全局加锁,也可以是由局部锁去加。
加锁后必须要解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
参数为要加的锁。
成功返回0,失败返回错误码
如果一个锁没有被使用,则会返回0,申请上锁成功。
如果调用时,锁已经被使用。如果有其它执行流,同时在申请这把锁,但没有竞争这把锁成功则可能会发生这几种可能
- 线程被阻塞等待:线程会被挂起阻塞或被放到等待队列中,直到锁的解除。
- 自旋:循环等待,一直检查锁的情况,避免寄存器的切换。
- 失败返回:如果调用者设置了锁申请失败的方法。
解锁:
对一把锁的解锁
用法与pthread_mutex_lock一致
int pthread_mutex_ulock(pthread_mutex_t *mutex);
锁的使用,解决抢票的问题。
全局锁的使用
在if条件前加锁,保证一次只有一个执行流在判断
解锁, 完成票的--或者是在break结束前解锁
局部锁
为了能让调用任务能够获取锁和线程名
我们封装一个数据类型
创建一个局部锁并初始化,创建锁类型的对象。
通过封装Pthread实例化出一个线程
可以观察到,局部的锁和全局的锁效果一致,都能做到互斥的效果。
关于锁的细节点
- 访问同一个临界资源的线程,都必须加同一把锁。
- 加锁是把线程的并行访问,互斥为串行访问,效率自然会降低。所以我们要尽可能少的加锁
访问临界区的时候需要先加锁,锁必须让所有的线程都看到,那么锁必然也是临界资源。
那么锁也要被安全!所以锁必须是原子的。(一步汇编指令就可以完成)
一个线程申请到锁,可以被切换走吗?
- 一个线程申请到锁,仍然可以被切换走。线程的加锁,解锁本质也是代码。线程在麻袋任意处,包括加锁的代码,都可以OS或调度机制被切换走。切换是为了公平的享有资源。
- 然而线程一旦有了锁,就不担心在持有锁的期间被切换走,因为申请锁的过程是原子的,是一步完成的。要么成功获取锁继续执行,要么获取失败阻塞住。在持有锁期间,其它线程无法进入,因为要进入必须申请锁。
互斥锁的原理
大部分体系结构都存在俩条汇编指令
exchange 和 swap
目的是交换内存和CPU寄存器的数据
所谓的锁,最简单的结构其实就是一个结构体,结构体有一个成员int mutex=1
每次有线程申请这个锁,把0与寄存器中的1交换。在把0换到内存中,如果寄存器中的值是0,这锁申请成功。因为每一步操作都是原子的,不管是在哪一步切换线程,都不影响锁的申请。
下面从汇编角度理解锁的底层原理
有一把锁,就是在内存中定义一个mutex=1的变量,在CPU中有一个%a的寄存器
如果线程A去申请锁
会执行 mov 0 到 %a的寄存器中
交换内存中的mutex的值和%a的值,这一步操作也是原子的
最后执行判断,如果%a寄存器中的内容是1就代表锁申请成功,否则阻塞等待
在锁申请任何一步线程被切换中,都不会影响锁的申请,因为线程会带着寄存器(寄存器保存上下文数据,包括%a的0和1)离开。
等到线程被切换回来时,会加载寄存器内容,把刚才保存的%a再放回CPU中。
可重入VS线程安全
- 线程安全:多个线程并发同一份代码时,不会出现不同的结果。常见的多线程对全局变量访问,如果没有加锁,线程就是不安全的。
- 重入:同一个函数被不同的执行流调用时,一个执行流还没有结束,其它执行流就进入。一个函数如果在重入的情况下不会出现任何问题,就是可重入。而不可重入通常指的是一个函数在执行过程中,如果被其他线程或中断打断,并在该线程返回后再次调用,可能会导致错误的结果或行为。
线程安全讨论的是线程的特点,可重入讨论的函数的可重入。
常见线程不安全的情况:
- 不保护全局的变量
- 调用不安全的函数
- 调用指向静态的指针
- 总结就是 多线程访问没有加锁的全局变量、函数
常见不可重入的情况:
- 调用malloc/free malloc函数是用全局链表来管理堆的
- 调用标准IO库,IO库大部分是全局的
- 可重入函数调用数据结构
可重入与线程安全的联系
- 函数是可重入的,那么线程就是安全的。
- 函数不可重入,那么就不能被多个线程使用,就是引发线程安全。
- 如果一个函数有全局变量,那么这个函数是不可重入,线程是不安全的。
死锁
- 死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态
假设有俩个线程A 和线程B 线程A持有锁lock1 线程B持有锁lock2
线程A去申请lock2,申请不会成功线程A被阻塞挂起
线程B去申请lock1,申请不会成功,线程B被阻塞挂起
导致线程全部都被挂起,这就是死锁。
单执行流也会出现死锁吗?
是的。如果一个线程连续申请俩次同一个锁,并且都没有解锁。第一次申请锁是成功的,因为没有线程竞争。第二次申请锁会失败,被阻塞挂起。导致执行流陷入阻塞等待,永远不会被唤醒。
产生死锁的必要条件
- 互斥条件:一个资源每次被一个执行流使用。
- 请求与保持:一个执行流因请求资源阻塞时,对已经获得的资源保持不放。
- 不剥夺条件:一个执行流在以获得资源时,在未完成资源时,不强制剥夺其它资源。
- 循环等待:若干个执行流构成头尾相连的循环等待资源状态。
解释:
互斥条件指是线程加锁。就如上述例子中。线程A和线程B都加锁。
请求与保持是不释放自身的锁。线程A去申请lock2锁的时候,不会把自己的锁释放掉。
不剥夺是不会强制释放要申请的锁。线程A在申请lock2时,不会把lock2先释放再申请。
循环等待指若干线程互相申请锁。比如线程A申请lock2 线程B申请lock3 线程C申请lock1。
避免死锁的方法:
最根本的方法就是不加锁。
其次就是破坏锁四的必要条件的一个或者多个。
- 比如加锁顺序一致
线程A申请lock1 ,在线程B未持有lock2时候,线程A申请lock2。
- 避免加锁未释放的场景,先释放锁再去申请锁。
- 除此之外,还有一些避免死锁的算法,比如死锁检测算法和银行家算法。