目录
二十.线程互斥
20.1 什么是线程互斥?
20.2 为什么需要线程互斥?
20.3 互斥锁mutex
20.4 互斥量的接口
20.4.1 互斥量初始
20.4.2 互斥量销毁
20.4.3 互斥量加锁
20.4.4 互斥量解锁
20.4.5 互斥量的基本原理
20.4.6 带上互斥锁后的抢票程序
20.5 死锁问题
死锁的四个必要条件
如何避免死锁
20.6 互斥量的实现机制
二十一.线程同步
21.1 同步概念与竞态条件
21.2 条件变量
21.2.1 条件变量初始
21.2.2 条件变量销毁
21.2.3 等待满足
21.2.3 唤醒等待
21.3 利用条件变量实现线程同步
21.4 为什么pthread_cond_wait需要互斥量?
21.5 条件变量使用规范
二十.线程互斥
20.1 什么是线程互斥?
线程互斥是一种同步机制,用于控制对共享资源的访问,以确保在任意给定的时刻只有一个线程可以访问该资源。在多线程编程中,当多个线程同时竞争访问某个共享资源时,如果没有适当的同步机制,可能会导致竞争条件和数据不一致性的问题。线程互斥通过引入互斥锁等机制,使得在任意时刻只能有一个线程持有资源的访问权限,从而避免了竞争条件和数据不一致性的发生。
进程线程间的互斥相关背景概念
- 临界资源: 多线程执行流共享的资源叫做临界资源。
- 临界区: 每个线程内部,访问临界资源的代码,就叫做临界区。
- 互斥: 任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
- 原子性: 不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
20.2 为什么需要线程互斥?
这里我们直接举个栗子来回答这个问题,我们用代码来模拟一个抢票机制,这里的所定义的票数tickets就是所谓的临界资源,这里我们一共创建5个线程来模拟抢票程序,并不断打印时时监测抢票过程
#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;
int tickets = 1000;//定义一个全局变量,这就是临界资源,1000张票
void* ThreadRotinue(void* args)
{
int id = *(int*)args;
delete (int*)args;
while(true)
{
if(tickets > 0)
{
usleep(10000); //usleep函数能把线程挂起一段时间, 单位是微秒(千分之一毫秒)。
printf("线程[%d] 抢票:%d\n", id, tickets);
tickets--; //抢票,票数递减
}
else
{
break;
}
}
}
int main()
{
pthread_t tid[5];
for(int i = 0; i < 5; i++)//主线程创建出5个线程去抢票
{
int* id = new int(i);
pthread_create(tid + i, nullptr, ThreadRotinue, id);
}
for(int i = 0; i < 5; i++)
{
pthread_join(tid[i], nullptr); //等待线程
}
return 0;
}
运行结果如下:
这里我们惊讶的发现,结果竟然出现了票数剩余为负数的情况!
该代码中记录剩余票数的变量tickets就是临界资源,因为它被多个执行流同时访问,而判断tickets是否大于0、打印剩余票数以及--tickets
这些代码就是临界区,因为这些代码对临界资源进行了访问。
分析剩余票数出现负数的原因:
- if语句判断条件为真以后,代码可以并发的切换到其他线程。
- usleep用于模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段。
--
ticket
操作本身就不是一个原子操作。
为什么--ticket
不是原子操作?
我们对一个变量进行--
,我们实际需要进行以下三个步骤:
Load(加载):将共享变量
tickets
从内存加载到寄存器中。这一步确保了线程正在使用的是最新的变量值。Update(更新):在寄存器中执行减 1 操作。这意味着对寄存器中的值进行修改,而不是直接在内存中进行修改,以确保线程独占了这个操作。
Store(存储):将新的值从寄存器写回到共享变量
tickets
的内存地址。这样可以确保其他线程在需要访问该变量时,能够获取到更新后的值。
--
操作对应的汇编代码如下 :
这个过程我们可以用下面几个图片形象表示 :
1.现在有两个线程thread1和thread2,thread1处于运行中、thread2等待中
当thread1把tickets的值读进CPU由于时间片耗尽被切走了,假设此时thread1读取到的值就是1000,而当thread1被切走时,寄存器中的1000叫做thread1的上下文信息,因此需要被保存起来,之后thread1就被挂起了,放到了等待队列。(也就是说thread1只进行了Load(加载)操作)
2.此时thread2被调度了,thread1处于等待中
此时thread2被调度了,由于thread1只进行了 Load(加载),此时thread2此时看到tickets的值还是1000,有可能系统给thread2的时间片较多,导致thread2一次性执行了100次完整的
--
操作才被切走,最终tickets由1000减到了900。
3.thread2时间片耗尽被切走了,切到thread1带着上下文信息恢复
此时thread2时间片到了被挂起了,又切换到了thread1,它就带着上下文过来恢复,而他的上下文记录到它还处于刚刚完成对ticket的Load(加载)操作,此时寄存器中load的ticket值仍然是1000,这时它接着完成了Update(更新)操作,也就是对1000减到999,最后再然后再Store(存储)操作将更新的ticket写回到内存中,此时内存中的值又由900变成了999
为了解决这个问题,这里我们引入互斥锁mutex的概念
20.3 互斥锁mutex
- 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况变量归属单个线程,其他线程无法获得这种变量。
- 但有时候,很多变量都需要在线程间共享,这样的变量成为共享变量,可以通过数据的共享,完成线程之间的交互。
- 多个线程并发的操作共享变量,就会带来一些问题。
要解决上述抢票系统的问题,需要做到三点:
- 代码必须有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且此时临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。
20.4 互斥量的接口
20.4.1 互斥量初始
在使用互斥量之前,需要对其进行初始化。一般使用 pthread_mutex_init 函数进行初始化,其原型如下:
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
参数说明:
- mutex:需要初始化的互斥量。
- attr:初始化互斥量的属性,一般设置为NULL即可。
返回值说明:
- 互斥量初始化成功返回0,失败返回错误码。
调用pthread_mutex_init函数初始化互斥量叫做动态分配,除此之外,我们还可以用下面这种方式初始化互斥量,该方式叫做静态分配:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
20.4.2 互斥量销毁
在不再需要使用互斥量时,需要将其销毁以释放资源。一般使用 pthread_mutex_destroy 函数进行销毁,其原型如下:
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数说明:
- mutex:需要销毁的互斥量。
返回值说明:
- 互斥量销毁成功返回0,失败返回错误码。
销毁互斥量需要注意:
- 使用
PTHREAD_MUTEX_INITIALIZER
初始化(静态分配)的互斥量不需要销毁。 - 不要销毁一个已经加锁的互斥量。
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁。
20.4.3 互斥量加锁
当线程需要访问临界资源时,需要先对互斥量加锁,以确保只有一个线程能够进入临界区。一般使用 pthread_mutex_lock 函数进行加锁,其原型如下:
int pthread_mutex_lock(pthread_mutex_t *mutex);
参数说明:
- mutex:要加锁的互斥量
返回值说明:
- 加锁成功返回0,失败返回错误码。
调用pthread_mutex_lock
时,可能会遇到以下情况:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么
pthread_mutex_lock
调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
20.4.4 互斥量解锁
当线程访问完临界资源后,需要对互斥量解锁,以允许其他线程进入临界区。一般使用 pthread_mutex_unlock
函数进行解锁,其原型如下:
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数说明:
- mutex:需要解锁的互斥量。
返回值说明:
- 互斥量解锁成功返回0,失败返回错误码。
20.4.5 互斥量的基本原理
互斥量的初始化与销毁: 在使用互斥量之前,需要对其进行初始化,一般通过
pthread_mutex_init
函数实现。销毁互斥量时使用pthread_mutex_destroy
函数。这些操作确保互斥量的正确性和可用性。互斥量的加锁与解锁: 当线程需要访问临界资源时,首先需要对互斥量进行加锁,以确保只有一个线程能够进入临界区。加锁使用
pthread_mutex_lock
函数,解锁则使用pthread_mutex_unlock
函数。这些操作保证了临界资源的独占性。
引入互斥量后,当一个线程申请到锁进入临界区时,在其他线程看来该线程只有两种状态,要么没有申请锁,要么锁已经释放了,因为只有这两种状态对其他线程才是有意义的。
例如,图中线程1进入临界区后,在线程2、3、4看来,线程1要么没有申请锁,要么线程1已经将锁释放了,因为只有这两种状态对线程2、3、4才是有意义的,当线程2、3、4检测到其他状态时也就被阻塞了
此时对于线程2、3、4而言,它们就认为线程1的整个操作过程是原子的。
临界区内的线程可能进行线程切换吗?
临界区内的线程完全可能进行线程切换,但即便该线程被切走,其他线程也无法进入临界区进行资源访问,因为此时该线程是拿着锁被切走的,锁没有被释放也就意味着其他线程无法申请到锁,也就无法进入临界区进行资源访问了。
其他想进入该临界区进行资源访问的线程,必须等该线程执行完临界区的代码并释放锁之后,才能申请锁,申请到锁之后才能进入临界区。
20.4.6 带上互斥锁后的抢票程序
#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;
int tickets = 1000; // 定义一个全局变量,这就是临界资源,1000张票
pthread_mutex_t mutex; // 定义互斥锁
void* ThreadRotinue(void* args)
{
int id = *(int*)args;
delete (int*)args;
while(true)
{
pthread_mutex_lock(&mutex); // 加锁
if(tickets > 0)
{
usleep(10000); // usleep函数能把线程挂起一段时间,单位是微秒(千分之一毫秒)。
printf("线程[%d] 抢票:%d\n", id, tickets);
tickets--; // 抢票,票数递减
}
else
{
pthread_mutex_unlock(&mutex); // 解锁
break;
}
pthread_mutex_unlock(&mutex); // 解锁
}
}
int main()
{
pthread_t tid[5];
pthread_mutex_init(&mutex, NULL); // 初始化互斥锁
for(int i = 0; i < 5; i++) // 主线程创建出5个线程去抢票
{
int* id = new int(i);
pthread_create(tid + i, nullptr, ThreadRotinue, id);
}
for(int i = 0; i < 5; i++)
{
pthread_join(tid[i], nullptr); // 等待线程
}
pthread_mutex_destroy(&mutex); // 销毁互斥锁
return 0;
}
演示效果:
20.5 死锁问题
死锁是多线程编程中常见的问题,指的是两个或多个线程相互等待对方持有的资源,导致所有线程都无法继续执行的状态。 在使用互斥锁时,如果不注意锁的加锁顺序,就容易导致死锁问题。
这里我们举一个经典的死锁例子:
#include <iostream>
#include <thread>
#include <mutex>
using namespace std;
mutex mutex1;
mutex mutex2;
void threadFunction1() {
lock_guard<mutex> lock1(mutex1);
this_thread::sleep_for(chrono::milliseconds(100));
lock_guard<mutex> lock2(mutex2);
cout << "Thread 1 acquired mutex1 and mutex2" << endl;
}
void threadFunction2() {
lock_guard<mutex> lock2(mutex2);
this_thread::sleep_for(chrono::milliseconds(100));
lock_guard<mutex> lock1(mutex1);
cout << "Thread 2 acquired mutex2 and mutex1" << endl;
}
int main() {
thread t1(threadFunction1);
thread t2(threadFunction2);
t1.join();
t2.join();
return 0;
}
运行代码后:
可以观察到,此时程序就处于一个被阻塞的状态
用ps命令查看该进程时可以看到,该进程当前的状态是Sl+,其中的l实际上就是lock的意思,表示该进程当前处于一种死锁的状态。
- 在这个示例中,有两个线程,每个线程都试图先锁定 mutex1,然后再锁定 mutex2。当一个线程已经锁定了 mutex1,而另一个线程已经锁定了 mutex2,那么它们都会等待对方释放对方所持有的互斥量。这种情况下就可能发生死锁。
- 例如,线程1获得了 mutex1 的锁,然后暂停,线程2获得了 mutex2 的锁,然后暂停。接下来,线程1试图获取 mutex2 的锁,但由于线程2已经持有了 mutex2 的锁,因此线程1会被阻塞。同样的,线程2也试图获取 mutex1 的锁,但由于线程1已经持有了 mutex1 的锁,因此线程2也会被阻塞。这样,两个线程就会相互等待,导致死锁。
- 为了避免这种死锁,我们应该保持一致的锁定顺序。例如,可以约定所有线程都先锁定 mutex1,然后再锁定 mutex2。
死锁的四个必要条件
- 互斥条件: 一个资源每次只能被一个执行流使用。
- 请求与保持条件: 一个执行流因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件: 一个执行流已获得的资源,在未使用完之前,不能强行剥夺。
- 循环等待条件: 若干执行流之间形成一种头尾相接的循环等待资源的关系。
注意: 这是死锁的四个必要条件,也就是说只有同时满足了这四个条件才可能产生死锁。
如何避免死锁
- 加锁顺序:对多个互斥量加锁时,保持一致的加锁顺序,避免不同线程以不同的顺序加锁而导致死锁。
- 加锁时间:尽量减小临界区的范围,在持有锁的时间内,尽快完成对资源的操作。
- 超时机制:在获取锁的时候设置超时,如果超过一定时间仍未获得锁,则放弃获取资源。
- 避免嵌套锁:尽量避免在一个互斥区域内再次申请其他锁,以免造成死锁。
除此之外,还有一些避免死锁的算法,比如死锁检测算法和银行家算法。
20.6 互斥量的实现机制
锁是否需要被保护?
我们说被多个执行流共享的资源叫做临界资源,访问临界资源的代码叫做临界区。所有的线程在进入临界区之前都必须竞争式的申请锁,因此锁也是被多个执行流共享的资源,也就是说锁本身就是临界资源。
既然锁是临界资源,那么锁就必须被保护起来,但锁本身就是用来保护临界资源的,那锁又由谁来保护的呢?
锁实际上是自己保护自己的,我们只需要保证申请锁的过程是原子的,那么锁就是安全的。
如何实现申请锁的过程是原子的?
- 上面我们已经说明了
--
和++
操作不是原子操作,可能会导致数据不一致问题。 - 要保证申请锁的过程是原子的,通常使用底层的硬件指令来实现。大多数体系结构提供了一种原子交换指令,如 xchg 或 exchange 指令。这些指令可以在一个操作中完成寄存器和内存单元之间的数据交换,保证了这个操作的原子性。因此,申请锁的过程可以通过这些原子交换指令来实现,确保在任何时候只有一个线程能够成功地获取锁。
下面我们来看看lock和unlock的伪代码:
%al是一个cpu上的寄存器,xchgb是交换指令。
我们创建锁,本质是在内存上创建一个变量,初始化锁是将锁的初始化为一个非0的值。
例如,此时内存中mutex的值为1,thread1申请锁时先将al寄存器中的值设为0,然后将al寄存器中的值与内存中mutex的值进行交换。
交换完成后检测该线程的al寄存器中的值为1,则该线程申请锁成功,可以进入临界区对临界资源进行访问。
而此后的thread2若是再申请锁,与内存中的mutex交换得到的值就是0了,此时该线程申请锁失败,需要被挂起等待,直到锁被释放后再次竞争申请锁
二十一.线程同步
21.1 同步概念与竞态条件
- 同步概念:同步是指在多线程环境下,协调不同线程之间的执行顺序和操作,以确保它们能够按照预期的顺序执行和相互协作。在多线程编程中,同步用于解决竞争条件和数据一致性的问题,确保线程之间的协作正确可靠。
- 竞态条件: 因为时序问题,而导致程序异常,我们称之为竞态条件。
举个生活例子:
同步就是操作过程中必须要有先后,比如妈妈做完饭后,儿子才能开始吃饭。一家人到齐后才能吃饭。
- 首先需要明确的是,单纯的加锁是会存在某些问题的,如果个别线程的竞争力特别强,每次都能够申请到锁,但申请到锁之后什么也不做,所以在我们看来这个线程就一直在申请锁和释放锁,这就可能导致其他线程长时间竞争不到锁,引起饥饿问题。
- 单纯的加锁是没有错的,它能够保证在同一时间只有一个线程进入临界区,但它没有高效的让每一个线程使用这份临界资源。
- 现在我们增加一个规则,当一个线程释放锁后,这个线程不能立马再次申请锁,该线程必须排到这个锁的资源等待队列的最后。
- 增加这个规则之后,下一个获取到锁的资源的线程就一定是在资源等待队列首部的线程,如果有十个线程,此时我们就能够让这十个线程按照某种次序进行临界资源的访问。
21.2 条件变量
条件变量是一种线程同步机制,用于在多个线程之间进行协调和通信。条件变量通常与互斥锁结合使用,用于等待某个条件的发生。当条件不满足时,线程可以调用条件变量的等待操作来等待条件的发生,同时释放互斥锁,让其他线程能够进入临界区。当条件满足时,线程可以调用条件变量的通知操作来通知等待的线程条件已经满足,从而唤醒等待的线程继续执行。
21.2.1 条件变量初始
初始化分为两种:
//动态分配
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
//静态分配
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
参数说明:
- cond:需要初始化的条件变量。
- attr:初始化条件变量的属性,一般设置为NULL。
返回值说明:
- 成功返回0,失败返回错误码。
21.2.2 条件变量销毁
条件变量的销毁可以使用 pthread_cond_destroy 函数,用于释放条件变量占用的资源。
int pthread_cond_destroy(pthread_cond_t *cond);
参数说明:
- cond:需要销毁的条件变量。
返回值说明:
- 成功返回0,失败返回错误码。
使用静态分配初始化的条件变量不需要销毁;
21.2.3 等待满足
线程可以使用条件变量的等待操作来等待条件的发生。等待操作通常与互斥锁一起使用,以确保等待操作的原子性。
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
参数说明:
- cond:需要等待的条件变量。
- mutex:当前线程所处临界区对应的互斥锁。
返回值说明:
- 成功返回0,失败返回错误码。
21.2.3 唤醒等待
条件变量的通知操作用于唤醒等待条件的线程。有两种通知方式:
唤醒单个等待线程:使用 pthread_cond_signal
函数。
int pthread_cond_signal(pthread_cond_t *cond);
唤醒全部等待线程:使用 pthread_cond_broadcast
函数。
int pthread_cond_broadcast(pthread_cond_t *cond);
参数说明:
- cond:唤醒在cond条件变量下等待的线程。
返回值说明:
- 函数调用成功返回0,失败返回错误码。
21.3 利用条件变量实现线程同步
#include <iostream>
#include <pthread.h>
#include <string>
#include <unistd.h>
using namespace std;
#define NUM 5
pthread_mutex_t mtx;
pthread_cond_t cond;
int tickets = 100;
void* producer(void* args)
{
string name = (char*)args;
while (tickets > 0)
{
pthread_mutex_lock(&mtx); // 加锁
// 生产者线程在唤醒消费者线程之前修改共享资源
pthread_cond_signal(&cond);//1.唤醒在条件变量下一个线程
pthread_mutex_unlock(&mtx); // 解锁
sleep(1);
}
}
void* buyer(void* args)
{
int id = *(int*)args;
delete (int*)args;
pthread_mutex_lock(&mtx); // 加锁
while (tickets > 0) {
// 消费者线程在循环中等待条件变量的信号
pthread_cond_wait(&cond, &mtx); // 等待唤醒
if (tickets > 0) {
cout << "线程[" << id << "] 抢到票:" << tickets << endl;
tickets--; // 抢票,票数递减
}
}
pthread_mutex_unlock(&mtx); // 解锁
return NULL;
}
int main()
{
pthread_mutex_init(&mtx, nullptr);
pthread_cond_init(&cond, nullptr);
pthread_t master; // 创建生产者线程
pthread_t worker[NUM]; // 创建消费者线程数组
pthread_create(&master, nullptr, producer, (void*)"boss");
for(int i = 0; i < NUM; i++)
{
int* num = new int(i);
pthread_create(worker + i, nullptr, buyer, (void*)num);
}
for(int i = 0; i < NUM; i++)
{
pthread_join(worker[i], nullptr);
}
pthread_join(master, nullptr);
pthread_mutex_destroy(&mtx);
pthread_cond_destroy(&cond);
return 0;
}
此时我们会发现这五个线程时具有明显的顺序性,这是因为这5个线程启动时默认都会在该条件变量下去等待,而我们每次都唤醒的是在当前条件变量下等待的第一个线程,当该线程执行完打印操作后会继续排到等待队列的尾部进行等待,所以我们能够看到一个轮换的现象。 这样就实现了线程的同步
21.4 为什么pthread_cond_wait需要互斥量?
- 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
- 条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化,所以一定要用互斥锁来保护,没有互斥锁就无法安全的获取和修改共享数据。
- 当线程进入临界区时需要先加锁,然后判断内部资源的情况,若不满足当前线程的执行条件,则需要在该条件变量下进行等待,但此时该线程是拿着锁被挂起的,也就意味着这个锁再也不会被释放了,此时就会发生死锁问题。
- 所以在调用
pthread_cond_wait
函数时,还需要将对应的互斥锁传入,此时当线程因为某些条件不满足需要在该条件变量下进行等待时,就会自动释放该互斥锁。 - 当该线程被唤醒时,该线程会接着执行临界区内的代码,此时便要求该线程必须立马获得对应的互斥锁,因此当某一个线程被唤醒时,实际会自动获得对应的互斥锁。
按照上面的说法,我们设计出如下的代码:先上锁,发现条件不满足,解锁,然后等待在条件变量上不就行了,如下代码:
//错误的设计
pthread_mutex_lock(&mutex);
while (condition_is_false){
pthread_mutex_unlock(&mutex);
//解锁之后,等待之前,条件可能已经满足,信号已经发出,但是该信号可能被错过
pthread_cond_wait(&cond);
pthread_mutex_lock(&mutex);
}
pthread_mutex_unlock(&mutex);
调用解锁之后,在调用pthread_cond_wait函数之前,如果已经有其他线程获取到互斥量,发现此时条件满足,于是发送了信号,那么此时pthread_cond_wait函数将错过这个信号,最终可能会导致线程永远不会被唤醒
21.5 条件变量使用规范
等待条件变量的代码
pthread_mutex_lock(&mutex);
while (条件为假)
pthread_cond_wait(&cond, &mutex);
修改条件
pthread_mutex_unlock(&mutex);
唤醒等待线程的代码
pthread_mutex_lock(&mutex);
//设置条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(&mutex);