目录
前言
哈喽,小伙伴们大家好。在之前的文章中我介绍过进程,想必小伙伴们对进程也都有一定的了解。今天我将介绍一个新概念——线程,线程的本质是什么呢?线程和进程的联系是什么呢?线程本身又有哪些特性呢?这些问题都将在下面的文章中得到答案,话不多说,我们赶紧开始吧。
一、Linux线程概念
线程概念:在一个进程里的执行路线就叫做线程。
我在之前的文章中提到过,每个进程都对应一个task_struct,而每个task_struct都指向不同的地址空间,在不同的地址空间中根据页表映射到物理内存中。这句话其实不太准确,因为这句话成立有一个前提,那就是进程内部只有一个执行流,也就是单线程的。
如果一个进程是多线程的,则如下图所示,每一个task_struct对应的是一个线程,而且这些线程都指向同一张地址空间表。站在内核角度,承担分配系统的基本实体,角度进程。控制块,地址空间,页表共同组成一个进程。
和进程间具有独立性不同,线程间的地址空间和物理内存是共享的,数据改变并不会有写时拷贝发生。
linux线程设计:
从linux内核的角度看,linux下并不存在真正意义上的多线程,所谓线程都是用进程模拟的。如果真的支持多线程,os就需要需要管理线程,线程的数量有很多,所以又要设置一套新的复杂的管理方法。而linux与其它操作系统不同,它并没有这样做,而是直接把线程模拟成进程来管理,减少了很多格外工作,这也是linux设计的非常精妙的一个地方。
在linux中,站在cpu的角度,它是无法区分进程和线程的,当然也不需要区分。cpu只关心一个一个独立的执行流。linux中的所有执行流,都叫做轻量级进程,每一个执行流对应一个task_struct,在cpu看来,task_struct的内容是要小于os原理上面的进程控制块的。(所谓os原理,从宏观角度看是所有操作系统都遵循的设计哲学,但每个操作系统又都有各自的特色)。
既然linux没有真正意义上的线程,所以linux也没有真正意义上的线程相关的系统调用。linux仅仅提供了创建轻量级进程的接口,创建进程共享空间。以上所说的,是站在内核的角度。从用户角度来说,我们只是想单纯使用多线程,并不关心如何实现,所以linux基于轻量级进程的系统调用,在用户层模拟实现了一套线程接口。
总结:
- 一切进程至少都有一个执行线程
- 线程在进程内部运行,本质是在进程地址空间内运行
- 在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化
- 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流
二、线程的特点
1、线程的优点
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换代价要小很多
- 线程占用的资源要比进程小很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
2、线程的缺点
- 性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
- 健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
- 缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
- 编程难度提高
编写与调试一个多线程程序比单线程程序困难得多。
- 线程异常
单个进程如果出现除零,野指针问题导致线程崩溃,进程也会崩溃。线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。
3、进程与线程
进程是资源分配的基本单位,线程是调度的基本单位,线程共享进程数据,但也有一部分自己的数据。
- 线程ID
- 一组寄存器
- 栈
- errno
- 信号屏蔽字
- 调度优先级
进程的多个线程共享同一块地址空间,因此Text segment和data segment是共享的。如果定义一个函数,在各线程都可以调用,如果定义一个全局变量,在各线程都可以访问到。除此之外,各线程还能共享以下进程资源和环境:
- 文件描述符表
- 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
- 当前工作目录
- 用户id和用户组id
三、线程控制
- 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头的,这些函数都为用户层函数,供用户调用。
- 要使用这些函数库,要通过引入头文<pthread.h>。
- 链接这些线程函数库时要使用编译器命令的“-lpthread”选项,因为不是c/c++库,不指定的话编译器找不到。
1、创建线程
功能:创建一个新的线程
原型:
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;失败返回错误码
我们在程序中创建一个线程,代码如下:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
void* routine(void* arg)
{
char* msg=(char*)arg;
while(1)
{
printf("%s\n",msg);
sleep(1);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid,NULL,routine,(void*)"thread 1");//创建线程
while(1)
{
printf("I am main\n");
sleep(2);
}
return 0;
}
进程运行后,使用ps命令查看进程。
我们发现有两个mythread也可以说是两个执行流在运行,它们的PID相同,但LWP不同。我们在上文中提到,在linux内核是无法区分进程和线程的,它会把所有执行流都当成一个轻量化进程处理,由此我们可以得出一个结论,OS在调度轻量化进程的时候,采用的并不是PID,而是LWP。应用层的线程和内核中的LWP是1:1的关系。
2、线程ID及地址空间布局
- pthread_ create函数会产生一个线程ID,存放在第一个参数指向的地址中,我们可以通过访问第一个参数来查看。
- 线程库也提供了pthread_ self函数,来获取线程自身的ID。
pthread_t pthread_self(void);
pthread_t 是什么类型取决于具体实现,可能是无符号长整形或其它类型。但 pthread_t类型的线程id其本质是进程地址空间上的一个地址。
由于在我的云服务器上pthread_t是无符号长整型,所以按地址打印会报警告,但并不影响结果,运行结果如下。 可以看出,pthread_t确实是一个地址。
LWP是内核层用来描述线程的标识, pthread_t类型的id为用户层的ID。用户需要通过线程ID来找到对应的线程,那么具体是如何找到的呢?
我们直到,线程很多,需要被管理,那么这个管理工作由谁来做呢?Linux不提供真正的线程,只提供LWP,意味着OS只需要对LWP内核执行流进程管理,而用户层的接口和其它数据由pthread来管理。
pthread是一个动态库,我们知道动态库也是文件,保存在磁盘中,然后根据页表映射到地址空间的共享区。 动态库映射到内存中后,每个线程都对应着一个内存块保存它的数据和属性,我们可以通过线程ID找到相应内存块的首地址,从而找到线程的所有信息。cpu在调度进行线程切换时,只需要到动态库中,找到相应的线程内存块,把线程的临时数据保存到局部存储区中,把临时变量保存到内存块的栈中,然后切换到下一个线程内存块提取相关数据,便能实现进程切换。内核仅仅要做的就是当用户层线程按照某种方式交给它一些数据时,找到LWP按要求去跑就好了。
3、线程终止
线程终止有三种方法:
(1)return XXX
return为正常退出,返回退出信息,但要注意mian thread退出代表整个进程退出。
(2)exit
exit叫做终止进程,任何一个线程调用exit都会导致进程终止。
我们很少在线程内调用exit,通常调用函数pthread_ exit终止自己。pthread_ exit(1)等价于return 1。
(3) pthread_cancel函数
功能:
取消一个执行中的线程
原型:
int pthread_cancel(pthread_t thread);
参数:
thread: 线程ID
返回值:成功返回0;失败返回错误码
pthread_cancel也可以用于线程取消自己,但一般不这么使用,因为在线程内部return和exit已经足够用了。一般用于在main thread取消其它线程,某个线程被取消后,退出码是-1。
4、线程等待
和进程相同,线程也需要被等待。原因如下:
- 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。
- 创建新的线程不会复用刚才退出线程的地址空间。
功能:等待线程结束
原型:
int pthread_join(pthread_t thread, void **value_ptr);
参数:
- thread:线程ID
- value_ptr:这是一个输出型参数,它指向一个指针,后者指向线程的返回值
返回值:成功返回0;失败返回错误码
调用该函数的线程将挂起等待,知道id为thread的线程终止。thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的,总结如下:
- 如果thread为return返回,value_ptr指向的单元中存放的是thread线程的返回值。
- 如果thread为自己调用pthread_exit(x)终止的,value_ptr指向的单元中存放的x。
- 如果thread是被别的线程调用pthread_cancel取消的,value_ptr指向的单元中存放的x。
- 如果对thread的终止状态不感兴趣,可以传NULL给value_ptr。
void* routine(void* arg)
{
int i=(int)arg;
int count=5;
while(count--)
{
printf("i am thread:%d\n",i);
sleep(1);
}
}
int main()
{
pthread_t tid[5];
int i=0;
for(;i<5;i++)
{
pthread_create(&tid[i],NULL,routine,(void*)i);
printf("thread%d create success\n",i);
}
i=0;
for(;i<5;i++)
{
void* ret=NULL;
pthread_join(tid[i],&ret);
printf("thread%d quit:%d\n",i,(int)ret);
}
return 0;
}
等待结果如下:
注意:线程等待只适用于线程正常退出情况,当出现异常时是无法完成等待工作的,因为如果有某个线程发生异常,操作系统会视为整个进程异常,直接杀死整个进程。
5、分离线程
- 默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
- 如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。
int pthread_detach(pthread_t thread);
可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:
pthread_detach(pthread_self());
线程分离后和分离前唯一的区别就是不再需要被等待了,它仍然和main thread还有其它线程共用一分资源,出现异常依旧会导致整个进程终止。同时一但线程分离出去,它的返回值将没有任何意义。
四、线程互斥
1、案例演示
线程互斥相关概念:
- 临界资源:多线程执行流共享的资源就叫做临界资源
- 临界区:每个线程内部,访问临界资源的代码就叫做临界区。
- 互斥:任何时候,互斥都保证只有一个执行流进入临界区,访问临界资源,对临界资源起保护作用。
- 原子性:不会被任何调度打断的操作,该操作只有两态,要么完成,要么未完成。
下面看一个代码样例,几个线程同时抢票,如果剩余票数小于0,则终止抢票。每隔100微秒抢一次,抢到后把剩余的票数打印出来。
#define NUM 10000
int ticket = NUM;
void *GetTicket(void *arg)
{
int number = (int)arg;
while(1){
if(tickets > 0){
usleep(100);
printf("thread [ %d ] 抢票: %d\n", number, ticket--);
}
else{
break;
}
}
}
int main()
{
pthread_t thds[5];
for(int i = 0; i < 5; i++){
pthread_create(&thds[i], NULL, GetTicket, (void*)i);
}
for(int i=0; i < 5; i++){
pthread_join(thds[i], NULL);
}
return 0;
}
运行结果如下(只截取后半段),我们发现出现了负数,按理来说到后就该终止的,但是实际情况却不是我们想的那样。这是因为tickets是临界资源,多个执行流共同访问临界资源造成的问题。
没有出现正确结果的原因:
- if 语句判断条件为真以后,代码可以并发的切换到其他线程。
- usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段。
- --ticket 操作本身就不是一个原子操作。
取出ticket--部分的汇编代码
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的新值从寄存器写回到内存中。
假设在ticket为1时进入循环,这时候发生了线程切换,在其它线程中ticket被减到了0,等返回来的时候ticket再减1就会出现负数。
2、互斥量
2.1互斥量的相关概念
要解决以上问题,需要做到:
- 线程间必须有互斥关系,当一个线程进入临界区后,不允许其它线程进入。
- 当临界区没有线程访问时,如果有多个线程要求进入临界区,只能允许一个进入。
要做到这些,本质上是需要一把锁,Linux提供的这把锁叫互斥量(mutex)。
2.2 互斥量的使用
锁的初始化:
原型:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数:
- mutex:要初始化的互斥量
- attr:NULL
锁的销毁:
int pthread_mutex_destroy(pthread_mutex_t *mutex);
注意:
- 不要销毁一个已经加锁的互斥量
- 如果已经销毁,要确保后面不会有线程尝试加锁
加锁与解锁:
加锁:
int pthread_mutex_lock(pthread_mutex_t *mutex);
解锁:int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号
某个线程调用 pthread_ lock 时,可能会遇到以下情况:
- 互斥量处于未锁状态,该函数会锁定互斥量,同时返回成功。
- 其它线程已经锁定互斥量,或者存在其它线程同时申请互斥量,该线程没有竞争过其它线程,则该线程会被挂起,等待互斥量解锁。
挂起:
这里简单聊聊什么是挂起。锁(软件)或磁盘,网卡,显示器(硬件)等都叫做资源。这些资源都有各自的等待队列,用来排列不同线程访问相应资源的先后顺序。进程线程等待某种资源,在OS层面就是当前进程线程的task_struct从运行队列中剥离下来加入到了对应资源的等待队列,进程状态由R->S,这种情况可以称为当前进程被挂起等待了。
在用户视角,用户看到的一般是自己的进程卡住不动了,一般称为应用阻塞。
案例改进:
我们为上面的抢票案例加上锁,但是注意,加锁本身是有损于性能的,这几乎是不可避免的。对临界资源起到保护作用的同时尽量减少加锁带来的性能损耗,是每个执行流都要遵守的标准。
#define NUM 10000
pthread_mutex_t lock;
int tickets = NUM;
void *GetTicket(void *arg)
{
int number = (int)arg;
while(1){
pthread_mutex_lock(&lock);
if(tickets > 0){
usleep(100);
printf("thread [ %d ] 抢票: %d\n", number, tickets--);
pthread_mutex_unlock(&lock);
}
else{
pthread_mutex_unlock(&lock);
break;
}
}
}
int main()
{
pthread_t thds[5];
pthread_mutex_init(&lock,NULL);
for(int i = 0; i < 5; i++)
{
pthread_create(&thds[i], NULL, GetTicket, (void*)i);
}
for(int i=0; i < 5; i++){
pthread_join(thds[i], NULL);
}
pthread_mutex_destroy(&lock);
return 0;
}
线程1申请锁成功后,在访问临界区时依旧可以进行线程切换。但即便线程1已经被cpu切走了,其它线程依旧无法进入临界区,因为锁在线程1手里,必须等线程1重新切回来解锁后,其它线程才能申请锁进入临界区。
2.3互斥量的原子性
锁的存在是为了保护其它临界资源,但是非常尴尬的一点就是锁本身就是临界资源。已经没有东西可以来保护锁了,所以锁要能自己保护自己,这要求申请锁的过程必须是原子的。
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性。cpu和内存交互数据通过总线,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
现在我们把lock和unlock的伪代码改一下:
cpu中的寄存器不是共享的,每个线程都有各自的寄存器用来保存上下文数据,但内存中的数据是共享的。把%al理解成cpu中每个线程对应的寄存器,mutex为内存中的互斥量也就是锁,第一步操作为把寄存器中的值清0,第二步操作为用寄存器中的值和mutex进行交换(注意这里和我们平时写的代码中的交换是不一样的,是一步完成),此时寄存器中的值为1,mutex为0,就相当于把锁拿在了手里。假设进程A执行到这一步时,发生了进程切换,切换到进程B,此时寄存器换成线程B的,里面的值设为0,此时线程B再与mutex发生交换,已经没有任何效果,因为锁已经到了线程A手里。只有等线程B访问完毕后把锁还回来,也就是再次交换,mutex的值才会变为1。这个设计的巧妙在于无论怎样交换,都只存在一个1,也就相当于只存在一把锁,只有一个线程能访问临界资源。
3、可重入与线程安全
3.1 线程安全
多个线程并发执行一段代码时,各个线程都能正常并正确的完成。通常对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现线程安全问题。
常见的线程不安全的情况:
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
3.2 重入
重入是针对函数说的,同一个函数被不同执行流调用,一个执行流还没有调用完毕,另一个执行流就进入,这称为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
常见不可重入的情况:
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
常见可重入情况:
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
3.3 可重入与线程安全的关系
联系:
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
区别:
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
4、死锁
死锁是指在一组执行流中各个执行流都占有不会释放的资源,但每个执行流都想申请其它执行流不会释放的资源而处于一种永久等待的状态。
死锁四个必要条件:
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求而保持资源:某个执行流因为请求资源而阻塞时,保持现有的资源不放
- 不剥夺条件:某个执行流获得的资源,在未使用完前不能强行剥夺
- 循环等待条件:若干执行流之间形成一种循环等待资源的关系
避免死锁的方法:
- 破坏死锁的四个必要条件
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配
五、线程同步
1、概念
同步:在保证数据安全的前提下,让各线程按照一个特定的顺序访问临界资源,从而有效的避免饥饿问题,叫做同步。
饥饿问题:个别线程竞争力很强,每次都能申请到锁,但就是不办事,就会使其它线程长时间竞争不到锁,从而造成饥饿问题。比如线程A负责读,线程B负责写,实际临界资源里依旧没有东西可读了,但由于线程A的竞争力较强,依旧会不停的竞争锁,竞争到锁后由于没有东西可以读再立刻释放锁,循环这个过程,就会造成线程B的饥饿。
2、条件变量
2.1 概念
我们先来看两个例子:
例子 一:假设有一个自习室,里面只能容纳一个人,钥匙挂在门外的墙上,小红去的最早,用钥匙打开门把钥匙放进了口袋里然后走进去后把门关上了,这时候再有人想进去自习就需要进去排队。小红学了一阵,出来后把门挂在了墙上,这时候外面已经有很多人在排队了,小红看了一下觉得竞争太激烈了,于是又打算进去学一会,由于小红离钥匙最近,所以很轻松的就又拿到了钥匙打开门走了进去,刚进去没一会小红又觉得学不下去于是又打开门走了出来,走出来后又感到焦虑然后又马上走了进去,一直循环这个过程。我们发现小红并没有利用好自习室,而是在开门关门中把资源浪费了。于是,自习室加了一个新规定,出来后不能马上进去,而是要排到队列的末尾去等待,小红再次打开门后,又感到焦虑,刚想回去就看到了这条规定,只好被迫去队尾排队了,其他人就可以愉快的使用自习室了。
例子二:同样是上面那间自习室,这件自习室需要装修,装修时常不确定。一群热爱学习的孩子非常想进去学习,于是每天都跑到自习室里询问有没有装修完,所以这间自习室的门每天都被不停的打开和关闭,而且每天都有一群孩子往自习室里跑,非常影响装修工人的工作。于是自习室老板加了每个孩子的微信,和他们说等装修好了我发微信通知你们,你们在家里等就好了,于是孩子们之后再也不用往自习室里跑了,装修工人也可以安心的工作。过了一阵,装修工人装修完毕,老板发微信通知,孩子们又可以愉快的上自习了。
- 第一个例子反应的是线程间的饥饿问题。
- 第二个例子描述的情况是某个线程发现访问某种资源(或变量)时什么都做不了,必须等待其它线程改变该资源的状态后才能进行有效访问。例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。
在这两种情况下我们就要用到条件变量。
条件变量是用来描述某种临界资源是否就绪的一种数据化描述,可以理解成条件变量中存在一个等待队列,可以设定一个条件,临界资源不满足访问条件时,想要访问该资源的线程就会被挂起,按顺序放到等待队列中,当条件满足时,再唤醒等待的线程,让线程按顺序访问资源。
注意:条件变量一般要搭配互斥量来使用,因为条件不会无缘无故的满足,必然牵扯到临界资源的数据变化,在改变临界资源的数据时需要使用互斥量来保护。
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_signal(pthread_cond_t *cond); 唤醒等待对列头部的线程
int pthread_cond_broadcast(pthread_cond_t *cond);唤醒等待队列中的全部线程
3、生产者消费者模型
3.1 概念
首先我们举个例子,厂商就相当于日常生活中的生产者,而我们就相当于消费者,但往往我们并不直接和厂商对接,因为我们想要买东西的时候厂商并不一定在生产,我们还要等厂商生产,而厂商生产出产品后我们又不一定恰好想要,厂商还要等我们想要的时候去拿,这样就会造成时间浪费。所以就有了超市这个中转站用来储存,厂商生产完了直接放到超市中储存,我们想要买东西直接去超市拿,这样就会使整个交易过程更加流畅。
我们再来看计算机中的生产者和消费者:生产者和消费者模型本质上是通过一个缓冲区来缓解生产者和消费者的强耦合问题,生产者和消费者之间不直接通讯,消费者想要获取数据,不必非要通知生产者,而是直接从缓冲区中拿数据。生产者生产了数据也不需要等待消费者处理,只要直接放到缓冲区中即可,这样可以通过缓冲区来平衡生产者和消费者的处理能力。这个缓冲区就是给生产者和消费者解耦的。
首先要明确一点,缓冲区为临界资源,无论是生产者还是消费者,每次都只能有一人去使用。
总结一下,生产者消费者模型的构成为:
- 一个交易场所:通常是内存中的一段缓冲区(自己通过某种方式组织起来)。
- 两个角色:生产者,和消费者(指特定的线程或进程)。
- 三种关系:生产者与生产者(竞争关系,互斥关系),消费者与消费者(竞争关系,互斥关系),生产者与消费者(竞争关系,同步关系(多线程协同))。
3.2 BlockingQueue
在多线程编程中阻塞队列是一种经常作为生产者消费者模型中缓冲区的数据结构。其与普通队列的不同之处是当它已经满时,往里面进行写数据的线程会被阻塞,当它为空时,从里面往外拿数据的线程会被阻塞。
我们用c++来模拟实现一下阻塞队列,为了方便理解,这里采用单生产者和单消费者。
template<class T>
class block_queue
{
public:
bool isfull()
{
return q.size()==cap;
}
bool isempty()
{
return q.empty();
}
block_queue(int _cap=NUM)
:cap(_cap)
{
pthread_cond_init(&full,nullptr);
pthread_cond_init(&empty,nullptr);
pthread_mutex_init(&lock,nullptr);
}
~block_queue()
{
pthread_cond_destroy(&full);
pthread_cond_destroy(&empty);
pthread_mutex_destroy(&lock);
}
void push(const T& in)
{
pthread_mutex_lock(&lock);
while(isfull())
{
pthread_cond_wait(&full,&lock);
}
q.push(in);
if(q.size()>=cap/2)
{
printf("数据已经很多了,消费者快来消费吧\n");
pthread_cond_signal(&empty);
}
pthread_mutex_unlock(&lock);
}
void pop(T& out)
{
pthread_mutex_lock(&lock);
while(isempty())
{
pthread_cond_wait(&empty,&lock);
}
out=q.front();
q.pop();
if(q.size()<cap/2)
{
printf("数据快没有了,生产者快来生产吧\n");
pthread_cond_signal(&full);
}
pthread_mutex_unlock(&lock);
}
private:
int cap;
pthread_mutex_t lock;
std::queue<T> q;
pthread_cond_t full;
pthread_cond_t empty;
};
注意在push和pop函数中,判空和判满都要用while,用if的话如果等待队列中有多个线程同时被唤醒则会发生错误,必须使用while循环判断。
六、POSIX信号量
1、概念
POSIX信号量和SystemV信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。 但POSIX可以用于线程间同步。信号量相当于一个计数器,用来描述临界资源中资源数目的计数器。
信号量存在的价值:
- 同步互斥
- 更细力度的临界资源管理
信号量其实不单单是一个计数器,我们可以理解为它是由计数器、等待队列和一把锁共同组成的。
- 申请信号量本质:信号量--,也可以称为P操作,当信号量中计数器为空时,P操作将会申请不到,该线程挂起,放到对应信号量的等待队列中。
- 释放信号量本质:信号量++,也可以称为V操作。V操作进行后,会在对应信号量的等待队列中唤醒一个线程。
信号量是由多个线程共同访问的,所以信号量本身也是临界资源,所以要求信号量的PV操作必须是原子的。
如果信号量的值为1基本等同于互斥锁。
2、相关函数
初始化信号量:
原型:
int sem_init(sem_t *sem, int pshared, unsigned int value);
参数:
- pshared:0表示线程间共享,非零表示进程间共享
- value:信号量初始值
销毁信号量 :
int sem_destroy(sem_t *sem);
等待信号量:
功能:等待信号量,会将信号量的值减1
int sem_wait(sem_t *sem); //P()
发布信号量:
功能:发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加1。
int sem_post(sem_t *sem);//V()
3、基于环形队列的生产消费模型
环形队列的特点:
- 环形队列采用数组模拟,用模运算来模拟环状特性。
- 环形结构起始状态和结束状态都是一样的,不好判断为空或者为满,所以可以通过加计数器或者标记位来判断满或者空。另外也可以预留一个空的位置,作为满的状态。
- 但是我们现在有信号量这个计数器,就很简单的进行多线程间的同步过程。
应该遵守的原则:
- 生产者和消费者可以通过信号量同时访问临界资源,但绝不可以访问临界资源的同一块位置。
- 无论是生产者还是消费者,都不能套对方一个圈,这样会造成数据覆盖。
实现方法:
生产者要生产数据,所以关心的是有没有格子。消费者消费数据,关心的是有没有数据。所以可以设置两个信号量,一个表示格子资源,一个表示数据资源。这样做绝对不可能出现数据不一致的问题,因为只有两种情况消费者和生产者会指向同一块空间,那就是环形队列为空或环形队列为满时,当环形队列为空时,数据资源为0,所以消费者会被阻塞,等待生产者生产数据。当环形队列为满时,格子资源为0,生产者会被阻塞,等待消费者消费数据。
代码如下:
template<class T>
class RingQuene
{
private:
std::vector<T> q;
int cap;
sem_t blank_sem;
sem_t data_sem;
int c_pos=0;
int p_pos=0;
void P(sem_t& sem)
{
sem_wait(&sem);
}
void V(sem_t& sem)
{
sem_post(&sem);
}
public:
RingQuene()
{
q.reserve(5);
cap=NUM;
sem_init(&blank_sem,0,5);
sem_init(&data_sem,0,0);
}
~RingQuene()
{
sem_destroy(&blank_sem);
sem_destroy(&data_sem);
}
void push(const T& in)
{
P(blank_sem);
q[p_pos]=in;
V(data_sem);
//p_pos不属于公共资源,所以可以在外面++
p_pos++;
p_pos=p_pos%cap;
}
void pop(T& out)
{
P(data_sem);
out=q[c_pos];
V(blank_sem);
c_pos++;
c_pos=c_pos%cap;
}
};
总结
今天的内容就到这里了,本文主要讲解了Linux多线程的相关知识。学完多线程之后,Linux系统就可以暂时告一段落了,仅仅有系统方面的知识是不够的,我们还要继续学习Linux网络,将系统与网络结合到一起,才能做出有趣的项目。如果感兴趣的小伙伴可以继续关注我的博客。山高路远,来日方长,我们下次见。