目录
Linux线程互斥
进程线程间的互斥相关背景概念
- 临界资源: 多线程执行流共享的资源叫做临界资源。
- 临界区: 每个线程内部,访问临界资源的代码,就叫做临界区。
- 互斥: 任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
- 原子性: 不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
临界资源和临界区
- 进程之间如果要进行通信我们需要先创建第三方资源,让不同的进程看到同一份资源
- 由于这份第三方资源可以由操作系统中的不同模块提供,于是进程间通信的方式有很多种。
- 进程间通信中的第三方资源就叫做临界资源,访问第三方资源的代码就叫做临界区。
而多线程 的大部分资源都是共享的,线程之间进行通信不需要费那么大的劲去创建第三方资源。
我们来做一个简单的实验
- 我们在全局区定义一个count变量,让新线程每隔一秒对该变量加一操作
- 让主线程每隔一秒获取count变量的值进行打印。
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
int count = 0;
void* Routine(void* arg)
{
// 新线程
while (1)
{
count++;
sleep(1);
}
// 线程终止
pthread_exit((void*)0);
}
int main()
{
// 线程创建
pthread_t tid;
pthread_create(&tid, NULL, Routine, NULL);
// 主线程
while (1)
{
printf("count: %d\n", count);
sleep(1);
}
pthread_join(tid, NULL);
return 0;
}
- 此时我们相当于实现了主线程和新线程之间的通信
- 全局变量count 就叫做临界资源,因为它被多个执行流共享
- 主线程中的printf 和 新线程中count++ 就叫做临界区,因为这些代码对临界资源进行了访问。
互斥和原子性
- 在多线程情况下,如果这多个执行流都自顾自的对临界资源进行操作,那么此时就可能导致数据不一致的问题。(比如我们在抢票的时候,多个用户抢重复的票的时候,就会有导致强抢到重复的票)
- 解决该问题的方案就叫做互斥,互斥的作用就是,保证在任何时候有且只有一个执行流进入临界区对临界资源进行访问。
- 原子性指的是不可被分割的操作,该操作不会被任何调度机制打断,该操作只有两态,要么完成,要么未完成。
我们来做一个简单的实验
- 下面我们模拟实现一个非常简单的抢票系统,我们将记录票的剩余张数的变量定义为 全局变量tickets
- 主线程创建六个新线程,让这六个新线程进行抢票,当票被抢完后(全局变量tickets < 0)这六个线程自动退出。
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
int tickets = 1000;
void* TicketGrabbing(void* arg)
{
const char* name = (char*)arg;
while (1)
{
if (tickets > 0)
{
usleep(10000);
printf("[%s] get a ticket, left: %d\n", name, --tickets);
}
else
break;
}
printf("%s quit!\n", name);
pthread_exit((void*)0);
}
int main()
{
// 线程创建
pthread_t t1, t2, t3, t4, t5, t6;
pthread_create(&t1, NULL, TicketGrabbing, "thread 1");
pthread_create(&t2, NULL, TicketGrabbing, "thread 2");
pthread_create(&t3, NULL, TicketGrabbing, "thread 3");
pthread_create(&t4, NULL, TicketGrabbing, "thread 4");
pthread_create(&t5, NULL, TicketGrabbing, "thread 5");
pthread_create(&t6, NULL, TicketGrabbing, "thread 6");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
pthread_join(t5, NULL);
pthread_join(t6, NULL);
return 0;
}
- 记录剩余票数的全局变量tickets 就是临界资源
- 判断全局变量tickets是否大于0、打印剩余票数以及--tickets
- 我们发现出现了剩余票数为负数的情况,因为多个执行流都自顾自的对临界资源进行操作,所以导致数据不一致的问题。
剩余票数出现负数(数据不一致)的原因:
- if 语句判断条件为真以后,可执行程序可以并发的切换到其他线程
- usleep 用于模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
- --tickets 操作本身就不是一个原子操作
为啥--tickets 操作本身就不是一个原子操作???
我们对一个变量进行 --操作,我们实际需要进行以下三个步骤:
- load:将共享变量tickets 从内存加载到寄存器中。
- update:更新寄存器里面的值,执行 -1 操作。
- store:将新值从寄存器写回共享变量tickets 的内存地址。
--操作对应的汇编:
- 既然 --操作 需要三个步骤才能完成,那么就有可能当 线程一 刚把 全局变量tickets 的值读进CPU就被切走了,也就是从CPU上剥离下来
- 假设此时线程一 读取到全局变量tickets的值就是1000,而当线程一 被切走时,寄存器中的1000叫做线程一 的上下文信息,因此需要被保存起来,之后线程一 就被挂起了。
- 假设此时线程二 被调度了,由于线程一 只进行了--操作 的第一步,因此线程二此时看到tickets的值还是1000
- 而系统给线程二 的时间片可能较多,导致线程二 一次性执行了100次 --操作才被切走,最终tickets由1000减到了900
- 此时操作系统再把线程一 恢复上来,恢复的本质就是继续执行线程一的代码,并且要将线程一曾经的硬件上下文信息恢复出来
- 此时寄存器当中的值是恢复出来的1000,然后线程一 继续执行 --操作 的第二步和第三步,最终将全局变量tickets的值为 999写回内存
总结:
线程一 抢了1张票,线程二 抢了100张票,而此时剩余的票数却是999,也就相当于多出了100张票。
- 因此对一个变量进行 --操作并不是原子的
- 虽然- -tickets 看起来就是一行代码,但这行代码被编译器编译后本质上是三行汇编
- 同理可得,对一个变量进行++ 也需要对应的三个步骤,即 ++操作也不是原子操作
互斥量mutex
- 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况变量归属单个线程,其他线程无法获得这种变量。
- 但有时候,很多变量都需要在线程间共享,这样的变量成为共享变量,可以通过数据的共享,完成线程之间的交互。
- 多个线程并发的操作共享变量,就会带来一些问题。
如果要解决上述抢票系统的问题,需要做到三点:
- 代码必须有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且此时临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
互斥量的接口
初始化互斥量
初始化互斥量的函数叫做 pthread_mutex_init,该函数的函数原型如下:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数:
- mutex:需要初始化的互斥量。
- attr:初始化互斥量的属性,一般设置为NULL即可。
返回值:
- 如果初始化成功,返回值为 0。
- 如果发生错误,返回值为以下错误码之一
EAGAIN
:表示系统资源不足,无法初始化互斥量。ENOMEM
:表示内存不足,无法分配所需的资源。EBUSY
:表示正在进行初始化的互斥量已经被初始化。EINVAL
:表示提供的属性对象无效。
程序调用 pthread_mutex_init函数 初始化互斥量叫做动态分配,除此之外,我们还可以用下面这种方式初始化互斥量,该方式叫做静态分配:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
销毁互斥量
销毁互斥量的函数叫做 pthread_mutex_destroy,该函数的函数原型如下:
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数:
- mutex:需要销毁的互斥量。
返回值:
- 如果成功销毁互斥量,返回值为 0 。
- 如果发生错误,返回值为以下错误码之一:
EBUSY
:互斥量当前正被锁定或者正在被条件变量使用,无法销毁。EINVAL
:mutex
不是一个有效的互斥量。
销毁互斥量需要注意:
- 使用
PTHREAD_MUTEX_INITIALIZER
初始化的互斥量不需要销毁。 - 不要销毁一个已经加锁的互斥量。
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁。
互斥量加锁
互斥量加锁的函数叫做 pthread_mutex_lock,该函数的函数原型如下:
int pthread_mutex_lock(pthread_mutex_t *mutex);
参数:
mutex
:一个指向要锁定的互斥量对象的指针。
返回值:
-
如果成功获取了互斥锁,返回值为 0。
-
如果发生错误,返回值将是以下错误码之一:
-
EINVAL
:表示互斥量mutex
不是有效的互斥量。 -
EAGAIN
:表示当前互斥量已被锁定,并且调用线程已对该互斥量设置了非阻塞属性。 -
EDEADLK
:表示当前线程已经持有该互斥量,再次尝试锁定会导致死锁。
-
程序调用 pthread_mutex_lock 时,可能会遇到以下情况:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_mutex_lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
互斥量解锁
互斥量解锁的函数叫做 pthread_mutex_unlock,该函数的函数原型如下:
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数:
- mutex:需要解锁的互斥量
返回值:
- 如果成功解锁互斥量,返回值为 0 。
- 如果发生错误,返回值为以下错误码之一:
EINVAL
:mutex
不是一个有效的互斥量。EPERM
:当前线程不持有该互斥量的锁,无权解锁。
我们就开始解决上面数据重复的问题了
- 我们在上述的抢票系统中引入互斥量,每一个线程要进入临界区之前都必须先申请锁
- 只有申请到锁的线程才可以进入临界区对临界资源进行访问
- 并且当线程出临界区的时候需要释放锁,这样才能让其余要进入临界区的线程继续竞争锁。
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
int tickets = 1000;
pthread_mutex_t mutex;
void* TicketGrabbing(void* arg)
{
const char* name = (char*)arg;
while (1){
pthread_mutex_lock(&mutex);
if (tickets > 0)
{
usleep(100);
printf("[%s] get a ticket, left: %d\n", name, --tickets);
pthread_mutex_unlock(&mutex);
}
else
{
pthread_mutex_unlock(&mutex);
break;
}
}
printf("%s quit!\n", name);
pthread_exit((void*)0);
}
int main()
{
pthread_mutex_init(&mutex, NULL);
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, TicketGrabbing, "thread 1");
pthread_create(&t2, NULL, TicketGrabbing, "thread 2");
pthread_create(&t3, NULL, TicketGrabbing, "thread 3");
pthread_create(&t4, NULL, TicketGrabbing, "thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
pthread_mutex_destroy(&mutex);
return 0;
}
运行程序,此时在抢票过程中就不会出现票数剩余为负数的情况了。
注意:
- 在大部分情况下,加锁本身都是有损于性能的事,它让多执行流由并行执行变为了串行执行,这几乎是不可避免的。
- 我们应该在合适的位置进行加锁和解锁,这样能尽可能减少加锁带来的性能开销成本。
- 进行临界资源的保护,是所有执行流都应该遵守的标准,这时程序员在编码时需要注意的。
互斥量实现原理探究
程序加锁后的原子性体现在哪里?
-
操作的完整性:当一个线程获取到锁并执行被保护的代码段时,这一系列操作会作为一个不可分割的单元来执行。例如,对一个共享变量的读取、修改和写入操作,如果在加锁的范围内,就不会被其他线程中断,从而保证了这一系列操作的完整性和一致性。
-
数据的一致性:确保在同一时刻只有一个线程能够访问和修改被锁保护的数据。这避免了多个线程同时修改数据导致的数据不一致或错误的结果。
-
避免竞争条件:防止多个线程同时竞争对共享资源的访问,从而消除由于竞争导致的不确定和错误的结果。
例如,图中线程1进入临界区后,在线程2、3、4看来,线程1要么没有申请锁,要么线程1已经将锁释放了,因为只有这两种状态对线程2、3、4才是有意义的,当线程2、3、4检测到其他状态时也就被阻塞了。此时对于线程2、3、4而言,它们就认为线程1的整个操作过程是原子的。
临界区内的线程可以进行线程切换吗?
- 临界区内的线程完全可能进行线程切换
- 即便该线程被切走,其他线程也无法进入临界区进行资源访问,因为此时该线程是拿着锁被切走的,锁没有被释放也就意味着其他线程无法申请到锁,也就无法进入临界区进行资源访问了
- 其他想进入该临界区进行资源访问的线程,必须等该线程执行完临界区的代码并释放锁之后,才能申请锁,申请到锁之后才能进入临界区。
锁是否需要被保护?
- 我们说被多个执行流共享的资源叫做临界资源,访问临界资源的代码叫做临界区。
- 所有的线程在进入临界区之前都必须竞争式的申请锁,因此锁也是被多个执行流共享的资源,也就是说锁本身就是临界资源。
- 既然锁是临界资源,那么锁就必须被保护起来,但锁本身就是用来保护临界资源的,那锁又由谁来保护的呢?
- 锁实际上是自己保护自己的,我们只需要保证申请锁的过程是原子的,那么锁就是安全的。
如何保证申请锁的过程是原子的?
- 上面我们已经说明了--操作 和 ++操作 不是原子操作,可能会导致数据不一致问题。
- 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用就是把寄存器和内存单元的数据相交换。
- 由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期。
线程安全VS可重入
- 线程安全: 多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现线程安全问题。
- 可重入: 同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则是不可重入函数。
常见的线程不安全的情况
- 不保护共享变量的函数。
- 函数状态随着被调用,状态发生变化的函数。
- 返回指向静态变量指针的函数。
- 调用线程不安全函数的函数。
常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
- 类或者接口对于线程来说都是原子操作。
- 多个线程之间的切换不会导致该接口的执行结果存在二义性。
常见的不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。
- 调用了标准I/O库函数,标准I/O可以的很多实现都是以不可重入的方式使用全局数据结构。
- 可重入函数体内使用了静态的数据结构。
常见的可重入的情况
- 不使用全局变量或静态变量。
- 不使用malloc或者new开辟出的空间。
- 不调用不可重入函数。
- 不返回静态或全局数据,所有数据都由函数的调用者提供。
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。
可重入与线程安全联系
- 函数是可重入的,那就是线程安全的。
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入与线程安全区别
- 可重入函数是线程安全函数的一种。
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数的锁还未释放则会产生死锁,因此是不可重入的。
常见锁
死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。
单执行流中的死锁
- 单执行流也有可能产生死锁
- 如果某一执行流连续申请了两次锁,
- 因为该执行流第一次申请锁的时候是申请成功的,但第二次申请锁时因为该锁已经被申请过了,于是申请失败导致被挂起直到该锁被释放时才会被唤醒
- 但是这个锁本来就在自己手上,自己现在处于被挂起的状态根本没有机会释放锁,所以该执行流将永远不会被唤醒,此时该执行流也就处于一种死锁的状态。
我们来做一个简单的实验
- 我们让主线程创建的新线程连续申请了两次锁
#include <stdio.h>
#include <pthread.h>
pthread_mutex_t mutex;
void* Routine(void* arg)
{
// 互斥量加锁
pthread_mutex_lock(&mutex);
pthread_mutex_lock(&mutex);
pthread_exit((void*)0);
}
int main()
{
// 线程创建
pthread_t tid;
// 初始化互斥量
pthread_mutex_init(&mutex, NULL);
pthread_create(&tid, NULL, Routine, NULL);
pthread_join(tid, NULL);
pthread_mutex_destroy(&mutex);
return 0;
}
- 运行代码,此时该程序实际就处于一种被挂起的状态
- 使用 ps 命令查看该进程时可以看到该进程当前处于一种死锁的状态。
阻塞
- 进程运行时是被CPU调度的,换句话说进程在调度时是需要用到CPU资源的
- 每个CPU都有一个运行等待队列(runqueue),CPU在运行时就是从该队列中获取进程进行调度的
- 在运行等待队列中的进程本质上就是在等待CPU资源
- 实际上不止是等待CPU资源如此,等待其他资源也是如此,比如 锁的资源、磁盘的资源、网卡的资源 等等,它们都有各自对应的资源等待队列。
当某一个进程在被CPU调度时,该进程需要用到锁的资源,但是此时锁的资源正在被其他进程使用
- 首先该进程的 状态 就会由 R状态变为某种阻塞状态,比如S状态。并且该进程会被移出运行等待队列,被链接到等待锁的资源的资源等待队列,而CPU则继续调度运行等待队列中的下一个进程。
- 此后若还有进程需要用到这一个锁的资源,那么这些进程也都会被移出运行等待队列,依次链接到这个锁的资源等待队列当中。
- 直到使用锁的进程已经使用完毕,也就是锁的资源已经就绪,此时就会从锁的资源等待队列中唤醒一个进程,将该进程的状态由S状态改为R状态,并将其重新链接到运行等待队列,等到CPU再次调度该进程时,该进程就可以使用到锁的资源了。
总结
- 站在操作系统的角度,进程等待某种资源,就是将当前进程的task_struct放入对应的等待队列,这种情况可以称之为当前进程被挂起等待了
- 站在用户角度,当进程等待某种资源时,用户看到的就是自己的进程卡住不动了,我们一般称之为应用阻塞了
- 这里所说的资源可以是硬件资源也可以是软件资源,锁本质就是一种软件资源,当我们申请锁时,锁当前可能并没有就绪,可能正在被其他线程所占用,此时当其他线程再来申请锁时,就会被放到这个锁的资源等待队列当中
死锁的四个必要条件
- 互斥条件: 一个资源每次只能被一个执行流使用
- 请求与保持条件: 一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件: 一个执行流已获得的资源,在未使用完之前,不能强行剥夺
- 循环等待条件: 若干执行流之间形成一种头尾相接的循环等待资源的关系
注意: 这是死锁的四个必要条件,也就是说只有同时满足了这四个条件才可能产生死锁
避免死锁
- 破坏死锁的四个必要条件
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配
除此之外,还有一些避免死锁的算法,比如死锁检测算法和银行家算法。
Linux线程同步
条件变量
- 当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了
- 例如一个线程访问队列时,发现队列为空,它只能等待,直到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量
条件变量是利用线程间共享的全局变量进行同步的一种机制,条件变量是用来描述某种资源是否就绪的一种数据化描述
条件变量主要包括两个动作:
- 一个线程等待条件变量的条件成立而被挂起
- 另一个线程使条件成立后唤醒等待的线程
条件变量通常需要配合互斥锁一起使用
同步概念与竞态条件
- 同步: 在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,这就叫做同步。
- 竞态条件: 因为时序问题,而导致程序异常,我们称之为竞态条件。
为啥要有同步?
- 单纯的加锁是会存在某些问题的,如果个别线程的竞争力特别强,每次都能够申请到锁,但申请到锁之后什么也不做,所以在我们看来这个线程就一直在申请锁和释放锁,这就可能导致其他线程长时间竞争不到锁,引起饥饿问题
- 单纯的加锁是没有错的,它能够保证在同一时间只有一个线程进入临界区,但它没有高效的让每一个线程使用这份临界资源
- 现在我们增加一个规则,当一个线程释放锁后,这个线程不能立马再次申请锁,该线程必须排到这个锁的资源等待队列的最后
- 增加这个规则之后,下一个获取到锁的资源的线程就一定是在资源等待队列首部的线程,如果有十个线程,此时我们就能够让这十个线程按照某种次序进行临界资源的访问
我们讲述一个案例
- 现在有两个线程访问一块临界区,一个线程往临界区写入数据,另一个线程从临界区读取数据
- 但负责数据写入的线程的竞争力特别强,该线程每次都能竞争到锁,那么此时该线程就一直在执行写入操作,直到临界区被写满,此后该线程就一直在进行申请锁和释放锁。
- 而负责数据读取的线程由于竞争力太弱,每次都申请不到锁,因此无法进行数据的读取,引入同步后该问题就能很好的解决
条件变量函数
初始化条件变量
初始化条件变量的函数叫做pthread_cond_init,该函数的函数原型如下:
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
参数:
- cond:需要初始化的条件变量。
- attr:初始化条件变量的属性,一般设置为NULL即可。
返回值:
-
如果初始化成功,返回值为 0。如果发生错误,返回值为以下错误码之一:
-
ENOMEM
:表示内存不足,无法分配所需的资源。 EBUSY
:表示正在进行初始化的条件变量已经被初始化。EINVAL
:表示提供的属性对象无效。
-
调用pthread_cond_init函数 初始化条件变量叫做动态分配,除此之外,我们还可以用下面这种方式初始化条件变量,该方式叫做静态分配:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
销毁条件变量
销毁条件变量的函数叫做pthread_cond_destroy,该函数的函数原型如下:
int pthread_cond_destroy(pthread_cond_t *cond);
参数:
cond
:是一个指向要销毁的条件变量的指针。
返回值:
如果成功销毁条件变量,返回值为 0。如果发生错误,返回值为以下错误码之一:
EBUSY
:条件变量当前正被使用,无法销毁。EINVAL
:cond
不是一个有效的条件变量。
注意:
- 使用
PTHREAD_COND_INITIALIZER(静态分配)
初始化的条件变量不需要销毁。
等待条件变量满足
- 这个
pthread_cond_wait
函数的作用是将当前线程挂起,等待条件变量cond
被触发。 - 在调用
pthread_cond_wait
时,线程会自动释放mutex
锁,以便其他线程可以进入临界区修改共享数据并触发条件变量。 - 当条件变量被触发时,线程会被唤醒并重新获取
mutex
锁,然后继续执行后续的代码。
等待条件变量满足的函数叫做pthread_cond_wait,该函数的函数原型如下:
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
参数:
cond
:是一个指向要等待的条件变量的指针。mutex
:是一个指向与条件变量相关联的互斥锁的指针。在调用pthread_cond_wait
之前,线程必须已经锁定了这个互斥锁。
返回值:
如果成功等待并被唤醒,返回值为 0。如果发生错误,返回值为以下错误码之一:
EINVAL
:cond
或mutex
不是有效的条件变量或互斥锁。EPERM
:当前线程不持有mutex
锁,无权调用此函数。
唤醒等待
唤醒等待的函数如下:
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
- pthread_cond_signal函数用于唤醒等待队列中首个线程。
- pthread_cond_broadcast函数用于唤醒等待队列中的全部线程。
参数:
- cond:唤醒在cond条件变量下等待的线程。
我们来做一个简单的实验
- 我们用主线程创建三个新线程,让主线程控制这三个新线程活动
- 这三个新线程创建后都在条件变量下进行等待,直到主线程检测到键盘有输入时才唤醒一个等待线程,如此进行下去。
#include <iostream>
#include <cstdio>
#include <pthread.h>
pthread_mutex_t mutex;
pthread_cond_t cond;
void* Routine(void* arg)
{
pthread_detach(pthread_self());
std::cout << (char*)arg << " run..." << std::endl;
while (true){
pthread_cond_wait(&cond, &mutex); //阻塞在这里,直到被唤醒
std::cout << (char*)arg << "活动..." << std::endl;
}
}
int main()
{
pthread_t t1, t2, t3;
pthread_mutex_init(&mutex, nullptr);
pthread_cond_init(&cond, nullptr);
pthread_create(&t1, nullptr, Routine, (void*)"thread 1");
pthread_create(&t2, nullptr, Routine, (void*)"thread 2");
pthread_create(&t3, nullptr, Routine, (void*)"thread 3");
while (true){
getchar();
pthread_cond_signal(&cond);
}
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&cond);
return 0;
}
为什么pthread_cond_wait需要互斥量
- 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
- 条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化,所以一定要用互斥锁来保护,没有互斥锁就无法安全的获取和修改共享数据。
- 当线程进入临界区时需要先加锁,然后判断内部资源的情况,若不满足当前线程的执行条件,则需要在该条件变量下进行等待,但此时该线程是拿着锁被挂起的,也就意味着这个锁再也不会被释放了,此时就会发生死锁问题。
- 所以在调用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函数将错过这个信号,最终可能会导致线程永远不会被唤醒,因此解锁和等待必须是一个原子操作。
而实际进入pthread_cond_wait函数后,会先判断条件变量是否等于0,若等于0则说明不满足,此时会先将对应的互斥锁解锁,直到pthread_cond_wait函数返回时再将条件变量改为1,并将对应的互斥锁加锁。
条件变量使用规范
等待条件变量的代码
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);