目录
- 什么是线程
- 多级页表
- 线程为什么比进程更加轻量化
- 线程优缺点
- 线程创建
- 线程等待
- 线程退出
- 线程库跨平台
- 线程的栈所在位置
- 线程的栈
- 线程局部存储
- 线程分离
- 线程互斥
- 线程安全VS可重入函数
- 线程同步
- 生产消费者模型
- 信号量
- 环形生产消费者模型
- 实现一个简易线程池
- 线程封装
- 线程安全的单例模式
- 自旋锁
- 读者写者锁
- 其他的概念
什么是线程
我们先来回顾一下进程的定义,
我们都知道进程=内核数据结构(task_struct等)+代码和数据。我们不妨想一下,如果一个进程不止有一个task_struct而是有多个呢?没错,那就是线程。在我们的实际进程中,可能有多个执行流,也就是多个线程,它们共享所属进程的资源(如内存、文件句柄等),但拥有独立的执行堆栈、程序计数器和寄存器状态(上下文寄存器),也就是共享一些资源但又会占用一部分资源。
此时我们又应该怎么认识我们之前认知中的进程呢,很简单,将它认为是只有一个线程的进程这样的特殊情况就好了。那我们又该怎么认为现在的进程呢?现在我们认为进程是系统分配资源的基本实体 ,内核数据结构,代码和数据,线程本身都是资源,线程是我们进程内部的执行流资源。系统以进程为单位给我们分配资源,进程再将资源分配给线程。线程则是操作系统调度的基本单位。线程是操作系统能够进行运算调度的最小单位,也是进程中的实际执行单元。它被包含在进程内,作为程序执行流的最小单元,负责执行具体的任务指令。任何执行流想要执行,都要有资源,地址空间是进程的资源窗口,所以线程在进程地址空间中运行。
在操作系统理论中,我们应该先对进程进行组织,再在进程内部对线程进行组织。这在一些实际的操作系统中也确实是这么实现的,但是这么设计既要设计进程的描述组织调度方式,又要设计线程的描述组织调度方式,这无疑增加了代码量,实现更困难,维护也更麻烦。我们的Linux设计者认为既然已经设计了进程的描述组织调度方式了,为什么不直接复用呢?所以在Linux中,Linux内核未严格区分线程与进程,而是通过轻量级进程(LWP) 模拟线程(用进程的数据结构模拟的线程)。每个LWP是独立调度实体(task_struct),但共享同一进程的地址空间和资源,这是十分卓越的设计。进程在Linux存在吗?肯定是存在的,进程地址空间、页表等各种数据结构都在,怎么能说不存在呢,但是PCB这一概念被模糊,Linux中只有task_struct,所以严格来说可以认为Linux中没有真正意义上的进程。线程在Linux存在吗?存在的,多个task_struct对应一个进程地址空间,是操作系统的实际执行单元,怎么能说不存在呢,但是Linux没有TCB(Thread Control Block,线程控制块,对应PCB),所以严格来说也可以认为Linux中没有真正意义上的线程。task_struct中既有PCB的内容,也有TCB中的内容,将两者融合了。
对于cpu来说,它是不会区分进程和线程的概念的,cpu只有执行流的概念,执行流切换就是task_struct切换,所以我们认为线程 <= 执行流 <= 进程。
多级页表
我们插个题外话来谈谈页表,我们都知道页表是用来存虚拟地址和物理地址的映射的,怎么存的呢?我们先假如是直接进行映射,即一个虚拟地址直接对一个物理地址,页表存一个虚拟地址再存一个物理地址然后再加上一些标记位,我们不往多了说,就算它8字节,假设是x86系统,也就是4CB,2^32字节,一个字节一个地址就是8*2^32,不用算,内存肯定存不下,因为1字节的地址用了超过1字节的空间来存它,肯定存不下,虽然实际可能不会用满虚拟地址但是这也足以说明这种方式的不可行。所以我们的页表实际是怎么存的呢?我们的虚拟地址有32位,
我们先将其分为10、10、12。高十位存的是页目录的下标,页目录是一个指针数组(指针存在CR3寄存器中,自动寻址),存的是二级页表的地址,2^10就是1024,所以该数组最多可以存1024个二级页表。我们通过指针找到二级页表时,二级页表也是一个指针数组,存的是实际物理内存页框的起始地址,中十位存的是二级页表的下标,2^10就是1024,所以该数组同样最多可以存1024个页框起始地址。我们通过页框起始地址找到页框时,因为x86下一个page是4kb,也就是2^12字节,正好对应虚拟地址的后12位,这后12位存的就是页框偏移量,我们就能通过偏移量找到实际物理地址了。当然这样的描述是基于x86和4kb大小的page的,实际现代计算机如果是x64,拥有更大的地址总线,更大的page,位数分配可能会不一样,但是核心思想是一样的。我们来算算多级页表占用内存的情况,假如用满1个页目录和1024个二级页表,那么就是 1024 * 4 + 1024 * 4 * 1024 = 4198400 ,也就是4mb左右,很小了,这还是在虚拟地址存满的情况下,一般来说是不会存满的,而且我们的页框地址也算大了,因为页框本身就是2^12,我们只要存2^20的地址就行,这样直接乘2 ^12就能得出页框地址。一个字节的虚拟地址只能对应一个字节的物理地址,当我们使用语言创建变量时,会标明类型,这样就能在编译后转化成读取指定字节的物理内存,我们取地址时也是拿到对应变量的最低地址字节,起始地址 + 类型 = 起始地址 + 偏移量。如果我们的虚拟地址对应的物理内存实际没有载入,页表还有对应的标记位记录,这时引发缺页中断异常,进行写实拷贝,虚拟地址会存入CR2寄存器保存防止丢失,拷贝完成再使用该地址继续访问。
线程为什么比进程更加轻量化
线程比进程更加轻量化,体现在两点:
(1)创建释放更加轻量化。
(2)切换更加轻量化。
创建释放更加轻量化很好理解,因为线程的创建不一定需要系统分配各种资源(内核数据结构、代码和数据),当然如果这个线程要新创建一个进程的话另说。对于切换更加轻量化我们怎么理解呢?在线程切换时,仅需保存并切换线程上下文相关的寄存器,寄存器状态、栈指针、线程本地存储(TLS)等,数据量通常为几kb,而很多进程上下文相关的寄存器则不需要切换。当然这样的差距不足以说更加轻量化,更重要的是,根据局部性原理,我们的cpu中的Cache(高速缓存存储器)缓存区会对大概率接下来要访问的数据做提前载入,这些缓存被称为热数据,进程切换会使这些缓存数据全部作废,而线程切换不会,这导致了线程切换比进程更加轻量化。Linux的进程在创建时,其第一个线程被称为主线程,存储了当前进程整体的时间片,也存储了线程自己的时间片,进程的时间片是固定的,分配给进程中的线程,这样就不会因为线程的增多出现抢占时间片的情况了,当我们的线程执行时,不仅会更新线程自己的时间片,也会更新主线程的进程时间片,当进程时间片到了的时候就会进行进程切换。
线程优缺点
线程优点
创建一个新线程的代价要比创建一个新进程小得多
与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
线程占用的资源要比进程少很多
能充分利用多处理器的可并行数量
在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
线程缺点
性能损失:一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。IO密集型比计算密集型更适合多线程,计算密集型在线程过多时就会浪费性能在线程切换上。
健壮性降低:编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
缺乏访问控制:进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
编程难度提高:编写与调试一个多线程程序比单线程程序困难得多
线程创建
在Linux中没有线程的概念,只有轻量化进程的概念,所以系统调用接口只有轻量化进程相关的接口,而我们想要创建线程,怎么办呢?Linux的开发者为我们提供了pthread线程库,对Linux的轻量化进程接口做了封装,这个库在几乎所有的Linux平台中都是默认自带的,我们可以使用它。
首先使用接口
c
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
可以创建线程。这个接口的参数很多,首先第一个参数是一个输出型参数,我们先创建好一个pthread_t 变量之后传进去就行,输出的是什么我们之后讲。第二个参数是一个联合体,我们通过这个变量提前设置线程的属性,我们可以直接传nullptr表示全部默认,所以我们这里先传nullptr。第三个参数传函数指针,就是线程创建好之后要执行的函数,该函数接受一个void *参数,返回一个void *变量。最后一个参数传要作为参数传给第三个参数的函数的void *指针。函数的返回0表示执行成功,非0表示失败。
此外,创建的线程可以通过接口
c
pthread_t pthread_self(void);
查看自己的 pthread_t ID。
我们的线程执行函数为什么要设计成void *(*start_routine) (void *)呢?这是为了应对不同的情况而设置的。由于接口的设计者也不知道我们要传什么参数,所以直接给一个指针,这样我们可以传任意参数的指针进去,甚至可以传结构体指针达到一次性传多个参数的效果。对于返回值,也是同理,我们的接口设计者也不知道我们要传什么变量回去,索性就传指针,简单的变量直接强转成void *就行,之后在强转,如果想传很多数据,也能直接在堆上申请空间然后传指针。所以这样的设计是为了我们可以更灵活的创建线程设计的。
我们写一段代码试一下,
cpp
#include<iostream>
#include<stdio.h>
#include <pthread.h>
#include<unistd.h>
using namespace std;
void* my_thread_1(void* arg)
{
while(1)
{
cout << "我是线程1\n";
sleep(1);
}
return nullptr;
}
int main()
{
pthread_t PT;
pthread_attr_t a;
pthread_create(&PT, nullptr, my_thread_1, nullptr);
while(1)
{
cout << "我是主线程\n";
sleep(1);
}
return 0;
}
这里我们要注意编译时指定动态库,因为mythread属于第三方库,虽然库和头文件装在了系统路径下了,库名还是要指定以下的。
我们发现确实创建出了线程,因为有两个死循环同时在打印,没有两个执行流是不可能做到的。但是进程打印只有一个,这也正常,因为是进程打印,我们使用ps -aL
可以打印所有的线程,
我们在打印的列表上看到了LWP,LWP就是Light Weight Process,轻量级进程,我们可以认为这是轻量级进程ID。除此之外我们还发现LWP和PID很相近,且有一个线程的LWP和PID一样,没错,LWP和PID一样的就是主线程,这两个参数其实共享一个分配体系,进程创建时候的第一个线程就是主线程,系统为他分配LWP,它的LWP同样也是进程PID,一个进程中的所有线程的PID都是一样的,都是主线程的LWP。
既然可以创建线程了,我们正好写一段代码验证一下线程在进程地址空间中哪些资源是共享的,
cpp
#include<iostream>
#include<stdio.h>
#include <pthread.h>
#include<unistd.h>
#include<string>
using namespace std;
int a = 1;
int* re_stack = nullptr;
string* re_heap = nullptr;
void print()
{
cout << "a:" << a << "\n";
}
void* my_thread_1(void* arg)
{
int cnt = 5;
int x = 666;
re_stack = &x;
re_heap = new string("hello linux");
while(cnt--)
{
cout << "我是线程1 ";
print();
sleep(1);
}
return nullptr;
}
int main()
{
pthread_t PT;
pthread_attr_t a;
pthread_create(&PT, nullptr, my_thread_1, nullptr);
int cnt = 10;
while(cnt--)
{
cout << "我是主线程 ";
if(re_stack) (*re_stack)++, cout << *re_stack << endl;
if(re_heap) (*re_heap) += "linux", cout << *re_heap << endl;
print();
sleep(1);
}
return 0;
}

从这段代码中的结果中我们可以得出一些结论。首先我们各个线程可以调用同一个函数,也就是该函数被重入了,所以可以得出代码段是共享的。然后一个线程修改堆的数据其他线程都能看到,且创建堆的进程退出堆还在,所以堆也是共享的,这也是我们线程执行函数可以传指针的理论依据。对于栈来说,是每个线程一个的,因为虽然线程没有退出时栈上的数据可以被其他线程访问修改,但是线程一结束栈就释放了,数据不一样了,所以栈不是共享的。此外,我们发现命令行打印时有时会很混乱,这也变相标明了所有线程共享打开文件,因为共享打开的显示器文件,所以共享对应的缓冲区,所以会出现数据干扰。对于栈和堆的结论我们一点也不感到奇怪,因为栈的作用就是保存执行流运行时的数据,要是共享会影响彼此,而堆的作用则是持久性的保存,不和执行流强挂钩,线程间共享没有坏处,反而因此达到了通信的效果,所以我们会发现线程间通信比进程容易,因为同进程的线程之间的独立性没有那么强,所以我们才会研究线程之间的并发性,因为它们的通信成本低。对于代码段共享这点我们就明白了线程的控制没有那么难,因为进程地址空间中的虚拟地址个个唯一,所以代码地址不重复,我们给定线程的入口,让它自己跑就行,函数调用就跳转,没什么限制,这样自然而然地就能完成线程的执行。
线程等待
其实我们线程创建完并不是就意味着结束了,我们的线程也要返回结果,所以我们要进行接收,因为线程是异步的,所以我们创建函数那不会阻塞等待返回结果,而是通过线程等待 函数等待线程,接收返回值,回收进程。注意,等待不一定必须是创建线程的线程等待,二者之间没有绑定关系,理论上只要有等待线程的pthread_t 就能进行等待。使用接口
c
int pthread_join(pthread_t thread, void **retval);
可以等待线程,第一个参数传线程创建函数的第一个返回型参数,第二个参数传二级指针接收线程执行函数的返回值,因为其返回值是一个void *指针,所以这里用二级指针接受,所以这也是一个输出型参数,我们先创建一个void *指针取地址传进去就行了,不想接受可以传nullptr。函数返回0表示函数执行成功,非0表示失败。我们写一段代码演示一下,
cpp
#include<iostream>
#include<stdio.h>
#include <pthread.h>
#include<unistd.h>
#include<string>
using namespace std;
void* my_thread_1(void* arg)
{
cout << "我是线程1\n";
string* str = new string("hello linux");
sleep(6);
return (void*)str;
}
int main()
{
pthread_t PT;
pthread_create(&PT, nullptr, my_thread_1, nullptr);
cout << "我是主线程\n";
void* ret;
sleep(3);
pthread_join(PT, &ret);
string* Ret = static_cast<string*> (ret);
cout << *Ret << endl;
delete(Ret);
return 0;
}

可以看到线程确实会阻塞式的等待创建出来的线程,且线程结束时返回值确实通过retval拿到了。我们要是让等待的线程sleep的时间长一点,退出的线程等待时间短一点,
可以看到,线程提前退出了,并没有僵尸线程这样的东西出现,所以只有僵尸进程,没有僵尸线程。但是这不妨碍我们等待。
线程退出
在之前的代码中我们有一点需要注意的就是线程在执行完一开始传的线程执行函数之后就退出了,不会像进程那样执行到main函数返回,那如果我们在线程中使用exit函数会怎么样?
cpp
#include<iostream>
#include<stdio.h>
#include <pthread.h>
#include<unistd.h>
#include<string>
void* my_thread_1(void* arg)
{
cout << "我是线程1\n";
string* str = new string("hello linux");
sleep(6);
exit(11);
// return (void*)str;
}
int main()
{
pthread_t PT;
pthread_create(&PT, nullptr, my_thread_1, nullptr);
cout << "我是主线程\n";
void* ret;
sleep(3);
pthread_join(PT, &ret);
string* Ret = static_cast<string*> (ret);
cout << *Ret << endl;
delete(Ret);
return 0;
}

我们发现进程提前退出了,因为等待后的打印没有打印,使用echo $?
打印返回状态我们发现是11,和线程中使用exit中的参数一致,所以不论那个线程使用exit函数都会直接终止整个进程,exit是用来退出进程的。而线程要想退出有接口
c
void pthread_exit(void *retval);
这是线程自己的exit函数,参数也是传void *,和返回值照应。
除此之外,我们创建线程的线程也可以通过接口
c
int pthread_cancel(pthread_t thread);
取消(不要自己取消自己),
cpp
#include<iostream>
#include<stdio.h>
#include <pthread.h>
#include<unistd.h>
#include<string>
using namespace std;
void* my_thread_1(void* arg)
{
cout << "我是线程1\n";
sleep(6);
string* str = new string("hello linux");
return (void*)str;
}
int main()
{
pthread_t PT;
pthread_create(&PT, nullptr, my_thread_1, nullptr);
sleep(1);
pthread_cancel(PT);
cout << "我是主线程\n";
void* ret;
pthread_join(PT, &ret);
cout << (long long)ret << endl;
// delete(Ret);
return 0;
}

我们会发现创建的线程退出了,且返回值是强转成void *的-1,因为pthread库规定了线程被取消之后会返回一个宏
c
#define PTHREAD_CANCELED ((void *) -1)
一般来说线程取消不太常见。
我们可以将创建出来的线程进行替换吗,
cpp
#include<iostream>
#include<stdio.h>
#include <pthread.h>
#include<unistd.h>
#include<string>
using namespace std;
void* my_thread_1(void* arg)
{
int cnt = 5;
while(cnt--)
{
cout << "我是线程1" << endl;
if(cnt == 2) execl("/usr/bin/ls", "ls", "-l", "-a", NULL);
sleep(1);
}
return nullptr;
}
int main()
{
pthread_t PT;
pthread_create(&PT, nullptr, my_thread_1, nullptr);
int cnt = 5;
while(cnt--)
{
cout << "我是主线程" << endl;
sleep(1);
}
pthread_join(PT, nullptr);
return 0;
}

我们发现全部被替换了,因为execl是进程替换,不能进行线程替换,因为线程本来就属于一个进程,共享进程代码段,只能执行线程内的代码。
我们的线程中的一个异常了会怎么样呢,
cpp
#include<iostream>
#include<stdio.h>
#include <pthread.h>
#include<unistd.h>
#include<string>
using namespace std;
void* my_thread_1(void* arg)
{
int cnt = 5;
while(cnt--)
{
cout << "我是线程1" << endl;
if(cnt == 2) cnt /= 0;
sleep(1);
}
return nullptr;
}
int main()
{
pthread_t PT;
pthread_create(&PT, nullptr, my_thread_1, nullptr);
int cnt = 5;
while(cnt--)
{
cout << "我是主线程" << endl;
sleep(1);
}
pthread_join(PT, nullptr);
return 0;
}

我们发现一个线程异常了它所属的进程中的所有线程都得遭殃,因为线程异常信号还是发给进程的,进程收到信号会直接退出。
线程库跨平台
我们在实际开发时使用的语言诸如c++、java等对对应操作系统上的线程库有进一步的封装,我们在实际开发时如果需要考虑跨平台,就推荐使用语言提供的线程库。因为不同平台的接口是不一样的,Linux有pthread库,windows也有他自己的库,不一样的,而市面上的成熟语言对各个平台都有对应的版本,使用各自平台自己的系统调用接口进行的封装,所以它们就具有跨平台性。而C语言基本被各个系统选用作为系统编写语言,使用c式的系统调用接口不具备跨平台性,c语言本身也没有推出自己的标准线程库,所以使用C语言就没有开箱即用的便利感,但还是有一些第三方库可以实现跨平台性的。倘若写的代码不用跨平台,那就随意了。
线程的栈所在位置
我们之前的线程创建系统调用接口的第一个返回参数到底是什么呢,我们写一段代码打印一下,
cpp
#include<iostream>
#include<stdio.h>
#include <pthread.h>
#include<unistd.h>
#include<string>
using namespace std;
void* my_thread_1(void* arg)
{
int cnt = 5;
while(cnt--)
{
sleep(1);
}
return nullptr;
}
int main()
{
pthread_t PT;
pthread_create(&PT, nullptr, my_thread_1, nullptr);
int cnt = 5;
while(cnt--)
{
cout << PT << endl;
sleep(1);
}
pthread_join(PT, nullptr);
return 0;
}

我们完全看不出这是什么,这玩意既不是PID也不是LWP,到底是啥呢?这里再介绍一个接口
cpp
pthread_t pthread_self(void);
这玩意可以打印自己的pthread_t也就是线程ID ,我们使用它在主线程和创建的线程中各自以16进制打印,
cpp
#include<iostream>
#include<stdio.h>
#include <pthread.h>
#include<unistd.h>
#include<string>
using namespace std;
void* my_thread_1(void* arg)
{
int cnt = 5;
while(cnt--)
{
printf("%p\n", pthread_self());
sleep(1);
}
return nullptr;
}
int main()
{
pthread_t PT;
pthread_create(&PT, nullptr, my_thread_1, nullptr);
int cnt = 5;
while(cnt--)
{
printf("%p\n", (void*)PT);
sleep(1);
}
pthread_join(PT, nullptr);
return 0;
}

我们可以得出两个结论,第一,线程创建时的第一个输出型参数返回的就是创建线程的pthread_t,第二,这个pthread_t不是我们使用ps -aL
打印的LWP,反而看起来像一个地址。其实这就是一个地址,我们的线程创建函数会调用轻量化进程接口
c
int clone(int (*fn)(void *), void *child_stack,
int flags, void *arg, ...
/* pid_t *ptid, struct user_desc *tls, pid_t *ctid */ );
其中第二个参数就是让我们自定义一个栈。我们的Linux中只有轻量化进程的概念,而我们用户要用线程,所以线程的概念是库为我们维护的,线程的底层是执行流,执行流是系统为我们维护的,而线程的有些信息系统不会去维护,比如线程ID,线程的栈地址,执行的回调方法,这些就要有库去维护,我们的库是动态库,动态库先加载到内存,然后在进程运行时会被页表映射加载到共享区,这样的话,各个进程中的线程都需要库去维护,先描述再组织,库会用结构体将需要管理的线程信息描述出来,再将这些结构体组织在一起。这样就形成了类似TCB的结构,这是用户层的TCB。当我们使用pthread_create创建线程时,库中先开辟TCB的空间进行初始化,再将TCB中的栈地址传给clone进行初始化。需要注意,主线程也有TCB,只不过主线程的TCB用的不是库中分配的栈,而是指向地址空间中的栈,即传统意义上的栈。
于是乎,每个TCB的TID我们就用这个TCB的起始地址表示,这样既能唯一表示每一个TCB,又能通过地址直接找到TCB,因为这是用户层的结构且这是虚拟地址。通过pthread_t的值我们也能看出这个值很大,应该是在共享区中。因为pthread库在共享区,所以除了主线程之外的线程的栈都在共享区维护(主线程的栈就是地址空间中的栈)。我们要想操作一个线程,我们就用pthread_t找到TCB,在通过TCB找到对应的执行流task_struct,这两个结构体包含了该执行流的全部信息,这样我们就能进行各种操作了,这就是我们为什么创建之后的使用pthread函数对线程进行操作时都需要pthread_t的原因。
线程的栈
每一个线程都有自己的栈结构,栈的作用就是保证执行流的调用链正常展开,保存执行流运行过程中的临时数据,理解了这个我们也就不难理解为什么线程可以共享进程中的绝大部分资源却不会共享栈了。下面我们来看一段代码
cpp
#include<iostream>
#include<stdio.h>
#include <pthread.h>
#include<vector>
#include<unistd.h>
using namespace std;
struct task
{
string* _str;
pthread_t _PT;
};
void* my_pthread(void* arg)
{
int g_val = 0;
string* str = static_cast<string*>(arg);
for(int i = 0; i < 10; ++i)
{
printf("%s,g_val:%d, &g_val:%p\n", str->c_str(), g_val++, &g_val);
sleep(1);
}
return nullptr;
}
int main()
{
vector<task> arr;
for(int i = 0; i < 10; ++i)
{
task tmp;
tmp._str = new string("thread");
*tmp._str += i + '0';
pthread_create(&tmp._PT, nullptr, my_pthread, (void*)tmp._str);
arr.push_back(tmp);
}
for(auto& e : arr)
{
pthread_join(e._PT, nullptr);
}
for(auto& e : arr)
{
delete e._str;
}
return 0;
}

我一次性创建了多个线程,但是它们共用一个函数,这再次证明了代码段是共享的,而且同样一段代码中创建的变量也不会互相共享,地址都是不一样的,这正印证了我们上面所说的,我们栈中存的就是执行流运行时的临时变量,栈必须要独立,因为不独立的话执行流连最基本的动作都会相互干扰。
但是栈的独立并不意味着别的执行流就无法访问我自己栈中的数据,进程中的线程都在同一个进程地址空间下,共用一套虚拟地址,想要访问对应的栈的数据就拿着对应的虚拟地址就能访问,任何一个执行流都能访问。栈的独立性并不意味着栈空间的绝对私有化,让别的执行流无法看到,而是在于每个执行流运行时对于各自栈的默认享有,使用。
栈的这种并非绝对独立的特性使多个执行流都能访问,多个执行流都能访问意味着多个执行流看到了同一份资源,看到了同一份资源意味着可以进行通信,但是完全不建议这样做,栈是执行流的工作暂存区,不是线程间的通信区,想要进行线程间通信,我们有多种选择,毕竟线程间共享了很多资源,堆和全局变量都是很好的选择,线程间通信不是一件难事。
线程局部存储
我们都知道全局变量可以被进程中的所有的线程同时访问修改,我们可以利用它进行线程间通信,但是倘若我们想要一个私有的全局变量呢?也就是只有一个全局变量,但是每个执行流都有自己的一个副本,自己访问修改不影响别的执行流,这听起来可能会很怪,怎么会有这样的需求,但是我们自己仔细想想就会发现这样的需求还是存在的。我们要是刷过一些算法题的读者可能就会遇到bfs和dfs算法,即利用很深的递归调用链以很简单的逻辑解决比较复杂问题,对于这样的问题中我们肯定有一些数据想让所有的递归函数栈都能访问修改,这种数据我们可以通过参数传递的方式做到,只是有一点麻烦罢了,但是如果这样的数据不仅是函数要拿到修改,还要在调用链回到自己时拿到整个调用链到底之后得到的数据从而根据这个数据做出一些分支逻辑时,就必须要占用函数返回值了,如果此时是一个这样的数还行,多个呢?我们只能返回一个值,所以最简单的方式就是定义一个全局变量了,所有栈都能访问修改,修改完全部的调用栈都能看到,这样的全局变量我们肯定也不希望别的执行流访问修改,所以就要用到这样的线程私有全局变量了。怎样定义呢?定义变量时在前面加上关键字__thread
就行。
c
__thread int a = 0;
这是一个编译关键字,不是c和c++语言提供的,是编译器提供的扩展关键字,类似一个编译选项。当我们使用这个关键字定义变量时,编译器就会在编译时在线程库的TCB中开辟一块空间存储一份定义变量的副本,
我们可以写一段代码演示一下,
cpp
#include<iostream>
#include<stdio.h>
#include <pthread.h>
#include<vector>
#include<unistd.h>
using namespace std;
__thread int a = 666;
int b = 777;
void* my_pthread(void* arg)
{
static __thread int c = 888;
printf("&a = %p,&b = %p,&c = %p\n", &a, &b, &c);
return nullptr;
}
int main()
{
vector<pthread_t>arr;
for(int i = 0; i < 10; ++i)
{
pthread_t tmp;
pthread_create(&tmp, nullptr, my_pthread, nullptr);
}
sleep(1);
for(auto& e : arr)
{
pthread_join(e, nullptr);
}
my_pthread(nullptr);
return 0;
}

我们可以看到如果是普通的全局变量,地址会非常小,因为定义在地址空间的靠下位置,且普通的全局变量的地址所有的线程都是统一的。但是对于__thread
定义的全局变量,地址就非常大,这就表明其在共享区中,而且我们也能看到,所有__thread
定义的全局变量地址都不一样,正如我之前所说。__thread
只能定义内置类型,不能定义自定义类型,这点需要注意。
线程分离
我们的线程在等待其他线程退出时是阻塞式的等待,但有时创建的线程我们并不关心它的执行结果,这是我们就可以使用线程分离,分离后的线程不再需要等待了,线程退出时由系统自动回收。使用接口
c
int pthread_detach(pthread_t thread);
可以分离线程,线程可以由其他线程对自己分离,也能自己分离,
c
pthread_detach(pthread_self());
我们写一段代码演示一下,
cpp
#include<iostream>
#include<stdio.h>
#include <pthread.h>
#include<vector>
#include<unistd.h>
using namespace std;
void* my_pthread(void* arg)
{
sleep(1);
return nullptr;
}
int main()
{
pthread_t PT;
pthread_create(&PT, nullptr, my_pthread, nullptr);
pthread_detach(PT);
sleep(2);
int ret = pthread_join(PT, nullptr);
cout << ret << endl;
return 0;
}

我们打印pthread_join函数的返回值,这个函数返回0表示函数执行成功过,非0表示失败,这里就是失败了,原因也很简单,因为该线程已经分离,执行结束系统自动回收了,没必要等待了,这时再等发现没有这个线程就会报错返回。我们可以试着打印一下22的错误原因
错误显示无效参数,符合这里的场景。
线程互斥
下面我写了一段模拟抢票的代码,
cpp
#include<iostream>
#include<stdio.h>
#include <pthread.h>
#include<vector>
#include<unistd.h>
#include<errno.h>
#include<string.h>
using namespace std;
struct task
{
string _name;
pthread_t _PT;
};
int num = 1000;
void* my_pthread(void* arg)
{
task* tk = static_cast<task*>(arg);
while(num > 0)
{
usleep(1000);
printf("%s抢到一张票:%d\n", (tk->_name).c_str(), num);
num--;
}
return nullptr;
}
int main()
{
vector<task*>arr;
for(int i = 0; i < 10; ++i)
{
task* tmp = new task;
tmp->_name = "thread";
tmp->_name += i + '0';
pthread_create(&(tmp->_PT), nullptr, my_pthread, tmp);
arr.push_back(tmp);
}
for(auto& e : arr)
{
pthread_join(e->_PT, nullptr);
}
for(auto& e : arr)
{
delete e;
}
return 0;
}
我们跑一下,
我们会发现结果很离谱,主要有两个问题,一是多个进程枪到了同一个票,二是票抢到了负数,我们来分析一下为什么会有这俩个问题。为什么会出错呢?想都不用想,肯定是因为多个线程同时访问修改同一份资源导致的,多个线程对全局变量这个共享资源做修改,从而产生了错误。
我们看向代码中对全局变量减减的这句代码,我们的代码在经过编译器编译之后,这条代码会转化成三条指令,先将内存中的变量读到寄存器中,再减减,最后写回内存中。而我们的线程在运行过程中随时都可能切换,假如线程在执行完内存变量载入寄存器之后就被切换了,切换前会保存线程上下文,寄存器的内容被保存,假如此时寄存器中的内容为1000,切换到下一个线程后没有被切换的情况下顺利抢了一张,因为上一个线程没有到回写内存的那一步,于是内存的内容在这个线程开始运行时还是1000,抢了一张就是999,回写到内存后进程切换了,之前的进程又被切换上来了(假设,实际上不会这么快又轮到它),这时恢复上下文,寄存器中还是1000,减减完回写到内存中还是999,这就是为什么这么多线程抢了同一张票的原因。
我们的循环执行时会不断地执行while中的条件语句,假如我们的num也就是票数所剩无几时,有大于所剩票数的线程同时到了while的条件语句这,虽然所剩的票数已经不够这么多线程了,但是程序自身是不会明白的,程序只会执行一条条指令,现在的情况就是num还大于0,所以线程都进入了while中,下面的就不用讲了,那么多线程进去,票自然就被抢成了负数。
我们总结上面所属的问题,归根结底就是多线程访问共享资源带来的问题,这也是多线程所要面对的最大的难题。
我们要怎么解决访问共享内存的问题呢?很简单,既然多个线程访问会出问题,那我们就不让多个线程同时访问,这就要使用互斥锁了。在讲锁之前我想要想讲清楚几个概念:
原子性 :原子性指一个操作在执行过程中不可被中断,要么完全执行,要么完全不执行,不会出现部分完成的状态。
互斥 :互斥性保证任意时刻仅有一个线程访问临界资源,避免多线程同时修改共享数据。
同步 :同步在互斥基础上协调线程的执行顺序,确保任务按依赖关系有序执行。
临界资源 :一次仅允许一个进程或线程访问的共享资源。
临界区:临界区是程序中访问临界资源的代码片段,必须通过同步机制确保原子执行(即执行过程不可中断)。
互斥锁
pthread库是Linux系统中实现多线程编程的核心工具,其提供的锁机制用于解决多线程环境下的资源竞争问题,确保数据一致性和线程安全。互斥锁可以保证同一时刻仅有一个线程访问共享资源(临界区)。
要使用互斥锁我们要先创建锁它,我们直接创建一个pthread_mutex_t
类型的变量就算是创建了一个互斥锁了,pthread_mutex_t
是pthread库定义的一种类型。在创建完所之后,我们要使用
c
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
对其进行初始化才能使用,第一个参数传我们创建好的互斥锁指针,第二个参数可以设置初始化设置成的属性,我们这里直接传nullptr表示默认就行。
创建完互斥锁我们最后在不用的时候还要销毁它,使用接口
c
int pthread_mutex_destroy(pthread_mutex_t *mutex);
可以对互斥锁进行销毁,参数传对应的互斥锁指针就行。
当我们想要创建一个全局或静态互斥锁时我们可以使用宏进行创建,
c
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
当我们使用这个宏进行创建时我们就可以不再进行初始化和销毁了。为什么不能用在局部变量上呢?因为普通局部变量(非 static)存储在栈上,其生命周期仅限于函数执行期间,且内存地址在运行时动态分配。而PTHREAD_MUTEX_INITIALIZER是编译时的宏展开,要求互斥锁的地址在编译期已知,这与局部变量的运行时特性冲突。
创建完之后我们要使用互斥锁,使用接口
c
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
就能对指定范围的代码进行上锁,参数传互斥锁指针。lock就是上锁的意思,lock后面的代码就加锁了,unlock表示解锁,unlock后面的代码不再加锁。也就是我们在要上锁的代码区域的头尾加上lock和unlock就行了。我们写一段代码演示一下,
cpp
#include<iostream>
#include<stdio.h>
#include <pthread.h>
#include<vector>
#include<unistd.h>
#include<errno.h>
#include<string.h>
using namespace std;
int num = 1000;
void* my_pthread(void* arg)
{
task* tk = static_cast<task*>(arg);
while(1)
{
pthread_mutex_lock(tk->_mx);
if(num > 0)
{
printf("%s抢到一张票:%d\n", (tk->_name).c_str(), num);
num--;
pthread_mutex_unlock(tk->_mx);
}
else
{
pthread_mutex_unlock(tk->_mx);
break;
}
}
return nullptr;
}
int main()
{
vector<task*>arr;
pthread_mutex_t mx;
pthread_mutex_init(&mx, nullptr);
for(int i = 0; i < 10; ++i)
{
task* tmp = new task;
tmp->_name = "thread";
tmp->_name += i + '0';
tmp->_mx = &mx;
pthread_create(&(tmp->_PT), nullptr, my_pthread, tmp);
arr.push_back(tmp);
}
for(auto& e : arr)
{
pthread_join(e->_PT, nullptr);
}
for(auto& e : arr)
{
delete e;
}
pthread_mutex_destroy(&mx);
return 0;
}
首先我要讲一点代码的改动,这是对抢票代码的更改,之前我的抢票代码的while循环是这样的
cpp
while(num > 0)
{
pthread_mutex_lock(tk->_mx);
printf("%s抢到一张票:%d\n", (tk->_name).c_str(), num);
num--;
pthread_mutex_unlock(tk->_mx);
}
现在是这样的
cpp
while(1)
{
pthread_mutex_lock(tk->_mx);
if(num > 0)
{
printf("%s抢到一张票:%d\n", (tk->_name).c_str(), num);
num--;
pthread_mutex_unlock(tk->_mx);
}
else
{
pthread_mutex_unlock(tk->_mx);
break;
}
}
我将条件语句放到了循环内,因为条件语句也要对共享资源做访问,如果将其放在while()当循环条件我们就得把lock放在while()上面,这会出现什么问题呢?这会出现锁失效的问题。我们的锁的原理是先运行完lock函数的线程可以运行下面的代码,相当于上了锁的区域先运行完lock的线程获得钥匙,可以继续运行,其余的线程都不能运行,都会阻塞在lock函数处等待锁资源,当我们的代码运行到unlock时会归还锁资源,相当于归还钥匙,这时lock函数不再阻塞函数,就可以通过该函数再次获得锁资源。如果我们将lock放在while外,unlock在锁内,就会出现还完锁之后不再回运行lock函数的情况,因为lock在循环外,这样我们就不再被lock函数阻塞,可以自由地跑了,这样就失去了锁的效果。如果我们将unlock也放在循环外呢,就不会出现循环内归还锁资源却不再遇到lock函数的问题了,但是问题是这样的话其他的线程都会被阻塞在循环外,获得锁资源的线程在不跑完循环的情况下不会归还锁资源,票全被一个线程抢了,违背了公平性,所以必须在循环内。因此我将代码改成了上面这样。
我们锁的本质是用时间换安全,当我们加锁之后,线程对于代码和数据的访问有并行访问变成了串行访问,即线程一个个排队访问,这会导致我们代码的运行时间变长,所以我们加锁的原则就是保证临界区的代码越少越好。
我们的锁本身是为了限制线程访问共享资源而设计的,但是当所有的线程都访问锁,锁本身就成了一种共享资源,所以我们加锁和解锁的过程就也是原子的,如果不这样那锁本身的原子性都没法保证就更不可能保证临界资源的原子性了。
我们跑一下代码看看问题解决了没有,
我们发现确实没有抢到重复的票了,也没有抢到负数票了,但是却出现了新的问题,票全被两个线程抢了,这是为什么呢?
在纯互斥环境中,如果锁的资源分配不合理,就会导致其他线程的饥饿问题,但是并不是说只要是互斥就一定会有饥饿问题,只不过我们这的场景确实有饥饿问题。饥饿问题是怎么产生的呢?这就要谈到我们锁的一个特性了,线程在持有锁被切换时不会归还锁,而是持有锁退出,持有锁的线程被切走的期间其他的线程照样会被阻塞在lock函数这,它们得不到锁资源,我们线程访问临界区的时候,对于其他线程是原子的。对应到我们这里的场景,一个线程得到锁之后进入临界区,执行完代码之后返还锁,这时又会回到循环开头再次申请锁,因为虽然线程释放了锁,但是没有在释放锁的实际被切换,所以又拿到了锁,因为代码归还锁的时机和拿锁之间的实际太短了,线程在这个时机被切换的概率很小,因为cpu太快了,中途被系统自然中断的可能性很小,而如果在持有锁的状态下被切换的又不会归还锁,而在持有锁的状态下被切换才是大概率事件,所以这就造成了所得分配不不合理。
那么我们应该如何解决这个问题呢?很简单,我们在归还锁之后sleep一会就行,当我们调用sleep函数时,其内部会调用系统调用接口,此时线程主动让出时间片,系统陷入内核态进行线程切换,说白了就是要让线程赶紧换人,不要一直占着位子。
加完我们发现果然,此时票就被线程均匀的抢到了。
在实际使用时我们还可以将锁封装成类从而方便地使用它,
cpp
#include<iostream>
#include<stdio.h>
#include <pthread.h>
#include<vector>
#include<unistd.h>
#include<errno.h>
#include<string.h>
using namespace std;
struct task
{
string _name;
pthread_t _PT;
pthread_mutex_t* _mx;
};
class mutex_guard //使用全局锁封装
{
public:
mutex_guard()
{
pthread_mutex_lock(&_mx);
}
~mutex_guard()
{
pthread_mutex_unlock(&_mx);
}
private:
static pthread_mutex_t _mx;
};
pthread_mutex_t mutex_guard:: _mx = PTHREAD_MUTEX_INITIALIZER;
int num = 1000;
void* my_pthread(void* arg)
{
task* tk = static_cast<task*>(arg);
while(1)
{
{
mutex_guard tmp;
if(num > 0)
{
printf("%s抢到一张票:%d\n", (tk->_name).c_str(), num);
num--;
}
else
{
break;
}
}
usleep(1000);
}
return nullptr;
}
int main()
{
vector<task*>arr;
for(int i = 0; i < 10; ++i)
{
task* tmp = new task;
tmp->_name = "thread";
tmp->_name += i + '0';
pthread_create(&(tmp->_PT), nullptr, my_pthread, tmp);
arr.push_back(tmp);
}
for(auto& e : arr)
{
pthread_join(e->_PT, nullptr);
}
for(auto& e : arr)
{
delete e;
}
return 0;
}
这样的封装方式通过类的生命周期自动调用构造析构函数完成加锁解锁,我们称其为RAII风格的锁。
互斥锁为什么是原子的
明白了互斥锁的使用,我们不仅产生了好奇,为什么互斥锁是原子的呢?我之前说过为什么减减操作不是原子的,因为其在编译后会转换成三条汇编,在执行每一条汇编时都可能会被切换,所以它不是原子的。我们可以认为每一条汇编指令都是原子的。据此我们看看lock函数的汇编,
在cpu中提前内置了一套指令集,这样cpu才能读取汇编指令调用寄存器做出各种操作,其中就有一个交换指令(swap、exchange,这里对应xchgb指令),可以一步就交换寄存器和内存中的数据,这是原子的,而这就是加锁操作原子的关键。我们的mutex在一开始是1,我们的寄存器一开始会被初始化成0,所有的线程都可以并发访问lock函数,但是只有最先执行完xchgb指令的函数能拿到那个1,一旦这个1被拿到了,mutex就变成0了,之后的线程无论怎么换都是0换0,拿不到1就拿不到锁,会在条件编译时被挂起,这就保证了互斥锁的原子性。
我们的解锁操作的汇编是怎样的呢?
可以看到,就是直接将mutex置为1就行。为什么不交换寄存器的值呢,首先寄存器不会一直为1,因为会在lock函数被初始化成0,第二unlock不一定一定由持有锁的线程执行,只是大部分情况是这样,所以不能设置成寄存器交换。
死锁
死锁的产生需要有四个必要条件:
互斥条件:一个资源每次只能被一个执行流使用。
请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。
不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺。
循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。
如何避免死锁呢?
破坏死锁的四个必要条件。
加锁顺序一致。
避免锁未释放的场景。
资源一次性分配。
Linux的pthread库提供了非阻塞的lock函数,
c
int pthread_mutex_trylock(pthread_mutex_t *mutex);
我们可以使用它完成一些规避死锁的操作。
死锁在现在已经有了许多算法可以成熟的解决它(死锁检测算法、银行家算法)。
线程安全VS可重入函数
线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,
并且没有锁保护的情况下,会出现该问题。
重入:如果一个函数在执行过程尚未结束时,再次被调用(无论是直接递归,还是间接通过信号处理函数等机制) 会导致未定义行为(如数据损坏、死锁),那么这个函数就是不可重入的。这里的不同的执行流调用有多种情况,可以是多线程,可以是递归调用,也可以是硬件中断等。
常见的线程不安全的情况:
不保护共享变量的函数。
函数状态随着被调用,状态发生变化的函数。
返回指向静态变量指针的函数。
调用线程不安全函数的函数。
常见的线程安全的情况:
每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
类或者接口对于线程来说都是原子操作。
多个线程之间的切换不会导致该接口的执行结果存在二义性。
常见不可重入的情况:
调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。
调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
可重入函数体内使用了静态的数据结构。
常见可重入的情况:
不使用全局变量或静态变量。
不使用用malloc或者new开辟出的空间。
不调用不可重入函数。
不返回静态或全局数据,所有数据都有函数的调用者提供。
使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。
可重入与线程安全联系:
函数是可重入的,那就是线程安全的。
函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入与线程安全区别:
可重入函数是线程安全函数的一种。
线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生
死锁,因此是不可重入的。
可重入函数比线程安全函数更加严格,线程安全函数要求多个线程之间访问同一个函数能过保持安全就行了,而可重入函数即使是一个线程,一个执行流也要保证任何时刻都能再次调用这个函数而不引发错误,所以可重入函数一定线程安全,反之则不行,可重入函数是线程安全函数的子集。
线程同步
什么是线程同步,线程同步是在保证数据安全的情况下,让我们的线程访问资源具有一定的顺序性。在之前的线程互斥的代码中我们就遇到了单一线程获取锁的能力过强从而导致线程饥饿的问题,这样的问题我们用sleep函数暂时解决了,因为我们只是想让线程按顺序访问,没有特殊要求。互斥锁只解决了"同一时间只能有一个线程进入临界区"的问题,保证了数据安全。它不关心哪个线程下一个获得锁。获得锁的顺序完全由操作系统的调度器决定,通常是"谁抢到算谁"(非公平锁常见行为)。当我们需要让线程按照一定的特定顺序访问时,我们单纯靠锁进行互斥就无法解决了,所以我们就需要实现线程同步。使用条件变量就可以实现线程同步。
条件变量专门用于实现线程间的协作式等待。它允许线程在特定条件不满足时主动阻塞(休眠),并在其他线程改变条件后将其唤醒,从而高效协调线程执行顺序。条件变量的底层是一个等待队列,阻塞队列按照先后顺序排在队列中,通过条件变量的唤醒接口可以将队列的线程唤醒。条件变量的实现必须依赖锁,这个下面会讲原因。
我们来认识一下pthread库提供的条件变量的接口。首先条件变量和锁一样,是一种库中定义的一种数据类型(pthread_cond_t
),我们想要使用条件变量就要先创建它,然后我们使用接口
c
int pthread_cond_init(pthread_cond_t *restrict cond,
const pthread_condattr_t *restrict attr);
第一个参数传要初始化的条件变量的指针,第二个参数可以设置条件变量的属性,我们一般就直接传nullpter表示默认,返回值表示函数是否执行成功。
创建之后我们要是想要销毁时我们可以用接口
c
int pthread_cond_destroy(pthread_cond_t *cond);
参数传条件变量。而且对于条件变量我们也能在创建一个全局或静态条件变量时使用宏来创建,这时我们同样也可以不用再进行初始化和销毁了。
c
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
其实我们会发现条件变量和锁的接口非常相似。
当我们初始化好条件变量之后我们就可以使用它了,使用接口
c
int pthread_cond_wait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex);
这个函数是条件变量实现线程间协作等待的核心,改函数会执行三个动作:释放互斥锁 (mutex)、阻塞等待信号、被唤醒后重新获取锁。这就表示我们在使用这个函数前需要提前上锁,为什么后面会讲。函数的参数传条件变量指针和锁指针,返回值表示函数执行是否成功。
当我们想要唤醒阻塞的线程时,使用接口
bash
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
可以唤醒阻塞的线程,pthread_cond_signal可以唤醒队头的线程,pthread_cond_broadcast可以唤醒被阻塞的全部线程。
我们来写一段代码演示一下条件变量,
cpp
#include<iostream>
#include<stdio.h>
#include<vector>
#include<unistd.h>
#include<pthread.h>
using namespace std;
pthread_mutex_t MT = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t CD = PTHREAD_COND_INITIALIZER;
int num = 0;
struct task
{
task(const string& str): _name(str)
{
}
string _name;
pthread_t _PT;
};
void* my_thread(void* arg)
{
task* tk = static_cast<task*>(arg);
while(1)
{
pthread_mutex_lock(&MT);
pthread_cond_wait(&CD, &MT);
cout << tk->_name << ", " << "num:" << num++ << endl;
pthread_mutex_unlock(&MT);
}
}
int main()
{
vector<task*> arr;
for(int i = 0; i < 10; ++i)
{
task* tk = new task("thread");
tk->_name += i + '0';
pthread_create(&(tk->_PT), nullptr, my_thread, (void*)tk);
usleep(1000);
}
while(1)
{
sleep(1);
pthread_mutex_lock(&MT);
cout << "The signal has been sent" << endl;
pthread_mutex_unlock(&MT);
pthread_cond_broadcast(&CD);
}
return 0;
}

可以看到我用条件变量让线程按照创建顺序依次执行打印和加加操作,全局变量也是依次增加。当线程申请锁成功进入wait函数时,其他的线程还在lock函数这里阻塞,我们必须要让其他线程也跑到wait函数中进入阻塞队列,所以一进入wait函数就会释放锁,这时lock函数阻塞的队列就又可以继续争夺锁了,如此往复,所有的线程按争夺锁的顺序进入wait函数,排到了阻塞队列中,这时唤醒等待,就会从队头以依次唤醒,唤醒之后又会第一时间拿到锁,因为我们还在lock和unlock之间的临界区中(也可能不是临界区,但是这样的情况几乎不可能出现,没有临界资源用锁和条件变量干啥?),访问临界区的只能有一个线程,所以我们必须再次拿到锁,避免后续唤醒的线程和我们抢夺共享资源。在这段代码包括下面的代码中,都要对sleep函数进行一定的调用,这其实很重要,比如这里我们在创建线程时用到了sleep,这保证了创建的线程之间有明确的先后顺序,如果不加,线程创建的速度很快,所有的线程启动的速度很接近,这样实际跑起来后就会几乎同时跑到lock函数争夺锁,这样开始进入阻塞队列的顺序也就有可能发生变化,就不会是按照创建顺序了,再者是主线程的while循环广播唤醒,也是sleep间隔,因为如果不这样,那就会快速的释放阻塞队列,跑的快的线程会因此套圈,即前面的线程排在了阻塞队列的后面。当然上面所述的只是我调控线程运行,想让其完美按照创建顺序打印的有心之举,实际在线程中打印顺序什么的也无妨,但是我还是想讲一下sleep函数的用处。
上面的代码对条件变量的使用做了最基本的演示,但是这其实没有展现出它最大的价值和普遍的用法,因此,我们需要介绍一下生产消费者模型。
生产消费者模型
生产者-消费者模型是解决并发协作问题的经典设计模式,其核心在于解耦生产与消费过程,通过缓冲区协调两者速率差异。
打一个最贴近生活的例子就是我们的超市,超市本身就像一块内存空间,供货商就是生产者,客户就是消费者。供货商只和超市打交道,超市货架上需要多少货供货商就放多少,他不关心客户干啥,客户也不和供货商打交道,他只看货架上有什么货,选择性的拿。这样,生产者和消费者就一定程度地解耦了,注意不是全部解耦,两者之间还是有关联的。比如供货商给货,但是客户不买,货架空不出来,供货商就没法再供货了,同样的,客户买货,但是供货商不给货,客户就买不到了,但是不可否认超市的存在解耦了两者之间的很多关系。
对于生产消费者模型,我们可以总结出321原则,即:
一个交易场所:特定结构的内存空间。
两种角色:生产和消费。
三种关系:并发访问共享资源时会出现
生产者vs生产者(互斥关系,生产者之间会竞争内存空间来放自己生产的数据)。
消费者vs消费者(互斥关系,消费者之间会竞争内存中的数据)。
生产者vs消费者(互斥关系,互斥出于内存安全的考虑,两者不能访问一块空间。同步关系,按照顺序访问,没有数据消费者停止消费,没有内存空间生产者停止生产)。
其实像我们的文件系统中的 用户层 -> 内核缓冲区 <- 文件 就有一点生产消费者模型的意思。
生产消费者模型的优点就是支持忙闲不均,将生产和消费进行解耦。
我们来实现一个简易的生产消费者模型。
cpp
#include<iostream>
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
#include<vector>
#include<string>
#include<queue>
#include<time.h>
#define N 10
#define Task task<int>
#define Block_queue block_queue<int>
using namespace std;
template<class T>
class block_queue;
template<class T>
struct task
{
task(const string& str, block_queue<T>* const bq): _name(str), _bq(bq)
{
}
string _name;
pthread_t _PT;
block_queue<T>* _bq;
int _number;
};
template<class T>
class block_queue
{
public:
block_queue()
{
high_water = (_max * 2) / 3;
low_water = _max / 3;
// cout << "high_water:" << high_water << endl << "low_water:" << low_water << endl;
}
int push(const T& x)
{
pthread_mutex_lock(&_MT);
while(_qu.size() >= _max)
{
pthread_cond_wait(&_CD_PUSH, &_MT);
}
_qu.push(x);
// if(_qu.size() >= high_water) pthread_cond_signal(&_CD_POP);
pthread_cond_signal(&_CD_POP);
// pthread_cond_broadcast(&_CD_POP);
pthread_mutex_unlock(&_MT);
return 0;
}
T pop()
{
pthread_mutex_lock(&_MT);
while(_qu.size() == 0)
{
pthread_cond_wait(&_CD_POP, &_MT);
}
T back = _qu.front();
_qu.pop();
// if(_qu.size() <= low_water) pthread_cond_signal(&_CD_PUSH);
pthread_cond_signal(&_CD_PUSH);
// pthread_cond_broadcast(&_CD_PUSH);
pthread_mutex_unlock(&_MT);
return back;
}
void lock()
{
pthread_mutex_lock(&_MT);
}
void unlock()
{
pthread_mutex_unlock(&_MT);
}
private:
queue<T> _qu;
int _max = N;
// public:
static pthread_mutex_t _MT;
static pthread_cond_t _CD_PUSH;
static pthread_cond_t _CD_POP;
int high_water;
int low_water;
};
template<class T>
pthread_mutex_t block_queue<T>::_MT = PTHREAD_MUTEX_INITIALIZER;
template<class T>
pthread_cond_t block_queue<T>::_CD_PUSH = PTHREAD_COND_INITIALIZER;
template<class T>
pthread_cond_t block_queue<T>::_CD_POP = PTHREAD_COND_INITIALIZER;
void* my_producer(void* arg)
{
Task* tk = static_cast<Task*>(arg);
while(1)
{
tk->_number = rand() % 10;
// usleep(10000);
tk->_bq->push(tk->_number);
cout << "生产者生产了:" << tk->_number << endl;
}
return nullptr;
}
void* my_consumer(void* arg)
{
Task* tk = static_cast<Task*>(arg);
while(1)
{
// usleep(10000);
cout << "消费者消费掉:" << tk->_bq->pop() << endl;
}
return nullptr;
}
int main()
{
srand(time(nullptr));
vector<Task*> arr;
block_queue<int> BQ;
Task* tmp = new Task("my_producer_thread", &BQ);
pthread_create(&(tmp->_PT), nullptr, my_producer, tmp);
arr.push_back(tmp);
tmp = new Task("my_consumer_thread", &BQ);
pthread_create(&(tmp->_PT), nullptr, my_consumer, tmp);
arr.push_back(tmp);
for(auto& e : arr)
{
pthread_join(e->_PT, nullptr);
}
for(auto& e : arr)
{
delete e;
}
return 0;
}
通过这段代码我们就能明白为什么条件变量必须搭配锁来使用了,我们条件变量之所以叫条件变量,一个重要的因素就是条件,条件变量是为了解决线程同步问题而诞生的,线程同步是是在保证数据安全的情况下,让我们的线程访问资源具有一定的顺序性,为什么访问资源需要一定的顺序呢?说白了,就是为了维持资源处在一定的状态下,所以我们调节线程访问资源的顺序,免不了要对共享资源的状态进行检查,比如资源为空了,线程就要等待这样的,这是锁和条件变量也就是互斥和同步的本质区别,互斥是非黑即白的,资源为空了也不能等待,因为即使等待了也没用,锁被自己占据着,其他线程无法对资源进行修改,所以只能退出,线程互斥的情况下访问顺序没法修改,只能以退出再开的方式再次访问,这就是忙等待,忙等待(Busy Waiting)是一种线程同步机制,指线程在等待某个条件满足时,持续运行并主动轮询检查条件状态,而不是让出CPU资源进入休眠状态,这种访问无疑增加了cpu的工作量,降低了代码效率。而线程同步则避免,线程访问资源的条件不满足,可以直接等待,让其他的线程上,等到条件满足了再通知自己,这实现了真正的顺序调节。对于判断资源的条件是否满足这一步,其实也是对资源的访问,大家都要判断,也就意味着这就是共享资源,所以我们必须在判断前加锁,这就是为什么条件变量必须搭配锁来使用的原因。当线程持有锁进入wait函数,我们必须释放锁进入等待队列,因为不这样其他线程就没法进来,而当阻塞队列的线程被唤醒时,又得重新申请锁,因为终究访问的是临界资源,必须加锁保护资源。条件变量与锁的配合解决了"在保证安全的前提下实现状态依赖等待"这一核心问题。
我们将代码跑起来,这里我先写的是单消费者对单生产者,之后会改成多对多。这里我们看到代码正常的跑起来了,由于代码没有sleep,基本就是生产者填满阻塞换消费者,开始肯定是生产者先启动,因为开始时队列为空,消费者是阻塞的,生产者先生产再通知,但是因为没有sleep,所以即使通知了也不会立刻线程切换消费者,而是会继续生产,直到填满队列阻塞换线程消费者才会开始消费。所以我们可以将生产者和消费者的循环结尾都加上sleep,
其实cout作为全局资源,打印时严格来说也要加锁,这里我嫌麻烦就没有加了,我们可以将sleep的时间调的不一样,这样两个线程使用cout的时机会错开,一般不会起冲突。当我们加上sleep之后就会发现是生产一个消费一个了。我们也可以为这里加上高低水位线,即生产超过高水位才通知消费者,消费低于低水位线才通知生产者。
注意这里也要注意sleep的使用,因为是生产者先启动,此时如果是消费者sleep的时间长就会在消费者还没进入阻塞时生产者就插入了,此时生产者再睡的话就会去切换线程到消费者,这时消费者再过条件判断就不会进阻塞,也就不会达到生产者生产消费者一直阻塞的情况了,所以这里推荐生产者sleep而消费者不。当然我们也能设置初始状态将队列填满这时就是生产者会阻塞等待消费者了,
当然这里同样也要注意sleep的运用,这里推荐消费者sleep生产者不,让生产者先进阻塞队列。
所以通过上面的实验我们实现了生产者消费者的相互协同,队列满了生产者会等消费者,队列空了消费者会等生产者。其中一个慢另一个也慢,这就是线程同步。
接下来我们将其改成多生产对多消费,
cpp
#include<iostream>
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
#include<vector>
#include<string>
#include<queue>
#include<time.h>
#define N 10
#define Task task<int>
#define Block_queue block_queue<int>
using namespace std;
template<class T>
class block_queue;
template<class T>
struct task
{
task(const string& str, block_queue<T>* const bq): _name(str), _bq(bq)
{
}
string _name;
pthread_t _PT;
block_queue<T>* _bq;
int _number;
};
template<class T>
class block_queue
{
public:
block_queue()
{
high_water = (_max * 2) / 3;
low_water = _max / 3;
// for(int i = 0; i < _max; ++i)
// {
// T n = rand() % 10; // 不泛型
// _qu.push(n);
// }
// cout << "high_water:" << high_water << endl << "low_water:" << low_water << endl;
}
int push(const T& x)
{
pthread_mutex_lock(&_MT);
while(_qu.size() >= _max)
{
pthread_cond_wait(&_CD_PUSH, &_MT);
}
_qu.push(x);
// if(_qu.size() >= high_water) pthread_cond_signal(&_CD_POP);
// pthread_cond_signal(&_CD_POP);
pthread_cond_broadcast(&_CD_POP);
pthread_mutex_unlock(&_MT);
return 0;
}
T pop()
{
pthread_mutex_lock(&_MT);
while(_qu.size() == 0)
{
pthread_cond_wait(&_CD_POP, &_MT);
}
T back = _qu.front();
_qu.pop();
// if(_qu.size() <= low_water) pthread_cond_signal(&_CD_PUSH);
// pthread_cond_signal(&_CD_PUSH);
pthread_cond_broadcast(&_CD_PUSH);
pthread_mutex_unlock(&_MT);
return back;
}
void lock()
{
pthread_mutex_lock(&_MT);
}
void unlock()
{
pthread_mutex_unlock(&_MT);
}
private:
queue<T> _qu;
int _max = N;
// public:
static pthread_mutex_t _MT;
static pthread_cond_t _CD_PUSH;
static pthread_cond_t _CD_POP;
int high_water;
int low_water;
};
template<class T>
pthread_mutex_t block_queue<T>::_MT = PTHREAD_MUTEX_INITIALIZER;
template<class T>
pthread_cond_t block_queue<T>::_CD_PUSH = PTHREAD_COND_INITIALIZER;
template<class T>
pthread_cond_t block_queue<T>::_CD_POP = PTHREAD_COND_INITIALIZER;
pthread_mutex_t cout_mt = PTHREAD_MUTEX_INITIALIZER;
void* my_producer(void* arg)
{
Task* tk = static_cast<Task*>(arg);
while(1)
{
tk->_number = rand() % 10;
sleep(1);
tk->_bq->push(tk->_number);
pthread_mutex_lock(&cout_mt);
cout << tk->_name << "生产了:" << tk->_number << endl;
pthread_mutex_unlock(&cout_mt);
}
return nullptr;
}
void* my_consumer(void* arg)
{
Task* tk = static_cast<Task*>(arg);
while(1)
{
sleep(1);
int ret = tk->_bq->pop(); // 避免死锁
pthread_mutex_lock(&cout_mt);
cout << tk->_name << "消费掉:" << ret << endl;
pthread_mutex_unlock(&cout_mt);
}
return nullptr;
}
int main()
{
srand(time(nullptr));
vector<Task*> arr;
block_queue<int> BQ;
for(int i = 0; i < 10; ++i)
{
Task* tmp = new Task("my_producer_thread", &BQ);
tmp->_name += '0' + i;
pthread_create(&(tmp->_PT), nullptr, my_producer, tmp);
arr.push_back(tmp);
}
for(int i = 0; i < 10; ++i)
{
Task* tmp = new Task("my_consumer_thread", &BQ);
tmp->_name += '0' + i;
pthread_create(&(tmp->_PT), nullptr, my_consumer, tmp);
arr.push_back(tmp);
}
for(auto& e : arr)
{
pthread_join(e->_PT, nullptr);
}
for(auto& e : arr)
{
delete e;
}
return 0;
}
在这段代码中就能解释清楚一个很细小但是很重要的点,那就是为什么条件变量的检查要使用while而不是if,
cpp
int push(const T& x)
{
pthread_mutex_lock(&_MT);
while(_qu.size() >= _max)
{
pthread_cond_wait(&_CD_PUSH, &_MT);
}
_qu.push(x);
// if(_qu.size() >= high_water) pthread_cond_signal(&_CD_POP);
// pthread_cond_signal(&_CD_POP);
pthread_cond_broadcast(&_CD_POP);
pthread_mutex_unlock(&_MT);
return 0;
}
T pop()
{
pthread_mutex_lock(&_MT);
while(_qu.size() == 0)
{
pthread_cond_wait(&_CD_POP, &_MT);
}
T back = _qu.front();
_qu.pop();
// if(_qu.size() <= low_water) pthread_cond_signal(&_CD_PUSH);
// pthread_cond_signal(&_CD_PUSH);
pthread_cond_broadcast(&_CD_PUSH);
pthread_mutex_unlock(&_MT);
return back;
}
之前的单生产对单消费的代码其实即使是写if也不会有什么问题,但是多生产对多消费就不一定了。我们试想一下假如有多个线程因为资源为空而进入了阻塞队列等待,这时其他线程在跑,补充了一部分资源,但是这些资源不够等待的线程一起消费,但是因为意外唤醒了阻塞队列的全部线程,线程们先争夺锁,之后因为是if语句判断的,拿到锁之后不用二次进行条件判断就执行代码消费资源了,当消费完释放锁,这时wait中lock着等待的线程争夺所资源的能力很强,又拿到了锁,这时也不用二次进行条件判断就又消费资源去了,如此往复,最终资源被消耗空了还有线程会来,这就引发了错误。怎么解决呢?很简单,不用if用while就行,用while时,线程被唤醒还要再进行一次条件判断,这就避免了上述的错误。事实上,我们说上面写的单生产对单消费的代码不会出错,这是因为单生产对单消费的条件变量阻塞队列只会有一个线程,所以按照理论来说,只要被正常唤醒就是有资源,阻塞队列只有一个线程那就算资源只有一份也能正常跑,可是实际上计算机中有一个叫做伪唤醒的概念,即一个正在条件变量上等待的线程(通过 pthread_cond_wait()或类似函数),在没有任何线程显式地发出信号(如调用 pthread_cond_signal()或 pthread_cond_broadcast())的情况下,或者在被信号唤醒后条件实际上并未满足的情况下,就"莫名其妙"地从等待状态中返回(即唤醒),这通常是操作系统线程调度、信号处理、处理器架构实现或性能优化的副作用,而不是刻意为之的设计错误。所以即使是单生产对单消费的代码,逻辑上没有错误,我们也不要使用if来进行条件判断,因为这种极小概率发生的伪唤醒要是发生了,此时资源正好为空,仍然会发生错误,所以我建议只要是条件判断,统一用while。
实际跑起来我们发现很顺利。
当然这样的代码也还是略显稚嫩了,因为我们只是生产数字,消费数字而已,实际的生产消费者模型会生产具体的任务进行消费,比如我们定义一个类,里面有任务信息和执行方法,我们的生产者就可以接收任务信息(从用户,网络等中获取 ),堆上开辟空间创建类对象,再将指针放到特定结构的内存中,这样消费者线程拿到后开始执行指定的消费方法,这是比较成熟的过程,所以我们之前画的示意图可以再进行拓展,
所以我们都说生产消费者模型是高效的,高效在哪?生产消费者模型允许一个生产者或一个消费者访问同一块内存结构,访问本身并不高效啊。生产消费者模型高效的点在于不一定所有线程都在竞争共享资源,有的线程可以在获取外部数据生产任务,也可以在执行消费方法消费获取到的数据,只有生产者写入缓存和消费者读取缓存是竞争的点(而这个过程其实只占据很小部分时间),这将持锁时间压缩到了极致,并将资源获取和使用解耦,实现生产消费的异步,提升了并行度(生产者加载下一批数据时,消费者正处理前一批数据 → 形成流水线并行),这才是生产消费者模型的高效所在。
信号量
之前我介绍了互斥锁,我们可以将互斥锁理解成一个VIP影厅,这个影厅只售卖一张VIP票,有票的人可以进入,其他人都不能看,而这张票谁先买到就是谁的。信号量我们则可以看成一个普通影厅,售卖很多张票,有票的都能进入,但每个人都只能坐自己的座位,票同样是先到先得。如果我们将共享资源只看成一整份,那么想要保护好它就要采用锁,一次只能一个线程访问该资源。如果我们将资源看成很多份,那么我们就可以采用信号量,信号量的本质就是一个计数器,我们提前将资源分成多份,给信号量初始化成对应的份数,信号量就是一个描述资源数目的计数器,当线程想要访问共享资源时,我们就将信号量减减,这被称为P操作(sem_wait),当我们使用完想要归还资源时,我们就将信号量加加,这被称为V操作(sem_post)。PV操作在POSIX中提供了对应的接口,接口是原子的,支持不持锁并发访问,当线程申请资源失败也就是信号量已经为0时,就会阻塞在函数中直到信号量不为0。所以我们就可以明白,信号量的数目由接口维护,保证不会有超过资源份数的线程访问资源,但是资源的映射得由我们维护,即持票入场后该坐到那个位置上?不能有多个线程访问同一份资源,这需要我们编码实现。信号量本质是对资源的预定。
接下来介绍一下POSIX的信号量接口。首先信号量也是库定义的一种类型(sem_t
),我们要想使用就得先创建一个,然后使用接口
c
int sem_init(sem_t *sem, int pshared, unsigned int value);
进行初始化,第一个参数传创建的信号量的指针,第二个参数传选项,0表示线程间共享,非0表示进程间共享(需配合共享内存),我们一般传0,第三个参数传信号量初始值(传1相当于互斥锁)。
有创建当然有销毁,使用接口
c
int sem_destroy(sem_t *sem);
可以销毁信号量.
P操作的接口是
c
int sem_wait(sem_t *sem);
V操作的接口是
c
int sem_post(sem_t *sem);
要使用信号量我们还得有一个合适的对象,这就是我们接下来要介绍的环形生产消费者模型。
环形生产消费者模型
环形生产消费模型顾名思义就是环状队列的生产消费者模型,
环形队列可以使用数组模拟,给定一个头下标和尾下标,头下标是生产者,尾下标是消费者,每次加加取余就不会越界。头尾下标只有在空和满时才会指向同一个位置,这两种情况本来就只有一边才能访问,其余的情况头尾指针都指向不同的位置,所以我们可以同时访问。总结一下,指向同一个位置时,为空生产者访问,为满消费者访问,同时头指针不能超过尾指针,也就是不能套圈,尾指针也不能超过头指针。我们可以给一个剩余空间资源的信号量,剩余数据资源的信号量,初始剩余空间资源的信号量为满,剩余数据资源的信号量为0。因为我们维护了头尾指针,也就是维护了初始剩余空间资源的一个映射和剩余数据资源一个映射,这两个不是同一份资源,所以是可以同时访问的,但是因为生产者和消费者各只有一个映射位置,所以在生产者和生产者之间、消费者和消费者之间还是互斥的,所以我们还是要使用锁维持这两个关系之间的互斥,但是生产者和消费者之间不再像条件变量那样是完全互斥的了,而是部分互斥部分同步的关系,这和生产消费者模型的理论符合。
接下来我们就来写一下代码,
cpp
#include<iostream>
#include<stdio.h>
#include<pthread.h>
#include<semaphore.h>
#include<string>
#include<vector>
#include<unistd.h>
#include<time.h>
#define Task task<int>
#define Rblock_Queue rblock_queue<int>
using namespace std;
template<class T>
class rblock_queue;
template<class T>
struct task
{
task(const string& name, int num, Rblock_Queue* const rq): _name(name), _RQ(rq)
{
_name += to_string(num);
}
string _name;
pthread_t _PT;
Rblock_Queue* _RQ;
};
template<class T>
class rblock_queue
{
public:
rblock_queue(): _qu(_cap), _head(0), _tail(0)
{
sem_init(&c_st, 0, 0);
sem_init(&p_st, 0, _cap);
pthread_mutex_init(&c_mt, nullptr);
pthread_mutex_init(&p_mt, nullptr);
}
void push(const T& x)
{
sem_wait(&p_st);
pthread_mutex_lock(&p_mt);
_qu[_head++] = x;
_head %= _cap;
pthread_mutex_unlock(&p_mt);
sem_post(&c_st);
}
T pop()
{
sem_wait(&c_st);
pthread_mutex_lock(&c_mt);
const T& ret = _qu[_tail++];
_tail %= _cap;
pthread_mutex_unlock(&c_mt);
sem_post(&p_st);
return ret;
}
~rblock_queue()
{
sem_destroy(&c_st);
sem_destroy(&p_st);
pthread_mutex_destroy(&c_mt);
pthread_mutex_destroy(&p_mt);
}
private:
int _cap = 5;
vector<T> _qu;
int _head; // 头下标,生产者下标
int _tail;// 尾下标,消费者下标
sem_t c_st; // 数据资源信号量
sem_t p_st; // 空间资源信号量
pthread_mutex_t c_mt; // 消费者锁
pthread_mutex_t p_mt; // 生产者锁
};
// 静态在多个类类型对象存在时不好用
// template<class T>
// pthread_mutex_t rblock_queue<T>::c_mt = PTHREAD_MUTEX_INITIALIZER;
// template<class T>
// pthread_mutex_t rblock_queue<T>::p_mt = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t m_mt = PTHREAD_MUTEX_INITIALIZER;
void* my_producer(void* arg)
{
Task* tk = static_cast<Task*>(arg);
while(1)
{
sleep(2);
int n = rand() % 10 + 1;
tk->_RQ->push(n);
pthread_mutex_lock(&m_mt);
cout << tk->_name << "生产了:" << n << endl;
pthread_mutex_unlock(&m_mt);
}
return nullptr;
}
void* my_consumer(void* arg)
{
Task* tk = static_cast<Task*>(arg);
while(1)
{
sleep(1);
int ret = tk->_RQ->pop();
pthread_mutex_lock(&m_mt);
cout << tk->_name << "消费了:" << ret << endl;
pthread_mutex_unlock(&m_mt);
}
return nullptr;
}
int main()
{
srand(time(nullptr));
vector<Task*> arr;
Rblock_Queue bq;
for(int i = 0; i < 5; ++i)
{
Task* tk = new Task("my_producer_thread", i, &bq);
pthread_create(&(tk->_PT), nullptr, my_producer, (void*)tk);
arr.push_back(tk);
}
for(int i = 0; i < 5; ++i)
{
Task* tk = new Task("my_consumer_thread", i, &bq);
pthread_create(&(tk->_PT), nullptr, my_consumer, (void*)tk);
arr.push_back(tk);
}
for(auto& e : arr)
pthread_join(e->_PT, nullptr);
for(auto& e : arr)
delete e;
return 0;
}

生产者关心空间资源,生产数据资源,消费者消费数据资源,腾出空间资源。上述的代码有一点需要着重讲解,那就是信号量应该在锁之前P还是锁之后P呢?答案是锁之前,因为PV操作本身具有原子性,不需要锁保护,将其加入锁后的临界区会降低效率,本来可以先并行申请完信号量加锁,若先加锁,所有线程必须穿行申请,这降低了代码效率,所以在锁外P就好。
实现一个简易线程池
cpp
#include<iostream>
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
#include<string>
#include<vector>
#include<time.h>
#include<queue>
using namespace std;
enum
{
zero = -1,
unknow = -2
};
char OP[] = "+-*/%";
class task
{
int _a;
int _b;
char _op;
int _result = 0;
int _exitcode = 0;
public:
task(int a, int b, char op): _a(a), _b(b), _op(op)
{
}
void operator()(const string& name)
{
switch(_op)
{
case '+':
_result = _a + _b;
break;
case '-':
_result = _a - _b;
break;
case '*':
_result = _a * _b;
break;
case '/':
if(_b == 0) _exitcode = zero;
else _result = _a / _b;
break;
case '%':
if(_b == 0) _exitcode = zero;
else _result = _a % _b;
break;
default:
_exitcode = unknow;
break;
}
if(_exitcode == zero)
{
cout << name << ":除零错误" << endl;
}
else if(_exitcode == unknow)
{
cout << name << ":未知符号" << endl;
}
else
{
cout << name << ":" << _a << " " << _op << " " << _b << " = " << _result << endl;
}
}
void print()
{
cout << "生成了一个任务:" << _a << " " << _op << " " << _b << " = ?" << endl;
}
};
struct thread_info
{
thread_info(const string& str, int n)
{
_name = str + to_string(n);
}
string _name;
pthread_t _PT;
};
template<class T>
class thread_pool
{
typedef thread_pool<T> Thread_Pool;
static void* Solution(void* arg)
{
Thread_Pool* self = static_cast<Thread_Pool*>(arg);
while(1)
{
usleep(1000);
T get = self->pop();
get(self->GetThreadName(pthread_self()));
}
return nullptr;
}
string GetThreadName(pthread_t tid)
{
for(auto& e : _arr)
if(e._PT == tid) return e._name;
return "NONE";
}
public:
thread_pool()
{
pthread_mutex_init(&_mt, nullptr);
pthread_cond_init(&push_cd, nullptr);
pthread_cond_init(&pop_cd, nullptr);
}
~thread_pool()
{
pthread_mutex_destroy(&_mt);
pthread_cond_destroy(&push_cd);
pthread_cond_destroy(&pop_cd);
for(auto& e : _arr)
pthread_join(e._PT, nullptr);
}
void start()
{
for(int i = 0; i < _max; ++i)
{
_arr.push_back(thread_info("thread-", i));
pthread_create(&(_arr[i]._PT), nullptr, Solution, (void*)this);
}
}
void push(const T& tk)
{
pthread_mutex_lock(&_mt);
while(_qu.size() >= _max)
{
pthread_cond_wait(&push_cd, &_mt);
}
_qu.push(tk);
pthread_cond_broadcast(&pop_cd);
pthread_mutex_unlock(&_mt);
}
T pop()
{
pthread_mutex_lock(&_mt);
while(_qu.size() <= 0)
{
pthread_cond_wait(&pop_cd, &_mt);
}
T ret = _qu.front();
_qu.pop();
pthread_cond_broadcast(&push_cd);
pthread_mutex_unlock(&_mt);
return ret;
}
private:
int _max = 5;
vector<thread_info> _arr;
queue<T> _qu;
pthread_mutex_t _mt;
pthread_cond_t push_cd;
pthread_cond_t pop_cd;
};
int main()
{
srand(time(nullptr));
thread_pool<task> tp;
tp.start();
while(1)
{
usleep(1000);
task tk(rand() % 10, rand() % 10, OP[rand() % 5]);
tk.print();
tp.push(tk);
}
return 0;
}

实际上线程池也好还是进程池也好,本质还是生产消费者模型的思想。这段代码有一点需要注意,那就是线程创建函数的函数指针需要传void*(*)(void*)的,但是我们定义在类内的成员函数都默认自带一个this指针作为参数隐式传入,所以我们直接传类内的void*(*)(void*)会报错,所以我们可以定义成静态函数,这样就不会报错了,但是我们也失去了this指针,所以我们可以通过参数传入this指针。
线程封装
我们可以用类对线程进行简易封装,使其使用起来类似于c++自己的线程库。
cpp
#include<iostream>
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
#include<string>
#include<vector>
#include<time.h>
#include<queue>
typedef void (*callback_t)();
static int num = 1;
using namespace std;
class Thread
{
public:
static void *Routine(void *args)
{
Thread* thread = static_cast<Thread*>(args);
thread->Entery();
return nullptr;
}
Thread(callback_t cb):tid_(0), name_(""), start_timestamp_(0), isrunning_(false),cb_(cb)
{}
void Run()
{
name_ = "thread-" + std::to_string(num++);
start_timestamp_ = time(nullptr);
isrunning_ = true;
pthread_create(&tid_, nullptr, Routine, this);
}
void Join()
{
pthread_join(tid_, nullptr);
isrunning_ = false;
}
std::string Name()
{
return name_;
}
uint64_t StartTimestamp()
{
return start_timestamp_;
}
bool IsRunning()
{
return isrunning_;
}
void Entery()
{
cb_(); // 可以传参
}
~Thread()
{}
private:
pthread_t tid_;
std::string name_;
uint64_t start_timestamp_;
bool isrunning_;
callback_t cb_;
};
void Print()
{
while(true)
{
printf("我是线程\n");
sleep(1);
}
}
int main()
{
std::vector<Thread> threads;
for(int i = 0 ;i < 10; i++)
{
threads.push_back(Thread(Print));
}
for(auto &t : threads)
{
t.Run();
}
for(auto &t : threads)
{
t.Join();
}
return 0;
}

线程安全的单例模式
什么是单例模式,一个类定义出来,需要自始至终保证只创建一个对象,并提供全局访问点,这就叫单例模式。在很多服务器开发场景中, 经常需要让服务器加载很多的数据 (上百G) 到内存中. 此时往往要用一个单例的类来管理这些数据。
如何实现单例模式呢?单例模式就意味着不能够随随便便就创建一个单例模式的对象,所以我们要将构造函数私有化,然后通过静态成员函数在内部对构造函数进行调用,当然,对于拷贝构造和赋值运算符重载函数这种可以间接创造类对象的函数我们也应该私有化或者删除,这样我们只能通过调用类中定义的静态成员函数进行创建获取。
单例模式的实现方式也有两种,分别是饿汉式和懒汉式。吃完饭, 立刻洗碗,这种就是饿汉方式。 因为下一顿吃的时候可以立刻拿着碗就能吃饭,吃完饭,先把碗放下,然后下一顿饭用到这个碗了再洗碗,就是懒汉方式
饿汉方式
饿汉方式应用到代码中就是我们在还没有用这个单例模式类时就提前把他创建好,这样之后要用时就不用创建,只用给一个静态成员函数获取这个对象就好了,
cpp
#include<iostream>
using namespace std;
class Hungry_Man
{
public:
static Hungry_Man& GetHM()
{
return _HM; // 直接返回已创建的实例
}
private:
Hungry_Man()
{
cout << "构造完成" << endl;
}
Hungry_Man(const Hungry_Man& ) = delete;
Hungry_Man& operator=(const Hungry_Man&) = delete;
static Hungry_Man _HM;
};
Hungry_Man Hungry_Man::_HM;
int main()
{
Hungry_Man& tmp = Hungry_Man::GetHM();
return 0;
}
这里静态函数返回指针也是可以的。饿汉方式本身就是线程安全的,因为类类型对象在一开始就创建好了,不会使用线程创建。
懒汉方式
懒汉方式应用到代码中就是我们在使用这个类类型对象时才创建这个对象,所以我们不会提前创建这个类类型对象,
cpp
#include<iostream>
#include<unistd.h>
using namespace std;
class Hungry_Man
{
public:
static Hungry_Man* GetHM()
{
if(!_HM) // 指针为空就创建,不为空就返回现成的
{
_HM = new Hungry_Man;
return _HM;
}
return _HM;
}
private:
Hungry_Man()
{
cout << "构造完成" << endl;
}
Hungry_Man(const Hungry_Man& ) = delete;
Hungry_Man& operator=(const Hungry_Man&) = delete;
static Hungry_Man* _HM;
};
Hungry_Man* Hungry_Man::_HM = nullptr;
int main()
{
int cnt = 3;
while(cnt--)
{
cout << "wait" << endl;
sleep(1);
}
Hungry_Man* tmp = Hungry_Man::GetHM();
return 0;
}

可以看到,我们懒汉模式不会在一开始就创建,只有在真正要用到时调用函数才会创建。
明眼人都能看得出来,懒汉模式的静态函数是线程不安全的,因为可能会出现多个线程同时通过if语句进入从而new了多次的情况,所以我们应该怎么办?很简单,加锁呗。
cpp
#include<iostream>
#include<unistd.h>
#include<pthread.h>
#include<vector>
using namespace std;
pthread_mutex_t m_mt = PTHREAD_MUTEX_INITIALIZER;
class Hungry_Man
{
public:
static Hungry_Man* GetHM()
{
if(!_HM)
{
pthread_mutex_lock(&_MT);
if(!_HM) _HM = new Hungry_Man();
pthread_mutex_unlock(&_MT);
}
return _HM;
}
private:
Hungry_Man()
{
cout << "构造完成" << endl;
}
Hungry_Man(const Hungry_Man& ) = delete;
Hungry_Man& operator=(const Hungry_Man&) = delete;
static Hungry_Man* _HM;
static pthread_mutex_t _MT;
};
Hungry_Man* Hungry_Man::_HM = nullptr;
pthread_mutex_t Hungry_Man::_MT = PTHREAD_MUTEX_INITIALIZER;
void* my_thread(void* arg)
{
Hungry_Man* tmp = Hungry_Man::GetHM();
pthread_mutex_lock(&m_mt);
printf("%p\n", tmp);
pthread_mutex_unlock(&m_mt);
return tmp;
}
int main()
{
vector<pthread_t> arr;
for(int i = 0; i < 10; ++i)
{
pthread_t tmp;
pthread_create(&tmp, nullptr, my_thread, nullptr);
arr.push_back(tmp);
}
void* ret = nullptr;
for(auto& e : arr)
{
pthread_join(e, &ret);
}
delete (Hungry_Man*)ret;
return 0;
}

这里的双重鉴定需要着重讲一下,
cpp
static Hungry_Man* GetHM()
{
if(!_HM)
{
pthread_mutex_lock(&_MT);
if(!_HM) _HM = new Hungry_Man();
pthread_mutex_unlock(&_MT);
}
return _HM;
}
为什么要有两个 if(!_HM) 呢?原因是我们只在刚开始类类型对象还没有创建时对于临界资源的争抢(有读有写)才会发生错误,创建完成之后 if(!_HM) 永远不会再通过了,这时还要频繁的加解锁就会影响代码性能,所以我们在外层再加上一层 if(!_HM) ,这样之后再有线程进入时就不用加减锁了。
自旋锁
什么是自旋锁?自旋锁(spinlock)是一种用于保护共享资源的锁机制,常用于多核处理器中(单核cpu无效),在某个核(或线程)试图获取锁时,如果发现锁已被其他核持有,它会忙等(不断循环检查)而不是让出 CPU 时间片。我们之前讲的lock函数就不是自旋锁,因为它是阻塞式地等待,阻塞了就被切换了,而有一个接口
c
int pthread_mutex_trylock(pthread_mutex_t *mutex);
就不是阻塞式地等待,这个函数获取锁失败就出错返回,我们要是给一个while循环里面放一个,我们就可以自己实现一个自旋锁了,但是其实我们也大可不必这样,因为pthread库自己提供了自旋锁的接口,
c
int pthread_spin_lock(pthread_spinlock_t *lock);
int pthread_spin_trylock(pthread_spinlock_t *lock);
c
int pthread_spin_unlock(pthread_spinlock_t *lock);
c
int pthread_spin_destroy(pthread_spinlock_t *lock);
int pthread_spin_init(pthread_spinlock_t *lock, int pshared);
使用方法和mutex基本一样,自旋锁的lock函数会不断地自旋,其内部进行了特殊优化,循环内部只有几条指令,循环速度很快,可以持续对所锁资源进行检查,trylock则同样是lock的非阻塞版本,因为lock函数的自选行为,所以在我们看来还是阻塞的,trylock则是自旋锁获取锁资源失败时不在自旋而是返回的版本。
自旋锁和互斥锁我们到底使用谁呢?两者都有使用场景,在执行临界区很短的情况下,我们如果使用互斥锁进行切换的话,很短的时间内所资源就又会释放,这是我们又要切回来,得不偿失,这种情况我们用自旋锁会更好。如果执行临界区很长的情况下,使用自旋锁傻傻地原地等待占用cpu则很浪费资源,这时我们则用互斥更好。
读者写者锁
在编写多线程的时候,有一种情况是十分常见的。那就是,有些公共数据修改的机会比较少。相比较改写,它们读的机会反而高的多。通常而言,在读的过程中,往往伴随着查找的操作,中间耗时很长。给这种代码段加锁,会极大地降低我们程序的效率。那么有没有一种方法,可以专门处理这种多读少写的情况呢? 有,那就是读写锁。
读者-写者锁是一种多线程同步机制,它针对读多写少的场景进行了优化,允许多个读者同时访问共享资源,但只允许一个写者独占访问。读者获得读者锁之后之后来的线程也可以直接访问,不会阻塞,而写者会被阻塞,写者获取写者锁之后才能对临界资源进行访问写入,其余的读者和写者都会被阻塞。
一般来说,都是读者多写者少,所以读者写者锁默认读者优先,即当至少一个读者持有读锁时,后续新到达的读者可以立即获取读锁,无需等待写者完成。写者必须阻塞直到所有读者(包括新加入的读者)释放锁。当写者请求写锁时,系统仅在所有现有读锁释放后才会分配写锁,但在此期间新读者仍可插队获取读锁,进一步延迟写者的执行。
我们来用互斥锁简单模拟一下读者优先的情况,
cpp
#include<iostream>
#include<unistd.h>
#include<pthread.h>
#include<vector>
#include<string>
using namespace std;
pthread_mutex_t r_mt = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t w_mt = PTHREAD_MUTEX_INITIALIZER;
int num = 0;
int reader = 0;
void* read(void* arg)
{
int cnt = 5;
while(cnt--)
{
usleep(1000);
pthread_mutex_lock(&r_mt);
++reader;
if(reader == 1) pthread_mutex_lock(&w_mt); //第一个读者来了,把写者锁申请了
pthread_mutex_unlock(&r_mt);
printf("%d\n", num);
pthread_mutex_lock(&r_mt);
--reader;
if(reader == 0) pthread_mutex_unlock(&w_mt); // 最后一个读者走了,把锁还回去
pthread_mutex_unlock(&r_mt);
}
return nullptr;
}
void* write(void* arg)
{
int cnt = 5;
while(cnt--)
{
usleep(1000);
pthread_mutex_lock(&w_mt);
num++;
pthread_mutex_unlock(&w_mt);
}
return nullptr;
}
int main()
{
vector<pthread_t> arr;
for(int i = 0; i < 5; ++i)
{
pthread_t tmp;
pthread_create(&tmp, nullptr, read, nullptr);
arr.push_back(tmp);
}
for(int i = 0; i < 1; ++i)
{
pthread_t tmp;
pthread_create(&tmp, nullptr, write, nullptr);
arr.push_back(tmp);
}
for(auto& e : arr)
pthread_join(e, nullptr);
return 0;
}
当然这只是最简易的模拟,实际要想实现真正调度合理的读者优先,肯定还是要加入一些复杂的调度逻辑,这就不在我的能力范围内了。
再来模拟一下写者优先的问题,
cpp
class write_first
{
public:
write_first(): _reader_count(0), _writer_count(0), _waiting_writers(0)
{
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_read_cond, nullptr);
pthread_cond_init(&_write_cond, nullptr);
}
// 获取读锁
void reader_lock_wp()
{
pthread_mutex_lock(&_mutex);
// 如果有写者正在写或等待,读者等待
while (_writer_count > 0 || _waiting_writers > 0)
{
pthread_cond_wait(&_read_cond, &_mutex);
}
_reader_count++;
pthread_mutex_unlock(&_mutex);
}
// 释放读锁
void reader_unlock_wp()
{
pthread_mutex_lock(&_mutex);
_reader_count--;
// 如果没有读者了,唤醒写者
if (_reader_count == 0 && _waiting_writers > 0)
{
pthread_cond_signal(&_write_cond);
}
pthread_mutex_unlock(&_mutex);
}
// 获取写锁
void writer_lock_wp()
{
pthread_mutex_lock(&_mutex);
_waiting_writers++;
// 如果有其他读者或写者,等待
while (_reader_count > 0 || _writer_count > 0)
{
pthread_cond_wait(&_write_cond, &_mutex);
}
_waiting_writers--;
_writer_count++;
pthread_mutex_unlock(&_mutex);
}
// 释放写锁
void writer_unlock_wp()
{
pthread_mutex_lock(&_mutex);
_writer_count--;
// 优先唤醒写者(写者优先)
if (_waiting_writers > 0)
{
pthread_cond_signal(&_write_cond);
}
// 如果没有写者等待,唤醒所有读者
else
{
pthread_cond_broadcast(&_read_cond);
}
pthread_mutex_unlock(&_mutex);
}
~write_first()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_read_cond);
pthread_cond_destroy(&_write_cond);
}
private:
pthread_mutex_t _mutex; // 保护所有共享变量的互斥锁
pthread_cond_t _read_cond; // 读者条件变量
pthread_cond_t _write_cond; // 写者条件变量
int _reader_count; // 当前读者数量
int _writer_count; // 当前写者数量
int _waiting_writers; // 等待的写者数量
};
write_first wf;
int num = 0;
void* read(void* arg)
{
int cnt = 5;
while(cnt--)
{
usleep(1000);
wf.reader_lock_wp();
printf("%d\n", num);
wf.reader_unlock_wp();
}
}
void* write(void* arg)
{
int cnt = 5;
while(cnt--)
{
usleep(1000);
wf.writer_lock_wp();
++num;
wf.writer_unlock_wp();
}
}
int main()
{
vector<pthread_t>arr;
for(int i = 0; i < 3; ++i)
{
pthread_t tmp;
pthread_create(&tmp, nullptr, write, nullptr);
arr.push_back(tmp);
}
for(int i = 0; i < 5; ++i)
{
pthread_t tmp;
pthread_create(&tmp, nullptr, read, nullptr);
arr.push_back(tmp);
}
for(auto& e : arr)
pthread_join(e, nullptr);
return 0;
}
写者优先的逻辑要复杂一点。
读者与写者两者的机制就决定了读者更具有优势一点,当读者优先时,写者就有饥饿的可能,写者优先读者饥饿的可能很小,这是肯定的,读者写者模型读者多写者少的情况偏多,写者可能会饥饿也是自然。想要真正对读者写者模型及逆行合理的调度,Linux也是给我们提供了相关的接口,
c
int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t *attr, int pref);
/*
pref 共有 3 种选择
PTHREAD_RWLOCK_PREFER_READER_NP (默认设置) 读者优先,可能会导致写者饥饿情况
PTHREAD_RWLOCK_PREFER_WRITER_NP 写者优先,目前有 BUG,导致表现行为和
PTHREAD_RWLOCK_PREFER_READER_NP 一致
PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP 写者优先,但写者不能递归加锁
*/
c
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,const pthread_rwlockattr_t
*restrict attr);
c
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
c
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
我们只要有一把读写锁就行了,在读者访问时用读者上锁,写着访问时用写者上锁就行,剩下的调度问题有接口帮我们解决,不用我们写,我们写的也没有库的好。使用方法不用多说,都是一脉相承的设计逻辑。
其他的概念
悲观锁:在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行
锁等),当其他线程想要访问数据时,被阻塞挂起。
乐观锁:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,
会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。
CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不
等则失败,失败则重试,一般是一个自旋的过程,即不断重试。
STL中的容器是否是线程安全的?不是。原因是,STL 的设计初衷是将性能挖掘到极致,而一旦涉及到加锁保证线程安全,会对性能造成巨大的影响。而且对于不同的容器,加锁方式的不同,性能可能也不同(例如hash表的锁表和锁桶)。因此 STL 默认不是线程安全,如果需要在多线程环境下使用,往往需要调用者自行保证线程安全。
智能指针是否是线程安全的?对于 unique_ptr,由于只是在当前代码块范围内生效,因此不涉及线程安全问题。对于 shared_ptr,多个对象需要共用一个引用计数变量,所以会存在线程安全问题。但是标准库实现的时候考虑到了这个问题,基于原子操作(CAS)的方式保证 shared_ptr 能够高效,原子的操作引用计数。