【Linux 21】线程安全
文章目录
- 🌈 一、线程互斥
- ⭐ 1. 线程间互斥的相关概念
- 🌙 1.1 临界资源和临界区
- 🌙 1.2 互斥和原子性
- ⭐ 2. 互斥量 mutex
- ⭐ 3. 互斥量接口
- 🌙 3.1 初始化互斥量
- 🌙 3.2 销毁互斥量
- 🌙 3.3 互斥量上锁
- 🌙 3.4 互斥量解锁
- 🌙 3.5 互斥量使用示例
- ⭐ 4. 互斥量的实现原理
- 🌙 4.1 临界区中的线程也能被线程切换走
- 🌙 4.2 锁也是需要被保护的共享资源
- 🌙 4.3 如何保证申请锁的过程是原子的
- 🌈 二、可重入 VS 线程安全
- ⭐ 1. 线程安全和可重入的概念
- ⭐ 2. 常见的线程不安全
- ⭐ 3. 常见的线程安全
- ⭐ 4. 常见的不可重入
- ⭐ 5. 常见的可重入
- ⭐ 6. 可重入和线程安全的联系
- ⭐ 7. 可重入和线程安全的区别
- 🌈 三、常见锁概念
- ⭐ 1. 死锁的概念
- ⭐ 2. 产生死锁的必要条件
- ⭐ 3. 如何避免死锁
- 🌈 四、线程同步
- ⭐ 1. 同步概念和竞态条件
- ⭐ 2. 条件变量概念
- ⭐ 3. 条件变量函数
- 🌙 3.1 初始化条件变量
- 🌙 3.2 销毁条件变量
- 🌙 3.4 让线程去条件变量处等待
- 🌙 3.5 唤醒在条件变量处等待的线程
- 🌙 3.6 条件变量函数用例
- 🌙 3.7 错误的设计
- ⭐ 4. 条件变量使用规范
🌈 一、线程互斥
⭐ 1. 线程间互斥的相关概念
- 临界资源:多个线程之间共享的资源被称作临界资源 (全局变量就是一种临界资源)。
- 临界区:线程内部,用来访问临界资源的代码,被称作临界区。
- 互斥:互斥是用来保护临界资源的,互斥保证在任何时刻都只能由一个执行流进入临界区去访问临界资源。
- 例:一个整形全局变量,一个线程想对该变量 + 1,另一个线程想并发的对该变量 - 1,就可能会出现访问数据不一致的问题。
- 一个数本来是 0,线程 A 想要 + 1,而线程 B 想要 - 1,就会导致最终的结果不确定了。
- 原子性:不会被任何的调度机制打断的操作,对于原子性来说,只有 完成 / 未完成 两种状态。
🌙 1.1 临界资源和临界区
- 多个线程之间能够看到的同一份资源被称作临界资源,而专门用来访问临界资源的代码段被称为临界区。
- 因为多线程的大部分资源都是共享的,因此线程之间进行通信不需再去创建第三方资源。
举个例子
- 定义一个初始值为 0 的 count 全局变量,再弄出两个线程,新线程对 count 变量进行自增操作,主线程对 count 变量进行打印操作。
#include <iostream>
#include <unistd.h>
#include <pthread.h>using std::cout;
using std::endl;int count = 0; // 临界资源// 新线程
void* thread_run(void* args)
{while (true){count++; // 临界区,这一行是用来访问临界资源的 sleep(1);}
}// 主线程
int main()
{pthread_t tid;pthread_create(&tid, nullptr, thread_run, nullptr);while (true){cout << "count: " << count << endl; // 临界区,这一行是用来访问临界资源的 sleep(1);}return 0;
}
🌙 1.2 互斥和原子性
- 如果多个线程同时对临界资源进行操作,就可能导致数据不一致的问题,解决该问题的方案就叫做互斥。
- 互斥能够保证在任何时刻都只能有一个执行流进入临界区对临界资源进行访问。
- 原子性指的是不会被任何调度机制打断的操作,对于原子性来说,只有 完成 / 未完成 两种状态。
- 从语言层面上看,可以认为一条汇编就是原子的,一条汇编语句要么被 CPU 执行,要么就不被执行。
1. 举个例子
- 模拟实现一个抢票系统,定义一个全局变量用来表示票仓中剩余的票数。
- 主线程创建 4 个新线程,让这 4 个新线程执行同一个抢票代码,当票仓中的剩余票数为 0 时,这 4 个线程自动退出。
#include <cstdio>
#include <string>
#include <iostream>
#include <unistd.h>
#include <pthread.h>using std::cout;
using std::endl;
using std::string;int g_tickets = 1000; // 初始票仓有一千张票 (公共资源 - 没有保护 - 临界资源)// 新线程
void* grab_ticket(void* args)
{string thread_name = static_cast<const char*>(args);// 这 4 个线程执行同样的抢票逻辑,当票仓票数为 0 时,这 4 个线程就都不能动了while (true){if (g_tickets > 0){usleep(1000); // 充当抢票花费的时间printf("[%s] 获取了一张票,剩余: %d\n", thread_name.c_str(), g_tickets--);}else{break;}}// 实际情况下,抢完票后还有后续的动作,此处不进行实现pthread_exit(nullptr);
}// 主线程
int main()
{pthread_t t1, t2, t3, t4;pthread_create(&t1, nullptr, grab_ticket, (void*)"thread 1");pthread_create(&t2, nullptr, grab_ticket, (void*)"thread 2");pthread_create(&t3, nullptr, grab_ticket, (void*)"thread 3");pthread_create(&t4, nullptr, grab_ticket, (void*)"thread 4");pthread_join(t1, nullptr);pthread_join(t2, nullptr);pthread_join(t3, nullptr);pthread_join(t4, nullptr);return 0;
}
- 本该在票数为 0 时停止的线程居然直接将票数抢到了负数,这显然不符合逻辑。
2. 把票抢到负数的原因
- if 语句在判断条件为真 (即 g_tickets 的值 > 0),代码此时可以并发的切换到其他线程。
- usleep 用于模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段。
--g_tickets
和if (g_tickets > 0)
不是一个原子的操作。- 假设此时 g_tickets 的值为 1,线程 A 刚把 g_tickets 从内存拷贝到寄存器中,正准备判断时,线程 A 的时间片到了。必须得切换到其他线程,此时线程 A 就只好带着自己得寄存器中存着的 g_tickets 上一边呆着去,此时线程 A 的寄存器中存着的 g_tickets 的值是 1。
- 切换到线程 B 之后,线程 B 也要把 g_tickets 从内存拷贝到寄存器中,线程 B 通过 if 判断出 g_tickets 的值 > 0,然后线程 B 就会将 g_tickets 在内存中的的值减 1,此时 g_tickets 的值已经变成 0 了。
- 等到线程 A 切换回来后,线程 A 的寄存器中 g_tickets 的值还是 1,线程 A 以为 g_tickets 的值依旧是 1 ,能够通过 if 判断,但不知道线程 B 已经将 g_tickets 减到 0 了,线程 A 再对 g_tickets 在内存中的值减 1 时就会将 g_tickets 减到 - 1 去。
- 对 g_tickets 执行判断和自减操作都需要将 g_tickets 从内存读取到寄存器中。
- 在多线程访问时,这种将票数干到负数的情况被称为数据不一致。
3. 让 g_tickets 进行自减为什么不是原子的操作
- 在对变量进行
--
操作时,从汇编层面上看,其实有 3 条指令:load
:将全局变量 g_tickets 从内存中加载到寄存器中。update
:更新寄存器中的值,对 g_tickets 执行 - 1 操作。store
:将 - 1 后的新值从寄存器写回到 g_tickets 的内存地址。
⭐ 2. 互斥量 mutex
- 多线程中,为了解决类似于上述抢票系统这样的数据不一致问题,需要将临界区保护起来,同一时刻只允许一个线程访问临界区。
- 为了搞定这个问题,就需要一把锁,在有线程访问临界区时,用这把锁将临界区锁起来,访问完之后再解锁,这把锁被称为互斥量 mutex。
互斥量 mutex 能解决的问题
- 代码必须有互斥行为,当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且此时临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
⭐ 3. 互斥量接口
- 可以使用
pthread_mutex_t
这个类型来定义一个互斥量(锁)。
🌙 3.1 初始化互斥量
- 初始化互斥量的方法有 2 种。
1. 静态初始化
- 如果定义的是全局的锁,可以使用静态的方式初始化这把锁,也可以使用动态的方式初始化这把锁。
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
2. 动态初始化
- 如果定义的是一把局部的锁,则必须用动态的方式初始化这把锁。
#include <pthread.h>int pthread_mutex_init( /* 初始化成功时返回 0,失败时返回错误码 */pthread_mutex_t *restrict mutex, /* 需要初始化的互斥量 (锁) */const pthread_mutexattr_t *restrict attr); /* 互斥量 (锁) 的属性,一般设置为 空 即可 */
🌙 3.2 销毁互斥量
#include <pthread.h>int pthread_mutex_destroy( /* 销毁成功时返回 0,失败时返回错误码 */pthread_mutex_t *mutex); /* 要销毁的互斥量 (锁) */
注意事项
- 如果定义的互斥量 (锁) 是全局的就不用销毁,但如果是局部的,用完锁之后必须要销毁。
- 如果一个互斥量被上了锁,则不能被销毁。
- 不能对一个已经被销毁的互斥量进行加锁操作。
🌙 3.3 互斥量上锁
#include <pthread.h>int pthread_mutex_lock( /* 上锁成功时返回 0,失败时返回错误码 */pthread_mutex_t *mutex); /* 需要上锁的互斥量 (锁) */
加锁的原则
- 尽可能的给少的代码块加锁,如果给一大段的代码加锁,线程之间就变成串行执行了,多线程就没意义了。
- 一般来说,都是给临界区加锁,只需要保护会访问到临界资源的那部分代码即可。
- 谁加的锁,就让谁解锁,最好不要出现线程 A 加锁却让线程 B 解锁的情况。
为互斥量上锁会遇到的情况情况
-
互斥量处于解锁状态:pthread_mutex_lock 函数会将该互斥量锁定,同时返回 0 表示上锁成功。
-
互斥量处于上锁状态:这种状态标识已经有其他线程为该互斥量上了锁,或者其他线程在同时申请这个互斥量,但本线程没竞争到这个互斥量,此时 pthread_mutex_lock 调用就会陷入阻塞状态,一直等到互斥量解锁为止。
🌙 3.4 互斥量解锁
#include <pthread.h>int pthread_mutex_unlock( /* 解锁成功时返回 0,失败时返回错误码 */pthread_mutex_t *mutex); /* 需要解锁的互斥量 (锁) */
🌙 3.5 互斥量使用示例
- 为上面的抢票系统添加一个全局的互斥量 (锁),只有争到这把锁的线程才能进到临界区去访问临界资源。
- 当线程走出临界区之后还需要将互斥量解锁,让其余进程能够去争夺这把锁。
#include <cstdio>
#include <string>
#include <iostream>
#include <unistd.h>
#include <pthread.h>using std::cout;
using std::endl;
using std::string;// 初始票数
int g_tickets = 1000;// 定义全局互斥锁并初始化
pthread_mutex_t gmutex = PTHREAD_MUTEX_INITIALIZER;// 新线程
void* grab_ticket(void* args)
{string thread_name = static_cast<const char*>(args);while (true){pthread_mutex_lock(&gmutex); // 上锁if (g_tickets > 0){usleep(1000);printf("[%s] 获取了一张票,剩余: %d\n", thread_name.c_str(), --g_tickets);pthread_mutex_unlock(&gmutex); // 解锁}else{pthread_mutex_unlock(&gmutex); // 解锁break;}}pthread_exit(nullptr);
}// 主线程
int main()
{pthread_t t1, t2, t3, t4;pthread_create(&t1, nullptr, grab_ticket, (void*)"thread 1");pthread_create(&t2, nullptr, grab_ticket, (void*)"thread 2");pthread_create(&t3, nullptr, grab_ticket, (void*)"thread 3");pthread_create(&t4, nullptr, grab_ticket, (void*)"thread 4");pthread_join(t1, nullptr);pthread_join(t2, nullptr);pthread_join(t3, nullptr);pthread_join(t4, nullptr);return 0;
}
- 现在这几个线程再怎么折腾也不会把票抢到负数了。
⭐ 4. 互斥量的实现原理
🌙 4.1 临界区中的线程也能被线程切换走
- 即使是在临界区中干活的线程,它也是能够进行线程切换的。但即使该线程被切换走了,其他线程也无法进入临界区。
- 因为该线程被切走时,它是抱着钥匙走的,锁没有没解开就意味着其他线程是无法申请到锁,从而进入临界区的。
- 其他线程想要进入临界区,只能等待正在访问临界区的线程将锁解开,然后其他线程去竞争这把锁,争到锁的线程才能访问临界区。
🌙 4.2 锁也是需要被保护的共享资源
- 多线程之间需要竞争锁才能访问临界区,这说明了锁本身也是一种临界资源。
- 既然锁也是临界资源,那么就需要被保护起来,实际上,锁只要保证申请锁的过程是原子的就能保护好自己。
🌙 4.3 如何保证申请锁的过程是原子的
- 为了实现互斥锁的操作,大多数的体系结构都提供了 swap 或 exchange 指令,其作用是将寄存器和内存单元的数据互换。
- 由于从汇编层面上看,只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期。
- 查看一下 lock 和 unlock 的伪代码
1. 线程 lock 争锁的本质
- 设 mutex 的初始值为 1,al 是线程独立拥有的一组寄存器中的的一个,每个线程都有这样的一组寄存器,当线程申请锁时,需要执行以下步骤:
- 使用 movb 指令将 al 寄存器中的值清零,多个线程可同时执行该动作。
- 使用 xchgb 指令将 al 寄存器和 mutex 中的值互换。
- 判断 al 寄存器中的值是否 > 0,若 > 0 则申请锁成功,此时就能进入临界区访问临界资源了。申请锁失败的线程会被挂起等待,直到锁被释放后再次去竞争申请锁。
- 因此,线程之间争锁的本质就是在争夺 mutex 中的那个 1,看哪个线程自己的寄存器能抢到这个 1,就说明哪个线程争到了这把锁。
- 线程只要想争锁,就必须从第 1 步开始。即使前面的线程中途被切走,这个线程也是拿着这个 1 走的,mutex 中的值还是 0,其余线程没法争锁。
2. 线程 unlock 解锁的本质
- 使用 movb 指令将内存中的 mutex 的值重置回 1,让下一个申请锁的线程在执行 xchgb 交换指令后能够得到这个 1。
- 唤醒所有因为申请锁失败而被挂起等待 mutex 的线程,让它们继续去争锁。
🌈 二、可重入 VS 线程安全
⭐ 1. 线程安全和可重入的概念
- 线程安全: 多个线程并发执行同一段代码时,不会出现不同的结果。
- 常见于全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现线程安全问题。
- 重入: 同一个函数被不同的执行流调用,当一个执行流还没有跑完这个函数时,就有其他的执行流再次进入该函数,称之为重入。
- 一个函数在被重入的情况下,运行结果不会出现问题,则该函数被称为可重入函数,否则,是不可重入函数。
- 可重入和不可重入得当成特点来看待,没有谁好谁坏的说法。
⭐ 2. 常见的线程不安全
- 不保护共享变量的函数。
- 函数状态随着被调用,状态发生变化的函数。
- 返回指向静态变量指针的函数。
- 调用线程不安全函数的函数 。
⭐ 3. 常见的线程安全
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
- 类或者接口对于线程来说都是原子操作。
- 多个线程之间的切换不会导致该接口的执行结果存在二义性。
⭐ 4. 常见的不可重入
- 调用了 malloc / free 的函数,因为 malloc 函数是用全局链表来管理堆的。
- 调用了标准 I / O 库的函数,标准 I / O 库的很多实现都是以不可重入的方式使用全局数据结构。
- 可重入函数体内使用了静态的数据结构。
⭐ 5. 常见的可重入
- 不使用全局变量或静态变量。
- 不使用 malloc / new 开辟出的空间。
- 不调用不可重入函数。
- 不返回静态或全局数据,所有数据都由函数的调用者提供。
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。
⭐ 6. 可重入和线程安全的联系
- 函数是可重入的,那就是线程安全的。
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
⭐ 7. 可重入和线程安全的区别
- 可重入函数是线程安全函数的一种。
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数的锁还未释放则会产生死锁,因此是不可重入的。
🌈 三、常见锁概念
⭐ 1. 死锁的概念
- 进程 / 线程 之间相互等待对方所持有的不会被释放的资源,而处于的一种永久等待状态被称为死锁。
- 例:线程 A、B 都需要同时持有两把锁才能开始干活,但是 A、B 此时各自持有一把锁,都在期望得到对方的锁,且 A、B 都不会释放自己持有的锁,那么这两个线程就陷入了永久等待对方的持有的资源的一种死锁状态。
1. 单线程也会产生死锁
- 一个线程在申请完锁之后,在还没将这个锁释放的情况下,又去申请这把锁,此时该线程就会被挂起阻塞等待这个锁被释放了。
#include <pthread.h>pthread_mutex_t gmutex = PTHREAD_MUTEX_INITIALIZER;// 新线程
void* thread_run(void* args)
{pthread_mutex_lock(&gmutex); // 第一次申请锁pthread_mutex_lock(&gmutex); // 在锁未释放的情况下又申请了一次锁pthread_exit(nullptr);
}// 主线程
int main()
{pthread_t tid;pthread_create(&tid, nullptr, thread_run, nullptr); pthread_join(tid, nullptr);return 0;
}
⭐ 2. 产生死锁的必要条件
- 互斥条件:一个资源每次只能被一个执行流使用。
- 请求与保持条件:一个执行流因为请求资源而被阻塞时,这个执行流不释放已经获得的资源。
- 不剥夺条件:一个执行流对于已经获得的资源,在未使用完之前,不能强行剥夺。
- 循环等待条件:多个执行流之间相互等待对方所持有的资源。
⭐ 3. 如何避免死锁
- 破坏产生死锁的 4 个必要条件中的任意一个即可。
- 加锁的顺序要和申请锁的顺序保持一致。
- 避免锁未释放的场景(比如代码只写了 lock 而没写 unlock)。
- 将资源一次性分配完。
🌈 四、线程同步
⭐ 1. 同步概念和竞态条件
- 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,这就叫做同步。
- 竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。
1. 什么是饥饿问题
- 在某些操作系统中,线程之间可能不是均匀的竞争到锁,而可能是某些线程本身的争锁能力特别厉害,导致任务全让这个线程干完了,其他线程竞争不到锁就会产生饥饿问题。
- 而上述情况还算是乐观的,起码争锁能力强的线程有在干活。但如果线程在申请锁之后什么也不做,就一直在申请锁和释放锁,纯纯的浪费资源。
2. 如何解决饥饿问题
- 线程同步能够有效解决饥饿问题,而条件变量则是实现线程同步的一种机制。
- 同步规定,上次持有锁的线程短期内不能再持有锁,就能很好的解决饥饿问题。
⭐ 2. 条件变量概念
- 条件变量是利用线程间共享的全局变量的一种实现同步的机制,条件变量是用来描述某种资源是否就绪的一种数据化描述。
1. 条件变量的主要动作
- 让线程等待条件变量的条件成立而被挂起。
- 另一个线程使条件成立后唤醒等待的线程。
2. 演示条件变量
- 当前有 A、B、C 这 3 个线程需要访问临界区去拿资源,还有 1 个线程 Q 需要访问临界区去放资源,这 4 个线程都需要争同一把锁。
- 如果线程 C 的争锁能力特别强,其余 3 个线程没一个抢的赢它的,那么就只有 C 能访问临界区。但临界资源是有限的,如果线程 Q 不能进来放资源,线程 C 不停的争锁解锁却拿不到资源是没有意义的。
- 此时就可以设置一个条件变量,让线程 Q 管着这个条件变量,让释放完锁的线程都去这个条件变量的位置等待。
- 即使线程 Q 争锁的能力最弱也没关系,等到了所有需要拿资源的线程都释放了锁然后去条件变量处等待后,线程 Q 照样能拿到锁。
- 线程 Q 在放完资源释放锁之后,会将所有在条件变量处等待的线程唤醒,让它们重新去竞争锁,这样就能实现线程的同步了。
3. 条件变量就是一种数据类型
- 条件变量这种数据类型的内部应该存在两个东西
struct cond
{int flag; // 1. 判断条件变量是否就绪tcb_queue; // 2. 维护一个线程队列,线程就是在这个队列里排队
};
⭐ 3. 条件变量函数
-
可以使用
pthread_cond_t
类型来定义一个条件变量。 -
所有的条件变量函数的返回值都是调用函数成功时返回 0,失败时返回错误码。
🌙 3.1 初始化条件变量
- 同初始化互斥量一样,初始化条件变量也有静态初始化和动态初始化两种方式。
1. 静态分配
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
2. 动态分配
- 全局的条件变量可以使用 静态 / 动态 的方式初始化。
- 局部的条件变量必须使用 动态 的方式初始化。
#include <pthread.h>int pthread_cond_init(pthread_cond_t *restrict cond, /* 需要初始化的条件变量 */const pthread_condattr_t *restrict attr); /* 条件变量的属性,一般都设置为空 */
🌙 3.2 销毁条件变量
- 局部的条件变量必须销毁,全局的则不用。
#include <pthread.h>int pthread_cond_destroy(pthread_cond_t *cond); // 销毁指定的 cond 条件变量
🌙 3.4 让线程去条件变量处等待
#include <pthread.h>int pthread_cond_wait( pthread_cond_t *restrict cond, /* 条件变量,指定线程需要去 cond 条件变量处等待 */pthread_mutex_t *restrict mutex); /* 互斥锁,需要释放当前线程所持有的互斥锁 */
- 哪个线程调用的该函数,就让哪个线程去指定的条件变量处等待,还要将这个线程持有的锁释放,让其他线程能够争夺这把锁。
- 线程在哪调用的这个函数,被唤醒之后就要从这个地方继续向下执行后续代码。
- 当线程被唤醒之后,线程是在临界区被唤醒的,线程要重新参与对 mutex 锁的竞争,线程被唤醒 + 重新持有锁两者加起来线程才真正被唤醒。
🌙 3.5 唤醒在条件变量处等待的线程
- 唤醒条件变量的方式有 2 种,分别是唤醒全部线程以及唤醒首个线程。
#include <pthread.h>int pthread_cond_broadcast(pthread_cond_t *cond); // 唤醒在 cond 条件变量队列处等待的 所有 线程
int pthread_cond_signal(pthread_cond_t *cond); // 唤醒在 cond 条件变量队列处等待的 首个 线程
- 该函数说是唤醒了线程,其实只是一种伪唤醒,只有当线程被伪唤醒 + 重新持有锁才是真唤醒。
- 只有被真唤醒的线程才会继续去执行后续代码。
🌙 3.6 条件变量函数用例
- 让主线程创建 6 个线程,其中一个是管理条件变量的 master 线程,另外 5 个是会到条件变量处等待的 slaver 线程。
- 在所有的 slaver 线程都跑到条件变量处排队后,让 master 线程挨个唤醒条件变量队列队头的线程。
- 注:master + slaver + 主线程 共 7 个线程,这个 master 线程只是用来唤醒在条件变量处等待的 slaver 线程而已,不是正儿八经的主线程。
#include <vector>
#include <string>
#include <iostream>
#include <unistd.h>
#include <pthread.h>using std::cin;
using std::cout;
using std::endl;
using std::string;
using std::vector;// 定义全局条件变量并初始化
pthread_cond_t gcond = PTHREAD_COND_INITIALIZER;// 定义全局互斥锁并初始化
pthread_mutex_t gmutex = PTHREAD_MUTEX_INITIALIZER;// master 线程执行的代码
void *maste_core(void *args)
{sleep(3);cout << "master 开始工作" << endl;string thread_name = static_cast<const char *>(args);while (true){pthread_cond_signal(&gcond); // 唤醒在 gconde 条件变量下等待的线程cout << "master 唤醒了一个线程" << endl;sleep(1);}
}// slaver 线程执行的代码
void *slaver_core(void *args)
{string thread_name = static_cast<const char *>(args);while (true){// cout 是向显示器输出内容,显示器是临界资源,也需要使用互斥锁保护pthread_mutex_lock(&gmutex); // 1.加锁pthread_cond_wait(&gcond, &gmutex); // 2.去 gcond 条件变量处等待,之后被唤醒会继续往后执行cout << "当前被唤醒的线程是: " << thread_name << endl;pthread_mutex_unlock(&gmutex); // 3.解锁sleep(1);}
}// 启动 master 线程
void start_master(vector<pthread_t> *tids)
{pthread_t tid;int n = pthread_create(&tid, nullptr, maste_core, (void *)"master thread");if (n == 0){cout << "master thread 创建成功" << endl;tids->emplace_back(tid);}
}// 启动 slaver 线程
void start_slaver(vector<pthread_t> *tids, size_t thread_num)
{for (size_t i = 0; i < thread_num; i++){char *thread_name = new char[64];snprintf(thread_name, 64, "slaver %ld", i + 1);pthread_t tid;int n = pthread_create(&tid, nullptr, slaver_core, static_cast<char *>(thread_name));if (n == 0){cout << "slaver thread 创建成功: " << thread_name << endl;tids->emplace_back(tid);}}
}// 等待所有线程
void wait_thread(vector<pthread_t> &tids)
{for (auto tid : tids)pthread_join(tid, nullptr);
}const size_t num = 5; // 创建的 slaver 线程的数量// 主线程
int main()
{vector<pthread_t> tids;start_master(&tids); // 用 master 线程控制其余线程start_slaver(&tids, num); // 其余的 slaver 线程wait_thread(tids); // 主线程等待 master + slaver 这 6 个线程return 0;
}
- 这 5 个 slaver 线程被唤醒时具有明显的顺序性,因为当这若干个线程启动时默认都会在该条件变量处等待。
- 而每次唤醒的都是条件变量队列的队头线程,当该线程执行完打印操作后会继续排到等待队列的尾部进行 wait,所以能够看到一个周转的现象。
🌙 3.7 错误的设计
- 不可以先解锁,然后再去条件变量处等待。
// 错误的设计
pthread_mutex_lock(&mutex);while (condition_is_false)
{pthread_mutex_unlock(&mutex);// 解锁之后,等待之前,条件可能已经满足,信号已经发出,但是该信号可能被错过pthread_cond_wait(&cond);pthread_mutex_lock(&mutex);
}pthread_mutex_unlock(&mutex);
- 因为 unlock 和 wait 并不是原子操作,在 unlock 之后,wait 之前,如果有其他线程在申请这个互斥锁,发现条件满足,直接把这把锁拿走了。
- 此时 pthread_cond_wait 函数会错误这个信号,最终导致线程永远不会被唤醒。
- 实际在进入 pthread_cond_wait 函数后,会先判断条件变量是否等于 0,若等于 0 则说明条件不满足,会先将对应的互斥锁解开,直到 pthread_cond_wait 函数返回时再将条件变量改为 1,并将对应的互斥锁上锁。
⭐ 4. 条件变量使用规范
- 等待条件变量的代码
pthread_mutex_lock(&mutex);
while (条件为假)pthread_cond_wait(&cond, &mutex);
修改条件
pthread_mutex_unlock(&mutex);
- 唤醒等待线程的代码
pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(&cond); // 也可以使用 broadcast 唤醒所有线程
pthread_mutex_unlock(&mutex);