作者:@小萌新
专栏:@Linux
作者简介:大二学生 希望能和大家一起进步!
本篇博客简介:简单介绍linux中线程互斥
线程同步与互斥
原因
我们在之前线程控制的博客中讲过 线程是共用同一个进程地址空间的 也就是说它们的很多资源是共享的
这么多资源共享势必缺乏访问控制造成一些安全问题
所以说为了解决这些安全问题我们需要进行线程的同步和互斥
线程互斥
基本概念
在深入学习线程互斥之前我们需要先了解一些基本概念
- 临界资源: 多线程执行流共享的资源叫做临界资源
- 临界区: 每个线程内部 访问临界资源的代码 就叫做临界区
- 互斥: 任何时刻 互斥保证有且只有一个执行流进入临界区 访问临界资源 通常对临界资源起保护作用
- 原子性: 不会被任何调度机制打断的操作 该操作只有两态 要么完成 要么未完成
- 同步:多线程或多进程环境中 确保多个线程或进程之间能够按照预定的顺序执行
下面我们使用一个小故事来解释下上面的概念
假设你现在在一个食堂里面 这个食堂只有一个窗口 里面有一个阿姨在里面打饭
你吃饭吃的特别快而且跑步速度特别快每次都是第一个到食堂的
有一天你第一个到食堂之后你把盘子给阿姨让阿姨开始打饭 食堂阿姨给你打完饭之后你一秒钟就把所有的饭吃完了并且又把饭盒给阿姨再次让阿姨给你打饭
别人在你打饭的途中跟阿姨说自己也饿了 能不能让阿姨先给自己打饭 阿姨就跟没听到一样
那么在别人眼里阿姨打饭的过程就是原子性且互斥的 即阿姨只有开始打饭和空闲中这两种状态且在给一个人打饭的时候不能被打断
但是你又特别能吃自己一个人在那里吭哧吭哧的吃了两个小时 别人都饿的不行
食堂的管理者肯定不能容忍这样子的情况发生
所以说食堂的管理者规定当你打完饭之后你必须要去队列尾部继续排队 这样子大家就能以一个相对的顺序来打饭了 这就叫做同步
其中打饭的人就可以类比为线程 饭菜就叫做临界资源 打饭这个动作就是临界区 食堂阿姨就是一把锁
问题引出
我们首先使用代码的方式介绍下临界资源和临界区的概念
下面我们会写出一段代码 在这段代码中会有一个全局变量count
其中子线程会不断的让count进行加加操作
主线程会不停的打印count的值
1 #include <iostream>
2 using namespace std;
3 #include <unistd.h>
4 #include <pthread.h>
5 #include <cstdio>
6
7 int count = 0;
W> 8 void* new_thread(void* args)
9 {
10 while(1)
11 {
12 sleep(1);
13 count++;
14 }
15 }
16
17 int main()
18 {
19 // 我们下面的代码是为了证明临界资源这个概念
20 // 我们将会定义一个整型count为临界资源
21 // 主线程每隔一秒打印这个临界资源
22 // 新县城不停的将这个数++
23 pthread_t tid;
24 pthread_create(&tid , NULL , new_thread ,(void *)"new thread");
25 while(1)
26 {
27 printf("the count is: %d\n",count);
28 sleep(1);
29 }
30 return 0;
31 }
我们编译运行上述代码
这里有两个执行流对于count进行了访问
所以说count就是临界资源 访问count的代码就是临界区
抢票系统
下面我们模拟实现一个抢票系统
我们创建五个线程一起去抢票 如果当前的票数大于0则打印自己抢到票了 tik–
如果当前的票数小于0则线程退出
代码表示如下
1 #include <iostream>
2 using namespace std;
3 #include <unistd.h>
4 #include <pthread.h>
5 #include <vector>
6 #include <cstdio>
7 // 下面的代码逻辑是写出五个线程实现抢票逻辑
8 // 一共1000张票 如果票数大于0就开始抢票tik--
9 // 如果票数小于0就直接退出
10 const int NUM = 5;
11 int ticket = 1000;
12
W> 13 void* get_ticket(void* args)
14 {
15 while(1)
16 {
17 if(ticket > 0)
18 {
19 usleep(10000);
W> 20 printf("i get a ticket im:%x , ticket left:%d\n", pthread_self() , --ticket);
21 }
22 else
23 {
24 break;
25 }
26 }
27 pthread_exit((void*)0);
28 }
29
30 int main()
31 {
32 pthread_t tid;
33 vector<pthread_t> v;
34 int i = 0;
35 for (i = 0; i < NUM ; i++)
36 {
37 pthread_create(&tid , NULL , get_ticket , (void *)"new pthread");
38 v.push_back(tid);
39 }
40
41 for (i = 0; i < NUM ; i++)
42 {
43 pthread_join(v[i] , NULL);
44 }
45 return 0;
46 }
运行结果如下
我们发现这里竟然会出现负票数的情况
这显然是我们不能忍受的 如果说在实际生活中 飞机上只有100个座位但是你卖出了150张票 那么恐怕这架飞机就起飞不了了
那么为什么会出现这种情况呢?
因为该代码中的ticket
票数是一个临界资源 它被多个执行流同时访问者
它的原因主要有下面三点
if
语句判断条件为真以后 代码可以并发的切换到其他线程usleep
用于模拟漫长业务的过程 在这个漫长的业务过程中 可能有很多个线程会进入该代码段--ticket
操作本身就不是一个原子操作
为什么说
--ticket
不是一个原子操作呢?
在语言层面上看起来--ticket
就是一行代码 好像是不可中断的
但是在操作系统中它却并非是这样子的
我们都知道计算机中的cpu才有计算功能 所以说如果我们想要将一个值-- 必须要先将这个值放入到cpu的寄存器中
在系统层面将一个值–需要经历下面的三个步骤
- 将共享变量tickets从内存加载到寄存器中
- 更新寄存器里面的值 执行-1操作
- 将新值从寄存器写回共享变量tickets的内存地址
其中它对应的汇编代码如下
既然–操作要经历三个步骤才能完成 那么有可能thread1刚刚把1000读进cpu就被切换了
当该线程被切换的时候会保存它对应的上下文信息 1000这个数据当然也在里面 之后thread1被挂起
之后我们的thread2进程就被调度了 因为当thread1被切换的时候内存中的tickek值并没有被改变 所以说thread2看到的值还是1000
我们假设thread2的竞争性比较强 它执行了100次之后才被切换 那么此时的ticket的值就由1000变成了900
当thread2切换挂起之后我们的thread1回来继续执行 此时恢复它的上下文数据
由于上次保存时它寄存器中的数据是1000 所以说再经历23两步操作之后变为999之后加载到内存中
于是内存中的数据便从900变成999了 相当于此时多了1000张票
从上面的流程中我们可以看出 --ticket
这个操作并不是原子性的
那么我们如何解决上面的问题呢?
其实思路很简单 我们只需要将--ticke
t这个操作变成原子性的就好了
那么怎么将它变成原子性的呢? 我们的策略是加锁
互斥量mutex
要解决上面的抢票问题我们需要做到下面三点
- 代码必须有互斥行为:当代码进入临界区执行时 不允许其他线程进入该临界区
- 如果多个线程同时要求执行临界区的代码 并且此时临界区没有线程在执行 那么只能允许一个线程进入该临界区
- 如果线程不在临界区中执行 那么该线程不能阻止其他线程进入临界区
我们在上面已经说过了 做到这三点只需要一步加锁就可以
在linux中 我们将这锁叫做互斥量
互斥量的接口
互斥量定义
我们可以使用下面的代码来定义一个互斥量
pthread_mutex_t mutex
其中mutex是一个变量名
初始化互斥量
初始化互斥量的函数叫做pthread_mutex_init 该函数的函数原型如下:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
返回值说明:
- 如果初始化成功返回0 失败返回错误码
参数说明:
- mutex:需要初始化的互斥量
- attr:初始化互斥量的属性 一般设置为NULL即可
调用pthread_mutex_init函数初始化互斥量叫做动态分配 除此之外 我们还可以用下面这种方式初始化互斥量 该方式叫做静态分配:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
销毁互斥量
销毁互斥量的函数叫做pthread_mutex_destroy 该函数的函数原型如下:
int pthread_mutex_destroy(pthread_mutex_t *mutex);
返回值说明:
- 如果销毁成功返回0 失败返回错误码
参数说明:
- mutex:需要加锁的互斥量
销毁互斥量时需要注意的点有
- 使用PTHREAD_MUTEX_INITIALIZER初始化的互斥量不需要销毁
- 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量 要确保后面不会有线程再尝试加锁
互斥量加锁
互斥量加锁的函数叫做pthread_mutex_lock 该函数的函数原型如下:
int pthread_mutex_lock(pthread_mutex_t *mutex);
返回值说明:
- 互斥量加锁成功返回0 失败返回错误码
参数说明:
- mutex:需要加锁的互斥量
调用pthread_mutex_lock
时,可能会遇到以下情况:
- 互斥量处于未锁状态 该函数会将互斥量锁定 同时返回成功
- 发起函数调用时 其他线程已经锁定互斥量 或者存在其他线程同时申请互斥量 但没有竞争到互斥量 那么pthread_mutex_lock调用会陷入阻塞(执行流被挂起) 等待互斥量解锁
互斥量解锁
互斥量解锁的函数叫做pthread_mutex_unlock 该函数的函数原型如下:
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值说明:
- 互斥量解锁成功返回0 失败返回错误码
参数说明:
- mutex:需要解锁的互斥量
使用互斥解决抢票问题
我们利用互斥量 将抢票的过程变成一个原子性的过程 即在抢票前后加锁
代码表示如下
1 #include <iostream>
2 using namespace std;
3 #include <unistd.h>
4 #include <pthread.h>
5 #include <vector>
6 #include <cstdio>
7 // 下面的代码逻辑是写出五个线程实现抢票逻辑
8 // 一共1000张票 如果票数大于0就开始抢票tik--
9 // 如果票数小于0就直接退出
10 const int NUM = 5;
11 int ticket = 1000;
12 pthread_mutex_t mutex;
W> 13 void* get_ticket(void* args)
14 {
15 while(1)
16 {
17 pthread_mutex_lock(&mutex);
18 if(ticket > 1)
19 {
20 usleep(10000);
W> 21 printf("i get a ticket im:%x , ticket left:%d\n", pthread_self() , --ticket);
22 pthread_mutex_unlock(&mutex);
23 }
24 else
25 {
26 pthread_mutex_unlock(&mutex);
27 break;
28 }
29 }
30 pthread_exit((void*)0);
31 }
32
33 int main()
34 {
35 pthread_t tid;
36 vector<pthread_t> v;
37 pthread_mutex_init(&mutex , NULL);
38 int i = 0;
39 for (i = 0; i < NUM ; i++)
40 {
41 pthread_create(&tid , NULL , get_ticket , (void *)"new pthread");
42 v.push_back(tid);
43 }
44
45 for (i = 0; i < NUM ; i++)
46 {
47 pthread_join(v[i] , NULL);
48 }
49 pthread_mutex_destroy(&mutex);
50 return 0;
51 }
演示结果如下
我们发现确实不会再出现票数小于0的情况了
但是在加锁的过程中我们要注意以下几点
- 在大部分情况下 加锁本身都是有损于性能的事 它让多执行流由并行执行变为了串行执行 这几乎是不可避免的
- 我们应该在合适的位置进行加锁和解锁 这样能尽可能减少加锁带来的性能开销成本
- 进行临界资源的保护 是所有执行流都应该遵守的标准 这也是程序员在编码时需要注意的
互斥量原理
加锁后的原子性体现在哪里?
在加锁后 线程2 3 4 看线程1只有两种状态 上锁和没有上锁 我们就可以称tread1中间的行为是原子性的
当进程1进入临界区之后thread2 3 4变无法进入临界区了 变成阻塞状态
临界区内的线程可能进行线程切换吗?
临界区内的线程可以进行线程切换 只不过当它进行线程切换的时候会拿走锁 此时其他线程由于没法申请到锁依旧无法进入到临界区 这也是加锁后效率降低的原因之一
锁是否需要被保护?
事实上锁是为了保护临界资源而产生的 但是锁本身就是一个临界资源 所以说我们也需要一系列的措施来保护锁
我们在设计的时候是使用锁来保护自身的 即我们只要保证申请和释放锁的过程是原子性的那么锁这个临界资源就是安全的
那么我们如何保证申请和释放锁的原子性呢?
一般认为在汇编层面上的一行代码就是不可被中断的
我们在上面已经证明了++和–操作不是原子结构 使用它们可能会导致数据不一致的问题 所以说我们需要一条指令 它能在汇编层面上的一条语句内交换两个值来实现互斥结构
实际上为了实现互斥锁操作 大多数体系结构都提供了swap或exchange指令 该指令的作用就是把寄存器和内存单元的数据相交换
下面是lock和unlock的伪代码
我们默认mutex的值为1 al是线程中的一个寄存器 当我们申请锁的时候需要经历下面的步骤
- 首先将自己的al寄存器中的值归0 该动作可以被多个线程同时执行 因为每个线程都有自己的一组寄存器(上下文信息)
- 交换al寄存器和mutex中的值
- 判断自己al寄存器中的值是不是大于0的 如果是大于0的就说明申请锁成功 可以访问临界资源 如果不是就说明申请锁失败被挂起
例如 此时内存中mutex的值为1 线程申请锁时先将al寄存器中的值清0 然后将al寄存器中的值与内存中mutex的值进行交换
交换完成后判断al寄存器中的值 大于0 则表示申请成功 可以进入临界区对临界资源进行访问
之后mutex的值就一直为0了 所以说后来的线程交换数据之后 al寄存器中的值始终为0 也就是申请锁失败 之后一直被挂起等待锁释放
当线程释放锁的时候要经历下面两个步骤
- 将内存中的mutex置回1 使得下一个申请锁的线程在执行交换指令后能够得到1 形象地说就是“将锁的钥匙放回去”
- 唤醒等待Mutex的线程 唤醒这些因为申请锁失败而被挂起的线程 让它们继续竞争申请锁
可重入和线程安全
- 线程安全: 多个线程并发同一段代码时 不会出现不同的结果 常见对全局变量或者静态变量进行操作 并且没有锁保护的情况下 会出现线程安全问题
- 重入: 同一个函数被不同的执行流调用 当前一个流程还没有执行完 就有其他的执行流再次进入 我们称之为重入 一个函数在重入的情况下 运行结果不会出现任何不同或者任何问题 则该函数被称为可重入函数 否则是不可重入函数
常见的线程安全情况
- 不保护共享变量的函数
- 函数状态随着被调用 状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限 而没有写入的权限 一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
常见的不可重入情况
- 调用了malloc/free函数 因为malloc函数是用全局链表来管理堆的
- 调用了标准I/O库函数 标准I/O可以的很多实现都是以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
常见的可重入情况
- 不使用全局变量或静态变量
- 不使用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据 所有数据都由函数的调用者提供
- 使用本地数据或者通过制作全局数据的本地拷贝来保护全局数据
可重入与线程安全联系
- 函数是可重入的 那就是线程安全的
- 函数是不可重入的 那就不能由多个线程使用 有可能引发线程安全问题
- 如果一个函数中有全局变量 那么这个函数既不是线程安全也不是可重入的
可重入与线程安全区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的 而可重入函数则一定是线程安全的
- 如果对临界资源的访问加上锁 则这个函数是线程安全的 但如果这个重入函数的锁还未释放则会产生死锁 因此是不可重入的
常见锁概念
死锁
死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去
单执行流可能产生死锁吗?
答案当然是可以的
单执行流也有可能产生死锁 如果某一执行流连续申请了两次锁 那么此时该执行流就会被挂起
因为该执行流第一次申请锁的时候是申请成功的 但第二次申请锁时因为该锁已经被申请过了 于是申请失败导致被挂起直到该锁被释放时才会被唤醒 但是这个锁本来就在自己手上 自己现在处于被挂起的状态根本没有机会释放锁 所以该执行流将永远不会被唤醒 此时该执行流也就处于一种死锁的状态
代码表示如下
1 #include <iostream>
2 using namespace std;
3 #include <unistd.h>
4 #include <pthread.h>
5
6 pthread_mutex_t mutex;
7
W> 8 void* test_mutex(void* args)
9 {
10 pthread_mutex_lock(&mutex);
11 pthread_mutex_lock(&mutex);
12 pthread_exit((void*)0);
13 }
14 int main()
15 {
16 // 申请一个死锁
17 pthread_mutex_init(&mutex , NULL);
18 pthread_t tid;
19 pthread_create(&tid , NULL , test_mutex ,(void *)"new thread");
20 pthread_join(tid , NULL);
21 pthread_mutex_destroy(&mutex);
22 return 0;
23 }
我们运行起来之后就会发现这种情况
进程一直处于一个被挂起的状态
我们可以使用ps指令来查看这个进程的状态
我们可以发现进程的后面出现了一个l状态(lock) 表示该进程目前处于一个死锁的状态
什么叫做阻塞?
进程运行时是被CPU调度的 换句话说进程在调度时是需要用到CPU资源的 每个CPU都有一个运行等待队列(runqueue) CPU在运行时就是从该队列中获取进程进行调度的
在运行等待队列中的进程本质上就是在等待CPU资源 实际上不止是等待CPU资源如此 等待其他资源也是如此 比如锁的资源、磁盘的资源、网卡的资源等等,它们都有各自对应的资源等待队列
例如 当某一个进程在被CPU调度时 该进程需要用到锁的资源 但是此时锁的资源正在被其他进程使用:
- 那么此时该进程的状态就会由R状态变为某种阻塞状态 比如S状态 并且该进程会被移出运行等待队列并被链接到等待锁的资源的资源等待队列 而CPU则继续调度运行等待队列中的下一个进程
- 此后若还有进程需要用到这一个锁的资源 那么这些进程也都会被移出运行等待队列 依次链接到这个锁的资源等待队列当中
- 直到使用锁的进程已经使用完毕 也就是锁的资源已经就绪 此时就会从锁的资源等待队列中唤醒一个进程 将该进程的状态由S状态改为R状态 并将其重新链接到运行等待队列 等到CPU再次调度该进程时 该进程就可以使用到锁的资源了
总结下:
- 站在操作系统的角度 进程等待某种资源 就是将当前进程的task_struct放入对应的等待队列 这种情况可以称之为当前进程被挂起等待了
- 站在用户角度 当进程等待某种资源时 用户看到的就是自己的进程卡住不动了 我们一般称之为应用阻塞了
- 这里所说的资源可以是硬件资源也可以是软件资源 锁本质就是一种软件资源 当我们申请锁时 锁当前可能并没有就绪 可能正在被其他线程所占用 此时当其他线程再来申请锁时 就会被放到这个锁的资源等待队列当中
死锁的四个必要条件
- 互斥条件: 一个资源每次只能被一个执行流使用
- 请求与保持条件: 一个执行流因请求资源而阻塞时 对已获得的资源保持不放
- 不剥夺条件: 一个执行流已获得的资源 在未使用完之前 不能强行剥夺
- 循环等待条件: 若干执行流之间形成一种头尾相接的循环等待资源的关系
注意 这是四个必要条件 也就是说四个条件全部满足才能够形成死锁
避免死锁
- 破坏死锁的四个必要条件。
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配
我们还可以使用一些算法来避免死锁 比如说银行家算法还有死锁检测算法