目录
1. 线程安全
1.1 线程不安全前期
1.2 线程不安全原因
2. 线程互斥
2.1 加锁保护(代码)
2.2 锁的本质
3. 可重入对比线程安全
4. 死锁
4.1 死锁的必要条件
4.2 避免死锁
5. 笔试面试题
答案及解析
本篇完。
1. 线程安全
基于上一篇线程控制,这里创建个linux_23文件,在里面写代码,先看一段模拟抢票的代码:
Makefile:
mythread:mythread.cc
g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:
rm -f mythread
mythread.cc:(创建了三个新线程抢票)
#include <iostream>
#include <cstdio>
#include <cerrno>
#include <cstring>
#include <thread>
#include <unistd.h>
#include <pthread.h>
using namespace std;
// 如果多线程访问同一个全局变量,并对它进行数据计算,多线程会互相影响吗
int tickets = 10000; // 在并发访问的时候,导致了我们数据不一致的问题
void *getTickets(void *args)
{
(void)args;
while(true)
{
if(tickets > 0)
{
usleep(1000);
printf("%p: %d\n", pthread_self(), tickets);
tickets--;
}
else
{
break;
}
}
return nullptr;
}
int main()
{
pthread_t t1,t2,t3,t4;
// 多线程抢票的逻辑
pthread_create(&t1, nullptr, getTickets, (void*)"user1");
pthread_create(&t2, nullptr, getTickets, (void*)"user2");
pthread_create(&t3, nullptr, getTickets, (void*)"user3");
pthread_create(&t4, nullptr, getTickets, (void*)"user4");
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
pthread_join(t3, nullptr);
}
编译运行:
运行以后,发现出现了负数票,这不合理,票抢完就应该停止了,包括我们的代码逻辑都是这样写的,但是此时就出现了这种情况。
- 上面现象的原因是发生了线程不安全问题。
为什么产生了线程不安全现象:
上面现象故意弄出来的,涉及到了线程调度,利用了线程调度的特性造出了一个这样的现象。要想出现上面的现象,就需要尽可能让多个线程交叉执行。多个线程交叉执行的本质:就是让调度器尽可能的频繁发生线程调度与切换。
虽然看起来是多个线程在同时运行,但这是由于CPU运行速度太快导致的,实际上,CPU是一个线程一个线程执行的。现在就是要让CPU频繁调度,不停的切换线程,一个线程还没有执行完就再执行下一个,每个线程都执行一点,这样交叉执行。
当一个线程进行延时的时候,CPU并不会等它,而是会将它放在等待队列里,然后去执行另一个线程,等延时线程醒来以后才会接着执行。
线程在时间片到来,更高优先级线程到来,线程等待的时候会发生线程切换。
线程是在从内核态转换成用户态的时候检测是否达到线程切换的条件的。
线程检测是否切换是以内核态的身份去检测的,执行的是3~4G内核空间中的代码,本质上是操作系统在检测。
1.1 线程不安全前期
假设tickets已经只剩一张了,即全局变量tickets = 1。
主线程创建好4个新线程以后,4个新线程便开始执行了,在执行到延时的时候,新线程就会被放在等待队列里。看CPU及内核:
if(tickets > 0)判断的本质逻辑: 从内存中读取数据到CPU寄存器 -> 进行判断。
在线程user1执行到if判断时,CPU从内存中将tickets变量中的数据1拿到了CPU的寄存器ebx中。
CPU进行判断后,发现符合大于0的条件。
当线程user1符合条件继续向下执行延时代码时,CPU将线程user1切走了,换上了user2。
在线程user1被切走的时候,它的上下文数据也会被切走。
所以ebx寄存器中的1也会跟着user1的PCB被切走。
user2被调度时仍然重复user1的过程,执行延时被切走,再换上user3,以此类推,直到user4被切走。四个线程都拿到了tickets=1,所以符合条件,都能向下执行。
当user4被挂起后,user1差不多就该醒来了。user1唤醒以后接着被切走的位置继续执行:
执行tickets - - 的本质:
- 从内存中读取数据到CPU的寄存器
- 更改数据
- 写回数据到内存中
虽然C/C++代码只有一条语句,但是汇编后至少有3条语句。
user1执行tickets–以后,抢票成功了,并且将抢票后的tickets=0写回到了内存中。
此时user2醒来了,同样接着它被切走的位置继续执行,此时user2回来后认为tickets=1,所以就向下执行了:
当执行tickets减减时,仍然需要三步:
- 从内存中读取tickets=0到CPU寄存器ebx中。
- 修改值,从0变成-1。
- 将-1写回内存中。
当user2执行完后,user3和user4醒来同样继续向下执行,重复上面的过程,仍然对tickets减一,所以导致结果不合理。
1.2 线程不安全原因
只存在两个线程,对全局变量tickets仅作减减操作:
线程A先被CPU调度,进行减减操作。
- 从内存中将tickets=1000取到寄存器ebx中。
- 进行减减操作,tickets变成了999。
- 在执行第三步写回数据之前,线程A被切走了。
线程A切走的同时,它的上下文,也就是tickets=999也被切走了。
线程B此时被调度,线程A在等待队列。
- 线程B先从内存中读取tickets = 1000到寄存器ebx中。
- 进行减减操作。
- 将减减后的值写回到内存中。
- 线程B将减减操作完整的执行了很多遍,直到tickets=200时才被切下去。
线程B被切走以后,线程A又接着被调度。
线程A接着被切走的位置开始执行,也就是执行减减的第三步操作壹壹写回。
- 线程A被调度后,先恢复上下文,将被切走时的tickets=999恢复到了ebx寄存器中。
- 然后执行第三步,将tickets=999写回到了内存中。
线程B辛辛苦苦将tickets从1000减到了200,线程A重新被调度后,直接将tickets又从200写回到了999。上面这种现象被叫做数据不一致问题。
- 导致数据不一致问题的原因:共享资源没有被保护,多线程对该资源进行了交叉访问。
而解决数据不一致问题的办法就是对共享资源加锁。
2. 线程互斥
看看几个基本概念:
临界资源:多个执行流进行安全访问的共享资源。
上面现象中的tickets很显然就不是临界资源,因为多线程对它的访问并不安全,存在数据不一致问题。
临界区:多个执行流中,访问临界资源的代码。
假设上面例子中的是临界资源,那么每个线程都存在一部分临界区,就是对tickets进行判断,打印,减减部分的代码。多个线程中的这部分代码属于临界区。
线程互斥:让多个线程串行访问共享资源,任何时候只有一个执行流在访问共享资源。
上面例子中如果多个线程能够串行访问tickets,而不是交叉访问,也不会产生数据不一致问题。而让共享资源变成临界资源就是为了实现互斥,也就是让多个线程串行访问原本的共享资源。
原子性:对一个资源进行访问的时候,要么不做,要么就做完。
在C/C++中的减减和加加操作,看似是一句代码,但是对应着三条汇编指令,上面例子中,线程A在执行第三步之前被切走了,导致减减操作没有完成,这种行为就不具有原子性,因为对共享资源的操作没有做完。
对一个资源进行操作,如果只用一条汇编就能完成,那么就具有原子性,反之就不具有原子性。(这是当前的一种理解,这种理解只能算原子性中的一个子集,是为了方便表述。)
2.1 加锁保护(代码)
要想解决多线程的数据不一致问题,就需要做到以下几点:
- 代码必须要有互斥行为,当一个线程进入临界区执行代码时,不允许其他线程进入该临界区
- 如果有多个线程同时请求执行临界区代码,并且临界区没有线程在执行代码,那么只允许一个线程进入该临界区。
- 如果线程不在临界区中执行代码,那么该线程不能阻止其他线程进入临界区。
要做到上面三点,只需要一把锁就可以,持有锁的线程才能进入临界区中执行代码,并且其他线程无法进入该临界区。
锁:就是互斥量,也叫互斥锁。
加锁可以让共享资源临界资源化,从而保护共享资源的安全,让多个线程串行访问共享资源。
锁相关的系统调用:
pthread_mutex_t lock; // 定义一把锁
和创建线程一样,锁也需要创建,POSIX提供了锁的变量类型,如上面代码所示,其中mutext是互斥量的意思。
初始化锁:man pthread_mutex_init:
int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);
- 形参1:创建的互斥锁指针
- 形参2:锁的属性,一般情况下设为nullptr
- 返回值:初始化成功返回0,失败返回错误码
- 作用:将创建的锁初始化。
销毁锁:
int pthread_mutex_destroy(pthread_mutex_t *mutex);
- 形参:创建的互斥锁指针
- 返回值:销毁成功返回0,失败返回错误码
- 作用:当锁使用完后,必须进行销毁
全局或者静态锁初始化:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
如果锁是全局的或者被static修饰的静态锁,只需要使用上面语句初始化锁即可。
加锁:man pthread_mutex_lock:
int pthread_mutex_lock(pthread_mutex_t *mutex);
- 形参:创建的互斥锁指针
- 返回值:加锁成功返回0,失败返回错误码
- 作用:给临界区加锁,让多线程串行访问临界资源
解锁:
int pthread_mutex_unlock(pthread_mutex_t *mutex);
- 形参:创建的互斥锁指针
- 返回值:解锁成功返回0,失败返回错误码
- 作用:解锁,让多线程恢复并发执行
锁其实起一个区间划分的作用,在加锁和解锁之间的代码就是临界区,多个执行流只能串行执行临界区代码,从而保护公共资源,使之成为临界资源。
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(lock);
//临界区
//...
pthread_mutex_unlock(lock);
加锁和解锁两句代码圈定了临界区的范围。
现在将抢票代码加上锁,看看是否还会出现多线程数据不一致问题:
Makefile:
mythread:mythread.cc
g++ -o $@ $^ -g -std=c++11 -lpthread
.PHONY:clean
clean:
rm -f mythread
mythread.cc:
在主线程中创建一个互斥锁,并且初始化,在所有新线程等待成功后将锁释放。
但是此时的锁是存在于主线程的栈结构中,需要让所有新线程看到这把锁。(创建成全局就不用)
在线程数据类中再增加一个锁指针,此时所有线程就都能看到这把锁了。
#include <iostream>
#include <cstdio>
#include <cerrno>
#include <cstring>
#include <cassert>
#include <thread>
#include <unistd.h>
#include <pthread.h>
using namespace std;
int tickets = 10000; // 在并发访问的时候,导致了我们数据不一致的问题 -> 临界资源
#define THREAD_NUM 10
class ThreadData
{
public:
ThreadData(const std::string &n,pthread_mutex_t *pm):tname(n), pmtx(pm)
{}
public:
std::string tname;
pthread_mutex_t *pmtx;
};
void *getTickets(void *args)
{
ThreadData *td = (ThreadData*)args;
while(true) // 抢票逻辑
{
int n = pthread_mutex_lock(td->pmtx); // 加锁
assert(n == 0);
// 临界区
if(tickets > 0) // 判断的本质也是计算的一种
{
usleep(rand()%1500);
printf("%s: %d\n", td->tname.c_str(), tickets);
tickets--; // 也可能出现问题
n = pthread_mutex_unlock(td->pmtx); // 解锁
assert(n == 0);
}
else
{
n = pthread_mutex_unlock(td->pmtx); // break之前解锁
assert(n == 0);
break;
}
usleep(rand()%2000); // 抢完票,其实还需要后续的动作
}
delete td;
return nullptr;
}
int main()
{
time_t start = time(nullptr);
pthread_mutex_t mtx;
pthread_mutex_init(&mtx, nullptr);
pthread_t t[THREAD_NUM];
for(int i = 0; i < THREAD_NUM; i++) // 多线程抢票的逻辑
{
std::string name = "thread ";
name += std::to_string(i+1);
ThreadData *td = new ThreadData(name, &mtx);
pthread_create(t + i, nullptr, getTickets, (void*)td);
}
for(int i = 0; i < THREAD_NUM; i++)
{
pthread_join(t[i], nullptr);
}
pthread_mutex_destroy(&mtx);
return 0;
}
此时抢票的结果是正常了,最终抢到1结束,符合我们的预期。
但发现抢票的速度比以前慢了好多。
因为加锁和解锁的过程是多个线程串行执行的,并且临界区的代码也是串行执行的,所以速度就变慢了。
需要注意的是
- 当一个线程从临界区中出来并且释放锁后,执行后续任务时,其他线程才有更大几率去竞争锁。
- 加锁时,一定要保证临界区的粒度非常小。将那些不是必须放在临界区中的代码放在临界区外。
- 加锁是程序员行为,要加锁就所有线程都加锁,否则就起不到保护共享资源的效果。
2.2 锁的本质
如何看待锁?
在上面代码中,一个锁必须让所有线程都看到,所以锁本身就是一个共享资源。
既然是共享资源,锁也必须是安全的,那么是谁来保证锁的安全性呢?
锁是通过加锁和解锁是原子的来保证自身的安全的。
一个线程,如果申请成功锁,那么它就会继续向下执行,如果暂时申请不成功呢?
此时就被阻塞住了,线程和进程都是存在的。
- 一个锁只能被申请一次,只有锁被释放后才能再次申请。
当一个线程申请锁暂时失败以后,就会阻塞不动。
- 当一个线程申请锁成功,进入临界区访问临界资源,其他线程要想进入临界区只能阻塞等待,等锁释放。
- 当一个线程申请锁成功,进入临界区访问临界资源,同样是能被切走的,而且该线程是抱着锁走的,其他线程仍然无法申请锁成功。
操作系统内部并不存在锁的概念,所以调度器在调度轻量级进程的时候并不会考虑是否有锁。
所以站在其他线程的角度,锁只有两种状态:
- 申请锁前
- 申请锁后
站在其他线程的角度,看到当前持有锁的过程就是原子的。
加锁解锁的原理:
经过上面的例子,我们认识到一个事实,c/c++中加加和减减的操作并不是原子的,所以会导致多线程数据不一致的问题。
而为了能让加锁过程是原子的,在大多数体系结构了,都提供了swap或者xchange汇编指令,通过一条汇编指令来保证加锁的原子性。
加锁解锁的伪汇编代码:
lock:
movb %al, $0
xchange %al, mutex
if(al寄存器的内容 > 0)
{
return 0;
}
else
{
挂起等待;
}
goto lock;
unlock:
movb mutex, $1
唤醒等待mutex的线程;
return 0;
加锁过程中,xchange是原子的,可以保证锁的安全。
锁只能被一个线程持有,而且由于xchange汇编只有一条指令,即使申请锁的过程被切走也不怕。
一旦一个线程通过xchage拿到了锁,即使它被切走,也是拿着锁走的,其他线程是无法拿到锁的,只有等它将锁释放。
只有持有锁的线程才能执行下去,锁相当于一张入场卷。
这样来看,释放锁的过程其实对原子性的要求并没有那么高,因为释放锁的线程必定是持有锁的线程,不持有锁的线程都不会执行到这里,都在阻塞等待。
3. 可重入对比线程安全
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。
之前在信号部分就提到过重入,进程在执行一个函数,收到某个信号在处理信号时又调用了这个函数。今天在多线程这里,理解重入更加容易,我们以前写的多线程代码都是可重入的。
可重入和不可重入:一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
常见可重入情况:
- 不使用全局变量或静态变量。
- 不使用用malloc或者new开辟出的空间。
- 不返回静态或全局数据,所有数据都有函数的调用者提供。
常见可重入情况:
- 不使用全局变量或静态变量。
- 不使用用malloc或者new开辟出的空间。
- 不返回静态或全局数据,所有数据都有函数的调用者提供。
总的来说,一个函数中如果使用了全局数据,或者静态数据,以及堆区上的数据,就是不可重入的,反之就是可重入的。
线程安全:
多个线程并发同一段代码时,不会出现不同的结果(数据不一致)。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
互斥锁就是让不安全的线程变安全,也就是前面我们所学习的内容。
常见线程安全情况:
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
- 类或者接口对于线程来说都是原子操作。
- 多个线程之间的切换不会导致该接口的执行结果存在二义性。
多线程共同执行的代码段中,如果有全局变量或者静态变量没有被保护,那么就是线程不安全的。
常见线程不安全情况:
- 不保护共享变量的函数。
- 函数状态随着被调用,状态发生变化的函数。
- 返回指向静态变量指针的函数。
可重入与线程安全的联系:
多线程是通过调用函数来实现的,所以线程安全和重入就存在一些联系:
- 函数是可重入的,那就是线程安全的,因为没有全局或者静态变量,不会产生数据不一致问题。
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。出发对不可重入函数的全局变量进行保护。
- 如果一个函数中有全局变量并且没有保护,那么这个函数既不是线程安全也不是可重入的。
可重入与线程安全的区别:
可重入和线程安全是不同的两个东西,但是又存在一定的交集。
- 可重入说的是函数。
- 线程安全说的是线程。
可重入函数是线程安全函数的一种,因为不存在全局或者静态变量。
线程安全不一定是可重入的,而可重入函数则一定是线程安全的。因为线程安全的情况可能是对全局变量进行了保护(加了锁)。
由于线程可以加锁,所以说线程安全的情况比可重入要多。
4. 死锁
我们前面例子中写的都是只有一把锁的情况,在实际使用中有可能会存在多把锁,此时就可能造成死锁。
死锁:一组执行流中的各个执行流均占有不会释放的锁资源,但因互相申请被其他进程所站用不会释放的锁资源而处于的一种永久等待状态。
通俗来说就是一个线程自己持有锁,并且不会释放,但是还要申请其他线程的锁,此时就容易造成死锁。
一把锁也是会有死锁的情况的,连续申请俩次就是死锁。
在上面演示一个线程暂时申请锁失败而阻塞时,就是死锁。
4.1 死锁的必要条件
死锁的四个必要条件:
① 互斥
这一点不用说,只要用到锁就会互斥。
② 请求与保持
请求就是指一个执行流申请其他锁,保持是指不释放自己已经持有的锁。
③ 不剥夺
一个执行流已经持有锁,在不主动释放前不能强行剥夺。
④ 环路等待
线程A,B,C都持有一把锁,并且不释放。
- 线程A 申请 线程B持有的锁B
- 线程B 申请 线程C持有的锁C
- 线程C 申请 线程A持有的锁A
此时就构成了环路阻塞等待。
只有符合上面四个条件就会造成死锁。而要破坏死锁只要破坏其中一个条件即可。
4.2 避免死锁
① 上面四个必要条件中的第一个无法破坏,因为我们使用的就是锁,锁就具有互斥的性质。只能破坏其他三个条件。
② 加锁顺序一致
这是为了避免形参环路等待,只要不构成环路即可。
③ 避免锁位释放的场景
④ 避免锁位释放的场景
临界资源尽量一次性分配好,不要分布在太多的地方加锁,这样的话导致死锁的概率就会增加。
解决死锁的基本方法如下:
预防死锁、避免死锁、检测死锁、解除死锁。
解决死锁的常用策略如下:
鸵鸟策略 对可能出现的问题采取无视态度,前提是出现概率很低
预防策略 破坏死锁产生的必要条件
避免策略 银行家算法,分配资源前进行风险判断,避免风险的发生
检测与解除死锁 分配资源时不采取措施,但是必须提供死锁的检测与解除手段
可以避免(预防)死锁的算法(了解):
- 死锁检测算法
- 银行家算法(避免策略)
银行家算法的思想在于将系统运行分为两种状态:安全/非安全,有可能出现风险的都属于非安全。
银行家算法的思想是为了避免出现“环路等待”条件
5. 笔试面试题
1. 以下描述正确的有:
A.可以使用ps -l命令查看轻量级进程信息
B.可以使用ps -L命令查看轻量级进程信息
C.可以使用pthread_self接口获取轻量级进程ID
D.可以使用getpid接口获取轻量级进程ID
2. 以下描述正确的有:[多选]
A.pthread_create函数是一个库函数, 代码当中如果使用该函数创建线程, 则需要在编译的时候链接“libpthread.so”线程库
B.那个线程调用pthread_exit函数, 那个线程就退出。俗称“谁调用谁退出”
C.在有多个线程的情况下,主线程调用pthread_cancel(pthread_self()), 则主线程状态为Z, 其他线程正常运行
D.在有多个线程的情况下,主线程从main函数的return返回或者调用pthread_exit函数,则整个进程退出
3. 下列不属于POSIX互斥锁相关函数的是:()
A.int pthread_mutex_destroy(pthread_mutex_t* mutex)
B.int pthread_mutex_lock(pthread_mutex_t* mutex)
C.int pthread_mutex_trylock(pthread_mutex_t* mutex)
D.int pthread_mutex_create(pthread_mutex_t* mutex)
4. 进程A、B共享变量x,需要互斥执行;
进程B、C共享变量y,B、C也需要互斥执行,
因此进程A、C必须互斥执行
A.错
B.对
5. 设两个进程共用一个临界资源的互斥信号量mutex,当mutex=1时表示()。
A.一个进程进入了临界区,另一个进程等待
B.没有一个进程进入临界区
C.两个进程都进入临界区
D.两个进程都在等待
6. 在一段时间内,只允许一个进程访问的资源被称为()
A.共享资源
B.临界区
C.临界资源
D.共享区
7. 简述轻量级进程ID与进程ID之间的区别
8. 简述LWP与pthread_create创建的线程之间的关系
9. 简述什么是LWP
10. 简述什么是线程互斥,为什么需要互斥
答案及解析
1. B
A错误,
B正确 ps命令用于查看进程信息,其中-L选项用于查看轻量级进程信息
C错误 pthread_self() 用于获取用户态线程的tid,而并非轻量级进程ID
D错误 getpid() 用于获取当前进程的id,而并非某个特定轻量级进程
2. ABC
C:主线程调用pthread_cancel(pthread_self())函数来退出自己, 则主线程对应的轻量级进程状态变更成为Z, 其他线程不受影响,这是正确的(正常情况下我们也不会这么做....)
D:主线程调用pthread_exit只是退出主线程,并不会导致进程的退出
3. D
A pthread_mutex_destroy 用于销毁互斥锁
B pthread_mutex_lock 用于加锁保护临界区
C pthread_mutex_trylock 用户非阻塞加锁
D 没有这个函数 pthread_create是线程创建函数,而互斥锁并没有对应的创建函数,而是直接定义pthread_mutex_t类型的互斥锁变量
4. A
进程A操作的x,C并不进行操作;进程C操作的y,进程A并不操作;因此A和C并不需要互斥执行
5. B
mutex简单理解就是一个0/1的计数器,用于标记资源访问状态:
0表示已经有执行流加锁成功,资源处于不可访问,
1表示未加锁,资源可访问。
因此选择B选项,表示没有执行流完成加锁对资源进行访问,资源处于可访问状态。
6. C
A 共享资源表示能够被多个执行流同时访问的资源
B 对临界资源进行操作的代码段被称作临界区
C 临界资源表示同一时间只能有一个执行流访问的共享资源
D 没有这个专业说法,非要简单理解就是可以共同执行的代码片段
题目为选择同一时间只有一个进程能访问的资源,则就是临界资源,因此选择C选项
7. 简述轻量级进程ID与进程ID之间的区别:
因为Linux下的轻量级进程是一个pcb,每个轻量级进程都有一个自己的轻量级进程ID(pcb中的pid),而同一个程序中的轻量级进程组成线程组,拥有一个共同的线程组ID
8. 简述LWP与pthread_create创建的线程之间的关系:
pthread_create是一个库函数,功能是在用户态创建一个用户线程,而这个线程的运行调度是基于一个轻量级进程实现的
9. 简述什么是LWP
LWP是轻量级进程,在Linux下进程是资源分配的基本单位,线程是cpu调度的基本单位,而线程使用进程pcb描述实现,并且同一个进程中的所有pcb共用同一个虚拟地址空间,因此相较于传统进程更加的轻量化
10. 简述什么是线程互斥,为什么需要互斥:
线程互斥指的是在多个线程间对临界资源进行争抢访问时有可能会造成数据二义,因此通过保证同一时间只有一个线程能够访问临界资源的方式实现线程对临界资源的访问安全性
本篇完。
下一篇:零基础Linux_24(多线程)线程同步+条件变量+生产者消费模型。