一、多线程并发
1.1 多线程并发引起的问题
我们先来看如下代码:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#define MAX 1E7
int giNum = 0;
void *func1(void *arg)
{
int i;
for (i = 1; i <= MAX; i++)
{
giNum++;
}
return NULL;
}
void *func2(void *arg)
{
int i;
for (i = 1; i <= MAX; i++)
{
giNum++;
}
return NULL;
}
int main()
{
pthread_t th1;
pthread_create(&th1, NULL, func1, NULL);
pthread_t th2;
pthread_create(&th2, NULL, func2, NULL);
pthread_join(th1, NULL);
pthread_join(th2, NULL);
printf("giNum = %d\n", giNum);
return 0;
}
代码的内容很简单:
- 创建了两个子线程 th1、th2
- 两个子线程分别执行
giNum++
操作 - 最后输出
giNum
的值
直观地看过去:
-
th1 运行时
giNum++
要执行 \(10^7\) 次 -
th2 运行时
giNum++
也要执行 \(10^7\) 次
似乎计算得到的最后 giNum 应该是 \(2\times10^7\)。但实际上是这样的吗?让我们来看一下运行结果:
多次运行,你会发现,仅有一次(甚至没有)结果是正确的。
1.2 知根知底
上述代码得到的结果为什么不如顺序执行所预期的那样呢?可以用程序修改变量值时所经历的三个步骤解释这个现象:
- 从内存单元读入寄存器
- 在寄存器中对变量操作(加/减1)
- 把新值写回到内存单元
即当我们当我们执行giNum++
时,底层发生的事件其实是:
- 内存中读取 giNum;
- 将 giNum++;
- 将 giNum 写入到内存。
这不是一个原子化操作,当两个线程交错运行的时候,很容易发生结果的丢失。因此最后的结果肯定是要 \(\leq 2\times10^7\) 的。这种情况有种专有名词,叫 race condition。为了解决这个问题,我们可以「加锁」。
二、线程锁
2.1 互斥量
多线程程序中可能会存在数据不一致的情况,那么如何保证数据一致呢?可以考虑同一时间只有一个线程访问数据。
而互斥量(mutex)就是一把锁。多个线程只有一把锁一个钥匙,谁上的锁就只有谁能开锁。当一个线程要访问一个共享变量时,先用锁把变量锁住,然后再操作,操作完了之后再释放掉锁;当另一个线程也要访问这个变量时,发现这个变量被锁住了,无法访问,它就会一直等待,直到锁没了,它再给这个变量上个锁,然后使用,使用完了释放锁,以此进行。这样即使有多个线程同时访问这个变量,也好像是对这个变量的操作是顺序进行的。
互斥变量使用特定的数据类型:pthread_mutex_t
。使用互斥量前要先初始化,初始化又分为静态初始化和动态初始化:
- 静态初始化:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
- 动态初始化:
pthread_mutex_init(&mutex,NULL);
第一种方法仅局限于静态初始化的时候使用:将「声明、定义、初始化」一气呵成,除此之外的情况都只能使用 pthread_mutex_init函数。
2.2 pthread_mutex_init
函数原型:int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
头 文 件:#include <pthread.h>
返 回 值:成功返回 0,失败返回错误码
参数介绍:
- mutex:指向 pthread_mutex_t 声明的变量的地址
- attr:指定了新建互斥锁的属性。一般置为 NULL(如果参数attr为 NULL,则使用默认的互斥锁属性,默认属性为快速互斥锁 )。
restrict 关键字只用于限制指针。告诉编译器所有修改该指针指向内存中的操作,只能通过本指针完成,不能通过除了本指针之外的变量或指针修改。
当我们通过 pthread_mutex_init() 初始化互斥量后,接下来就是上锁(pthread_mutex_lock)和解锁(pthread_mutex_unlock)操作了。
2.3 上锁 & 解锁
上锁 | 解锁 | |
---|---|---|
函数原型 | pthread_mutex_lock(pthread_mutex_t *mutex); | int pthread_mutex_unlock(pthread_mutex_t *mutex); |
头 文 件 | #include <pthread.h> | #include <pthread.h> |
返 回 值 | 成功返回 0,失败返回错误码 | 成功返回 0,失败返回错误码 |
让我们来梳理一下互斥量的使用流程:
- 通过 pthread_mutex_init() 购买一把锁
- 通过 pthread_mutex_lock() 加锁
- 通过 pthread_mutex_unlock() 解锁
下面让我们通过「锁」操作修改一下上述代码:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 静态初始化锁
void *func1(void *arg)
{
pthread_mutex_lock(&mutex);
puts("线程 th1 抢到锁");
puts("线程 th1 开始执行 giNum++");
int i;
for (i = 1; i <= MAX; i++)
{
giNum++;
}
pthread_mutex_unlock(&mutex);
return NULL;
}
void *func2(void *arg)
{
pthread_mutex_lock(&mutex);
puts("线程 th2 抢到锁");
puts("开始执行 giNum++");
int i;
for (i = 1; i <= MAX; i++)
{
giNum *= 2;
}
pthread_mutex_unlock(&mutex);
return NULL;
}
此时,再次运行程序,你会发现不管运行多少次,结果都是 \(giNum = 2\times10^7\)。
下面我们对上面的代码做个简单的修改,将 func2 中的giNum++
操作修改为giNum *= 2
,
三、条件变量
3.1 为什么要使用条件变量
如果没有条件变量,那么我们等待一个条件满足则会是下面这样的模型:
- 首先加锁进入临界区去查看条件是否满足,不满足则解锁离开临界区,睡眠一段时间再继续循环判断。
在这种情况下如果刚离开临界区,条件变为满足,那么线程必须还要等一段时间重新进入临界区才能知道条件满足(如果在这段时间内,条件依旧一直保持满足的话);如果这一小段时间条件又变为了不满足,那么这个线程还要继续循环判断,不断地加锁解锁(会影响使用同一把锁的其他线程),还不能第一时间收到条件满足。
这种模型既费时又开销大,所以条件变量的产生,正是为了不循环加锁解锁,并且第一时间收到条件满足的通知。
3.2 条件变量函数介绍
3.2.1 pthread_cond_t
条件变量使用特定的数据类型:pthread_cond_t
。使用条件变量前要先初始化,初始化又分为静态初始化和动态初始化:
- 静态初始化:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
- 动态初始化:
pthread_cond_init(&cond, NULL);
静态初始化的条件变量只能拥有默认的条件变量属性,不能设置其他条件变量属性。
3.2.2 pthread_cond_init
函数原型:int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
头 文 件:#include <pthread.h>
功 能:对条件变量初始化
返 回 值:成功返回 0,失败返回错误码
参数介绍:
- cond:需要初始化的条件变量
- attr:初始化时条件变量的属性,一般置为 NULL,表示使用默认属性
3.2.3 pthread_cond_destory
函数原型:int pthread_cond_destroy(pthread_cond_t *cond);
头 文 件:#include <pthread.h>
功 能:对条件变量反初始化(在条件变量释放内存之前)
返 回 值:成功返回 0,失败返回错误码
参数介绍:需要反初始化的条件变量
备注:此函数只是反初始化互斥量,并没有释放内存空间。如果互斥量是通过 malloc 等函数申请的,那么需要在 free 掉互斥量之前调用 pthread_mutex_destroy 函数
3.2.4 pthread_cond_wait
函数原型:int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
头 文 件:#include <pthread.h>
功 能:用于阻塞当前线程,等待别的线程使用pthread_cond_signal()
或pthread_cond_broadcast()
来唤醒它
返 回 值:成功返回 0,失败返回错误码
函数 pthread_cond_wait 必须与 pthread_mutex_t 配套使用。pthread_cond_wait() 一旦进入 wait 状态就会主动调用 pthread_mutex_unlock() 释放掉 mutex。当其他线程通过 pthread_cond_signal() 或 pthread_cond_broadcast() 把该线程唤醒,使 pthread_cond_wait() 返回时,该线程又主动调用 pthread_mutex_lock() 来获取该 mutex。
3.2.5 pthread_cond_signal
函数原型:int pthread_cond_signal(pthread_cond_t *cond);
头 文 件:#include <pthread.h>
功 能:发送一个信号给另外一个正在处于阻塞等待状态的线程,使其脱离阻塞状态
返 回 值:成功返回 0,失败返回错误码
使用 pthread_cond_signal 一般不会有「惊群现象」产生,它最多只给一个线程发信号。假如有多个线程正在阻塞等待着这个条件变量的话,那么是根据各等待线程优先级的高低确定哪个线程先接收到信号并开始继续执行。如果各线程优先级相同,则根据等待时间的长短来确定哪个线程获得信号。但无论如何一个 pthread_cond_signal() 调用最多发信一次。
3.2.6 pthread_cond_broadcast
函数原型:int pthread_cond_broadcast(pthread_cond_t *cond);
头 文 件:#include <pthread.h>
功 能:唤醒等待该条件的所有线程
返 回 值:成功返回 0,失败返回错误码
这两个函数 pthread_cond_broadcast() 和 pthread_cond_signal 用于通知线程条件变量已经满足条件(变为真)。在调用这两个函数时,是在给线程或者条件发信号。
3.3 如何使用条件变量
我们对「2.3」中的函数 func2 做个简单的修改:
#define MAX 3
void *func2(void *arg)
{
pthread_mutex_lock(&mutex);
puts("线程 th2 抢到锁,开始执行 giNum *= 2");
int i;
for (i = 1; i <= MAX; i++)
{
giNum *= 2;
}
pthread_mutex_unlock(&mutex);
return NULL;
}
对 func2() 做了个微小的改动:将giNum++
修改为了giNum *= 2
。
这样的话,线程抢到锁的顺序不同会影响giNum
的最终结果:
- th1 先抢到锁:giNum 先执行加操作,然后在执行乘操作,最终结果为 24
- th2 先抢到锁:giNum 先执行乘操作,然后在执行加操作,最终结果为 3
如果如何才能做到线程 th1 总是能够先抢到锁呢?下面我们通过条件变量的方式来实现这一想法。
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#define MAX 3
#define TRUE 1
#define FALSE 0
int giNum = 0;
int giFlag = FALSE; // TRUE:执行线程 2 的乘操作
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 静态初始化锁
pthread_cond_t cond = PTHREAD_COND_INITIALIZER; // 静态初始化条件变量
void *func1(void *arg)
{
pthread_mutex_lock(&mutex);
puts("线程 th1 抢到锁");
puts("线程 th1 开始执行 giNum++");
int i;
for (i = 1; i <= MAX; i++)
{
giNum++;
}
giFlag = TRUE; // 修改 giFlag 的值,使得线程 th2 满足条件
pthread_cond_signal(&cond); // 向线程 th2 发出信号
pthread_mutex_unlock(&mutex);
return NULL;
}
void *func2(void *arg)
{
pthread_mutex_lock(&mutex);
puts("线程 th2 抢到锁");
while (FALSE == giFlag) // 不满足线程 th2 的执行条件
{
puts("线程 th2 不满足条件,等待~");
pthread_cond_wait(&cond, &mutex); // 等待被触发
}
puts("线程 th2 满足条件,开始执行 giNum *= 2");
int i;
for (i = 1; i <= MAX; i++)
{
giNum *= 2;
}
pthread_mutex_unlock(&mutex);
return NULL;
}
int main()
{
pthread_t th1;
pthread_create(&th1, NULL, func1, NULL);
pthread_t th2;
pthread_create(&th2, NULL, func2, NULL);
pthread_join(th1, NULL);
pthread_join(th2, NULL);
printf("giNum = %d\n", giNum);
return 0;
}
经过修改后的代码就可以确保线程 th1 的「加」操作会先于线程 th2 的「乘」操作:
- 情况一:线程 th1 先抢到锁,顺利执行「加」操作,并将线程 th2 的触发条件
giFlag
修改为 TRUE;继而当线程 th2 抢到锁后,不会进入到 while 循环。 - 情况二:线程 th2 先抢到锁,但由于此时
giFlag
为 FALSE,所以会进入到 while 循环执行 pthread_cond_wait 语句,并阻塞在这儿释放掉 mutex;那么此时线程 th1 就可以顺利加锁,执行完「加」操作后将giFlag
置为 TRUE,并发出信号,使得线程 th2 可以继续向下执行。
关于为什么要使用 while 循环来判断条件是否满足,解释如下:某些应用,如线程池,pthread_cond_broadcast() 唤醒全部线程,但我们通常只需要一部分线程去做执行任务,而其它的线程则需要继续 wait,所以强烈推荐对 pthread_cond_wait()
使用 while 循环来做条件判断。
四、深入理解条件变量
以下内容摘抄自 linux 下 pthread_cond_t 详解,博主写的很详细,通俗易懂????
4.1 本文目的
首先说明,本文重点不在怎么用条件变量。这里我先列出 apue 中对于 pthread_cond_wait 函数的这么一段话:「调用者把锁住的互斥量传给函数,函数然后自动把调用线程放到等待条件的线程列表上,对互斥量解锁。这就关闭了条件检查和线程进入休眠状态等待条件改变这两个操作之间的时间通道,这样线程就不会错过条件的任何变化。pthread_cond_wait 返回时,互斥量再次被锁住。」
这段话的信息量很大,其中关于互斥量的操作可以理解为以下三个点:
- 调用 pthread_cond_wait 前需要先对互斥量 mutex 上锁,之后才能把 mutex 传入 pthread_cond_wait 函数
- 在 pthread_cond_wait 函数内部,会首先对传入的 mutex 解锁
- 当信号到来后,pthread_cond_wait 函数内部在返回前会去锁住传入的 mutex
我当时看到这里,各种疑问:1、传入前为何要锁;2、传入后为何要释放;3、返回时又为何再次锁?本文就这三个问题进行详细解释。
4.2 三个问题
要回答那三个问题,那么首先需要明白「等待与唤醒」的配合。其实这个图就能解释上述三个问题,不过我还是详细解释一下。
图中有一个关键点,就是「判断条件是否满足」的操作,是在「调用 pthread_cond_wait 之前、锁 mutex 之后」发生的。也就是说 pthread_cond_wait 不具备判断条件的能力,需要我们在外部写判断语句:
- 条件不满足时,才会进入 pthread_cond_wait
- 进入 pthread_cond_wait 先解锁然后就马上阻塞
- pthread_cond_signal 唤醒的是阻塞在 pthread_cond_wait 的线程
把这个基本流程弄清楚后,就可以解释那三个问题了。
4.2.1 传入前为何要锁
传入前锁 mutex 是为了保证线程从条件判断到进入 pthread_cond_wait 前,条件不被改变。
如果没有传入前的锁,就会有这样的情况:线程 A 在「判断条件不满足之后、调用 pthread_cond_wait 之前」,A 因为休眠、又或者因为多线程下多个线程执行顺序和快慢的因素,令线程 B 更改了条件,使得条件满足。但此时线程 A 还没有调用pthread_cond_wait。等到线程 A 启动调用 pthread_cond_wait 后虽然条件满足,但却收不到 pthread_cond_signal 的唤醒,就会一直阻塞下去。
结合下面的伪代码来加深理解:
/* 线程A执行函数 */
int giFlag = FALSE; // FALSE:线程A不满足执行条件
void *funcA(void *arg)
{
// pthread_mutex_lock(&mutex); // 传入前不加锁
while (FALSE == giFlag)
{
// 在调用pthread_cond_wait前,线程B启动并执行了函数funcB,修改条件并发出信号
// 但此时由于线程A还未执行pthread_cond_wait函数,所以会忽略掉线程B发出的信号
// 等到线程A开始执行pthread_cond_wait时,已经收不到来自线程B的信号了,会一直阻塞
pthread_cond_wait(&cond, &mutex);
}
// ToDo
pthread_mutex_unlock(&mutex);
return NULL;
}
/* 线程B执行条件 */
void *funcB(void *arg)
{
pthread_mutex_lock(&mutex);
// 线程B将giFlag置为TRUE,并通过cond_signal将信号发送给了线程A
giFlag = TRUE;
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);
return NULL;
}
4.2.2 传入后为何要释放
传入后解锁是为了条件能够被改变。
传入后的解锁,是因为调用 pthread_cond_signal 的那部分,需要先加锁更改条件后才调用 pthread_cond_signal(更改条件与等待条件满足,都是针对条件这一个资源的竞争,所以调用 pthread_cond_wait 和调用 pthread_cond_signal 的两个线程需要同一把锁)。
如果 pthread_cond_wait 内不对 mutex 解锁,那么在调用 pthread_cond_wait 后,其他线程就不能更改条件,条件就会一直不满足。
4.2.3 返回时又为何再次锁
- 返回前再次锁 mutex 是为了保证条件从「线程从 pthread_cond_wait 返回后」到「再次条件判断前」不被改变。
- 使得在「pthread_cond_signal之后」与「pthread_mutex_unlock 之前」可以执行其他的语句。
对于 1,这里的理由与传入 pthread_cond_wait 前锁 mutex 的理由差不多。如果不锁,那么线程 A 调用 pthread_cond_wait后,条件满足,线程 A 被唤醒,从 pthread_cond_wait 返回。线程 B 在此时更改了条件,使得条件不满足,线程 A 并不知道条件又被更改,还是以为条件满足,就可能出错。
对于 2,由于 mutex 在这时已经被这个线程锁住,还没有解锁,所以调用 pthread_cond_wait 的那个线程在 pthread_cond_wait 返回前的锁 mutex 的行为就会阻塞,直到 pthread_cond_signal 后的语句执行完并解锁,pthread_cond_wait 才会返回。
4.3 pthread_cond_signal 的两种写法
由于 pthread_cond_wait 返回前再次锁的行为,所以 pthread_cond_signal 不一定必须放在解锁 mutex之前。
4.3.1 写法一
{
pthread_mutex_lock(&mutex);
// ToDo
pthread_cond_signal(&cond);
// ToDo
pthread_mutex_unlock(&mutex);
}
缺点:在某些线程的实现中,会造成等待线程从内核中被唤醒(接收到了 cond_signal 发出的信号)回到用户空间,然后 pthread_cond_wait 返回前需要加锁,但是发现锁没有被释放,又回到内核空间所以一来一回会有性能的问题。
但是在 LinuxThreads 或者 NPTL 里面,就不会有这个问题。因为在 Linux 线程中,有两个队列,分别是 cond_wait 队列和mutex_lock 队列, cond_signal 只是让线程从 cond_wait 队列移到 mutex_lock 队列,而不用返回到用户空间,不会有性能的损耗,所以Linux中这样用没问题。
4.3.2 写法二
{
pthread_mutex_lock(&mutex);
// ToDo
pthread_mutex_unlock(&mutex);
pthread_cond_signal(&cond);
}
优点:不会出现之前说的那个潜在的性能损耗,因为在 signal 之前就已经释放锁了
缺点:如果 unlock 之后 signal 之前,发生进程交换,另一个进程(不是等待条件的进程)拿到这把梦寐以求的锁后加锁操作,那么等最终切换到等待条件的线程时锁被别人拿去还没归还,只能继续等待。