文章目录
linux多线程
1. 相关概念
1.1 线程概念详解
教材观点:
- 线程是一个执行分支,执行力度比进程更细,调度成本更低(不用对cache进行切换)
- 线程是进程内部的一个执行流
内核观点:
线程是CPU调度的基本单位,进程是承担分配系统资源的基本实体。
那么是不是所有的操作系统都是像上面那样干的呢?不是
有了线程之后,OS要不要管理线程呢?必须要;如何管理呢?先描述再组织
windows操作系统: 会给线程创建一个个TCB(线程控制块),它属于进程的PCB通过调度进程来进行线程的调度,即windows内核中有真正的线程
linux操作系统: linux内核设计者想法: 复用进程的pcb结构体,用pcb模拟线程的tcb不就行了,很好地复用了进程的设计方案;所以linux没有真正意义上的线程,而是用进程方案模拟的线程 。
复用代码和结构比较简单,好维护,效率更高,也更安全。— linux可以不间断的运行。— 实际上一款OS, 使用最频繁的功能,除了OS本身,下来就是进程了。
那么如何理解以前学习过的进程:
线程 VS 进程
进程是资源分配的基本单位
线程是调度的基本单位
线程共享进程数据,但也拥有自己的一部分数据:
- 线程ID
- 一组寄存器
- 栈
- errno
- 信号屏蔽字
- 调度优先级
进程的多个线程共享 同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
- 文件描述符表
- 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
- 当前工作目录
- 用户id和组id
进程和线程的关系如下图:
验证: 全局变量在多线程中, 我们的多线程看到的是同一个变量
#include<iostream>
#include<signal.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<pthread.h>
using namespace std;
int g_val=0; //全局变量在多线程场景中, 我们多线程看到的是同一个变量!
void*thread1_run(void*args)
{
while(1)
{
sleep(1);
cout<< "t1 thread..." << getpid() << " &g_val: " << &g_val << " g_val " << g_val <<endl;
}
}
void*thread2_run(void*args)
{
while(1)
{
sleep(1);
cout<< "t2 thread..." << getpid() << " &g_val: " << &g_val << " g_val " << g_val++ <<endl;
}
}
int main()
{
pthread_t t1,t2,t3;
pthread_create(&t1,nullptr,thread1_run,nullptr);
pthread_create(&t2,nullptr,thread2_run,nullptr);
while(1)
{
sleep(1);
cout<< "main thread..." << getpid() <<" &g_val: "<<&g_val<<" g_val " <<g_val<<endl;
}
}
运行结果:
因为执行流看到的资源是通过地址空间看到得的,多个LWP(线程)看到的是同一个地址空间。所以,所有的线程可能会共享进程的大部分资源。
线程的优点
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
计算密集型应用: 加密解密,文件压缩和解压等与算法有关的。 — CPU资源。这里的线程越多越好吗?不是,一定要合适(进程/线程CPU的个数/核数一致)
I/O密集型应用: 下载,上层,IO主要消耗IO资源,磁盘的IO,网络带宽等。这里的线程越多越好吗?不是,可以比较多 — 量化
线程的缺点
- 性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。 - 健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。 - 缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。 - 编程难度提高
编写与调试一个多线程程序比单线程程序困难得多
线程异常
- 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出
如下这样的代码, 在一个主线程中创建两个线程,让其中一个线程崩溃
#include<iostream>
#include<signal.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<pthread.h>
using namespace std;
void*thread1_run(void*args)
{
while(1)
{
sleep(1);
cout<< "t1 thread..." << getpid() <<endl;
}
}
void*thread2_run(void*args)
{
char*s="hello";
while(1)
{
sleep(1);
cout<< "t2 thread..." << getpid() <<endl;
*s='H'; //让这一个线程崩溃
}
}
int main()
{
pthread_t t1,t2,t3;
pthread_create(&t1,nullptr,thread1_run,nullptr);
pthread_create(&t2,nullptr,thread2_run,nullptr);
while(1)
{
sleep(1);
cout<< "main thread..." << getpid() << endl;
}
}
运行结果:
发现现象:
在多线程程序中,任何一个线程崩溃了,最后都会导致进程崩溃
为什么呢?
系统角度: 线程是进程的执行分支,线程崩溃了,就是进程奔溃了
信号角度: 页表转换的时候,MMU识别写入权限,没有验证通过,MMU出现异常,被OS识别到会给进程发信号(结合1.2)
线程用途
- 合理的使用多线程,能提高CPU密集型程序的执行效率
- 合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)
1.2 页表详解
为了解决一一映射页表体积过大问题,采用了类似哈希的结构。
虚拟地址的前10位是页目录,共计2^10个,即1KB大小;中间10位是页表,每一个页目录指向一张页表,每张页表大小1KB,共有1KB张页表,合计大小1MB;后12位代表所属页表指向物理内存的偏移量,加上这个偏移量,即可找到真实的物理地址
我们平时写出这样的代码,为什么程序会崩溃?
char*s="hello";
*s='H';
解释:
字符串常量区是不允许被修改的,只允许被读取!— 为什么? — s里面保存的是指向的字符的虚拟起始地址 — s寻址的时候必定会伴随虚拟到物理的转化 — MMU + 查页表的方式 — 对你的操作进行权限审查* — 发现你虽然能找到,但是你进行的操作是非法的 — MMU会发生异常 — 异常转换成信号,发送给目标进程 — 在从内核切换成为用户态的时候,进行信号处理 — 会终止进程,此程序发生崩溃。
深入理解现象:
我们实际在申请malloc内存时,OS只要给你在虚拟地址空间上申请就可以了,当你在真正访问空间(执行我自己代码),OS才会自动给你申请或者填充页表(缺页中断现象) + 申请具体的物理内存
2. 线程控制
2.0 POSIX线程库
两种角度:
- 用户角度: 只认线程
- 操作系统角度: Linux下没有真正的线程,而是用进程模拟的线程(LWP) — 所以,Linux不会提供直接创建线程的系统调用,它会给我们最多提供创建轻量级进程的接口
POSIX线程库: 即用户级线程库,对下将linux接口封装,对上给用户提供进行线程控制的接口 ,任何系统都要自带,它是一种原生线程库。
细节:
- 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头的
- 要使用这些函数库,要通过引入头文 <pthread.h>
- 链接这些线程函数库时要使用编译器命令的“-lpthread”选项
使用Linux线程库记得在编译时添加 -lpthread
选项
ps -aL
查看当前操作系统中的线程:
深入理解线程库
线程库也像前面我们学习的动静态库一样,它的本质就是一个文件,它从磁盘加载到物理内存通过页表映射到虚拟地址空间的共享区中,我们进程中的线程可以进行一系列操作,依赖的就是这个库文件,换句话说,我们可以随时访问库中的代码和数据。
那么我们如何管理库中的代码和数据呢?先描述再组织,创建类似的管理线程TCB。
类比文件系统中的struct FILE, 在语言层面上使用的是struct FILE,底层使用的是struct file来管理;这里底层使用LWP管理,语言层面上使用TCB。不论是底层还是在语言层面,两者都是在库中管理。
使用C++多线程接口在Linux环境创建多线程
-
任何语言,在Linux中使用多线程编程,必须使用-pthread进行链接。
-
C++的thread库,底层有条件编译会判断当前的运行环境,执行适用于Linux或windows的多线程代码。
-
在Linux环境中,C++的多线程,本质就是对pthread库的封装。
#include<iostream>
#include<unistd.h>
#include<cstring>
#include<cstdio>
#include<string>
#include<pthread.h>
#include<thread>
using namespace std;
void run1()
{
while(true)
{
cout<< "thread 1" <<endl;
sleep(1);
}
}
void run2()
{
while(true)
{
cout<< "thread 2" <<endl;
sleep(1);
}
}
void run3()
{
while(true)
{
cout<< "thread 3" <<endl;
sleep(1);
}
}
int main()
{
thread th1(run1);
thread th2(run2);
thread th3(run3);
th1.join();
th2.join();
th3.join();
return 0;
}
深入理解线程id
线程id,pthread_t 就是一个地址数据,用来标识线程相关属性集合的起始地址
线程有独立的栈结构:
所有线程都要有自己独立的栈结构,主线程用的是进程系统栈,新线程用的是库中提供的栈
我们写一段代码,打印出线程的id来观察一下:
此时我们拿到的线程id其实是库中该线程对应属性集的起始地址,类比上面我们讲过: 定义一个变量采用基地址+偏移量的方式,我们拿到这个变量的地址是它的起始地址(低地址)。
运行结果:
线程局部存储
这样的一段代码
运行结果:
我们观察发现3个线程中,&cnt的结果不同,有一个疑问3个线程中cnt的地址应该一样吗?
不应该一样也绝对不一样,因为3个线程用的是不同的栈,cnt是被开辟在不同的栈当中的
如何理解cnt是被开辟在不同的栈当中的呢?
cpu中存在ebp和esp两个寄存器,只要更改ebp和esp就能切换线程的栈,3个线程中的栈也就是这样切换的
还是上面的代码,我们定义一个全局变量g_val, 在threadRoutine函数中对g_val++,并打印出g_val的值和地址
观察运行结果发现: 3个线程一起对g_val进行++操作,并且g_val的地址相同
现象: 全局变量在已初始化数据段开辟空间,并不属于线程的私有数据,所以被所有线程共享;多个线程对全局变量做修改时,他们的地址相同
我们在全局变量g_val前添加 __thread后,发现现象又正常了,g_val立马变成每个线程私有的了。
__thread: 构建每个线程的局部存储
发现地址比上面的大: 因为这次是映射到堆与栈之间,所以地址变大了
2.1 线程的创建 - pthread_create
PTHREAD_CREATE(3)
#include<pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
参数:1、thread:输出型参数,指向线程标识符的指针,线程创建成功后将通过此指针返回线程标识符。
2、attr:线程属性,包括线程的栈大小、调度策略、优先级等信息。如果为NULL,则使用默认属性。
3、start_routine:线程启动后要执行的函数指针。
4、arg:线程函数的参数,将传递给线程函数的第一个参数。
返回值:pthread_create()成功返回0。失败时返回错误号,*thread中的内容是未定义的。
运行结果:
我们可以看到: 同一个进程中的线程PID相同,但是LWP不同。主线程的LWP等于PID
问题: 那么主线程和新线程哪个先运行呢?不确定,由调度器决定
线程创建时参数传一个对象
结合和后面线程控制的几个接口
#include<iostream>
#include<pthread.h>
#include<unistd.h>
#include<string>
using namespace std;
#define NUM 10
enum
{
OK=0,
ERROR
};
class ThreadData
{
public:
ThreadData(const string&name,int id,time_t createTime,int top)
:_name(name)
,_id(id)
,_createTime((uint64_t)createTime)
,_status(OK)
,_top(top)
,_result(0)
{}
~ThreadData()
{}
public:
//输入的
string _name;
int _id;
uint64_t _createTime;
//返回的
int _status;
int _top;
int _result;
};
void*thread_run(void*args)
{
ThreadData*td = static_cast<ThreadData*>(args);
for(int i=1;i<=td->_top;++i)
{
td->_result+=i;
}
cout<<td->_name<<" cal done "<<endl;
// pthread_exit(td); 这个或者下面的return都可以
return td; //可以返回对象
}
int main()
{
pthread_t tids[NUM];
for(int i=0;i<NUM;++i)
{
char tname[64];
snprintf(tname,64,"thread-%d",i+1);
ThreadData*td=new ThreadData(tname,i+1,time(nullptr),100+i*5);
pthread_create(tids+i,nullptr,thread_run,td); //创建线程时可以传对象
sleep(1);
}
void*ret=nullptr;
for(int i=0;i<NUM;++i)
{
int n=pthread_join(tids[i],&ret); //获取每个线程等待的结果
if(n!=0) cerr<<"pthread_join error"<<endl;
ThreadData*td = static_cast<ThreadData*>(ret); //接收结果
if(td->_status==OK)
{
cout<<td->_name<<"计算的结果是: "<< td->_result<<" (它要计算的是[1, "<<td->_top<< "])" <<endl;
}
delete td;
}
cout<<"all thread quit...."<< endl;
return 0;
}
运行结果:
线程创建时参数传一个字符串
#include<iostream>
#include<pthread.h>
#include<unistd.h>
using namespace std;
#define NUM 10
void*threadRun(void*args)
{
const char*name=static_cast<const char*>(args);
int cnt=5;
while(cnt)
{
cout<< name << " is running: " << cnt--<<endl;
sleep(1);
}
pthread_exit((void*)11);
}
int main()
{
pthread_t tid;
pthread_create(&tid,nullptr,threadRun,(void*)"thread 1"); //字符串是只读的, 所以要强转
sleep(3);
void*ret=nullptr;
pthread_join(tid,&ret);
cout<<" new thread exit : "<<(int64_t)ret << endl;
return 0;
}
运行结果:
2.2 线程的等待 - pthread_join
PTHREAD_JOIN(3)
#include<pthread.h>
int pthread_join(pthread_t thread, void **retval);
thread:要等哪一个线程
retval:输出型参数,用于获取线程函数返回时的退出结果(回调函数返回值是void*,这里用void**接收这个返回值)
返回值:在成功时,pthread_join()返回0; 在错误时,它返回一个错误码。
为什么需要线程等待?类比进程子进程退出,父进程不回收,就会出现僵尸状态。
- 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。
- 创建新的线程不会复用刚才退出线程的地址空间。
创建10个新线程然后每个线程退出,主线程进行线程等待,获取每个线程的等待结果
#include<iostream>
#include<pthread.h>
#include<unistd.h>
using namespace std;
#define NUM 10
void*thread_run(void*args) //thread_run方法被重入了
{
char*name =(char*)args;
while(true)
{
cout<<"new thread running, my thread name is: "<< name <<endl;
sleep(4);
break;
}
delete name;
return nullptr;
}
int main()
{
pthread_t tids[NUM];
for(int i=0;i<NUM;++i)
{
//每次循环都是重新去new,每次都有新的堆空间,再去创建,地址肯定不相同,相当于给每一个线程创建了一份堆空间
char*tname=new char[64];
snprintf(tname,64,"thread-%d",i+1);
pthread_create(tids+i,nullptr,thread_run,tname);
//tname缓冲区是共享的, 传递的是缓冲区的起始地址
}
for(int i=0;i<NUM;++i)
{
int n=pthread_join(tids[i],nullptr); //获取每个线程等待的结果
if(n!=0) cerr<<"pthread_join error"<<endl;
}
cout<<"all thread quit...."<<endl;
return 0;
}
运行结果:
2.3 线程的终止
还是上面的代码,如果在break前添加exit, 让这个线程退出,会发生什么现象呢?
运行结果:
我们发现只创建了几个新线程,右边的监控脚本什么也没有,这是为什么呢?
exit是进程退出, 不是线程退出, 只要有任意一个进程调用exit, 整个进程(所有线程)全部退出! 还来不及等待线程呢,整个进程已经全部退出了。
如果需要只终止某个线程而不终止整个进程,可以有三种方法:
- 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。
- 线程可以调用pthread_ exit终止自己。
- 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。
2.3.1 线程函数return
2.3.2 线程终止 - pthread_exit
功能:线程终止
原型
void pthread_exit(void *value_ptr);
参数
value_ptr:value_ptr不要指向一个局部变量。
返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)
需要注意,pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。
还是线程等待部分的代码,在delete后面添加pthread_exit(nullptr);
运行结果:
也可以这样写:
pthread_exit(void*)
可以获取线程退出的结果
运行结果:
2.3.3 线程取消 - pthread_cancel
PTHREAD_CANCEL(3)
#include<pthread.h>
int pthread_cancel(pthread_t thread);
thread:要取消哪一个线程
返回值:在成功时,pthread_cancel ()返回0; 在出错时,它返回一个非零的错误码。
一个线程被取消, 退出码: -1
结合线程创建时参数传一个字符串部分的代码
运行结果:
2.4 获取线程自身的ID - pthread_self
PTHREAD_SELF(3)
#include <pthread.h>
pthread_t pthread_self(void);
返回值:此函数始终成功,返回调用线程的ID。
结合线程创建时参数传一个字符串部分的代码
运行结果:
2.5 线程的分离 - pthread_detach
- 默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
- 如果不关心线程的返回值,join是一种负担,这个时候我们可以告诉系统,当线程退出时,自动释放线程资源。
PTHREAD_DETACH(3)
#include <pthread.h>
int pthread_detach(pthread_t thread);
thread:线程ID
返回值:在成功时,pthread_detach()返回0; 在错误时,它返回一个错误码。
可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:
pthread_detach(pthread_self());
一个线程如果被分离,就无法再被join, 如果join会报错
错误示例1:
一个线程不能既是joinable又是分离的。
运行结果:
此代码中,新线程被创建出来,新线程去执行打印新消息,主线程继续向下执行,主线程去join时发现新线程是线程分离的,join立马失败,直接出错返回,打印错误消息,立马return了,主线程退出就是进程退出,即进程中所有线程退出,所以此时右边的监测什么也没有
错误示例2:
把pthread_detach放在新线程的执行函数里,有可能发生主线程已经在join处开始等待了,新线程才走到执行分离的代码,等新线程执行完回调函数内的代码时,主线程自然join等待成功了。这是错误写法。
运行结果: 仿佛像是主线程join成功
正确用法: 创建新线程成功时,由主线程进行分离
void*threadRoutine(void* args)
{
string name=static_cast<const char*>(args);
sleep(5);
return nullptr;
}
int main()
{
pthread_t tid;
pthread_create(&tid,nullptr, threadRoutine ,(void*)"thread 1");
pthread_detach(tid); //还是放在这里比较好
sleep(1);
return 0;
}
3. 线程互斥
3.1 举例 — 多线程中全局变量并发访问的问题
int g_val=100; //__thread修饰的全局变量: 构建每个线程的局部存储
void*threadRoutine(void* args) //这个函数是被重入的
{
string name=static_cast<const char*>(args);
int cnt=5; //3个线程每一个线程都有一个
while(cnt)
{
cout<< name << " g_val: " << g_val-- << ", &g_val: " << &g_val << endl;
sleep(1);
}
return nullptr;
}
int main()
{
pthread_t t1,t2,t3;
pthread_create(&t1,nullptr, threadRoutine ,(void*)"thread 1");
pthread_create(&t2,nullptr, threadRoutine ,(void*)"thread 2");
pthread_create(&t2,nullptr, threadRoutine ,(void*)"thread 3");
pthread_join(t1,nullptr);
pthread_join(t2,nullptr);
pthread_join(t3,nullptr);
return 0;
}
此代码运行结果和线程局部存储部分代码运行结果相似,3个线程一起对g_val进行–操作,并且g_val的地址相同
我们以两个线程threadA和threadB来模拟一下此过程
两个线程去访问g_val的数据,因为时间片调度的关系,threadB好不容易把g_val中的值修改成10了,threadB时间片到了,调度threadA时它又把g_val中的值改回99了
3.2 进程线程间的互斥相关背景概念
- 临界资源:多线程执行流共享的资源就叫做临界资源
- 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
- 原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
在上面3.1的例子中:
3.3 举例 — 多线程并发抢票
#include<iostream>
#include<unistd.h>
#include<cstring>
#include<cstdio>
#include<string>
#include<pthread.h>
using namespace std;
int tickets=10000; //临界资源
void*threadRoutine(void*name)
{
string tname=static_cast<const char*>(name);
while(true)
{
if(tickets>0) //临界区
{
usleep(2000); // 模拟抢票花费的时间, 单位是微秒
cout<< tname << " get a ticket: " << tickets-- << endl; //临界区
}
else
{
break;
}
}
return nullptr;
}
int main()
{
pthread_t t[4];
int n=sizeof(t)/sizeof(t[0]);
for(int i=0;i<n;++i)
{
char*data=new char[64];
snprintf(data,64, "thread-%d", i+1);
pthread_create(t+i,nullptr,threadRoutine,data);
}
for(int i=0;i<n;++i)
{
pthread_join(t[i],nullptr);
}
return 0;
}
运行结果:
发现票直接被抢到了负数,这就是出现了3.1中多线程并发抢票的问题
3.4 互斥量mutex
要解决以上问题,需要做到三点:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。
3.4.1 互斥量的接口
初始化互斥量
初始化互斥量有两种方法:
静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
动态分配
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t* restrict attr);
参数:
mutex:要初始化的互斥量
attr:NULL
销毁互斥量
销毁互斥量需要注意:
- 使用PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
- 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
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 时,可能会遇到以下情况:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
3.4.2 互斥量实现原理
为了实现互斥锁操作,**大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,**即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
3.5 加锁后的多线程抢票
3.5.1 在3.1中的代码下加锁
#include<iostream>
#include<unistd.h>
#include<cstring>
#include<cstdio>
#include<string>
#include<pthread.h>
#include<pthread.h>
using namespace std;
int tickets=10000; //临界资源
pthread_mutex_t mutex; //锁
void*threadRoutine(void*name)
{
string tname=static_cast<const char*>(name);
while(true)
{
pthread_mutex_lock(&mutex); //所有线程都要遵守这个规则
if(tickets>0) //临界区
{
usleep(2000); // 模拟抢票花费的时间, 单位是微秒
cout<< tname << " get a ticket: " << tickets-- << endl; //临界区
pthread_mutex_unlock(&mutex);
}
else
{
pthread_mutex_unlock(&mutex);
break;
}
//后面还有动作
usleep(1000); //充当抢完1张票, 后续动作
}
return nullptr;
}
int main()
{
pthread_mutex_init(&mutex,nullptr); //初始化
pthread_t t[4];
int n=sizeof(t)/sizeof(t[0]);
for(int i=0;i<n;++i)
{
char*data=new char[64];
snprintf(data,64, "thread-%d", i+1);
pthread_create(t+i,nullptr,threadRoutine,data);
}
for(int i=0;i<n;++i)
{
pthread_join(t[i],nullptr);
}
pthread_mutex_destroy(&mutex); //销毁
return 0;
}
运行结果:
加锁后发现抢票可以正常进行
3.5.2 优化加锁后的多线程抢票
在main函数中定义一把锁,让锁可以被所有的线程看到
#include<iostream>
#include<unistd.h>
#include<string.h>
#include<cstdio>
#include<string>
#include<pthread.h>
using namespace std;
// 细节:
// 1. 凡是访问同一个临界资源的线程, 都要进行加锁保护, 而且必须加同一把锁, 这是一个游戏规则, 不能有例外
// 2. 每一个线程访问临界资源之前, 都得加锁, 加锁本质是 给临界区加锁, 加锁的粒度尽量要细一些
// 3. 线程访问临界区的时候, 需要先加锁 -> 所有线程都必须要先看到同一把锁 -> 锁本身就是公共资源 -> 锁如何保证自己的安全? ->
// 加锁和解锁本身就是原子性的!
// 4. 临界区可以是一行代码, 可以是一批代码, a.线程可能被切换吗? 当然可能, 不要特殊化加锁和解锁, 还有临界区的代码
// b.切换会有影响吗? 不会, 因为在我不在期间, 任何人都没有办法进入临界区, 因为他无法成功的申请到锁! 因为锁被我拿走了
// 5. 这也正是体现互斥带来的串行化的表现, 站在其他线程的角度,
// 对其他线程有意义的状态就是: 1. 锁被我申请(持有锁) 2. 锁被我释放了(不持有锁), 原子性就体现在这里
// 6. 解锁的过程也被设计成为原子的
// 7. 锁 的 原理的理解
//临界资源
int tickets=1000; //全局变量, 共享对象
//线程创建传递的参数 --- 锁可以被所有线程看到
class TData
{
public:
TData(const string &name,pthread_mutex_t*mutex)
:_name(name)
,_pmutex(mutex)
{}
~TData()
{}
public:
string _name;
pthread_mutex_t* _pmutex; //互斥锁对应的指针
};
void*threadRoutine(void*args)
{
TData*td=static_cast<TData*>(args);
while(true)
{
pthread_mutex_lock(td->_pmutex); //加锁, 是一个让不让你通过的策略
if(tickets>0)
{
usleep(2000);
cout<<td->_name<<" get a ticket: "<< tickets-- << endl; //临界区
pthread_mutex_unlock(td->_pmutex);
}
else
{
pthread_mutex_unlock(td->_pmutex);
break;
}
// 我们抢完一张票的时候, 我们还要有后续的动作
usleep(13);
}
return nullptr;
}
int main()
{
pthread_mutex_t mutex;
pthread_mutex_init(&mutex,nullptr);
pthread_t tid[4];
int n=sizeof(tid)/sizeof(tid[0]);
for(int i=0;i<n;++i)
{
char name[64];
snprintf(name,64,"thread-%d", i+1);
TData*td=new TData(name, &mutex);
pthread_create(tid+i,nullptr,threadRoutine, td);
}
for(int i=0;i<n;++i)
{
pthread_join(tid[i],nullptr);
}
pthread_mutex_destroy(&mutex);
return 0;
}
线程申请到锁后,在临界区中被切换会有影响吗?
不会, 因为在我不在期间, 任何人都没有办法进入临界区, 因为他无法成功的申请到锁! 因为锁被我拿走了
类比举例:
在你的学校里有1间VIP自习室,它是一个单间自习室只允许一人自习,假如某一天你来的很早,早早地进入了自习室中,你为了防止别人进来拿到钥匙开门后,将门从里面反锁,并将钥匙装入自己的兜里,此时外面陆陆续续来人了,但是他们进不来;
学习2小时后,你突然想要去上厕所,这时你从自习室里面走出来,将门锁上把钥匙装进自己的兜里才去上厕所,即使外面站了很多人,在你上厕所这段时间里也无人能进入此间自习室,因为钥匙被你带走了。
3.6 封装原生线程库和锁
3.6.1 封装原生线程库
#include<iostream>
#include<string>
using namespace std;
class Thread
{
public:
typedef enum
{
NEW=0,
RUNNING,
EXITED
}ThreadStatus;
typedef void (*func_t)(void*); //函数指针, 参数是void*
Thread(int num, func_t func, void*args)
:_tid(0)
,_status(NEW)
,_func(func)
,_args(args)
{
char name[128];
snprintf(name,sizeof(name),"thread-%d",num);
_name=name;
}
int status() {return _status;}
string threadname() {return _name;}
pthread_t thread_id()
{
if(_status==RUNNING)
return _tid;
else
return 0;
}
// runHelper是不是类的成员函数, 而类的成员函数, 具有默认参数this, 需要static
// void*runHelper(Thread*this, void*args) , 而pthread_create要求传的参数必须是: void*的, 即参数不匹配
// 但是static会有新的问题: static成员函数, 无法直接访问类属性和其他成员函数
static void*runHelper(void*args)
{
Thread*ts=(Thread*)args; //就拿到了当前对象
// _func(_args);
(*ts)();
}
//仿函数
void operator()()
{
_func(_args);
}
void run()
{
int n=pthread_create(&_tid,nullptr,runHelper,this); //this: 是当前线程对象Thread
if(n!=0) exit(-1);
_status=RUNNING;
}
void join()
{
int n=pthread_join(_tid,nullptr);
if(n!=0)
{
cerr<<" main thread join thread "<< _name << " error "<<endl;
}
_status=EXITED;
}
~Thread()
{}
private:
pthread_t _tid;
string _name;
func_t _func; //线程未来要执行的回调
void*_args; //调用回调函数时的参数
ThreadStatus _status;
};
3.6.2 封装锁 - RAII风格的锁
#include<iostream>
#include<pthread.h>
using namespace std;
class Mutex //自己不维护锁,由外部传入
{
public:
Mutex(pthread_mutex_t* mutex)
:_pmutex(mutex)
{
}
void lock()
{
pthread_mutex_lock(_pmutex);
}
void unlock()
{
pthread_mutex_unlock(_pmutex);
}
~Mutex()
{}
private:
pthread_mutex_t* _pmutex; //锁的指针
};
class LockGuard //自己不维护锁,由外部传入
{
public:
LockGuard(pthread_mutex_t* mutex)
:_mutex(mutex)
{
_mutex.lock();
}
~LockGuard()
{
_mutex.unlock();
}
private:
Mutex _mutex; //锁的指针
};
3.6.3 使用自己的封装实现多线程抢票
#include<iostream>
#include<unistd.h>
#include<string.h>
#include<cstdio>
#include<string>
#include<pthread.h>
#include"thread.hpp"
#include"lockGuard.hpp"
using namespace std;
//临界资源
int tickets=1000; //全局变量, 共享对象
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; //这是我在外部定义的锁
void threadRoutine(void*args)
{
string message = static_cast<const char*>(args);
while(true)
{
{ // 定义的临时对象, 可以自动完成加锁和解锁
LockGuard lockguard(&mutex); //RAII风格的锁
if (tickets > 0)
{
usleep(2000);
cout << message << " get a ticket: " << tickets-- << endl; // 临界区
}
else
{
break;
}
}
// 我们抢完一张票的时候, 我们还要有后续的动作
usleep(13);
}
}
int main()
{
Thread t1(1, threadRoutine,(void*)"helloyj1");
Thread t2(2, threadRoutine,(void*)"helloyj2");
Thread t3(3, threadRoutine,(void*)"helloyj3");
Thread t4(4, threadRoutine,(void*)"helloyj4");
t1.run();
t2.run();
t3.run();
t4.run();
t1.join();
t2.join();
t3.join();
t4.join();
return 0;
}
运行结果: 多线程正常抢票
4. 可重入VS线程安全
4.1 概念
- 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
- 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
4.2 常见的线程不安全的情况
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
4.3 常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
4.4 常见不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
4.5 常见可重入的情况
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
4.6 可重入与线程安全联系
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
4.7 可重入与线程安全区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
5. 常见锁概念
学习逻辑:
多线程代码 => 并发访问临界资源 => 加锁 => 可能导致死锁 => 解决死锁问题
死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。
举例:
张三和李四是两个好朋友,张三比较壮,这天两个人手中分别持有5毛钱,两人相约去商店买棒棒糖,结果到了商店得知一根棒棒糖1元钱,两人各自的钱都不够,于是张三让李四将5毛钱给他,让他凑成1元钱买糖,李四不愿意;同样李四也想拿张三手里的钱买糖,张三也不愿意;于是两人谁也不愿意给对方自己的钱但是却想要对方的钱,两人就这样僵持下去。这样的状态就是一种死锁的状态。
一把锁也会出现死锁:重复申请一把锁。
死锁四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
避免死锁
核心思想: 破坏死锁的4个必要条件的任意一个
- 能不用锁就不要用锁
- 主动释放锁
- 按照顺序申请锁
- 控制线程统一释放锁
验证: 一个线程申请锁,一个线程释放锁
#include<iostream>
#include<pthread.h>
#include<unistd.h>
using namespace std;
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;
void*threadRoutine(void*args)
{
cout<<"I am a new thread"<<endl;
pthread_mutex_lock(&mutex);
cout<<"I got a mutex"<<endl;
pthread_mutex_lock(&mutex); //申请锁的问题, 它会停下来
cout<<"I alive again"<<endl;
return nullptr;
}
int main()
{
pthread_t tid;
pthread_create(&tid,nullptr, threadRoutine,nullptr);
sleep(3);
cout<<"main thread run begin"<<endl;
pthread_mutex_unlock(&mutex);
cout<<"main thread unlock..."<<endl;
sleep(3);
return 0;
}
运行结果:
6. 线程同步
6.0 引入
自习室故事2.0版本
还是那个熟悉的自习室,故事的前半段已经在 3.5.2 优化加锁后的多线程抢票 这部分讲过了,下面进入故事的后半段。
有一天你来的很早,早早地进入自习室自习。自习两小时后你突然很饿,想起自己没吃早饭,此时你打算离开自习室去吃饭,这次你要带走自己的东西并且锁好门把钥匙挂在墙上后离开。但是当你刚出了自习室把钥匙挂在墙上后,看到外面陆陆续续的人又后悔了想起自己吃完饭后可能得等很久才能进入自习室,于是你后悔了不吃饭了,因为你离墙近,其他同学离墙远,于是你快速拿起钥匙开门后又进入自习室自习,你刚自习两分钟又饿了想离开去吃饭,你又把钥匙去挂在墙上,刚把钥匙挂在墙上你又后悔了又拿下钥匙进入自习室去自习了。因此你在这一天早晨重复最多的动作就是: 锁门挂钥匙拿钥匙开门。你自己没有好好自习,其他同学也无法进入自习室自习。那么请问: 你错了吗? 没错,但是不合理。你频繁的锁门挂钥匙拿钥匙开门动作,你自己没有实质性自习的同时,其他同学也只能等你真正归还钥匙才能进入自习室自习。
校领导看到这样现象,专门给此自习室增加了2条规矩: 1. 自习完毕的人归还完钥匙不能立即申请 2. 在外面等的人必须排队。
回到线程同步部分,如果一个线程频繁的申请锁释放锁,释放锁后又申请锁,就会造成其他线程饥饿的问题。要在安全的规则下,多线程访问资源具有一定的顺序性,为了合理解决饥饿问题,我们提出了线程同步,让多线程进行协同工作。
6.1 同步概念与竞态条件
- 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步
- 竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解。
6.2 条件变量
- 当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
- 例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。
6.2.1 条件变量的函数
初始化
初始化条件变量有两种方法:
静态分配
使用PTHREAD_COND_INITIALIZER初始化的条件变量不需要销毁
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
动态分配
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_broadcast(pthread_cond_t *cond); //唤醒所有线程
int pthread_cond_signal(pthread_cond_t *cond); //唤醒一个线程
6.2.2 条件变量使用示例
允许多线程在cond队列中队列式等待(就是一种顺序)
#include<iostream>
#include<pthread.h>
#include<unistd.h>
#include<cstring>
#include<string>
using namespace std;
const int num=5;
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
void*active(void*args)
{
string name=static_cast<const char*>(args);
while(true)
{
pthread_mutex_lock(&mutex);
pthread_cond_wait(&cond,&mutex); // pthread_cond_wait, 调用的时候会自动释放锁, TODO
cout<<name<<" 活动 " <<endl;
pthread_mutex_unlock(&mutex);
}
}
int main()
{
pthread_t tids[num];
for(int i=0;i<num;++i)
{
char*name=new char[32];
snprintf(name,32,"thread-%d",i+1);
pthread_create(tids+i,nullptr,active,name);
}
sleep(3);
// 唤醒等待
while(true)
{
cout<<"main thread wakeup thread... " <<endl;
// pthread_cond_signal(&cond); //一个一个唤醒
pthread_cond_broadcast(&cond); //唤醒所有线程
sleep(1);
}
for(int i=0;i<num;++i)
{
pthread_join(tids[i],nullptr);
}
return 0;
}
pthread_cond_broadcast(&cond)
运行结果:
pthread_cond_signal(&cond)
运行结果:
7. 生产者消费者模型
7.0 引入
我们可以以去超市买东西为例来看待这个模型,在超市买东西时,其实是供货商向超市提供商品,消费者去超市购买商品,超市在这里是一种交易场所。以这个为例,在生产者和消费者模型中,生产者和消费者是两线程,超市是一种特定的缓冲区
321原则
cp问题思路 + 深入理解
既然交易场所是一种特定的缓冲区,那么:
- 交易场所必须先被所有线程看到(生产者和消费者线程)
- 注定了,交易场所一定会是一个被多线程并发访问的公共区域!
- 注定了,多线程一定要保护共享资源的安全
- 注定了,一定是在这种情况下,要自己维护线程互斥与同步的关系
为何要使用生产者消费者模型
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。
生产者消费者模型优点
解耦
支持并发
支持忙闲不均
7.1 基于BlockingQueue的生产者消费者模型
BlockingQueue
在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)
7.1.1 单生产单消费模型
(1)blockQueue.hpp
#include<iostream>
#include<pthread.h>
#include<time.h>
#include<queue>
#include<unistd.h>
#include<string>
using namespace std;
const int gcap=5;
// 核心思想:
// 队列为空,消费者不应该再消费, 要等待, 后面有数据还要唤醒
// 队列为满,生产者不应该再生产, 要等待, 后面有数据还要唤醒
// 所以两者都要有自己的条件变量
// 不要认为, 阻塞队列只能放整数字符串之类的, 也可以放对象
template<class T>
class BlockQueue
{
public:
BlockQueue(const int cap=gcap)
:_cap(cap)
{
pthread_mutex_init(&mutex,nullptr);
pthread_cond_init(&_customerCond,nullptr);
pthread_cond_init(&_producerCond,nullptr);
}
bool isFull()
{
return _q.size()==_cap;
}
bool isEmpty()
{
return _q.empty();
}
void push(const T&in)
{
pthread_mutex_lock(&mutex);
// 细节1: 一定要保证, 在任何时候, 都是符合条件, 才进行生产
while(isFull()) //1. 我们只能在临界区内部, 判断临界资源是否就绪! 注定了我们在当前一定是持有锁的!
{
//2. 要让线程休眠等待, 不能持有锁等待!
//3. 注定了, pthread_cond_wait要有锁的释放的能力!
pthread_cond_wait(&_producerCond,&mutex); //我休眠(切换)了, 我醒来的时候, 在哪里往后执行呢?
//4. 当线程醒来的时候, 注定了继续从临界区内部继续运行! 因为我是在临界区被切走的!
//5. 注定了当线程被唤醒的时候, 继续在pthread_cond_wait函数处向后运行, 又要重新申请锁, 申请成功才会彻底返回
}
// 没有满的, 要让他进行生产
_q.push(in);
// 加策略
// if(_q.size() >= _cap/2)
pthread_cond_signal(&_customerCond); //唤醒, 要去唤醒对方
pthread_mutex_unlock(&mutex);
//pthread_cond_signal(&_customerCond); //也可以放在后面
}
void pop(T*out)
{
pthread_mutex_lock(&mutex);
while(isEmpty())
{
pthread_cond_wait(&_customerCond,&mutex); //等待
}
*out=_q.front();
_q.pop();
pthread_cond_signal(&_producerCond); //唤醒
pthread_mutex_unlock(&mutex);
}
~BlockQueue()
{
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&_customerCond);
pthread_cond_destroy(&_producerCond);
}
private:
queue<T> _q;
int _cap; //队列容量上限, 队列空满两种情况都要考虑
// 为什么这份代码, 只用一把锁呢?根本原因在于,
// 我们生产和消费访问的是同一个queue && queue被当做整体使用
pthread_mutex_t mutex; //保证数据安全
pthread_cond_t _customerCond; //消费者对应的条件变量, 空, wait
pthread_cond_t _producerCond; //生产者对应的条件变量, 满, wait
};
(2)main.cc(阻塞队列放整数)
#include"blockQueue.hpp"
void*customer(void*args)
{
BlockQueue<int> *bq=static_cast<BlockQueue<int>*>(args);
while(true)
{
//现象: 开始消费者,消费的慢, 生产者一瞬间把阻塞队列全部打满, 后面消费一个, 生产一个
// sleep(1);
// 1. 将数据从blockqueue中获取 --- 获取到了数据
int data=0;
bq->pop(&data);
// 2. 结合某种业务逻辑处理数据 --- TODO
cout<<"customer data: "<<data<<endl;
}
}
void*producer(void*args)
{
BlockQueue<int> *bq=static_cast<BlockQueue<int>*>(args);
while(true)
{
sleep(1); //现象: 生产一个, 消费一个
// 1. 先通过某种渠道获取数据
int data=rand()%10+1;
// 2. 将数据推送到blockqueue中 --- 完成生产过程
bq->push(data);
cout<<"producer data: "<<data<<endl;
}
}
int main()
{
// 单生产和单消费 ---> 多生产和多消费
srand((uint64_t)time(nullptr)^getpid());
BlockQueue<int> *bq=new BlockQueue<int>();
pthread_t c,p;
// 让消费者和生产者看到同一个阻塞队列
pthread_create(&c,nullptr,customer,bq);
pthread_create(&p,nullptr,producer,bq);
pthread_join(c,nullptr);
pthread_join(p,nullptr);
delete bq;
return 0;
}
运行结果:
(3)task.hpp
#pragma once
#include<iostream>
#include<string>
class Task
{
public:
Task()
{
}
Task(int x,int y,char op)
:_x(x)
,_y(y)
,_op(op)
,_result(0)
,_exitCode(0)
{
}
void operator()()
{
switch(_op)
{
case '+':
_result=_x+_y;
break;
case '-':
_result=_x-_y;
break;
case '*':
_result=_x*_y;
break;
case '/':
{
if(_y==0)
_exitCode=-1;
else
_result=_x/_y;
}
break;
case '%':
{
if(_y==0)
_exitCode=-2;
else
_result=_x%_y;
}
break;
default:
break;
}
}
string formatArg()
{
return to_string(_x) + _op + to_string(_y) + "=";
}
string formatRes()
{
return to_string(_result) + "(" + to_string(_exitCode) + ")";
}
~Task()
{
}
private:
int _x;
int _y;
char _op;
int _result;
int _exitCode;
};
(4)main1.cc(阻塞队列放对象[任务])
#include"blockQueue.hpp"
#include"task.hpp"
void*customer(void*args)
{
BlockQueue<Task> *bq=static_cast<BlockQueue<Task>*>(args);
while(true)
{
Task t; //调用它的无参构造函数
// sleep(1); //现象: 开始消费者,消费的慢, 生产者一瞬间把阻塞队列全部打满, 后面消费一个, 生产一个
// 1. 将数据从blockqueue中获取 --- 获取到了数据
bq->pop(&t);
t(); //调用operator()仿函数, 处理任务
// 2. 结合某种业务逻辑处理数据 --- TODO
cout<<"customer data: "<<t.formatArg()<<t.formatRes()<<endl;
}
}
void*producer(void*args)
{
BlockQueue<Task> *bq=static_cast<BlockQueue<Task>*>(args);
string opers="+-*/%";
while(true)
{
sleep(1); //现象: 生产一个, 消费一个
// 1. 先通过某种渠道获取数据
int x=rand()%20+1;
int y=rand()%10+1;
char op=opers[rand() % opers.size()];
Task t(x,y,op);
// 2. 将数据推送到blockqueue中 --- 完成生产过程
bq->push(t);
cout<<"producer Task: "<<t.formatArg()<<"?"<<endl;
}
}
int main()
{
// 单生产和单消费 ---> 多生产和多消费
srand((uint64_t)time(nullptr)^getpid());
BlockQueue<Task> *bq=new BlockQueue<Task>();
pthread_t c,p;
// 让消费者和生产者看到同一个阻塞队列
pthread_create(&c,nullptr,customer,bq);
pthread_create(&p,nullptr,producer,bq);
pthread_join(c,nullptr);
pthread_join(p,nullptr);
delete bq;
return 0;
}
运行结果:
7.1.2 多生产多消费模型
blockQueue.hpp
和task.hpp
跟上面单生产单消费模型代码相同
main2.cc
#include"blockQueue.hpp"
#include"task.hpp"
void*customer(void*args)
{
BlockQueue<Task> *bq=static_cast<BlockQueue<Task>*>(args);
while(true)
{
Task t; //调用它的无参构造函数
sleep(1); //现象: 开始消费者,消费的慢, 生产者一瞬间把阻塞队列全部打满, 后面消费一个, 生产一个
// 1. 将数据从blockqueue中获取 --- 获取到了数据
bq->pop(&t);
t(); //调用operator()仿函数, 处理任务
// 2. 结合某种业务逻辑处理数据 --- TODO
cout<<pthread_self()<<" | customer data: "<<t.formatArg()<<t.formatRes()<<endl;
}
}
void*producer(void*args)
{
BlockQueue<Task> *bq=static_cast<BlockQueue<Task>*>(args);
string opers="+-*/%";
while(true)
{
sleep(1); //现象: 生产一个, 消费一个
// 1. 先通过某种渠道获取数据
int x=rand()%20+1;
int y=rand()%10+1;
char op=opers[rand() % opers.size()];
Task t(x,y,op);
// 2. 将数据推送到blockqueue中 --- 完成生产过程
bq->push(t);
cout<<pthread_self()<<" | producer Task: "<<t.formatArg()<<"?"<<endl;
}
}
int main()
{
//多生产和多消费
srand((uint64_t)time(nullptr) ^ getpid());
BlockQueue<Task> *bq=new BlockQueue<Task>();
pthread_t c[2],p[3];
// 让消费者和生产者看到同一个阻塞队列
pthread_create(&c[0],nullptr,customer,bq);
pthread_create(&c[1],nullptr,customer,bq);
pthread_create(&p[0],nullptr,producer,bq);
pthread_create(&p[1],nullptr,producer,bq);
pthread_create(&p[2],nullptr,producer,bq);
pthread_join(c[0],nullptr);
pthread_join(c[1],nullptr);
pthread_join(p[0],nullptr);
pthread_join(p[1],nullptr);
pthread_join(p[2],nullptr);
delete bq;
return 0;
}
运行结果:
7.2 生产者消费者模型真的高效吗?
大量的生产者、消费者全部在争夺同一把锁,也就是说,一次只能放一个线程去阻塞队列中完成任务,那效率不是非常慢?不是的
因为传统的线程运作方式会让大部分线程阻塞在临界区之外,而生产者消费者模型则是将任务的工序拆开,一组线程分为生产者,另一组分为消费者。充分利用了生产者的阻塞时间,用以提前准备好生产资源;同时也利用了消费者计算耗时的问题,让消费者线程将更多的时间花在计算上,而不是抢不到锁造成线程“干等”。
生产者消费者模型可以在生产前和消费后,让线程并行执行,减少线程阻塞时间。
8. POSIX信号量
8.1 概念理解
在之前进程间通信博客中,我们感性地认识了信号量。今天这部分内容是对信号量更深的理解,便于后续用信号量来实现基于环形队列的生产消费者模型。
以前:
信号量(信号灯): 本质就是一个计数器, 信号量需要进行PV操作,P == --, V =++, 是原子的;二元信号量 == 互斥锁
今天:
信号量是描述临界资源中资源数目的。
-
每一个线程,在访问对应资源的时候,先申请信号量,申请成功,表示该线程允许使该资源,申请不成功,目前无法使用该资源
-
信号量的工作机制:信号量机制类似于我们看电影买票,是一种资源的预订机制
-
信号量已经是资源的计数器了,申请信号量成功,本身就表明资源可用;申请信号量失败本身表明资源不可用 — 本质就是把判断转化成为信号量的申请行为
8.2 接口介绍
8.2.1 初始化信号量
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
参数:
pshared:0表示线程间共享,非零表示进程间共享
value:信号量初始值
8.2.2 销毁信号量
int sem_destroy(sem_t *sem);
8.2.3 等待信号量(P操作–)
功能:等待信号量,会将信号量的值减1
int sem_wait(sem_t *sem); //P()
8.2.4 发布信号量(V操作++)
功能:发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加1。
int sem_post(sem_t *sem);//V()
8.3 基于环形队列的生产消费者模型
8.3.1 准备工作
(1)环形队列介绍
环形队列采用数组模拟,用模运算来模拟环状特性。多预留一个空的位置,作为满或空的状态
(2)构建cp问题
-
生产者和消费者关心的"资源", 是一样的吗?
不一样,生产者关心空间,消费者关心数据 -
只要信号量不为0,表示资源可用,表示线程可以访问
-
环形队列只要我们访问不同的区域,生产和消费行为可以同时进行吗?可以
-
生产者和消费者什么时候会访问同一个区域?
- 他们两个刚开始,队列中没有数据的时候(空) — 此时指向同一个位置,存在竞争关系,生产者先运行
- 队列中全都是数据时(满) — 消费者先运行
综上:
- 只有为空和为满的时候,cp才会指向同一个位置
- 其他情况,cp可以并发运行
- 我们要保证整体规则,同时也要保证为空或为满的时候的策略问题:
(1) 队列为空,生产者先运行
(2) 队列为满,消费者先运行
(3) 不能让生产者套圈消费者
(4) 不能让消费者超过生产者
8.3.2 代码
(1) 单生产单消费模型
<1> RingQueue.hpp
#include<iostream>
#include<pthread.h>
#include<vector>
#include<time.h>
#include<sys/types.h>
#include<unistd.h>
#include<semaphore.h>
using namespace std;
// 生产者和消费者要有自己的下标来表征生产和消费要访问哪个资源
static const int N=5;
template<class T>
class RingQueue
{
private:
void P(sem_t &s)
{
sem_wait(&s);
}
void V(sem_t &s)
{
sem_post(&s);
}
public:
RingQueue(int num=N)
:_ring(num)
,_cap(num)
{
sem_init(&_data_sem,0,0);
sem_init(&_space_sem,0,num);
_c_step=_p_step=0;
}
void push(const T&in) // 对应生产者
{
// 生产 --- 先要申请信号量
// 信号量申请成功 - 则一定能访问临界资源
P(_space_sem);
// 一定要有对应的空间资源给我!不用做判断, 是哪一个资源给生产者呢
_ring[_p_step++]=in;
_p_step%=_cap;
V(_data_sem);
}
void pop(T*out) // 对应消费者
{
// 消费
P(_data_sem);
*out=_ring[_c_step++];
_c_step%=_cap;
V(_space_sem);
}
~RingQueue()
{
sem_destroy(&_data_sem);
sem_destroy(&_space_sem);
}
private:
vector<T> _ring;
int _cap; // 环形队列容器大小
sem_t _data_sem; // 只有消费者关心
sem_t _space_sem; // 只有生产者关心
int _c_step; // 消费位置
int _p_step; // 生产位置
};
<2> main.cc(环形队列放整数)
#include"ringQueue.hpp"
// 生产者不断生产, 消费者不断消费
void*consumerRoutine(void*args)
{
RingQueue<int>*rq=static_cast<RingQueue<int>*>(args);
while(true)
{
// sleep(1); //现象: 开始消费者,消费的慢, 生产者一瞬间把环形队列全部打满, 后面消费一个, 生产一个
// 1. 将数据RingQueue从中获取 --- 获取到了数据
int data=0;
rq->pop(&data);
// 2. 结合某种业务逻辑处理数据 --- TODO
cout << "consumer done: " << data << endl;
}
}
void*producerRoutine(void*args)
{
RingQueue<int>*rq=static_cast<RingQueue<int>*>(args);
while(true)
{
// sleep(1); //现象: 生产一个, 消费一个
// 1. 先通过某种渠道获取数据
int data = rand() % 10 + 1;
// 2. 将数据推送到RingQueue中 --- 完成生产过程
rq->push(data);
cout << "producer done: " << data << endl;
}
}
运行结果:
下面加上了sleep便于观察运行结果,去掉这2个sleep就可以看到并发场景
<3> task.hpp
#pragma once
#include<iostream>
#include<string>
#include<unistd.h>
#include<string.h>
using namespace std;
class Task
{
public:
Task()
{
}
Task(int x,int y,char op)
:_x(x)
,_y(y)
,_op(op)
,_result(0)
,_exitCode(0)
{
}
void operator()()
{
switch(_op)
{
case '+':
_result=_x+_y;
break;
case '-':
_result=_x-_y;
break;
case '*':
_result=_x*_y;
break;
case '/':
{
if(_y==0)
_exitCode=-1;
else
_result=_x/_y;
}
break;
case '%':
{
if(_y==0)
_exitCode=-2;
else
_result=_x%_y;
}
break;
default:
break;
}
usleep(10000); // 这是处理任务需要的时间
}
string formatArg()
{
return to_string(_x) + _op + to_string(_y) + "= ?";
}
string formatRes()
{
return to_string(_result) + "(" + to_string(_exitCode) + ")";
}
~Task()
{
}
private:
int _x;
int _y;
char _op;
int _result;
int _exitCode;
};
<4> main1.cc(环形队列放任务)
#include"ringQueue.hpp"
#include"task.hpp"
// 生产者不断生产, 消费者不断消费
string opers="+-*/%";
void*consumerRoutine(void*args)
{
RingQueue<Task>*rq=static_cast<RingQueue<Task>*>(args);
while(true)
{
// sleep(1); //现象: 开始消费者,消费的慢, 生产者一瞬间把环形队列全部打满, 后面消费一个, 生产一个
// 1. 将数据RingQueue从中获取 --- 获取到了数据
Task t;
rq->pop(&t); //把任务从共享区拿到自己的私有上下文
// 2. 结合某种业务逻辑处理数据 --- TODO
t(); // --- 仿函数处理任务
cout << "consumer done, 处理完成的任务是: " << t.formatRes() << endl;
}
}
void*producerRoutine(void*args)
{
RingQueue<Task>*rq=static_cast<RingQueue<Task>*>(args);
while(true)
{
sleep(1); //现象: 生产一个, 消费一个
// 1. 先通过某种渠道获取数据
int x = rand() % 100;
int y = rand() % 100;
// 2. 将数据推送到RingQueue中 --- 完成生产过程
char op=opers[(x+y)%opers.size()];
Task t(x,y,op);
rq->push(t);
cout << "producer done, 生产的任务是: " << t.formatArg() << endl;
}
}
int main()
{
srand(time(nullptr)^getpid());
RingQueue<Task>*rq=new RingQueue<Task>();
// 单生产单消费
pthread_t c,p;
// 让生产者和消费者看到同一个环形队列
pthread_create(&c,nullptr,consumerRoutine,rq);
pthread_create(&p,nullptr,producerRoutine,rq);
pthread_join(c,nullptr);
pthread_join(p,nullptr);
delete rq;
return 0;
}
运行结果:
(2) 多生产多消费模型
<0> 分析
- 在多生产多消费的模型下,需要加锁吗?需要
多生产多消费即创建多线程来完成,多生产多消费模型下,生产者和消费者对应的生产坐标和消费坐标只有一个,即便是多生产多消费在进入环形队列时只能一个生产者进入,一个消费者进入,即存在互斥关系,注定要加锁。
- 那么我们是先申请锁还是先申请信号量呢?先申请信号量(即先分配资源)
先申请锁: 即当前线程持有锁期间,其他线程只能在外部进行等待
先申请信号量: 即使某个线程持有锁,其他线程也可以进行资源的分配
<1> RingQueue.hpp
#include<iostream>
#include<pthread.h>
#include<vector>
#include<time.h>
#include<sys/types.h>
#include<unistd.h>
#include<semaphore.h>
#include<mutex>
using namespace std;
// 生产者和消费者要有自己的下标来表征生产和消费要访问哪个资源
static const int N=5;
template<class T>
class RingQueue
{
private:
void P(sem_t &s)
{
sem_wait(&s);
}
void V(sem_t &s)
{
sem_post(&s);
}
void Lock(pthread_mutex_t &m)
{
pthread_mutex_lock(&m);
}
void Unlock(pthread_mutex_t &m)
{
pthread_mutex_unlock(&m);
}
public:
RingQueue(int num=N)
:_ring(num)
,_cap(num)
{
sem_init(&_data_sem,0,0);
sem_init(&_space_sem,0,num);
_c_step=_p_step=0;
pthread_mutex_init(&_c_mutex,nullptr);
pthread_mutex_init(&_p_mutex,nullptr);
}
void push(const T&in) // 对应生产者
{
// 1.信号量的好处:
// 可以不用在临界区内部做判断, 就可以知道临界资源的使用情况
// 2.什么时候用锁, 什么时候用sem? --- 你对应的临界资源, 是否被整体使用!
// 生产 --- 先要申请信号量
// 信号量申请成功 - 则一定能访问临界资源
P(_space_sem);
Lock(_p_mutex);
// 一定要有对应的空间资源给我!不用做判断, 是哪一个资源给生产者呢
_ring[_p_step++]=in;
_p_step%=_cap;
V(_data_sem);
Unlock(_p_mutex);
}
void pop(T*out) // 对应消费者
{
// 消费
P(_data_sem); // 1. 先申请信号量是为了更高效
Lock(_c_mutex); // 2.
*out=_ring[_c_step++];
_c_step%=_cap;
V(_space_sem);
Unlock(_c_mutex);
}
~RingQueue()
{
sem_destroy(&_data_sem);
sem_destroy(&_space_sem);
pthread_mutex_destroy(&_c_mutex);
pthread_mutex_destroy(&_p_mutex);
}
private:
vector<T> _ring;
int _cap; // 环形队列容器大小
sem_t _data_sem; // 只有消费者关心
sem_t _space_sem; // 只有生产者关心
int _c_step; // 消费位置
int _p_step; // 生产位置
pthread_mutex_t _c_mutex;
pthread_mutex_t _p_mutex;
};
<2> main2.cc
#include"ringQueue1.hpp"
#include"task.hpp"
// 生产者不断生产, 消费者不断消费
string opers="+-*/%";
void*consumerRoutine(void*args)
{
RingQueue<Task>*rq=static_cast<RingQueue<Task>*>(args);
while(true)
{
// sleep(1); //现象: 开始消费者,消费的慢, 生产者一瞬间把环形队列全部打满, 后面消费一个, 生产一个
// 1. 将数据RingQueue从中获取 --- 获取到了数据
Task t;
rq->pop(&t); //把任务从共享区拿到自己的私有上下文
// 2. 结合某种业务逻辑处理数据 --- TODO
t(); // --- 仿函数处理任务
cout << "consumer done, 处理完成的任务是: " << t.formatRes() << endl;
}
}
void*producerRoutine(void*args)
{
RingQueue<Task>*rq=static_cast<RingQueue<Task>*>(args);
while(true)
{
sleep(1); //现象: 生产一个, 消费一个
// 1. 先通过某种渠道获取数据
int x = rand() % 100;
int y = rand() % 100;
// 2. 将数据推送到RingQueue中 --- 完成生产过程
char op=opers[(x+y)%opers.size()];
Task t(x,y,op);
rq->push(t);
cout << "producer done, 生产的任务是: " << t.formatArg() << endl;
}
}
int main()
{
srand(time(nullptr)^getpid());
RingQueue<Task>*rq=new RingQueue<Task>();
// 多生产多消费
// 意义在哪里呢?意义绝对不在从缓冲区中放入和拿取, 意义在于, 放前并发构建Task, 获取后多线程可以并发处理Task,
// 因为这些操作没有加锁
pthread_t c[3],p[2];
// 让生产者和消费者看到同一个环形队列
for(int i=0;i<3;++i)
pthread_create(c+i,nullptr,consumerRoutine,rq);
for(int i=0;i<2;++i)
pthread_create(p+i,nullptr,producerRoutine,rq);
for(int i=0;i<3;++i)
pthread_join(c[i],nullptr);
for(int i=0;i<2;++i)
pthread_join(p[i],nullptr);
delete rq;
return 0;
}
运行结果:
9. 线程池
9.1 线程池介绍
-
线程池:
一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。 -
线程池的应用场景:
- 需要大量的线程来完成任务,且完成任务的时间比较短。 WEB服务器完成网页请求这样的任务,使用线程池技术是非常合适的。因为单个任务小,而任务数量巨大,你可以想象一个热门网站的点击次数。 但对于长时间的任务,比如一个Telnet连接请求,线程池的优点就不明显了。因为Telnet会话时间比线程的创建时间大多了。
- 对性能要求苛刻的应用,比如要求服务器迅速响应客户请求。
- 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。突发性大量客户请求,在没有线程池情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,短时间内产生大量线程可能使内存到达极限,出现错误。
- 线程池示例:
- 创建固定数量线程池,循环从任务队列中获取任务对象,
- 获取到任务对象后,执行任务对象中的任务接口
9.2 线程池代码
9.2.1 v1版本
利用linux原生线程库实现一个简单的线程池, task.hpp的代码见8.3.2
(1)threadPool_v1.hpp
#include<iostream>
#include<memory>
#include<pthread.h>
#include<vector>
#include<queue>
#include<unistd.h>
using namespace std;
const static int N=5;
template<class T>
class threadPool
{
public:
void lockQueue()
{
pthread_mutex_lock(&_lock);
}
void unlockQueue()
{
pthread_mutex_unlock(&_lock);
}
void threadWait()
{
pthread_cond_wait(&_cond,&_lock);
}
void threadWakeup()
{
pthread_cond_signal(&_cond); // 唤醒在条件变量下等待的线程
}
bool isEmpty()
{
return _tasks.empty();
}
T popTask()
{
T t=_tasks.front();
_tasks.pop();
return t;
}
// 参数和返回值都是 void*, 但是类内成员函数存在this指针, 所以要添加static
// 但是静态方法无法使用类内的非静态成员, 即需要传入this指针
static void*threadRoutine(void*args)
{
// 线程分离, 让主线程不要关心他们
pthread_detach(pthread_self());
threadPool<T>*tp=static_cast<threadPool<T>*>(args);
while(true)
{
// 1. 检测有没有任务 --- 本质是看队列是否为空
// --- 本质就是在访问共享资源 --- 必定加锁
// 2. 有: 处理
// 3. 无: 等待
// 细节: 必定加锁
tp->lockQueue();
while(tp->isEmpty())
{
// 等待, 在条件变量下等待
tp->threadWait();
}
T t=tp->popTask(); // 把任务从公共区域拿到私有区域
tp->unlockQueue();
// for test
// 处理任务应不应该在临界区中处理, 不应该, 这是线程自己私有的事情
t();
cout<<"thread handler done, result: "<<t.formatRes()<<endl;
}
}
threadPool(int num=N)
:_num(num)
,_threads(num)
{
pthread_mutex_init(&_lock,nullptr);
pthread_cond_init(&_cond,nullptr);
}
void init()
{
// TODO
}
void start()
{
for(int i=0;i<_num;++i)
{
pthread_create(&_threads[i],nullptr,threadRoutine, this);
}
}
void pushTask(const T&t)
{
lockQueue();
_tasks.push(t);
threadWakeup();
unlockQueue();
}
~threadPool()
{
pthread_mutex_destroy(&_lock);
pthread_cond_destroy(&_cond);
}
private:
vector<pthread_t> _threads; // 表示一组线程
int _num; // 表示线程数量
queue<T> _tasks; // 表示一批任务 --- 使用stl自动扩容的特性
pthread_mutex_t _lock;
pthread_cond_t _cond;
};
(2)main1.cc
#include"threadPool_v1.hpp"
#include"task.hpp"
int main()
{
unique_ptr<threadPool<Task>> tp(new threadPool<Task>);
// unique_ptr<threadPool<Task>> tp(new threadPool<Task>(calback()));
tp->init();
tp->start();
// 充当生产者, 比如从网络中读取数据, 构建成任务, 推送给线程池
while(true)
{
int x,y;
char op;
cout<<"please Enter x>";
cin>>x;
cout<<"please Enter y>";
cin>>y;
cout<<"please Enter op(+-*/%)>";
cin>>op;
Task t(x,y,op);
tp->pushTask(t);
}
}
运行结果:
9.2.2 v2版本
在v1的基础上利用自己封装的thread.hpp来实现,代码见3.6.1
(1)threadPool_v2.hpp
#include<iostream>
#include<memory>
#include<vector>
#include<queue>
#include<unistd.h>
#include"thread.hpp"
using namespace std;
const static int N=5;
template<class T>
class threadPool
{
public:
void lockQueue()
{
pthread_mutex_lock(&_lock);
}
void unlockQueue()
{
pthread_mutex_unlock(&_lock);
}
void threadWait()
{
pthread_cond_wait(&_cond,&_lock);
}
void threadWakeup()
{
pthread_cond_signal(&_cond); // 唤醒在条件变量下等待的线程
}
bool isEmpty()
{
return _tasks.empty();
}
T popTask()
{
T t=_tasks.front();
_tasks.pop();
return t;
}
static void threadRoutine(void*args)
{
threadPool<T>*tp=static_cast<threadPool<T>*>(args);
while(true)
{
// 1. 检测有没有任务 --- 本质是看队列是否为空
// --- 本质就是在访问共享资源 --- 必定加锁
// 2. 有: 处理
// 3. 无: 等待
// 细节: 必定加锁
tp->lockQueue();
while(tp->isEmpty())
{
// 等待, 在条件变量下等待
tp->threadWait();
}
T t=tp->popTask(); // 把任务从公共区域拿到私有区域
tp->unlockQueue();
// for test
// 处理任务应不应该在临界区中处理, 不应该, 这是线程自己私有的事情
t();
cout<<"thread handler done, result: "<<t.formatRes()<<endl;
}
}
threadPool(int num=N)
:_num(num)
{
pthread_mutex_init(&_lock,nullptr);
pthread_cond_init(&_cond,nullptr);
}
void init()
{
for(int i=0;i<_num;++i)
{
_threads.push_back(Thread(i,threadRoutine,this));
}
}
void check()
{
for(auto&t:_threads)
{
cout<<t.threadname()<<" running..."<<endl;
}
}
void start()
{
for(auto&t:_threads)
{
t.run();
}
}
void pushTask(const T&t)
{
lockQueue();
_tasks.push(t);
threadWakeup();
unlockQueue();
}
~threadPool()
{
for(auto&t:_threads)
{
t.join();
}
pthread_mutex_destroy(&_lock);
pthread_cond_destroy(&_cond);
}
private:
vector<Thread> _threads; // 表示一组线程
int _num; // 表示线程数量
queue<T> _tasks; // 表示一批任务 --- 使用stl自动扩容的特性
pthread_mutex_t _lock;
pthread_cond_t _cond;
};
(2)main2.cc
#include"threadPool_v2.hpp"
#include"task.hpp"
int main()
{
unique_ptr<threadPool<Task>> tp(new threadPool<Task>);
// unique_ptr<threadPool<Task>> tp(new threadPool<Task>(calback()));
tp->init();
tp->start();
// 充当生产者, 比如从网络中读取数据, 构建成任务, 推送给线程池
while(true)
{
int x,y;
char op;
cout<<"please Enter x>";
cin>>x;
cout<<"please Enter y>";
cin>>y;
cout<<"please Enter op(+-*/%)>";
cin>>op;
Task t(x,y,op);
tp->pushTask(t);
}
}
运行结果:
9.2.3 v3版本
v3版本在v2版本的基础上利用RAll风格的锁来实现线程池,thread.hpp和lockGuard.hpp的代码看前面的3.6.1和3.6.2。
(1)threadPool_v3.hpp
#include<iostream>
#include<memory>
#include<vector>
#include<queue>
#include<unistd.h>
#include"thread.hpp"
#include"lockGuard.hpp"
using namespace std;
const static int N=5;
template<class T>
class threadPool
{
public:
pthread_mutex_t* getlock()
{
return &_lock;
}
void threadWait()
{
pthread_cond_wait(&_cond,&_lock);
}
void threadWakeup()
{
pthread_cond_signal(&_cond); // 唤醒在条件变量下等待的线程
}
bool isEmpty()
{
return _tasks.empty();
}
T popTask()
{
T t=_tasks.front();
_tasks.pop();
return t;
}
static void threadRoutine(void*args)
{
threadPool<T>*tp=static_cast<threadPool<T>*>(args);
while(true)
{
// 1. 检测有没有任务 --- 本质是看队列是否为空
// --- 本质就是在访问共享资源 --- 必定加锁
// 2. 有: 处理
// 3. 无: 等待
// 细节: 必定加锁
T t;
{
LockGuard lockguard(tp->getlock());
while (tp->isEmpty())
{
// 等待, 在条件变量下等待
tp->threadWait();
}
t = tp->popTask(); // 把任务从公共区域拿到私有区域
}
// for test
// 处理任务应不应该在临界区中处理, 不应该, 这是线程自己私有的事情
t();
cout<<"thread handler done, result: "<<t.formatRes()<<endl;
}
}
threadPool(int num=N)
:_num(num)
{
pthread_mutex_init(&_lock,nullptr);
pthread_cond_init(&_cond,nullptr);
}
void init()
{
for(int i=0;i<_num;++i)
{
_threads.push_back(Thread(i,threadRoutine,this));
}
}
void check()
{
for(auto&t:_threads)
{
cout<<t.threadname()<<" running..."<<endl;
}
}
void start()
{
for(auto&t:_threads)
{
t.run();
}
}
void pushTask(const T&t)
{
LockGuard lockguard(&_lock);
_tasks.push(t);
threadWakeup();
}
~threadPool()
{
for(auto&t:_threads)
{
t.join();
}
pthread_mutex_destroy(&_lock);
pthread_cond_destroy(&_cond);
}
private:
vector<Thread> _threads; // 表示一组线程
int _num; // 表示线程数量
queue<T> _tasks; // 表示一批任务 --- 使用stl自动扩容的特性
pthread_mutex_t _lock;
pthread_cond_t _cond;
};
(2)main3.cc
#include"threadPool_v3.hpp"
#include"task.hpp"
int main()
{
unique_ptr<threadPool<Task>> tp(new threadPool<Task>);
tp->init();
tp->start();
tp->check();
// 充当生产者, 比如从网络中读取数据, 构建成任务, 推送给线程池
while(true)
{
int x,y;
char op;
cout<<"please Enter x>";
cin>>x;
cout<<"please Enter y>";
cin>>y;
cout<<"please Enter op(+-*/%)>";
cin>>op;
Task t(x,y,op);
tp->pushTask(t);
}
}
运行结果:
9.2.4 v4版本(基于懒汉方式实现单例模式的线程池)
(0)单例模式介绍
单例模式的详细介绍请看: C++博客中智能指针篇
某些类, 只应该具有一个对象(实例), 就称之为单例。
在很多服务器开发场景中, 经常需要让服务器加载很多的数据 (上百G) 到内存中. 此时往往要用一个单例的类来管理这些数据。
饿汉实现方式和懒汉实现方式
吃完饭, 立刻洗碗, 这种就是饿汉方式. 因为下一顿吃的时候可以立刻拿着碗就能吃饭。
吃完饭, 先把碗放下, 然后下一顿饭用到这个碗了再洗碗, 就是懒汉方式。
懒汉方式最核心的思想是 “延时加载”. 从而能够优化服务器的启动速度。
v4版本是在v3版本的基础上完成了基于懒汉方式实现单例模式的线程池
(1)threadPool_v4.hpp
注意事项:
- 加锁解锁的位置
- 双重 if 判定, 避免不必要的锁竞争
#include<iostream>
#include<memory>
#include<vector>
#include<queue>
#include<unistd.h>
#include"thread.hpp"
#include"lockGuard.hpp"
using namespace std;
const static int N=5;
template<class T>
class threadPool
{
public:
pthread_mutex_t* getlock()
{
return &_lock;
}
void threadWait()
{
pthread_cond_wait(&_cond,&_lock);
}
void threadWakeup()
{
pthread_cond_signal(&_cond); // 唤醒在条件变量下等待的线程
}
bool isEmpty()
{
return _tasks.empty();
}
T popTask()
{
T t=_tasks.front();
_tasks.pop();
return t;
}
static void threadRoutine(void*args)
{
threadPool<T>*tp=static_cast<threadPool<T>*>(args);
while(true)
{
// 1. 检测有没有任务 --- 本质是看队列是否为空
// --- 本质就是在访问共享资源 --- 必定加锁
// 2. 有: 处理
// 3. 无: 等待
// 细节: 必定加锁
T t;
{
LockGuard lockguard(tp->getlock());
while (tp->isEmpty())
{
// 等待, 在条件变量下等待
tp->threadWait();
}
t = tp->popTask(); // 把任务从公共区域拿到私有区域
}
// for test
// 处理任务应不应该在临界区中处理, 不应该, 这是线程自己私有的事情
t();
cout<<"thread handler done, result: "<<t.formatRes()<<endl;
}
}
static threadPool<T> * getinstance()
{
if (instance == nullptr) // 为什么要这样? 提高效率, 减少加锁的次数
{
LockGuard lockguard(&instance_lock);
if (instance == nullptr)
{
instance = new threadPool<T>();
instance->init();
instance->start();
}
}
return instance;
}
void init()
{
for(int i=0;i<_num;++i)
{
_threads.push_back(Thread(i,threadRoutine,this));
}
}
void check()
{
for(auto&t:_threads)
{
cout<<t.threadname()<<" running..."<<endl;
}
}
void start()
{
for(auto&t:_threads)
{
t.run();
}
}
void pushTask(const T&t)
{
LockGuard lockguard(&_lock);
_tasks.push(t);
threadWakeup();
}
~threadPool()
{
for(auto&t:_threads)
{
t.join();
}
pthread_mutex_destroy(&_lock);
pthread_cond_destroy(&_cond);
}
private:
threadPool(int num=N)
:_num(num)
{
pthread_mutex_init(&_lock,nullptr);
pthread_cond_init(&_cond,nullptr);
}
threadPool(const threadPool<T>&tp)=delete;
void operator=(const threadPool<T>&tp)=delete;
private:
vector<Thread> _threads;
int _num;
queue<T> _tasks;
pthread_mutex_t _lock;
pthread_cond_t _cond;
static threadPool<T>*instance;
static pthread_mutex_t instance_lock;
};
template<class T>
threadPool<T> * threadPool<T>::instance=nullptr;
template<class T>
pthread_mutex_t threadPool<T>::instance_lock=PTHREAD_MUTEX_INITIALIZER;
(2)main4.cc
#include"threadPool_v4.hpp"
#include"task.hpp"
int main()
{
// threadPool<Task>::getinstance();
printf("0x%x\n",threadPool<Task>::getinstance());
printf("0x%x\n",threadPool<Task>::getinstance());
printf("0x%x\n",threadPool<Task>::getinstance());
printf("0x%x\n",threadPool<Task>::getinstance());
printf("0x%x\n",threadPool<Task>::getinstance());
// 充当生产者, 比如从网络中读取数据, 构建成任务, 推送给线程池
while(true)
{
int x,y;
char op;
cout<<"please Enter x>";
cin>>x;
cout<<"please Enter y>";
cin>>y;
cout<<"please Enter op(+-*/%)>";
cin>>op;
Task t(x,y,op);
threadPool<Task>::getinstance()->pushTask(t); //单例对象有可能在多线程场景中使用
}
}
运行结果:
10. STL,智能指针和线程安全
10.1 STL中的容器是否是线程安全的?
不是。
原因是, STL 的设计初衷是将性能挖掘到极致, 而一旦涉及到加锁保证线程安全, 会对性能造成巨大的影响。而且对于不同的容器, 加锁方式的不同, 性能可能也不同(例如hash表的锁表和锁桶)。因此 STL 默认不是线程安全。如果需要在多线程环境下使用, 往往需要调用者自行保证线程安全。
10.2 智能指针是否是线程安全的?
对于 unique_ptr, 由于只是在当前代码块范围内生效, 因此不涉及线程安全问题。
对于 shared_ptr, 多个对象需要共用一个引用计数变量, 所以会存在线程安全问题。但是标准库实现的时候考虑到了这个问题, 基于原子操作(CAS)的方式保证 shared_ptr 能够高效, 原子的操作引用计数。
11. 其他常见的各种锁(了解)
- 悲观锁:在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起。
- 乐观锁:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。
- CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。
- 自旋锁,公平锁,非公平锁?
自旋锁
关于自旋锁和挂起等待锁的选择根据具体应用场景
PTHREAD_SPIN_LOCK(3P)
#include <pthread.h>
int pthread_spin_lock(pthread_spinlock_t *lock);
int pthread_spin_trylock(pthread_spinlock_t *lock);
13. 读者写者问题
读写锁
在编写多线程的时候,有一种情况是十分常见的。那就是,有些公共数据修改的机会比较少。相比较改写,它们读的机会反而高的多。通常而言,在读的过程中,往往伴随着查找的操作,中间耗时很长。给这种代码段加锁,会极大地降低我们程序的效率。那么有没有一种方法,可以专门处理这种多读少写的情况呢? 有,那就是读写锁。
读写锁行为
当前所锁态 | 读锁行为 | 写锁请求 |
---|---|---|
无锁 | 可以 | 可以 |
读锁 | 可以 | 阻塞 |
写锁 | 阻塞 | 阻塞 |
读者写者问题就类似于我们以前在学校里面画的黑板报:
- 画黑板报的同学是写者
- 黑板报是共享的缓冲区,称为交易场所
- 看黑板报的同学称为读者
这里先限制一下: 画黑板报的同学在同一时刻,只能由一名同学画
角色之间的关系分析:
一个读者或多个读者: 一个线程或多个线程
一个写者或多个写者: 一个线程或多个线程
黑板报: 共享资源
- 写者与写者之间:互斥关系(竞争画黑板报)
- 读者与读者之间:没有关系(你读你的,我读我的, 互不影响(共同读同一块黑板报,不存在一个个排队看黑板报))
- 写者与读者之间:互斥 && 同步(互斥:写者在画黑板报,黑板报还未画完(未写完数据),此时不允许读者来看黑板报(此时数据不完全),如果进行读取,只会读到残缺的数据。当读者在读黑板报,不允许写者进行画黑板报(写数据),要不然读者数据都没读完就被你写者新写的数据给覆盖掉了。同步:写者写完数据,就要等待读者读取完数据;读者读完数据了,就要等待写者重新进行写入新的数据。)
321原则
3种关系:
- 1、写者和写者: 互斥关系;
- 2、读者和读者: 没有关系,谁都可以读;
- 3、读者和写者: 互斥关系与同步关系。
2种角色: 读者和写者
1个交易场所: 通常指的是内存中的一段共享缓冲区
为什么cp问题中,消费者之间是互斥关系,而rw问题中,读者之间没有关系?
cp问题中消费者会拿走数据,但是读者不会
读写锁的接口
定义一个读写锁
pthread_rwlock_t xxx
初始化读写锁
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,
const pthread_rwlockattr_t*restrict attr);
销毁读写锁
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
加锁和解锁
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);//读锁加锁
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);//写锁加锁
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);//解锁共用
设置读写优先
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 写者优先,但写者不能递归加锁
*/