目录
- 前言
- 一、初识线程
- 1. 线程的概念
- 2. 线程的优缺点
- 3. 线程异常
- 4. 线程的用途
- 二、Linux中的线程和进程
- 1. 进程和线程的区别
- 2. 进程中的线程共享
- 三、Linux线程控制
- 1. POSIX线程库
- 2. 创建线程
- 3. 线程终止
- 4. 线程等待
- 5. 线程分离
- 四、Linux线程互斥
- 1. 线程互斥的概念
- 2. 互斥量mutex
- 3. 互斥量实现原理
- 五、死锁
- 1. 死锁的概念与特征
- 2. 死锁产生条件
- 3. 解决死锁的基本方法
- 六、可重入与线程安全
- 1. 可重入与线程安全的概念
- 2. 常见可重入与不可重入的情况
- 3. 常见的线程安全与不安全的情况
- 4. 可重入与线程安全联系与区别
- 七、Linux线程同步
- 1. 线程同步
- 2. 条件变量
- 2.1 条件变量的概念
- 2.2 条件变量函数
- 2.3 为什么 pthread_cond_wait 需要互斥量
- 3. 条件变量使用规范
前言
本文是博主学习了Linux中的多线程后进行的总结,通过阅读本文可以了解Linux中线程概念,理解线程与进程区别与联系。学会线程控制、线程创建、线程终止、线程等待,了解线程分离与线程安全概念,了解线程互斥与同步,学会使用互斥量、条件变量等。其他线程相关的内容将在后续呈现。
一、初识线程
1. 线程的概念
简单来说,一个程序里面的一条执行路线就可以叫做线程(thread),更准确的定义是:线程是一个进程内部的控制序列。所有的进程至少都有一个执行线程,线程在进程的内部运行,本质就是在进程地址空间中运行。
在Linux系统中,以CPU的视角看到的PCB都要比传统的进程更加轻量化。原因是Linux线程本质上就是进程,只是与进程间资源共享方式不同,线程间共享所有资源。每个线程都有自己的task_struct,因此每个线程都可被CPU调度。多线程间又共享同一进程资源。这两点刚好满足线程的定义。
Linux就是这样用进程实现了线程,所以线程又称为轻量级进程。
2. 线程的优缺点
线程的优点:
- 创建一个新线程的代价远小于创建一个进程的代价;
- 与进程之间的切换相比,线程间切换需要操作系统做的工作更少;
- 线程占用的资源远小于进程;
- 线程能够充分利用多处理器的可并行数量;
- 在等待慢速I/O操作结束的同时,程序可执行其他的任务;
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现;
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
线程的缺点:
-
性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。 -
健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。 -
缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。 -
编程难度提高
编写与调试一个多线程程序比单线程程序困难得多。
3. 线程异常
- 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃;
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。
4. 线程的用途
- 合理的使用多线程,能提高CPU密集型程序的执行效率;
- 合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)。
二、Linux中的线程和进程
1. 进程和线程的区别
-
进程是资源分配的基本单位;
-
线程是调度的基本单位;
-
线程共享进程数据,但也拥有自己的一部分数据:
线程ID
一组寄存器
栈
errno
信号屏蔽字
调度优先级
进程和线程的关系:
2. 进程中的线程共享
进程的多个线程共享同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
- 文件描述符表
- 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
- 当前工作目录
- 用户id和组id
三、Linux线程控制
1. POSIX线程库
POSIX Threads 简称 Pthread,是线程的 POSIX 标准,被定义在 POSIX.1c, Threads extensions (IEEE Std1003.1c-1995)标准里,该标准定义了一套 C 程序语言的类型、函数和常量,定义在 pthread.h
头文件和一个线程库里,内容包括线程管理、互斥锁、条件变量、读写锁和屏障等。
因为Pthread并非Linux系统的默认库,而是POSIX线程库。在Linux中将其作为一个库来使用,因此使用时必须加上-lpthread
(或 -pthread
)以显式链接该库。
2. 创建线程
在Linux中,使用函数pthread_create
来创建新线程,函数原型如下:
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
【参数】
- thread:返回线程ID;
- attr:设置线程的属性,attr为NULL表示使用默认属性;
- start_routine:是个函数地址,线程启动后要执行的函数;
- arg:传给线程启动函数的参数。
【返回值】
- 创建成功返回0,失败则返回错误码。
错误检查:
- 传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。
- pthreads函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做),而是将错误代码通过返回值返回。
- pthreads同样也提供了线程内的errno变量,以支持其它使用errno的代码。对于pthreads函数的错误,建议通过返回值判定,因为读取返回值要比读取线程内的errno变量的开销更小。
以下是一个创建线程的例子:
#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <pthread.h>
using namespace std;
void *callback1(void *args)
{
string name = (char *)args;
while (true)
{
cout << "I am " << name << endl;
sleep(1);
}
}
void *callback2(void *args)
{
string name = (char *)args;
while (true)
{
cout << "I am " << name << endl;
sleep(1);
}
}
int main()
{
pthread_t tid1;
pthread_t tid2;
if(pthread_create(&tid1, nullptr, callback1, (void *)"thread 1") != 0)
{
cout << "pthread_create thread 1: " << strerror(errno) << endl;
}
if(pthread_create(&tid2, nullptr, callback2, (void *)"thread 2") != 0)
{
cout << "pthread_create thread 2: " << strerror(errno) << endl;
}
while (true)
{
cout << "I am main thread" << endl;
sleep(1);
}
return 0;
}
运行结果:
线程ID及其进程地址空间
pthread_ create
函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面的线程ID不是一回事。前面的线程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。pthread_ create
函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。- 线程库NPTL提供了
pthread_ self
函数,可以获得线程自身的ID:
pthread_t pthread_self(void);
其中pthread_t
到底是什么类型呢?取决于实现。对于Linux目前实现的NPTL实现而言,pthread_t
类型的线程ID,本质就是一个进程地址空间上的一个地址。
3. 线程终止
如果需要只终止某个线程而不终止整个进程,可以有三种方法:
- 从线程函数return,这种方法对主线程不适用,因为从main函数return相当于调用
exit
。 - 线程可以调用
pthread_ exit
终止自己。 - 一个线程可以调用
pthread_ cancel
终止同一进程中的另一个线程。
pthread_exit
函数:使当前线程终止。
【函数原型】
void pthread_exit(void *value_ptr);
【参数】
- value_ptr:输出型参数,注意value_ptr不要指向一个局部变量,用法见后文线程等待。
【需要注意】
pthread_exit
或者 return 返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。
pthread_cancel
函数:取消一个执行中的线程。
【函数原型】
int pthread_cancel(pthread_t thread);
【参数】
- thread:要取消的线程ID。
【返回值】
- 调用成功返回0,失败则返回错误码。
用法举例:创建一个新线程,运行3秒后被主线程取消。
#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <pthread.h>
using namespace std;
int global_value = 100;
static void printTid(const char *name, const pthread_t &tid)
{
printf("%s 正在运行, thread id: 0x%x, global_value: %d\n", name, tid, global_value);
}
void *startRoutine(void *args)
{
const char *name = static_cast<const char *>(args);
while (true)
{
printTid(name, pthread_self());
sleep(1);
}
}
int main()
{
pthread_t tid;
int n = pthread_create(&tid, nullptr, startRoutine, (void*)"thread 1");
(void)n;
sleep(3); //代表main thread对应的工作
cout << "new thread been canceled" << endl;
}
运行结果:
4. 线程等待
为什么要线程等待?
因为已经退出的线程其空间没有被释放,仍然在进程的地址空间内。而且创建新的线程不会复用刚才退出线程的地址空间,如果不等待线程会造成可使用的地址空间越来越少,即造成内存泄漏。
在Linux中使用函数pthread_join
进程等待,函数原型如下:
int pthread_join(pthread_t thread, void **value_ptr);
【参数】
- thread:要等待的线程ID;
- value_ptr:是一个输入输出型参数,该参数由
pthread_exit
输出,其中包含着线程的退出信息。
【返回值】
- 调用成功返回0,失败则返回错误码。
用法举例:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
void *thread1(void *arg)
{
printf("thread 1 returning ... \n");
int *p = (int *)malloc(sizeof(int));
*p = 1;
return (void *)p;
}
void *thread2(void *arg)
{
printf("thread 2 exiting ...\n");
int *p = (int *)malloc(sizeof(int));
*p = 2;
pthread_exit((void *)p);
}
void *thread3(void *arg)
{
while (1)
{
printf("thread 3 is running ...\n");
sleep(1);
}
return NULL;
}
int main(void)
{
pthread_t tid;
void *ret;
// thread 1 return
pthread_create(&tid, NULL, thread1, NULL);
pthread_join(tid, &ret);
printf("thread return, thread id %X, return code:%d\n", tid, *(int *)ret);
free(ret);
// thread 2 exit
pthread_create(&tid, NULL, thread2, NULL);
pthread_join(tid, &ret);
printf("thread return, thread id %X, return code:%d\n", tid, *(int *)ret);
free(ret);
// thread 3 cancel by other
pthread_create(&tid, NULL, thread3, NULL);
sleep(3);
pthread_cancel(tid);
pthread_join(tid, &ret);
if (ret == PTHREAD_CANCELED)
printf("thread return, thread id %X, return code:PTHREAD_CANCELED\n", tid);
else
printf("thread return, thread id %X, return code:NULL\n", tid);
return 0;
}
运行结果:
5. 线程分离
默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join
操作,否则无法释放资源,从而造成系统泄漏。如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源,这就是线程分离。函数原型如下:
int pthread_detach(pthread_t thread);
线程分离可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:
pthread_detach(pthread_self());
注意:
joinable和分离是冲突的,一个线程不能既是joinable又是分离的。
用法如下:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
void *thread_run(void *arg)
{
pthread_detach(pthread_self());
printf("%s\n", (char *)arg);
return NULL;
}
int main(void)
{
pthread_t tid;
if (pthread_create(&tid, NULL, thread_run, (void*)"thread1 run...") != 0)
{
printf("create thread error\n");
return 1;
}
int ret = 0;
sleep(1); // 很重要,要让线程先分离,再等待
if (pthread_join(tid, NULL) == 0)
{
printf("pthread wait success\n");
ret = 0;
}
else
{
printf("pthread wait failed\n");
ret = 1;
}
return ret;
}
运行结果:
此时,如果线程分离了还继续等待线程,就会等待失败。
四、Linux线程互斥
1. 线程互斥的概念
线程互斥是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的。
线程与线程之间,都有共用的资源,如果一个线程修改公共资源,那么另一个线程在读取时,就会发生读取错误。或者说,在一个线程在读数据时,另一个同时在写,这时候就会发生数据错误。而为了保护临界区资源时,就引出线程互斥的概念。
为了保证数据变化是原子性的,我们在进行多线程访问临界区资源时,每次只有一个线程可以进入临界资源进行数据操作,其他线程在外等候。这样,就形成了线程之间的互斥关系。
互斥相关概念:
- 临界资源:多线程执行流共享的资源就叫做临界资源;
- 临界区:每个线程内部,访问临界资源的代码,就叫做临界区;
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用;
- 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
2. 互斥量mutex
大部分情况线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。多个线程并发的操作共享变量,会带来一些问题。
例如下面的操作共享变量会有问题的售票系统代码:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 20;
void *route(void *arg)
{
char *id = (char *)arg;
while (1)
{
if (ticket > 0)
{
usleep(1000);
printf("%s sells ticket:%d\n", id, ticket);
ticket--;
}
else
{
break;
}
}
}
int main(void)
{
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, route, (void *)"thread 1");
pthread_create(&t2, NULL, route, (void *)"thread 2");
pthread_create(&t3, NULL, route, (void *)"thread 3");
pthread_create(&t4, NULL, route, (void *)"thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
return 0;
}
运行结果:
为什么可能无法获得争取结果?
if
语句判断条件为真以后,代码可以并发的切换到其他线程;usleep
这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段;--ticket
操作本身就不是一个原子操作;
取出ticket–部分的汇编代码
objdump -d a.out > test.objdump
152 40064b: 8b 05 e3 04 20 00 mov 0x2004e3(%rip),%eax # 600b34 <ticket>
153 400651: 83 e8 01 sub $0x1,%eax
154 400654: 89 05 da 04 20 00 mov %eax,0x2004da(%rip) # 600b34 <ticket>
我们可以发现--
操作并不是原子操作,而是对应三条汇编指令:
load
:将共享变量ticket从内存加载到寄存器中;update
: 更新寄存器里面的值,执行-1操作;store
:将新值,从寄存器写回共享变量ticket的内存地址;
要解决以上问题,需要做到三点:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。
互斥量的接口:
1、初始化互斥量
初始化互斥量有两种方法:
- 方法1,静态分配:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
- 方法2,动态分配:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数:
mutex
:要初始化的互斥量attr
:NULL
2、互斥量加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:
- 调用成功返回0,失败返回错误码。
调用 pthread_lock
时,可能会遇到以下情况:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么
pthread_ lock
调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
3、销毁互斥量
int pthread_mutex_destroy(pthread_mutex_t *mutex);
销毁互斥量需要注意:
- 使用
PTHREAD_ MUTEX_ INITIALIZER
初始化的互斥量不需要销毁; - 不要销毁一个已经加锁的互斥量;
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁。
改进上面的售票系统:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <sched.h>
int ticket = 25;
pthread_mutex_t mutex;
void *route(void *arg)
{
char *id = (char *)arg;
while (1)
{
pthread_mutex_lock(&mutex);
if (ticket > 0)
{
usleep(1000);
printf("%s sells ticket:%d\n", id, ticket);
ticket--;
pthread_mutex_unlock(&mutex);
// sched_yield(); 放弃CPU
}
else
{
pthread_mutex_unlock(&mutex);
break;
}
}
}
int main(void)
{
pthread_t t1, t2, t3, t4;
pthread_mutex_init(&mutex, NULL);
pthread_create(&t1, NULL, route, (void *)"thread 1");
pthread_create(&t2, NULL, route, (void *)"thread 2");
pthread_create(&t3, NULL, route, (void *)"thread 3");
pthread_create(&t4, NULL, route, (void *)"thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
pthread_mutex_destroy(&mutex);
}
运行结果:
3. 互斥量实现原理
经过上面的例子,我们已经意识到单纯的 i--
或者 i++
都不是原子的,有可能会有数据一致性问题。为了实现互斥锁操作,大多数体系结构都提供了swap
或exchange
指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
现在我们把lock和unlock的伪代码改一下:
五、死锁
1. 死锁的概念与特征
概念:
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
特征:
发生死锁时,进程永远不能完成,系统资源被阻碍使用,以致于阻止了其他作业开始执行。
2. 死锁产生条件
如果在一个系统中以下四个条件同时成立,那么就能引起死锁。
- 互斥条件:一个资源每次只能被一个执行流使用。如果另一进程申请该资源,那么申请进程应等到该资源释放为止。
- 请求与保持条件:—个进程应占有至少一个资源,并等待另一个资源,而该资源为其他进程所占有。
- 不剥夺条件:资源不能被剥夺,即资源只能被进程在完成任务后自愿释放。
- 循环等待:若干执行流之间形成一种头尾相接的循环等待资源的关系。例如有一组等待进程 {P0,P1,…,Pn},P0 等待的资源为 P1 占有,P1 等待的资源为 P2 占有,……,Pn-1 等待的资源为 Pn 占有,Pn 等待的资源为 P0 占有。
注意所有四个条件必须同时成立才会出现死锁。循环等待条件意味着占有并等待条件,这样四个条件并不完全独立。
3. 解决死锁的基本方法
预防死锁:
- 资源一次性分配:一次性分配所有资源,这样就不会再有请求了(破坏请求条件);
- 只要有一个资源得不到分配,也不给这个进程分配其他的资源(破坏请保持条件);
- 可剥夺资源:即当某进程获得了部分资源,但得不到其它资源,则释放已占有的资源(破坏不可剥夺条件);
- 资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反(破坏环路等待条件)。
避免死锁:
- 死锁检测算法
- 银行家算法
解除死锁:
当发现有进程死锁后,便应立即把它从死锁状态中解脱出来,常采用的方法有:
- 剥夺资源:从其它进程剥夺足够数量的资源给死锁进程,以解除死锁状态;
- 撤消进程:可以直接撤消死锁进程或撤消代价最小的进程,直至有足够的资源可用,死锁状态.消除为止;所谓代价是指优先级、运行代价、进程的重要性和价值等。
六、可重入与线程安全
1. 可重入与线程安全的概念
线程安全:
- 多个线程并发同一段代码时,不会出现不同的结果称之为线程安全。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现线程安全问题。
重入:
- 同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
2. 常见可重入与不可重入的情况
常见可重入的情况:
- 不使用全局变量或静态变量;
- 不使用用malloc或者new开辟出的空间;
- 不调用不可重入函数;
- 不返回静态或全局数据,所有数据都有函数的调用者提供;
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。
常见不可重入的情况:
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的;
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构;
- 可重入函数体内使用了静态的数据结构。
3. 常见的线程安全与不安全的情况
常见的线程安全的情况:
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的;
- 类或者接口对于线程来说都是原子操作;
- 多个线程之间的切换不会导致该接口的执行结果存在二义性。
常见的线程不安全的情况
- 不保护共享变量的函数;
- 函数状态随着被调用,状态发生变化的函数;
- 返回指向静态变量指针的函数;
- 调用线程不安全函数的函数。
4. 可重入与线程安全联系与区别
联系:
- 函数是可重入的,那就是线程安全的;
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题;
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
区别:
- 可重入函数是线程安全函数的一种;
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的;
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
七、Linux线程同步
1. 线程同步
线程同步概念:
当有一个线程在对内存进行操作时,其他线程都不可以对这个内存地址进行操作,直到该线程完成操作, 其他线程才能对该内存地址进行操作,而其他线程又处于等待状态,称之为线程同步。
同步和竞态条件:
-
同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步。
-
竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。
2. 条件变量
2.1 条件变量的概念
条件变量(cond)是在多线程程序中用来实现"等待 --> 唤醒"逻辑常用的方法。 条件变量利用线程间共享的全局变量进行同步的一种机制,主要包括两个动作:一个线程等待"条件变量的条件成立"而挂起;另一个线程使"条件成立"。 为了防止竞争,条件变量的使用总是和一个互斥锁结合在一起。
当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列,这种情况就需要用到条件变量。
2.2 条件变量函数
初始化:
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);
参数:
cond
:要初始化的条件变量attr
:NULL
销毁:
int pthread_cond_destroy(pthread_cond_t *cond)
等待条件满足:
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
参数:
cond
:要在这个条件变量上等待mutex
:互斥量,详细内容见后文
唤醒等待:
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
简单案例:
#include <iostream>
#include <vector>
#include <functional>
#include <unistd.h>
#include <pthread.h>
using namespace std;
//定义一个条件变量
pthread_cond_t cond;
//定义一个互斥锁
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
vector<function<void()>> funcs;
void show()
{
cout << "hello show" << endl;
}
void print()
{
cout << "hello print" << endl;
}
volatile bool quit = false;
void *waitCommand(void *args)
{
pthread_detach(pthread_self());
while (!quit)
{
// 执行了下面的代码,证明某一种条件不就绪(现在还没有场景),要我这个线程等待
// 三个线程,都会进在条件变量下进行排队
pthread_cond_wait(&cond, &mutex); //让对应的线程进行等待,等待被唤醒
for (auto &f : funcs)
{
f();
}
}
cout << "thread id: " << pthread_self() << " end..." << endl;
return nullptr;
}
int main()
{
funcs.push_back(show);
funcs.push_back(print);
funcs.push_back([]()
{ cout << "你好世界!" << endl; });
pthread_cond_init(&cond, nullptr);
pthread_t t1, t2, t3;
pthread_create(&t1, nullptr, waitCommand, nullptr);
pthread_create(&t2, nullptr, waitCommand, nullptr);
pthread_create(&t3, nullptr, waitCommand, nullptr);
while (true)
{
sleep(1);
pthread_cond_broadcast(&cond);
}
cout << "main quit..." << endl;
/*
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
pthread_join(t3, nullptr);
*/
pthread_cond_destroy(&cond);
return 0;
}
2.3 为什么 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
。所以解锁和等待必须是一个原子操作。 int pthread_cond_wait(pthread_cond_ t *cond,pthread_mutex_ t * mutex)
进入该函数后,会去看条件量是否等于0?如果等于,就把互斥量变成1,直到cond_ wait
返回,把条件量改成1,把互斥量恢复成原样。
3. 条件变量使用规范
等待条件代码:
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);