还记得我们在学线程互斥的时候写的抢票系统代码吗,虽说我们通过使用互斥量来防止多线程访问时候的不安全问题,但是还有一个问题我们还没提及,就是如果当我们执行完临界区代码的时候,如果不进行 usleep() 的话,会发现执行结果全是单个线程在抢着票,而这就涉及到了线程同步与竞争条件问题!下面我们一一来了解一下它们!
- 线程同步:在保证数据安全的前提下,让多个线程能够按照一定的顺序访问临界资源,从而有效避免饥饿问题,叫做线程同步。
- 竞态条件:因为时序问题,当多个线程访问同一个共享变量时,最终的结果取决于线程执行的顺序,导致结果不可预测。
为了解决这些问题,可以使用同步机制来协调线程之间的访问。常用的同步机制有:
- 互斥锁(
Mutex):在进入临界区前获取锁,执行完临界区代码后释放锁,保证同一时间只有一个线程可以访问临界区。 - 屏障(
Barrier):多个线程需要等待其他线程执行完特定代码后再继续执行,可以使用屏障来同步线程的执行。 - 信号量(
Semaphore):控制对共享资源的访问数量,当信号量为0时,线程需要等待;当信号量为1时,线程可以访问共享资源。 - 条件变量(
Condition Variable):当某个条件不满足时,线程进入等待状态,直到其他线程发出特定信号或事件满足条件后,才会唤醒等待线程。
之前我们通过了互斥锁和屏障(解锁完休眠了一会)在一定程度上解决了这个问题,下面我们来学习条件变量和信号量!
Ⅱ. 条件变量
一、条件变量的概念
**条件变量(Condition Variable)**是一种同步机制,通常与互斥锁(Mutex)结合使用,用于线程间的等待和唤醒。它提供了一个让线程进入等待状态并释放锁的机制:
- 当某个条件满足时,唤醒等待线程继续执行。
- 当某个条件不满足时,线程进入等待状态,直到其他线程发出特定信号或事件满足条件后,才会唤醒等待线程。
当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了,一直阻塞着。例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。
条件变量可以分为两种类型:一种是用于通知单个线程的条件变量(signal),另一种是用于通知多个线程的条件变量(broadcast)。使用条件变量的基本流程如下:
- 初始化:使用
pthread_cond_init()函数初始化条件变量。 - 等待:使用
pthread_cond_wait()函数让线程进入等待状态并释放互斥锁,直到其他线程调用条件变量的通知函数将其唤醒。 - 通知单个线程:使用
pthread_cond_signal()函数唤醒等待队列中的一个线程,通常是等待时间最长的线程。 - 通知多个线程:使用
pthread_cond_broadcast()函数唤醒等待队列中的所有线程。 - 销毁:使用
pthread_cond_destroy()函数销毁条件变量。
接下来我们先学学条件变量的这些接口,然后通过生产者消费者模型来深入理解条件变量的用处!
二、条件变量的定义
定义条件变量对象的代码如下:
pthread_cond_t condition; pthread_cond_t 数据类型声明如下:
// come from /usr/include/bits/pthreadtypes.h
/* Data structure for conditional variable handling. The structure of the attribute type is not exposed on purpose. */
typedef union
{
struct
{
int __lock; // 用于实现对条件变量的互斥访问
unsigned int __futex; // 一个底层的系统调用,用于实现线程等待和唤醒,它是一个用户空间和内核空间之间的交互接口,用于实现线程的同步和通信
__extension__ unsigned long long int __total_seq; // 记录条件变量被唤醒的次数,包括所有线程的唤醒次数
__extension__ unsigned long long int __wakeup_seq; // 记录条件变量被唤醒的次数
__extension__ unsigned long long int __woken_seq; // 最后一次被唤醒的时间戳
void *__mutex; // 与条件变量相关联的互斥锁的指针/地址
unsigned int __nwaiters; // 记录当前等待的线程数
unsigned int __broadcast_seq; // 记录最后一次broadcast的时间戳
} __data;
char __size[__SIZEOF_PTHREAD_COND_T]; // 指定条件变量的大小
__extension__ long long int __align; // 指定该类型的对齐方式
} pthread_cond_t;三、条件变量的使用
① pthread_cond_init() 初始化
#include <pthread.h>
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
// 功能:动态初始化条件变量
// 返回值:成功返回0,失败返回错误编号
// 参数:
// cond:线程要等待的目标条件变量
// attr:条件变量属性,默认设为nullptr即可 上面那种方法是通过动态初始化条件变量,其实我们还可以通过静态的方式来初始化条件变量,并且这种方式不用进行条件变量的销毁!
#include <pthread.h>
pthread_cond_t cond = PTHREAD_COND_INITIALIZER; // 静态初始化条件变量② pthread_cond_destroy() 销毁
#include <pthread.h>
int pthread_cond_destroy(pthread_cond_t *cond);
// 功能:销毁条件变量
// 返回值:成功返回0,失败返回错误编号
// 参数:cond是要销毁的条件变量的指针③ 等待条件满足
#include <pthread.h>
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
// 功能:在指定条件变量下阻塞等待,直到被唤醒。
// 返回值:等待成功返回0,失败为非0,可以通过errno变量获取错误码。
// 参数:
// cond:等待的条件变量的指针
// mutex:保护条件变量的互斥锁的指针 需要注意的是,pthread_cond_wait() 函数在返回之前会自动释放 mutex 指向的互斥锁,并在接收到信号后重新获取锁,因此,在使用该函数时不需要手动解锁和加锁。
另外,由于 pthread_cond_wait() 函数可能会发生虚假唤醒(spurious wakeup)。因此,在调用该函数后应该再次检查条件变量是否满足要求,如果不满足,则应该继续等待。
其实除了上面这个等待函数,还有另外一个 pthread_cond_timedwait() 函数,它多了一个功能,就是可以设置一个时间,时间到了的话,即使条件不满足,也会被唤醒!下面是该函数的声明:
#include <pthread.h>
int pthread_cond_timedwait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex,
const struct timespec *restrict abstime);
// 功能:让线程等待条件变量的信号,如果在指定的时间内没有收到信号,则函数会返回一个超时错误。
// 返回值:等待成功返回0,失败为非0,可以通过errno变量获取错误码。
// 参数:
// cond:等待的条件变量的指针
// mutex:保护条件变量的互斥锁的指针
// abstime:绝对时间的指针,线程会在该时间之前等待条件变量的信号④ 唤醒等待
#include <pthread.h>
int pthread_cond_broadcast(pthread_cond_t *cond); // 广播方式唤醒等待的线程
int pthread_cond_signal(pthread_cond_t *cond); // 唤醒一个等待的线程
// 返回值:成功返回0,失败返回错误编号
// 参数:cond就是要唤醒等待线程的目标条件变量的指针⑤ 代码测试
#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
void* start_routine(void* args)
{
const char* name = static_cast<const char*>(args);
pthread_mutex_lock(&mutex);
cout << name << " is waiting... " << endl;
pthread_cond_wait(&cond, &mutex); // 等待条件变量信号
cout << name << " is waiting done, now is running... " << endl;
pthread_mutex_unlock(&mutex);
}
int main()
{
pthread_t t1;
pthread_create(&t1, nullptr, start_routine, (void*)"new thread");
// 先等3秒,先让新线程去等待,然后3秒后唤醒新线程
for(int i = 0; i < 3; ++i)
{
cout << "this is main thread, seconds: " << i + 1 << endl;
sleep(1);
}
// 唤醒新线程
pthread_cond_signal(&cond);
// 记得最后等待新线程
pthread_join(t1, nullptr);
return 0;
}
// 运行结果:
this is main thread, seconds: 1
new thread is waiting...
this is main thread, seconds: 2
this is main thread, seconds: 3
new thread is waiting done, now is running... 这段代码使用了条件变量和互斥锁实现了两个线程之间的同步。主线程创建了一个新线程,然后等待3秒钟,然后通过调用 pthread_cond_signal() 函数来唤醒新线程。新线程在启动后会调用 pthread_cond_wait() 函数,等待条件变量的信号。当主线程调用 pthread_cond_signal() 函数时,新线程会收到信号并从 pthread_cond_wait() )函数中返回,继续执行。
四、为什么 pthread_cond_init() 需要互斥量❓
条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且通过 pthread_cond_signal() 或者 pthread_cond_broadcast() 通知等待在条件变量上的线程。
条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化,所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据。
先来看看下面这段错误的设计:
// 错误的设计:
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() 将错过这个信号,可能会导致线程永远阻塞在这个 pthread_cond_wait() 。所以解锁和等待必须是一个原子操作。
💥所以 pthread_cond_wait() 的实现是这样子的:
- 在函数调用的时候,会以原子性的方式将锁释放,并将当前线程挂起
- 在函数返回的时候,会自动的重新获取之前释放的锁,并重新上锁
所以正确的设计是这样子的(伪代码):
- 等待条件代码
pthread_mutex_lock(&mutex);
while (condition_is_false)
pthread_cond_wait(&cond, &mutex);
修改条件
pthread_mutex_unlock(&mutex);- 给条件发送信号代码
pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex); 需要注意的是,在**使用条件变量时,需要将条件判断放在一个循环中,并在等待时重新检查条件。这是因为线程被唤醒后,条件可能已经改变,需要重新检查条件,如果条件仍然不满足,则继续等待。**
下面我们写一段代码,让两个线程,分别执行不同的函数,来共同完成打印 0~20 的任务,一个线程打印奇数,另一个线程打印偶数:
#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int count = 0;
void* odd_thread(void* args)
{
const char* name = static_cast<const char*>(args);
while(count < 20)
{
pthread_mutex_trylock(&mutex);
while(count % 2 == 0) // 如果count为偶数则等待
{
pthread_cond_wait(&cond, &mutex); // 此时将锁解开,让偶数线程去执行
}
// 执行到这说明奇数线程被唤醒,打印完就唤醒偶数线程
cout << name << " count is " << count++ << endl;
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);
}
}
void* even_thread(void* args)
{
const char* name = static_cast<const char*>(args);
while(count < 20)
{
pthread_mutex_trylock(&mutex);
while(count % 2 == 1) // 如果count为奇数则等待
{
pthread_cond_wait(&cond, &mutex); // 此时将锁解开,让奇数线程去执行
}
// 执行到这说明偶数线程被唤醒,打印完就唤醒奇数线程
cout << name << " count is " << count++ << endl;
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);
}
}
int main()
{
// 下面用两个线程打印0~20
// 奇数线程
pthread_t odd;
pthread_create(&odd, nullptr, odd_thread, (void*)"odd-thread");
// 偶数线程
pthread_t even;
pthread_create(&even, nullptr, even_thread, (void*)"even_thread");
// 记得最后等待新线程
pthread_join(odd, nullptr);
pthread_join(even, nullptr);
return 0;
}
// 运行结果:
even_thread count is 0
odd-thread count is 1
even_thread count is 2
odd-thread count is 3
even_thread count is 4
odd-thread count is 5
even_thread count is 6
odd-thread count is 7
even_thread count is 8
odd-thread count is 9
even_thread count is 10
odd-thread count is 11
even_thread count is 12
odd-thread count is 13
even_thread count is 14
odd-thread count is 15
even_thread count is 16
odd-thread count is 17
even_thread count is 18
odd-thread count is 19
even_thread count is 20 这段代码实现了两个线程交替打印 0~20 的功能,其中一个线程打印奇数,另一个线程打印偶数。这里使用了条件变量 pthread_cond_t 和互斥锁 pthread_mutex_t 来实现线程间的同步。
具体实现流程如下:
- 创建互斥锁
mutex和条件变量cond。 - 创建两个线程,一个线程打印奇数,另一个线程打印偶数。在每个线程中,使用
pthread_mutex_trylock尝试获取互斥锁,如果获取不到,则继续尝试。 - 如果获取到互斥锁,则进行判断。如果要打印的数是偶数(奇数)则调用
pthread_cond_wait等待条件变量,同时将互斥锁解锁,让另一个线程可以获取互斥锁并执行。在等待时,当前线程被阻塞,直到其他线程调用pthread_cond_signal信号并唤醒当前线程。 - 当条件变量被唤醒时,重新获取互斥锁,输出当前数,并调用
pthread_cond_signal信号唤醒另一个线程。 - 线程循环直到输出完
20个数,最后调用pthread_join等待两个线程结束。
需要注意的是,在使用条件变量时,需要将条件判断放在一个循环中,并在等待时重新检查条件。这是因为线程被唤醒后,条件可能已经改变,需要重新检查条件,如果条件仍然不满足,则继续等待。