目录
在 C 语言多线程程序中,各个线程除了可以使用自己的私有资源(局部变量、函数形参等)外,还可以共享全局变量、静态变量、堆内存、打开的文件等资源。
举个例子,编写一个多线程程序模拟“4个售票员共同卖 20 张票”的过程,代码如下所示:
/**
* 系统学习C语言 https://xiecoding.cn/c/
**/
#include<stdio.h>
#include<stdlib.h>
#include<pthread.h>
//全局变量,模拟总的票数
int ticket_sum = 10;
//模拟4个售票员一起售卖票的过程
void *sell_ticket(void *arg){
int i;
//4个售票员负责将 10 张票全部卖出
for (i = 0; i < 10; i++)
{
//直至所有票全部卖出,4 个售票员才算完成任务
if (ticket_sum > 0)
{
sleep(1);
//每个线程代表一个售票员
printf("%u 卖第 %d 张票\n", pthread_self(), 10 - ticket_sum + 1);
ticket_sum--;
}
}
return 0;
}
int main(){
int flag;
int i;
void *ans;
//创建 4 个线程,代表 4 个售票员
pthread_t tids[4];
for (i = 0; i < 4; i++)
{
flag = pthread_create(&tids[i], NULL, &sell_ticket, NULL);
if (flag != 0) {
printf("线程创建失败!");
return 0;
}
}
sleep(10); // 阻塞主线程,等待所有子线程执行结束
for (i = 0; i < 4; i++)
{
flag = pthread_join(tids[i], &ans);
if (flag != 0) {
printf("tid=%d 等待失败!", tids[i]);
return 0;
}
}
return 0;
}
程序中新建了 4 个子线程,每个线程都可以访问 ticket_sum 全局变量,它们共同执行 sell_ticket() 函数,模拟“4个售票员共同售卖 10 张票”的过程。
假设程序编写在 thread.c 文件中,执行过程如下:
[root@localhost ~]# gcc thread.c -o thread.exe -lpthread
[root@localhost ~]# ./thread.exe
3296569088 卖第 1 张票
3265099520 卖第 2 张票
3286079232 卖第 3 张票
3275589376 卖第 4 张票
3286079232 卖第 5 张票
3265099520 卖第 6 张票
3296569088 卖第 7 张票
3275589376 卖第 8 张票
3286079232 卖第 9 张票
3265099520 卖第 10 张票
3275589376 卖第 11 张票
3296569088 卖第 12 张票
3286079232 卖第 13 张票
程序的执行结果并不唯一,还可能输出如下类似的信息:
1492682496 卖第 1 张票
1503172352 卖第 1 张票
1482192640 卖第 1 张票
1471702784 卖第 1 张票
1503172352 卖第 5 张票
1482192640 卖第 6 张票
1492682496 卖第 6 张票
1471702784 卖第 6 张票
1503172352 卖第 9 张票
1492682496 卖第 9 张票
1471702784 卖第 9 张票
1482192640 卖第 12 张票
1503172352 卖第 13 张票
程序执行过程中,出现了“多个售票员卖出同一张票”以及“4个售票员多卖出 3 张票”的异常情况。造成此类问题的根本原因在于,进程中公有资源的访问权限是完全开放的,各个线程可以随时访问这些资源,程序运行过程中很容易出现“多个线程同时访问某公共资源”的情况。
例如,之所以会出现“多个售票员卖出同一张票”的情况,因为这些线程几乎同一时间访问 ticket_sum 变量,得到的是相同的值。出现“4 个售票员多卖出 3 张票”的原因是:4 个线程访问 ticket_sum 变量得到的都是一个大于 0 的数,每个线程都可以继续执行 if 语句内的代码。由于各个线程先后执行的顺序不同,有的线程先执行ticket_sum--
操作,导致其它线程计算 10-ticket_sum+1
表达式时,读取到的 ticket_num 变量的值为负数,因此表达式的值会出现大于 10 的情况。
我们通常将“多个线程同时访问某一公共资源”的现象称为“线程间产生了资源竞争”或者“线程间抢夺公共资源”,线程间竞争资源往往会导致程序的运行结果出现异常,感到匪夷所思,严重时还会导致程序运行崩溃。
幸运地是,Linux 提供了很多种解决方案,确定各个线程可以同步访问进程提供的公共资源(简称“线程同步”)。所谓线程同步,简单地理解就是:当一个线程访问某公共资源时,其它线程不得访问该资源,它们只能等待此线程访问完成后,再逐个访问该资源。
Linux 环境中,实现线程同步的常用方法有 4 种,分别称为互斥锁、信号量、条件变量和读写锁。
使用互斥锁实现线程同步
互斥锁实现多线程同步的核心思想是:有线程访问进程空间中的公共资源时,该线程执行“加锁”操作(将资源“锁”起来),阻止其它线程访问。访问完成后,该线程负责完成“解锁”操作,将资源让给其它线程。当有多个线程想访问资源时,谁最先完成“加锁”操作,谁就最先访问资源。
当有多个线程想访问“加锁”状态下的公共资源时,它们只能等待资源“解锁”,所有线程会排成一个等待(阻塞)队列。资源解锁后,操作系统会唤醒等待队列中的所有线程,第一个访问资源的线程会率先将资源“锁”起来,其它线程则继续等待。
本质上,互斥锁就是一个全局变量,它只有 "lock" 和 "unlock" 两个值,含义分别是:
- "unlock" 表示当前资源可以访问,第一个访问资源的线程负责将互斥锁的值改为 "lock",访问完成后再重置为“unlock”;
- "lock" 表示有线程正在访问资源,其它线程需等待互斥锁的值为 "unlock" 后才能开始访问。
通过对资源进行 "加锁(lock)"和 "解锁(unlock)",可以确保同一时刻最多有 1 个线程访问该资源,从根本上避免了“多线程抢夺资源”的情况发生。
再次强调,对资源进行“加锁”和“解锁”操作的必须是同一个线程。换句话说,哪个线程对资源执行了“加锁”操作,那么“解锁”操作也必须由该线程负责。
互斥锁的用法
POSIX 标准规定,用 pthread_mutex_t 类型的变量来表示一个互斥锁,该类型以结构体的形式定义在<pthread.h>
头文件中。举个例子:
pthread_mutex_t myMutex;
我们成功地定义了一个名为 myMutex 的互斥锁,但要想使用它,还要进行初始化操作。
1) 互斥锁的初始化
初始化 pthread_mutex_t 变量的方式有两种,分别为:
//1、使用特定的宏
pthread_mutex_t myMutex = PTHREAD_MUTEX_INITIALIZER;
//2、调用初始化的函数
pthread_mutex_t myMutex;
pthread_mutex_init(&myMutex , NULL);
以上两种初始化方式是完全等价的,PTHREAD_MUTEX_INITIALIZER 宏和 pthread_mutex_init() 函数都定义在 <pthread.h> 头文件中,它们的主要区别在于:
- pthread_mutex_init() 函数可以自定义互斥锁的属性(具体自定义的方法,这里不再进行讲解)。
- 对于调用 malloc() 函数分配动态内存的互斥锁,只能以第 2 种方法完成初始化;
pthread_mutex_init() 函数专门用于初始化互斥锁,语法格式如下:
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
mutex 参数表示要初始化的互斥锁;attr 参数用于自定义新建互斥锁的属性,attr 的值为 NULL 时表示以默认属性创建互斥锁。
pthread_mutex_init() 函数成功完成初始化操作时,返回数字 0;如果初始化失败,函数返回非零数。
注意,不能对一个已经初始化过的互斥锁再进行初始化操作,否则会导致程序出现无法预料的错误。
2) 互斥锁的“加锁”和“解锁”
对于互斥锁的“加锁”和“解锁”操作,常用的函数有以下 3 种:
int pthread_mutex_lock(pthread_mutex_t* mutex); //实现加锁
int pthread_mutex_trylock(pthread_mutex_t* mutex); //实现加锁
int pthread_mutex_unlock(pthread_mutex_t* mutex); //实现解锁
参数 mutex 表示我们要操控的互斥锁。函数执行成功时返回数字 0,否则返回非零数。
pthread_mutex_unlock() 函数用于对指定互斥锁进行“解锁”操作,pthread_mutex_lock() 和 pthread_mutex_trylock() 函数都用于实现“加锁”操作,不同之处在于当互斥锁已经处于“加锁”状态时:
- 执行 pthread_mutex_lock() 函数会使线程进入等待(阻塞)状态,直至互斥锁得到释放;
- 执行 pthread_mutex_trylock() 函数不会阻塞线程,直接返回非零数(表示加锁失败)。
3) 互斥锁的销毁
对于使用动态内存创建的互斥锁,例如:
pthread_mutex_t *myMutex = (pthread_mutex_t *)malloc(sizeof(pthread_mutex_t));
pthread_mutex_init(myMutex , NULL);
手动释放 myMutex 占用的内存(调用 free() 函数)之前,必须先调用 pthread_mutex_destory() 函数销毁该对象。
pthread_mutex_destory() 函数用于销毁创建好的互斥锁,语法格式如下:
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数 mutex 表示要销毁的互斥锁。如果函数成功销毁指定的互斥锁,返回数字 0,反之返回非零数。
注意,对于用 PTHREAD_MUTEX_INITIALIZER 或者 pthread_mutex_init() 函数直接初始化的互斥锁,无需调用 pthread_mutex_destory() 函数手动销毁。
互斥锁的实际应用
接下来,我们使用互斥锁对模拟“4 个售票员卖 10 张票”的程序进行改良,如下所示:
/**
* 系统学习C语言 https://xiecoding.cn/c/
**/
#include<stdio.h>
#include<stdlib.h>
#include<pthread.h>
#include<unistd.h>
int ticket_sum = 10;
//创建互斥锁
pthread_mutex_t myMutex = PTHREAD_MUTEX_INITIALIZER;
//模拟售票员卖票
void *sell_ticket(void *arg) {
//输出当前执行函数的线程 ID
printf("当前线程ID:%u\n", pthread_self());
int i;
int islock = 0;
for (i = 0; i < 10; i++)
{
//当前线程“加锁”
islock = pthread_mutex_lock(&myMutex);
//如果“加锁”成功,执行如下代码
if (islock == 0) {
//如果票数 >0 ,开始卖票
if (ticket_sum > 0)
{
sleep(1);
printf("%u 卖第 %d 张票\n", pthread_self(), 10 - ticket_sum + 1);
ticket_sum--;
}
//当前线程模拟完卖票过程,执行“解锁”操作
pthread_mutex_unlock(&myMutex);
}
}
return 0;
}
int main() {
int flag;
int i;
void *ans;
//创建 4 个线程,模拟 4 个售票员
pthread_t tids[4];
for (i = 0; i < 4; i++)
{
flag = pthread_create(&tids[i], NULL, &sell_ticket, NULL);
if (flag != 0) {
printf("线程创建失败!");
return 0;
}
}
sleep(10); //等待 4 个线程执行完成
for (i = 0; i < 4; i++)
{
//阻塞主线程,确认 4 个线程执行完成
flag = pthread_join(tids[i], &ans);
if (flag != 0) {
printf("tid=%d 等待失败!", tids[i]);
return 0;
}
}
return 0;
}
假设程序编写在 thread.c 文件中,执行过程为:
[root@localhost ~]# gcc thread.c -o thread.exe -lpthread
[root@localhost ~]# ./thread.exe
当前线程ID:149493504
当前线程ID:170473216
当前线程ID:159983360
当前线程ID:139003648
149493504 卖第 1 张票
149493504 卖第 2 张票
149493504 卖第 3 张票
139003648 卖第 4 张票
139003648 卖第 5 张票
139003648 卖第 6 张票
139003648 卖第 7 张票
139003648 卖第 8 张票
159983360 卖第 9 张票
159983360 卖第 10 张票
程序中共创建了 4 个线程,每个线程“开始卖票”前都会进行“加锁”操作(第 17 行),“卖票结束”后再执行“解锁”操作(第 28 行)。通过执行结果可以看到,互斥锁很好地解决了“线程间竞争资源”的问题,实现了线程同步。
使用信号量实现线程同步
信号量(Semaphore)的概念最早由荷兰计算机科学家 Dijkstra(迪杰斯特拉)提出,有时又称“信号灯”。本节,我们将详细地讲解如何使用信号量实现线程同步。
和互斥锁类似,信号量本质也是一个全局变量。不同之处在于,互斥锁的值只有 2 个(加锁 "lock" 和解锁 "unlock"),而信号量的值可以根据实际场景的需要自行设置(取值范围为 ≥0)。更重要的是,信号量还支持做“加 1”或者 “减 1”运算,且修改值的过程以“原子操作”的方式实现。
原子操作是指当多个线程试图修改同一个信号量的值时,各线程修改值的过程不会互相干扰。例如信号量的初始值为 1,此时有 2 个线程试图对信号量做“加 1”操作,则信号量的值最终一定是 3,而不会是其它的值。反之若不以“原子操作”方式修改信号量的值,那么最终的计算结果还可能是 2(两个线程同时读取到的值为 1,各自在其基础上加 1,得到的结果即为 2)。
多线程程序中,使用信号量需遵守以下几条规则:
- 信号量的值不能小于 0;
- 有线程访问资源时,信号量执行“减 1”操作,访问完成后再执行“加 1”操作;
- 当信号量的值为 0 时,想访问资源的线程必须等待,直至信号量的值大于 0,等待的线程才能开始访问。
根据初始值的不同,信号量可以细分为 2 类,分别为二进制信号量和计数信号量:
- 二进制信号量:指初始值为 1 的信号量,此类信号量只有 1 和 0 两个值,通常用来替代互斥锁实现线程同步;
- 计数信号量:指初始值大于 1 的信号量,当进程中存在多个线程,但某公共资源允许同时访问的线程数量是有限的(出现了“狼多肉少”的情况),这时就可以用计数信号量来限制同时访问资源的线程数量。
了解什么是信号量之后,接下来教大家如何创建并使用信号量。
信号量的具体用法
POSIX 标准中,信号量用 sem_t 类型的变量表示,该类型定义在<semaphore.h>
头文件中。例如,下面代码定义了名为 mySem 的信号量:
#include <semaphore.h>
sem_t mySem;
由此,我们就成功定义了一个 mySem 信号量。但要想使用它,还必须完成初始化操作。
1) 初始化信号量
sem_init() 函数专门用来初始化信号量,语法格式如下:
int sem_init(sem_t *sem, int pshared, unsigned int value);
各个参数的含义分别为:
- sem:表示要初始化的目标信号量;
- pshared:表示该信号量是否可以和其他进程共享,pshared 值为 0 时表示不共享,值为 1 时表示共享;
- value:设置信号量的初始值。
当 sem_init() 成功完成初始化操作时,返回值为 0,否则返回 -1。
2) 操作信号量的函数
对于初始化了的信号量,我们可以借助 <semaphore.h> 头文件提供的一些函数操作它,比如:
int sem_post(sem_t* sem);
int sem_wait(sem_t* sem);
int sem_trywait(sem_t* sem);
int sem_destroy(sem_t* sem);
参数 sem 都表示要操作的目标信号量。各个函数的功能如下:
- sem_post() 函数的功能是:将信号量的值“加 1”,同时唤醒其它等待访问资源的线程;
- 当信号量的值大于 0 时,sem_wait() 函数会对信号量做“减 1”操作;当信号量的值为 0 时,sem_wait() 函数会阻塞当前线程,直至有线程执行 sem_post() 函数(使信号量的值大于 0),暂停的线程才会继续执行;
- sem_trywait() 函数的功能和 sem_wait() 函数类似,唯一的不同在于,当信号量的值为 0 时,sem_trywait() 函数并不会阻塞当前线程,而是立即返回 -1;
- sem_destory() 函数用于手动销毁信号量。
以上函数执行成功时,返回值均为 0 ;如果执行失败,返回值均为 -1。
信号量的实际应用
前面讲过,信号量又细分为二进制信号量和计数信号量,虽然创建和使用它们的方法(函数)是相同的,但应用场景不同。
1) 二进制信号量
二进制信号量常用于代替互斥锁解决线程同步问题,接下来我们使用二进制信号量模拟“4 个售票员卖 10 张票”的过程:
/**
* 系统学习C语言 https://xiecoding.cn/c/
**/
#include<stdio.h>
#include<stdlib.h>
#include<pthread.h>
#include<semaphore.h>
#include<unistd.h>
//创建信号量
sem_t mySem;
//设置总票数
int ticket_sum = 10;
//模拟买票过程
void *sell_ticket(void *arg) {
printf("当前线程ID:%u\n", pthread_self());
int i;
int flag;
for (i = 0; i < 10; i++)
{
//完成信号量"减 1"操作,否则暂停执行
flag = sem_wait(&mySem);
if (flag == 0) {
if (ticket_sum > 0)
{
sleep(1);
printf("%u 卖第 %d 张票\n", pthread_self(), 10 - ticket_sum + 1);
ticket_sum--;
}
//执行“加1”操作
sem_post(&mySem);
sleep(1);
}
}
return 0;
}
int main() {
int flag;
int i;
void *ans;
//创建 4 个线程
pthread_t tids[4];
//初始化信号量
flag = sem_init(&mySem, 0, 1);
if (flag != 0) {
printf("初始化信号量失败\n");
}
for (i = 0; i < 4; i++)
{
flag = pthread_create(&tids[i], NULL, &sell_ticket, NULL);
if (flag != 0) {
printf("线程创建失败!");
return 0;
}
}
sleep(10);
for (i = 0; i < 4; i++)
{
flag = pthread_join(tids[i], &ans);
if (flag != 0) {
printf("tid=%d 等待失败!", tids[i]);
return 0;
}
}
//执行结束前,销毁信号量
sem_destroy(&mySem);
return 0;
}
假设程序编写在 thread.c 文件中,执行过程如下:
[root@localhost ~]# gcc thread.c -o thread.exe -lpthread
[root@localhost ~]# ./thread.exe
当前线程ID:1199965952
当前线程ID:1189476096
当前线程ID:1168496384
当前线程ID:1178986240
1199965952 卖第 1 张票
1189476096 卖第 2 张票
1199965952 卖第 3 张票
1178986240 卖第 4 张票
1168496384 卖第 5 张票
1189476096 卖第 6 张票
1199965952 卖第 7 张票
1178986240 卖第 8 张票
1168496384 卖第 9 张票
1189476096 卖第 10 张票
程序中信号量的初始值为 1,当有多个线程想执行 19~25 行代码时,第一个执行 sem_wait() 函数的线程可以继续执行,同时信号量的值会由 1 变为 0,其它线程只能等待信号量的值由 0 变为 1 后,才能继续执行。
2) 计数信号量
假设某银行只开设了 2 个窗口,但有 5 个人需要办理业务。如果我们使用多线程程序模拟办理业务的过程,可以借助计数信号量实现。
/**
* 系统学习C语言 https://xiecoding.cn/c/
**/
#include <stdio.h>
#include<pthread.h>
#include<stdlib.h>
#include<semaphore.h>
//设置办理业务的人数
int num = 5;
//创建信号量
sem_t sem;
//模拟办理业务的过程
void *get_service(void *arg)
{
int id = *((int*)arg);
//信号量成功“减 1”后才能继续执行
if (sem_wait(&sem) == 0)
{
printf("---customer%d 正在办理业务\n", id);
sleep(2);
printf("---customer%d 已办完业务\n", id);
//信号量“加 1”
sem_post(&sem);
}
return 0;
}
int main()
{
int flag,i,j;
//创建 5 个线程代表 5 个人
pthread_t customer[5];
//初始化信号量
sem_init(&sem, 0, 2);
for (i = 0; i < num; i++)
{
flag = pthread_create(&customer[i], NULL, get_service, &i);
if (flag != 0)
{
printf("线程创建失败!\n");
return 0;
}
else {
printf("customer%d 来办理业务\n",i);
}
sleep(1);
}
for (j = 0; j < num; j++)
{
flag = pthread_join(customer[j], NULL);
if (flag != 0) {
printf("tid=%d 等待失败!", customer[i]);
return 0;
}
}
sem_destroy(&sem);
return 0;
}
假设程序编写在 thread.c 文件中,执行过程为:
[root@localhost ~]# gcc thread.c -o thread.exe -lpthread
[root@localhost ~]# ./thread.exe
customer0 来办理业务
---customer0 正在办理业务
customer1 来办理业务
---customer1 正在办理业务
---customer0 已办完业务
customer2 来办理业务
---customer2 正在办理业务
---customer1 已办完业务
customer3 来办理业务
---customer3 正在办理业务
---customer2 已办完业务
customer4 来办理业务
---customer4 正在办理业务
---customer3 已办完业务
---customer4 已办完业务
程序中,sem 信号量的初始化为 2,因此该信号量属于计数信号量。借助 sem 信号量,第 14~21 行的代码段最多只能有 2 个线程同时访问。
使用条件变量实现线程同步
假设一个进程中包含多个线程,这些线程共享变量 x,我们希望某个(或某些)线程等待 "x==10' 条件成立后再执行后续的代码,该如何实现呢?
您可能想到用 while 循环实现,例如:
void* threadFun(void * args){
while(x != 10){
sleep(5);
}
// 待条件成立后,执行后续的代码
}
当线程执行此函数时,会判断 x 的值是否等于 10,如果不等则间隔 5 秒后再重复判断,直到 x 的值等于 10 ,线程才能执行后续的代码。
直观上看,while 循环确实能够阻塞线程,但这种方法存在严重的效率问题。当线程因条件不成立进入等待状态时,如果此时恰好有另一个线程将 x 的值改为 10,该线程必须等待 5 秒后才能继续执行。如果我们将等待时间缩短(或者直接将 sleep(5) 注释掉),线程将反复判断 x 的值是否等于 10,它可能会一直霸占着 CPU 资源,导致其它线程无法执行,x 变量的值会出现“长时间不改变”的情况。
针对类似的场景,我们推荐您用条件变量来实现。和互斥锁、信号量类似,条件变量本质也是一个全局变量,它的功能是阻塞线程,直至接收到“条件成立”的信号后,被阻塞的线程才能继续执行。
一个条件变量可以阻塞多个线程,这些线程会组成一个等待队列。当条件成立时,条件变量可以解除线程的“被阻塞状态”。也就是说,条件变量可以完成以下两项操作:
- 阻塞线程,直至接收到“条件成立”的信号;
- 向等待队列中的一个或所有线程发送“条件成立”的信号,解除它们的“被阻塞”状态。
为了避免多线程之间发生“抢夺资源”的问题,条件变量在使用过程中必须和一个互斥锁搭配使用。
条件变量的具体用法
POSIX 标准中,条件变量用 pthread_cond_t 类型的变量表示,此类型定义在<pthread.h>
头文件中。举个例子:
#include <pthread.h>
pthread_cond_t myCond;
由此,我们就成功创建了一个条件变量。要想使用 myCond 条件变量,还需要进行初始化操作。
1) 初始化条件变量
初始化条件变量的方式有两种,一种是直接将 PTHREAD_COND_INITIALIZER 赋值给条件变量,例如:
pthread_cond_t myCond = PTHREAD_COND_INITIALIZER;
还可以借助 pthread_cond_init() 函数初始化条件变量,语法格式如下:
int pthread_cond_init(pthread_cond_t * cond, const pthread_condattr_t * attr);
参数 cond 用于指明要初始化的条件变量;参数 attr 用于自定义条件变量的属性,通常我们将它赋值为 NULL,表示以系统默认的属性完成初始化操作。
pthread_cond_init() 函数初始化成功时返回数字 0,反之函数返回非零数。
当 attr 参数为 NULL 时,以上两种初始化方式完全等价。
2) 阻塞当前线程,等待条件成立
当条件不成立时,条件变量可以阻塞当前线程,所有被阻塞的线程会构成一个等待队列。
阻塞线程可以借助以下两个函数实现:
int pthread_cond_wait(pthread_cond_t* cond, pthread_mutex_t* mutex);
int pthread_cond_timedwait(pthread_cond_t* cond, pthread_mutex_t* mutex, const struct timespec* abstime);
cond 参数表示已初始化好的条件变量;mutex 参数表示与条件变量配合使用的互斥锁;abstime 参数表示阻塞线程的时间。
注意,abstime 参数指的是绝对时间,例如您打算阻塞线程 5 秒钟,那么首先要得到当前系统的时间,然后再加上 5 秒,最终得到的时间才是传递的实参值。
调用两个函数之前,我们必须先创建好一个互斥锁并完成“加锁”操作,然后才能作为实参传递给 mutex 参数。两个函数会完成以下两项工作:
- 阻塞线程,直至接收到“条件成立”的信号;
- 当线程被添加到等待队列上时,将互斥锁“解锁”。
也就是说,函数尚未接收到“条件成立”的信号之前,它将一直阻塞线程执行。注意,当函数接收到“条件成立”的信号后,它并不会立即结束对线程的阻塞,而是先完成对互斥锁的“加锁”操作,然后才解除阻塞。
两个函数都以“原子操作”的方式完成“阻塞线程+解锁”或者“重新加锁+解除阻塞”这两个过程。所谓“原子操作”,即当有多个线程执行相同的某个过程时,虽然它们都会访问互斥锁和条件变量,但之间不会相互干扰。
以上两个函数都能用来阻塞线程,它们的区别在于:pthread_cond_wait() 函数可以永久阻塞线程,直到条件变量成立的那一刻;pthread_cond_timedwait() 函数只能在 abstime 参数指定的时间内阻塞线程,超出时限后,该函数将重新对互斥锁执行“加锁”操作,并解除对线程的阻塞,函数的返回值为 ETIMEDOUT。
如果函数成功接收到了“条件成立”的信号,重新对互斥锁完成了“加锁”并使线程继续执行,函数返回数字 0,反之则返回非零数。
POSIX 标准规定,pthread_cond_wait() 和 pthread_cond_timedwait() 函数是可以作为“取消点”的函数。当线程接收到“强制终止执行”的信号后,执行到这两个函数时,线程就会终止执行。
3) 解除线程的“阻塞”状态
对于被 pthread_cond_wait() 或 pthread_cond_timedwait() 函数阻塞的线程,我们可以借助如下两个函数向它们发送“条件成立”的信号,解除它们的“被阻塞”状态:
int pthread_cond_signal(pthread_cond_t* cond);
int pthread_cond_broadcast(pthread_cond_t* cond);
cond 参数表示初始化好的条件变量。当函数成功解除线程的“被阻塞”状态时,返回数字 0,反之返回非零数。
两个函数都能解除线程的“被阻塞”状态,区别在于:
- pthread_cond_signal() 函数至少解除一个线程的“被阻塞”状态,如果等待队列中包含多个线程,优先解除哪个线程将由操作系统的线程调度程序决定;
- pthread_cond_broadcast() 函数可以解除等待队列中所有线程的“被阻塞”状态。
由于互斥锁的存在,解除阻塞后的线程也不一定能立即执行。当互斥锁处于“加锁”状态时,解除阻塞状态的所有线程会组成等待互斥锁资源的队列,等待互斥锁“解锁”。
4) 销毁条件变量
对于初始化好的条件变量,我们可以调用 pthread_cond_destory() 函数销毁它。
pthread_cond_destory() 函数的语法格式如下:
int pthread_cond_destroy(pthread_cond_t *cond);
cond 参数表示要销毁的条件变量。如果函数成功销毁 cond 参数指定的条件变量,返回数字 0,反之返回非零数。
值得一提的是,销毁后的条件变量还可以调用 pthread_cond_init() 函数重新初始化后使用。
条件变量的实际应用
接下来,通过一个实例给您演示条件变量的具体用法。
/**
* 系统学习C语言 https://xiecoding.cn/c/
**/
#include <stdio.h>
#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>
//初始化互斥锁
pthread_mutex_t myMutex = PTHREAD_MUTEX_INITIALIZER;
//初始化条件变量
pthread_cond_t myCond = PTHREAD_COND_INITIALIZER;
//设置全局变量
int x = 0;
//线程执行的函数
void * waitForTrue(void *args) {
int res;
//条件变量阻塞线程之前,先对互斥锁执行“加锁”操作
res = pthread_mutex_lock(&myMutex);
if (res != 0) {
printf("waitForTrue 加锁失败\n");
return NULL;
}
printf("------等待 x 的值为 10\n");
if (pthread_cond_wait(&myCond, &myMutex) == 0) {
printf("x = %d\n", x);
}
//最终将互斥锁解锁
pthread_mutex_unlock(&myMutex);
return NULL;
}
//线程执行的函数
void * doneForTrue(void *args) {
int res;
while (x != 10) {
//对互斥锁执行“加锁”操作
res = pthread_mutex_lock(&myMutex);
if (res == 0) {
x++;
printf("doneForTrue:x = %d\n", x);
sleep(1);
//对互斥锁“解锁”
pthread_mutex_unlock(&myMutex);
}
}
//发送“条件成立”的信号,解除 mythread1 线程的“被阻塞”状态
res = pthread_cond_signal(&myCond);
if (res != 0) {
printf("解除阻塞失败\n");
}
return NULL;
}
int main() {
int res;
pthread_t mythread1, mythread2;
res = pthread_create(&mythread1, NULL, waitForTrue, NULL);
if (res != 0) {
printf("mythread1线程创建失败\n");
return 0;
}
res = pthread_create(&mythread2, NULL, doneForTrue, NULL);
if (res != 0) {
printf("mythread2线程创建失败\n");
return 0;
}
//等待 mythread1 线程执行完成
res = pthread_join(mythread1, NULL);
if (res != 0) {
printf("1:等待线程失败\n");
}
//等待 mythread2 线程执行完成
res = pthread_join(mythread2, NULL);
if (res != 0) {
printf("2:等待线程失败\n");
}
//销毁条件变量
pthread_cond_destroy(&myCond);
return 0;
}
假设程序编写在 thread.c 文件中,执行过程如下:
[root@localhost ~]# gcc thread.c -o thread.exe -lpthread
[root@localhost ~]# ./thread.exe
------等待 x 的值为 10
doneForTrue:x = 1
doneForTrue:x = 2
doneForTrue:x = 3
doneForTrue:x = 4
doneForTrue:x = 5
doneForTrue:x = 6
doneForTrue:x = 7
doneForTrue:x = 8
doneForTrue:x = 9
doneForTrue:x = 10
x = 10
程序中共创建了 2 个线程 mythread1 和 mythread2,其中 mythread1 线程借助条件变量实现了“直到变量 x 的值为 10 时,才继续执行后续代码”的功能,mythread1 线程用于将 x 的变量修改为 10,同时向 mythread1 线程发送“条件成立”的信号,唤醒 mythread1 线程并继续执行。
使用读写锁实现线程同步
多线程程序中,如果仅有少数线程会对共享数据进行修改,多数线程只是读取共享数据的值,就适合用读写锁解决“线程间抢夺资源”的问题。
读写锁的核心思想是:将线程访问共享数据时发出的请求分为两种,分别是:
- 读请求:只读取共享数据,不做任何修改;
- 写请求:存在修改共享数据的行为。
当有多个线程发出读请求时,这些线程可以同时执行,也就是说,共享数据的值可以同时被多个发出读请求的线程获取;当有多个线程发出写请求时,这些线程只能一个一个地执行(同步执行)。此外,当发出读请求的线程正在执行时,发出写请求的线程必须等待前者执行完后才能开始执行;当发出写请求的线程正在执行时,发出读请求的线程也必须等待前者执行完后才能开始执行。
本质上,读写锁就是一个全局变量,发出读请求和写请求的线程都可以访问它。为了区别线程发出的请求类别,当读写锁被发出读请求的线程占用时,我们称它为“读锁”;当读写锁被发出写请求的线程占用时,称它为“写锁”。
为了让您更清楚地了解读写锁在多线程程序中发挥的作用,我们制作了下面这张表格:
当前读写锁的状态 | 线程发出“读”请求 | 线程发出“写”请求 |
---|---|---|
无锁 | 允许占用 | 允许占用 |
读锁 | 允许占用 | 阻塞线程执行 |
写锁 | 阻塞线程执行 | 阻塞线程执行 |
从上表可以看出,不同状态下的读写锁会以不同的方式处理发出读请求或写请求的线程:
1) 当读写锁未被任何线程占用时,发出读请求和写请求的线程都可以占用它。注意,由于读请求和写请求的线程不能同时执行,读写锁默认会优先分配给发出读请求的线程。
2) 当读写锁的状态为“读锁”时,表明当前执行的是发出读请求的线程(可能有多个)。此时如果又有线程发出读请求,该线程不会被阻塞,但如果有线程发出写请求,它就会被阻塞,直到读写锁状态改为“无锁”。
3) 当读写锁状态为“写锁”时,表明当前执行的是发出写请求的线程(只能有 1 个)。此时无论其它线程发出的是读请求还是写请求,都必须等待读写锁状态改为“无锁”后才能执行。
总的来说,对于进程空间中的共享资源,读写锁允许发出“读”请求的线程共享资源,发出“写”请求的线程必须独占资源,进而实现线程同步。
读写锁的具体用法
POSIX 标准中,读写锁用 pthread_rwlock_t 类型的变量表示,此类型定义在<pthread.h>
头文件中。举个例子:
pthread_rwlock_t myRWLock;
由此,我们就成功创建了一个读写锁。但要想使用 myRWLock 读写锁,还需要进行初始化操作。
1) 初始化读写锁
初始化读写锁的方法有两种,一种是直接将 PTHREAD_RWLOCK_INITIALIZER 宏赋值给读写锁变量,例如:
pthread_rwlock_t myRWLock = PTHREAD_RWLOCK_INITIALIZER;
还可以借助 pthread_rwlock_init() 函数初始化读写锁,此函数的语法格式为:
int pthread_rwlock_init(pthread_rwlock_t *rwlock, const pthread_rwlockattr_t *attr);
rwlock 参数用于指定要初始化的读写锁变量;attr 参数用于自定义读写锁变量的属性,置为 NULL 时表示以默认属性初始化读写锁。
当 pthread_rwlock_init() 函数初始化成功时,返回数字 0,反之返回非零数。
当 attr 参数为 NULL 时,以上两种初始化方式完全等价。
2) 线程发出“读锁”请求
通过以下两个函数,线程可以向读写锁发出“读锁”请求:
int pthread_rwlock_rdlock(pthread_rwlock_t* rwlock);
int pthread_rwlock_tryrdlock(pthread_rwlock_t* rwlock);
其中,rwlock 参数指的是初始化好的读写锁。
当读写锁处于“无锁”或者“读锁”状态时,以上两个函数都能成功获得读锁;当读写锁处于“写锁”状态时:
- pthread_rwlock_rdlock() 函数会阻塞当前线程,直至读写锁被释放;
- pthread_rwlock_tryrdlock() 函数不会阻塞当前线程,直接返回 EBUSY。
以上两个函数如果能成功获得读锁,函数返回数字 0,反之返回非零数。
3) 线程发出“写锁”请求
通过以下两个函数,线程可以向读写锁发出“写锁”请求:
int pthread_rwlock_wrlock(pthread_rwlock_t* rwlock);
int pthread_rwlock_trywrlock(pthread_rwlock_t* rwlock);
rwlock 参数指的是初始化好的读写锁。
当读写锁处于“无锁”状态时,两个函数都能成功获得写锁;当读写锁处于“读锁”或“写锁”状态时:
- pthread_rwlock_wrlock() 函数将阻塞线程,直至读写锁被释放;
- pthread_rwlock_trywrlock() 函数不会阻塞线程,直接返回 EBUSY。
以上两个函数如果能成功获得写锁,函数返回数字 0,反之返回非零数。
4) 释放读写锁
无论是处于“无锁”、“读锁”还是“写锁”的读写锁,都可以使用如下函数释放读写锁:
int pthread_rwlock_unlock (pthread_rwlock_t* rwlock);
rwlock 参数表示要释放的读写锁。
当函数成功释放读写锁时,返回数字 0,反之则返回非零数。注意,由于多个线程可以同时获得“读锁”状态下的读写锁,这种情况下一个线程释放读写锁后,读写锁仍处于“读锁”状态,直至所有线程都释放读写锁,读写锁的状态才为“无锁”状态。
5) 销毁读写锁
当读写锁不再使用时,我们可以借助如下函数将它销毁:
int pthread_rwlock_destroy(pthread_rwlock_t* rwlock);
参数 rwlock 表示要销毁的目标读写锁。
如果函数成功销毁指定的读写锁,返回数字 0,反之则返回非零数。
读写锁的实际应用
接下来通过一个实例,给您演示读写锁的用法:
/**
* 系统学习C语言 https://xiecoding.cn/c/
**/
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
int x = 0;
//创建读写锁变量
pthread_rwlock_t myrwlock;
void* read_thread(void* args){
printf("------%u read_thread ready\n",pthread_self());
while (1)
{
sleep(1);
//请求读锁
pthread_rwlock_rdlock(&myrwlock);
printf("read_thread: %u,x=%d\n", pthread_self(), x);
sleep(1);
//释放读写锁
pthread_rwlock_unlock(&myrwlock);
}
return NULL;
}
void* write_thread(void* param)
{
printf("------%u write_thread ready!\n",pthread_self());
while (1)
{
sleep(1);
// 请求写锁
pthread_rwlock_wrlock(&myrwlock);
++x;
printf("write_thread: %u,x=%d\n", pthread_self(), x);
sleep(1);
//释放读写锁
pthread_rwlock_unlock(&myrwlock);
}
return NULL;
}
int main()
{
int i;
//初始化读写锁
pthread_rwlock_init(&myrwlock, NULL);
//创建 3 个读 x 变量的线程
pthread_t readThread[3];
for (i = 0; i < 3; ++i)
{
pthread_create(&readThread[i], NULL, read_thread, NULL);
}
//创建 1 个修改 x 变量的线程
pthread_t writeThread;
pthread_create(&writeThread, NULL, write_thread, NULL);
//等待各个线程执行完成
pthread_join(writeThread, NULL);
for (int i = 0; i < 3; ++i)
{
pthread_join(readThread[i], NULL);
}
//销毁读写锁
pthread_rwlock_destroy(&myrwlock);
return 0;
}
假设程序编写在 thread.c 文件中,执行过程如下:
[root@localhost ~]# gcc thread.c -o thread.exe -lpthread
[root@localhost ~]# ./mythread.exe
------1134741248 read_thread ready
------1113761536 read_thread ready
------1103271680 write_thread ready!
------1124251392 read_thread ready
read_thread: 1124251392,x=0
read_thread: 1113761536,x=0
read_thread: 1134741248,x=0
write_thread: 1103271680,x=1
read_thread: 1134741248,x=1
read_thread: 1124251392,x=1
read_thread: 1113761536,x=1
write_thread: 1103271680,x=2
read_thread: 1124251392,x=2
read_thread: 1113761536,x=2
read_thread: 1134741248,x=2
注意,此程序会一直执行,按 "Ctrl+Z" 组合键可以使程序停止。
程序中共创建了 4 个子线程,其中 3 个线程用于读取 x 变量的值,读取变量前会先获得“读锁”。剩余的 1 个线程用于修改 x 变量的值,修改前先获得“写锁”。
通过执行结果不难看到,3 个读取 x 变量的线程总是能够同时获取到 x 变量的值,因为它们能够同时获得“读锁”并同时执行。