前言
1. 用例子理解线程
例子:
我是很不愿意在我的文章中讲例子去模拟的,我觉得多此一举,但是这里使用例子可能更形象。
社会资源分配的基本实体——家庭
- 一个家庭中有多个人:老一辈有老一辈的保持身体健康的任务,壮年一辈有壮年一辈的养家糊口的任务,年轻一辈则需要努力学习的任务,整个家庭每个成员的生活方式各不相同,但是都在为了这个家庭,追求更幸福的生活。
- 一个家庭中只有一个人:这个家庭的这个人,为了幸福可能一方面要学习努力成才,另一方面还要为了自己的生存而工作。他这一个人要干几种任务,他也是为了追求他这个家庭的幸福生活。
家庭与家庭之间各不干涉,任务也会有所差异。
把线程和进程带入例子:
- 线程就是家庭的每一个成员,而进程就是家庭。线程是独立的执行流,就如同家庭的成员每个人都有自己的生活。线程 < 进程
- 我们前面学习的进程,只有一个执行流的进程,就像只有一个人的家庭。进程=线程
2. 进程和线程
线程:在操作系统学科中,被定义成进程内的执行单元,共享进程的资源,但是拥有独立的栈空间和寄存器上下文等。
在操作系统学科中,定义了线程和线程的作用,但是具体到某个操作系统的实现方式,还是具有一定的差异。
- 例如:在Windows系统中,对线程进行了具体的实现(tcb——thread ctrl block),进行了描述,组织,调度,相关匹配算法等等工作,还要和进程建立联系,这是一项庞大且复杂的工作。
- 而Linux在实现时,认为线程和进程是类似的,所以Linux内核选择就复用了进程的数据结构、管理算法来模拟线程。Linux内核中task_struct数据结构可以用来表示进程和线程,而并不是使用单独的数据结构来表示线程,简化了内核的实现和管理。
根据上面的理解,画图加深理解:
解释:在Linux中
- 线程在进程的内部执行,线程在进程地址空间内运行,执行流要执行,就要有资源,因为地址空间是进程的资源窗口。
- 线程的执行粒度比进程更细,因为线程执行进程代码的一部分。
进程和线程的关系:
一个进程可有有多个线程,即多个执行流,也可以就一个执行流,也就是单进程
3. 进程地址空间——页表
虚拟地址通过页表转化物理地址
一、线程概念
1. 重新定义线程和进程
线程:线程是操作系统调度的基本单位
进程:进程是承担分配系统资源的基本实体。执行流也是资源,所以线程是进程内部的执行流资源
线程共享进程的大部分资源,也拥有自己一部分的独立的数据:
- 一组寄存器(线程被调度)——线程对应的上下文数据。
- 栈——不会互相出现执行流错乱
- 线程ID、errno、信号屏蔽字和调度优先级等。
上下文数据和栈是线程的动态特性,也是最重要的两个字段
线程共享进程的大部分资源:
- 线程共享同一地址空间——代码段和数据段都是共享的。定义一个全局变量或者函数,所有的线程都能看到
- 文件描述符表——某个线程打开文件,其它线程也都能看到。
- …
注:线程目前分配资源,本质就是分配地址空间范围
2. Linux线程周边概念
线程——栈:
每个线程在执行过程中都会维护自己的栈帧,有自己独立的栈空间,线程的栈空间是独立的,这样可以避免不同线程之间的数据冲突。执行流是在栈上运行的,执行路径和顺序是通过栈帧之间的压栈、出栈操作来控制的。在线程库和线程ID一讲中还会介绍
线程时间片:
线程的调度是基于时间片分配的,并且线程在进程的时间片资源范围内进行调度执行,多个线程之间共享该进程被分配的CPU时间片资源,通过操作系统的调度器来控制各个线程的执行顺序和时间片分配
线程比进程更加轻量化:
- 创建和释放更加轻量化——线程创建不需要分配独立的地址空间,而是共享所属进程的地址空间和其它资源。线程释放只需要释放线程的用户栈和上下文数据,进程需要释放整个地址空间等资源
- 切换更加轻量化——线程之间切换,只需要保存和恢复线程的上下文数据。不需要重新cache数据,不需要切换地址空间等。
cache:CPU中缓存的热数据——被高频访问。查看命令cat /proc/cpuinfo
3. 线程的优缺点
优点:
- 线程创建和销毁更加轻量化
- 线程之间的切换更加轻量化
- 线程占的资源比进程少很多,大部分资源共用
- 充分利用多处理器的并行数量,IO操作时程序执行其它任务——进程也行
- 计算密集型应用,在多处理器系统中,可以将计算分解到多个线程实现。注意:单CPU,只创建一个线程就行了,一个线程最快,线程切换时间都省了
- IO密集型应用,线程可以同时等待不同的IO操作
计算密集型: 一般多使用CPU资源的。eg:加密、解密和压缩算法等
IO密集型: 拷贝、网络传输、网络通信等
缺点:
- 性能缺失: 计算密集型线程的数量比可用的处理器多,会增加额外的同步和调度开销。
- 缺乏访问控制,健壮性降低: 线程可以看到程序的大部分资源,调用函数等操作可能会影响整个进程
- 编程难度高
二、线程控制
- Linux内核中没有明确的线程概念,只有轻量级进程。这也就意味着系统只会给我们提供轻量级进程的系统调用,而不会直接提供线程的系统调用。
- Linux程序员就在应用层,对轻量级进程的接口进行封装,进而为用户提供线程的接口——pthread线程库(原生线程库——NPTL)
- pthread线程库:几乎所有的Linux平台,都默认自带这个库。并且编写多线程代码需要使用这个第三方库,所以链接这些库时要带
-lpthread
选项,引入头文件<pthread.h>
1. 线程创建
接口介绍:pthread_create —— 创建一个新线程
头文件:
#include <pthread.h>
函数声明:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
参数:
1. thread(输出型参数):线程创建成功,该指针指向的值被设置为新线程的ID带出来
2. attr:用于设置新线程的属性, 例如设置线程栈大小,优先级等。不需要设置,传nullptr
3. start_routine:指向一个函数的指针,这个函数是新线程的入口函数。线程的执行流会从这个函数开始。
4. arg:传递给新线程函数的参数
返回值:
1. 成功返回0,失败返回错误码,第一个参数内容未定义。
2. 直接把错误码当成返回值比读取线程中的errno变量开销更小
测试代码1:
#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;
void *start_routine(void *args)
{
while (true)
{
cout << "I am new thread! pid:" << getpid() << endl;
sleep(1);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, start_routine, nullptr);
while (true)
{
cout << "I am main thread! pid: " << getpid() << endl;
sleep(1);
}
return 0;
}
makefile:
makefile只展示一次,如果没有特别更改
thread_ctrl:thread_ctrl.cc
g++ -o $@ $^ -std=c++11 -lpthread
#因为使用的是第三方线程库,所以要携带-l选项
.PHONY:clean
clean:
rm thread_ctrl
运行结果:
注:
- 线程库编译
- 一个执行流是不可能执行两个死循环的,所以上面的代码肯定是两个执行流
- start_routine函数是线程执行流的入口函数
ps -aL
——查看OS中所有轻量级进程。L(light)
测试代码2: 全局变量、可重入函数和线程入口函数传参测试
#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;
// 定义一个全局变量
int g_val = 100;
//show函数被重入了
void show(const string &name)
{
printf("[%s], say# ", name.c_str());
}
void *start_routine(void *args)
{
int cnt = 0;
while (true)
{
const char *name = (const char *)args;
show(name);
printf("pid: %d, g_val:%d, &g_val:%p\n", getpid(), g_val, &g_val);
sleep(1);
cnt++;
if (cnt == 2)
break;
}
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, start_routine, (void *)"Thread 1");
//休眠1秒,让新线程先执行
sleep(1);
//改变全局变量
g_val += 20;
cout << endl << cout << "g_val modified in the main thread!" << endl << endl;
show("main thread");
printf("pid: %d, g_val:%d, &g_val:%p\n", getpid(), g_val, &g_val);
sleep(2);
return 0;
}
运行结果:
- show函数被主线程和新线程重入
- 任何一个执行流调用某些函数或者变量,都可能影响其它执行流(整个进程都被影响)
- 新线程传参测试,代码中我将
"Thread 1"
当参数传给线程的入口函数
测试代码3: 线程收到信号
#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;
void *start_routine(void *args)
{
while(true)
{
cout << "I am new thread!" << endl;
sleep(1);
int a = 10;
a /= 0;
}
}
int main()
{
pthread_t id;
pthread_create(&id, nullptr, start_routine, nullptr);
//保证新线程先运行
sleep(1);
while(true)
{
cout << "I am main thread!" << endl;
sleep(1);
}
return 0;
}
运行结果:
结论:任何一个线程被干掉了,那这个进程也就被干掉了。线程收到除0错误的信号崩溃了,所以进程也崩溃了。
2. 线程等待、退出和取消操作
1. 线程等待接口:
头文件:
#include <pthread.h>
函数声明:
int pthread_join(pthread_t thread, void **retval);
参数:
1. thread:要等待线程的线程ID
2. retval(输出型参数):用于存储被等待线程的返回值。
返回值:
成功返回0, 失败返回错误码
功能:阻塞,等待指定的线程执行完成,并获取其返回值
2. 线程终止接口:
头文件:
#include <pthread.h>
函数声明:
void pthread_exit(void *retval);
参数:
retval:线程的返回值,类型(void*)
功能:线程终止,并通过参数retval设置线程的返回值,要被等待
线程需要等待,就像父进程等待子进程,线程也会出现内存泄漏的情况,但是不会像子进程那样会显示僵尸状态,线程不会显示出来。
- 测试代码1:
#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;
void *start_routine(void *args)
{
int cnt = 2;
while (cnt--)
{
cout << "I am new thread!" << endl;
sleep(1);
}
// exit(11); //exit是用来终止进程的,不能用来终止线程
// return (void*)100; //走到这里,线程默认退出
pthread_exit((void *)100); // return 和 pthread_exit的效果是一样的
}
int main()
{
pthread_t id;
pthread_create(&id, nullptr, start_routine, nullptr);
// 保证新线程先运行
usleep(10000);
void *retval = nullptr;
pthread_join(id, &retval); // main thread等待的时候,默认阻塞等待
cout << "thread quit... reval:" << ((long long int)retval) << endl;
sleep(1);
return 0;
}
运行结果:
注:return 和 pthread_exit的效果是一样的,exit是用来终止进程的,不能用来终止线程
- 测试代码2:线程的返回值正常用法
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <string>
using namespace std;
// 请求
class Request
{
public:
Request(int start, int end, const string &threadname)
: _start(start), _end(end), _threadname(threadname)
{
}
int sum(int _result)
{
for (int i = _start; i <= _end; i++)
{
_result += i;
}
return _result;
}
public:
int _start;
int _end;
string _threadname;
};
// 结果
class Response
{
public:
Response(int result, int exitcode)
: _result(result), _exitcode(exitcode)
{
}
public:
int _result; // 计算结果
int _exitcode; // 计算是否正确
};
void *sumCount(void *args) // 参数是一个类对象
{
Request *rq = static_cast<Request *>(args); // 强转,相当于Request *rq = (Request)args;
Response *rsp = new Response(0, 0);
rsp->_result = rq->sum(rsp->_result);
delete rq;
return rsp; // 返回值也是一个类对象
}
int main()
{
pthread_t tid;
Request *rq = new Request(1, 100, "Thread 1");
pthread_create(&tid, nullptr, sumCount, rq); // 传的是对象地址
void *retval;
pthread_join(tid, &retval);
Response *rsp = static_cast<Response *>(retval); // 强转
cout << "result:" << rsp->_result << ", exitcode:" << rsp->_exitcode << endl;
delete rsp;
return 0;
}
运行结果:线程入口函数可以返回类对象指针
小结:
-
线程等待的必要性:已退出的线程其空间没有被释放,仍在地址空间中,新创建的线程不会复用刚退出的线程的地址空间
-
在学习进程等待的时候,进程退出有三种状态分别是结果正确、结果不正确和异常退出。在父进程等待时会判断子进程的退出状态,这里线程等待没有对异常进行判断而是只是获取返回值,是因为如果线程异常了,那进程也完蛋了
-
线程返回所指向的内存单元必须是全局的或者是堆申请的
-
测试代码3:父进程先退出,子进程变成孤儿进程,会不会继续执行?
#include <iostream>
#include <unistd.h>
using namespace std;
int main()
{
pid_t pid = fork();
if(pid == 0)
{
//child
while(true)
{
cout << "I am a child proc, pid:" << getpid() << endl;
sleep(1);
}
exit(0);
}
//father
sleep(1);
cout << "I am a father proc, pid: " << getpid() << endl;
return 0;
}
运行结果:
注:while :; do ps -axj | head -1; ps -axj | grep thread_ctrl | grep -v grep;sleep 1; done
shell脚本,循环查看thread_ctrl生成的进程信息,之前也介绍过
上面的测试中(父进程先退出,子进程变成孤儿进程,由1号进程领养,会继续执行)。那主线程先退出,子线程会不会继续执行?
- 测试代码4:
#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;
void *start_routine(void *args)
{
while (true)
{
cout << "I am new thread!" << endl;
sleep(1);
}
cout << "new thread quit..." << endl;
return nullptr;
}
int main()
{
pthread_t id;
pthread_create(&id, nullptr, start_routine, nullptr);
sleep(1);
cout << "main thread quit..." << endl;
//return 0;
pthread_exit(nullptr);
}
运行结果:
结论:
- 主线程使用
return 0
返回,则其余线程也直接终止。主线程使用pthread_exit(nullptr);
返回,则主线程变成僵尸状态,其余线程继续执行 - 结论:主线程要最后退出
while :; do ps -aL | head -1; ps -aL | grep thread_ctrl | grep -v grep;sleep 1; done
查看线程的运行状态
3. 线程取消接口:
头文件:
#include <pthread.h>
函数声明:
int pthread_cancel(pthread_t thread);
参数:
thread:要取消线程的线程ID
返回值:
成功返回0,失败返回错误码
功能:像指定的线程发送取消请求,被取消的线程可以选择在何时相应该请求并终止线程。
测试代码:
#include <iostream>
#include <cstdio>
#include <pthread.h>
#include <unistd.h>
using namespace std;
void* start_routine(void* args)
{
cout << "Thread is running" << endl;
// 循环执行一些工作,模拟线程执行任务
while (1) {
// 可被取消的工作
cout << "output log..." << endl;
sleep(1);
}
}
int main() {
pthread_t tid;
pthread_create(&tid, nullptr, start_routine, nullptr);
sleep(1);
// 发送线程发送取消请求
pthread_cancel(tid);
// 等待被取消的线程结束
void* retval;
pthread_join(tid, &retval);
if (retval == PTHREAD_CANCELED) {
printf("Thread canceled\n");
} else {
printf("Thread not canceled\n");
}
return 0;
}
运行结果:
注:这并不是常用接口,了解即可
pthread_setcancelstate
可以设置线程的取消类型,何时相应该请求- 被取消的线程返回的结果是
PTHREAD_CANCELED
(是个宏),本质是被强转的-1
3. 线程库和线程ID
线程获取ID接口:
头文件:
#include <pthread.h>
函数声明:
pthread_t pthread_self(void);
返回值:
总是成功,返回调用者线程ID
功能:获取当前线程唯一的线程ID
测试代码:
#include <iostream>
#include <string>
#include <pthread.h>
#include <unistd.h>
using namespace std;
std::string toHex(pthread_t tid)
{
char hex[64];
snprintf(hex, sizeof(hex), "%p", tid);
return hex;
}
void *startRoutine(void *args)
{
while(true)
{
cout << "thread id: " << toHex(pthread_self()) << endl;
sleep(1);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, startRoutine, (void*)"thread 1");
cout << "main thread create thead done, new thread id : " << toHex(tid) << endl;
pthread_join(tid, nullptr);
return 0;
}
运行结果:
观察:发现这个id是一个很大的数字,被转十六进制以后很像地址空间中栈区或者共享区那一部分的地址
线程ID:
注意区分线程ID和LWP:线程ID是用户视角下的线程标识,而LWP是操作系统视角下的线程标识
- 线程ID:为了用户管理和操作线程而赋予每个线程的唯一标识。通过上面的测试,其实线程ID就是虚拟地址空间中的一个地址,属于**NPTL线程库(原生线程库)**范畴。
- LWP:方便OS调度和管理线程而使用的内部唯一标识,属于内核级别的。
线程底层实现:
- 轻量级进程: 上文也说到,Linux内核中只有轻量级进程的概念,没有明确的线程概念,所以不会给我们用户直接提供线程的系统调用,只会提供轻量级进程的系统调用。
轻量级系统调用接口:clone
- 原生线程库: 用户需要的是线程,所以Linux程序员在应用层对轻量级进程的接口进行封装,实现了一个库(pthread线程库),为用户提供了线程的接口。
- 线程ID: 线程库是基于内存的,所以要加载。动态库加载到共享区(之前的博客介绍)。
线程的tcb对线程属性的描述,在OS中表现不出来,因为是在库中维护的,由库管理
- 线程栈:
- 线程栈:除了主线程,其它线程的独立栈,都在共享区,具体来讲是在原生线程库(pthread库)中——> tid指向的用户tcb中。
- 每个执行流的本质就是一个调用链。栈结构的本质是为了支持应用层整个调用链所对应的临时变量的空间开辟和释放,使用要有独立的栈结构,让自己的调用链不受干扰
- 线程有独立的栈结构,注意这里没有说私有的,因为本质都是在同一个地址空间,要想访问,通过编码也是可以的
- 线程的局部存储:
__thread
关键字修饰的内置类型变量将会在每个线程中创建一个独立的示例,每个变量都有该变量副本,互不影响。
注:__thread
是编译选项,不能用来修饰自定义类型
线程的局部储存演示:使用多进程演示
#include <iostream>
#include <vector>
#include <unistd.h>
#include <pthread.h>
using namespace std;
#define NUM 3
__thread int g_val = 100;
struct threadData
{
string threadname;
};
// __thread threadData s; //不能对不是内置变量的使用__thread修饰
void *start_routine(void *args)
{
int cnt = 3;
while(cnt--)
{
g_val++;
cout << "g_val:" << g_val << " , &g_val:" << &g_val << endl;
sleep(1);
}
return nullptr;
}
int main()
{
vector<pthread_t> tids;
//创建多线程
for(int i = 0; i < NUM; i++)
{
pthread_t tid;
pthread_create(&tid, nullptr, start_routine, nullptr);
tids.push_back(tid);
sleep(1);
}
for(int i = 0; i < NUM; i++)
{
int n = pthread_join(tids[i], nullptr);
}
return 0;
}
运行结果:
注:虽然定义的g_val是全局变量,但是因为被__thread
修饰,所以被每一个线程都私有一份。只能修饰内置类型,不能修饰自定义类型
4. 线程分离
- 默认情况下线程退出被等待(joinable)是线程的属性,所以就必须要等待,不然会导致内存泄漏。如果不关心线程的返回值,那就没必要等待,可以告诉OS,线程退出时,直接释放不需要等待。
- joinable和线程分离是线程的属性,两者是对立的,不能同时存在,也就是当我们把线程进行分离,则该线程就不需要等待
- 线程分离操作可以是组内其它线程对目标线程分离,也可以是自己分离
分离线程的接口:
头文件:
#include <pthread.h>
函数声明:
int pthread_detach(pthread_t thread);
参数:
thread:对该线程进行分离
返回值:
成功返回0,失败返回错误码
代码测试:
#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;
void *start_routine(void *args)
{
//线程自己分离
pthread_detach(pthread_self());
int cnt = 3;
while(cnt--)
{
cout << "new thread runing..." << endl;
sleep(1);
}
return nullptr;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, start_routine, nullptr);
//先让线程进行分离
sleep(1);
int ret = pthread_join(tid, nullptr);
if(ret == 0)
{
cout << "thread wait success!!!" << endl;
}
else
{
cout << "thread wait fail!!!" << endl;
}
return 0;
}
运行结果:
三、多线程
1. 互斥
①引入互斥
先上一份代码,根据代码进入概念
代码:模拟多线程抢票代码,其中票数设为全局变量
#include <iostream>
#include <vector>
#include <string>
#include <pthread.h>
#include <unistd.h>
using namespace std;
#define NUM 3 //定义的线程创建数
//描述线程的结构体,这里就定义了一个名字
class threadData
{
public:
threadData(int number)
{
threadname = "thread-" + to_string(number);
}
public:
string threadname;
};
int tickets = 100; // 票数 多线程中的共享资源
void *getTickets(void *args)
{
threadData *td = static_cast<threadData *>(args);
const char *name = td->threadname.c_str();
while(true)
{
if(tickets > 0)
{
usleep(1000);
printf("who=%s, get a ticket:%d\n", name, tickets);
tickets--;
}
else
break;
}
printf("%s ... quit\n", name);
return nullptr;
}
int main()
{
vector<pthread_t> tids;
vector<threadData *> thread_datas;
// 创建多线程
for (int i = 0; i < NUM; i++)
{
pthread_t tid;
threadData *td = new threadData(i);
thread_datas.push_back(td);
pthread_create(&tid, nullptr, getTickets, thread_datas[i]);
tids.push_back(tid);
}
for(const auto &wait : tids)
{
pthread_join(wait, nullptr);
}
for(const auto &td : thread_datas)
{
delete td;
}
return 0;
}
运行结果:
-
问题:
- 在讲信号量这一部分时说到,在语言层减一操作并不是安全的。因为转成汇编就是三条(也可能多条)汇编,进程在运行时也随时会被切换
tickets
(全局的)是我们上文定义的共享数据,出现了数据不一致问题。原因:无疑多线程并发访问。
-
解释:
tickets--
问题,图解:
注:objdump -S tickets
查看tickets的反汇编——取出tickets--
的汇编代码
- 运行结果错误原因:数据不一致
注:线程切换时机:线程切换 ——> 内核返回用户会检查时间片,到了就切换。
usleep期间,线程休眠进入内核态,usleep结束唤醒线程,返回用户态继续执行
②线程安全和可重入
概念
- 线程安全:多线程并发访问同一份代码,不会出现不同的结果
- 重入:同一个函数在被不同的执行流调用,当前执行流没有执行完,其它执行流再次进入。
可重入函数:一个函数在重入的情况下,运行结果不会出现任何不同或者问题
不可重入函数:函数被重入,运行结果出现不同或者问题。大部分都是不可重入函数
常见线程安全情况:
- 线程对共享资源,只有读取权限没有写入权限
- 类或者接口对于线程来说是原子性操作
- 多执行流切换,不会导致该接口的运行结果存在二义性
常见线程不安全情况:
- 不保护共享变量和静态变量的函数——调用线程不安全的函数
- 函数状态会被执行流调用影响
常见不可重入的情况:
- 调用malloc和free函数:因为malloc函数是用全局链表来管理堆的,多线程同时调用,可能导致分配出错,内存泄漏和覆盖等问题
- STL容器:STL容器使用动态内存分配、指针等来管理数据结构,多线程同时调用同一个容器进行读写操作,可能会导致数据结构破坏和不一致问题。STL容器在内部会使用全局变量或静态变量来维护一些数据结构,全局变量和静态变量可能导致竞态条件和数据不一致问题。
- 调用标准IO库函数:底层实现使用了全局的数据结构
- 使用静态数据结构的函数
常见可重入的情况:
- 不使用全局变量或静态变量
- 不使用malloc和new
- 使用本地数据,或者说把全局数据拷贝到本地使用
线程安全和可重入的联系
联系:
- 函数可重入,则线程安全
- 不可重入的函数,就不能多个线程并发访问
- 一个函数有全局变量或者静态变量那这个函数即是不可重入也是线程不安全
③互斥和周边概念
如何解决上述问题?
先引入一批周边概念:
临界资源:多线程执行流共享的资源(eg:受锁保护的资源)
临界区:每个线程内部,访问临界资源的代码。eg:
原子性:操作只有两态,要么完成,要么未完成。
共享变量:在线程间共享的变量
互斥: 任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,对临界资源有保护作用。简单点说,对共享数据的任何访问,保证任何时候只有一个执行流
解决问题的三个方面
- 代码必须要有互斥(下面讲)行为:当代码进入临界区执行时,不允许其它线程进入该临界区
- 多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只允许一个线程进入该临界区
- 如果线程不在临界区执行,该线程不能阻止其它线程进入临界区
要做到这三点,本质上就是需要一把锁——Linux中叫互斥量
④锁——互斥量
锁对应着上文的解决问题的三点,也就是锁的作用
加锁的本质: 用时间换安全
加锁的表现: 线程在临界区的代码串行执行
所以加锁原则: 尽量保证临界区的代码,越少越好
接口使用
1. 锁的初始化和销毁
库提供的数据类型:pthread_mutex_t
定义锁
初始化互斥量有两种方式:动态分配和静态分配
动态分配:
头文件:
#include <pthread.h>
函数声明:
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
参数:
1. restrict mutex:指向需要初始化的互斥锁变量的指针
2. restrict attr:设置互斥锁属性,一般设置nullptr
返回值:
成功返回0,错误返回错误码
///
静态分配:
头文件:
#include <pthread.h>
使用:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
- 动态分配:运行时通过内存分配函数从堆内存中分配的。需要锁的时候动态创建锁,不需要时释放锁——释放内存
- 静态分配:编译时就为锁变量分配固定的内存空间,通常放在栈内存或全局数据区,不需要手动释放内存,锁的生命周期和程序一样。
销毁互斥量:
头文件:
#include <pthread.h>
函数声明:
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数:
mutex:指向需要销毁的互斥锁变量的指针
返回值:
成功返回0,错误返回错误码
注:
- 不要销毁一个已经加锁的互斥量。
- 使用PTHREAD_MUTEX_INITIALIZER初始化的互斥量不需要销毁
- 已经销毁的互斥量,确保后面没有再次对该互斥量加锁
2. 加锁和解锁
头文件:
#include <pthread.h>
函数声明:
int pthread_mutex_lock(pthread_mutex_t *mutex); //加锁
int pthread_mutex_unlock(pthread_mutex_t *mutex); //解锁
参数:
mutex:指向要加锁的互斥锁
返回值:
成功返回0,失败返回错误码
加锁和解锁的位置:
3. 使用锁修改抢票代码
- 测试代码1:
#include <iostream>
#include <vector>
#include <string>
#include <pthread.h>
#include <unistd.h>
using namespace std;
#define NUM 3
//全局的锁——静态分配
// pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t lock;
class threadData
{
public:
threadData(int number)
{
threadname = "thread-" + to_string(number);
}
public:
string threadname;
};
int tickets = 100; // 票数
void *getTickets(void *args)
{
threadData *td = static_cast<threadData *>(args);
const char *name = td->threadname.c_str();
while(true)
{
//申请锁成功,才能往后继续执行,不成功挂起等待
pthread_mutex_lock(&lock);
if(tickets > 0)
{
usleep(1000);
printf("who=%s, get a ticket:%d\n", name, tickets);
tickets--;
pthread_mutex_unlock(&lock);
}
else
{
pthread_mutex_unlock(&lock);
break;
}
usleep(1000);
}
printf("%s ... quit\n", name);
return nullptr;
}
int main()
{
//动态分配
pthread_mutex_init(&lock, nullptr);
vector<pthread_t> tids;
vector<threadData *> thread_datas;
// 创建多线程
for (int i = 0; i < NUM; i++)
{
pthread_t tid;
threadData *td = new threadData(i);
thread_datas.push_back(td);
pthread_create(&tid, nullptr, getTickets, thread_datas[i]);
tids.push_back(tid);
}
for(const auto &wait : tids)
{
pthread_join(wait, nullptr);
}
for(const auto &td : thread_datas)
{
delete td;
}
pthread_mutex_destroy(&lock);
return 0;
}
运行结果:结果正常
注:如果刚解锁就立刻再去申请,可能导致整个票都是一个执行流抢的,使用我在释放完票之后就usleep(也可以当作抢完票之后,还需要和信息绑定什么的工作)。
原因:唤醒线程的成本比这边刚解锁那边就申请锁的成本更高,所以导致别的线程没有机会
- 测试代码2:把锁封装在线程类描述的结构体中
#include <iostream>
#include <vector>
#include <string>
#include <pthread.h>
#include <unistd.h>
using namespace std;
#define NUM 3
//全局的锁——静态分配
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
class threadData
{
public:
threadData(int number, pthread_mutex_t *mutex)
{
threadname = "thread-" + to_string(number);
lock = mutex;
}
public:
string threadname;
pthread_mutex_t *lock;
};
int tickets = 100; // 票数
void *getTickets(void *args)
{
threadData *td = static_cast<threadData *>(args);
const char *name = td->threadname.c_str();
while(true)
{
//申请锁成功,才能往后继续执行,不成功挂起等待
pthread_mutex_lock(td->lock);
if(tickets > 0)
{
usleep(1000);
printf("who=%s, get a ticket:%d\n", name, tickets);
tickets--;
pthread_mutex_unlock(td->lock);
}
else
{
pthread_mutex_unlock(td->lock);
break;
}
usleep(1000);
}
printf("%s ... quit\n", name);
return nullptr;
}
int main()
{
vector<pthread_t> tids;
vector<threadData *> thread_datas;
// 创建多线程
for (int i = 0; i < NUM; i++)
{
pthread_t tid;
threadData *td = new threadData(i, &lock);
thread_datas.push_back(td);
pthread_create(&tid, nullptr, getTickets, thread_datas[i]);
tids.push_back(tid);
}
for(const auto &wait : tids)
{
pthread_join(wait, nullptr);
}
for(const auto &td : thread_datas)
{
delete td;
}
return 0;
}
运行结果:正确
- 测试代码3:RAII风格
封装的一个类文件:LockGuard.hpp
#pragma once
#include <pthread.h>
class Mutex
{
public:
Mutex(pthread_mutex_t *lock)
: lock_(lock)
{}
void Lock()
{
pthread_mutex_lock(lock_);
}
void Unlock()
{
pthread_mutex_unlock(lock_);
}
~Mutex()
{}
private:
pthread_mutex_t *lock_;
};
class LockGuard
{
public:
LockGuard(pthread_mutex_t *lock)
:mutex_(lock)
{}
~LockGuard()
{
mutex_.Unlock();
}
private:
Mutex mutex_;
};
tickets.cc
#include <iostream>
#include <vector>
#include <string>
#include <pthread.h>
#include <unistd.h>
#include "LockGuard.hpp"
using namespace std;
#define NUM 3
//全局的锁——静态分配
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
class threadData
{
public:
threadData(int number)
{
threadname = "thread-" + to_string(number);
}
public:
string threadname;
};
int tickets = 100; // 票数
void *getTickets(void *args)
{
threadData *td = static_cast<threadData *>(args);
const char *name = td->threadname.c_str();
while(true)
{
//花括号框起来,花括号结束,类对象释放
{
LockGuard lockGuard(&lock);
if(tickets > 0)
{
usleep(1000);
printf("who=%s, get a ticket:%d\n", name, tickets);
tickets--;
}
else
{
break;
}
}
usleep(1000);
}
printf("%s ... quit\n", name);
return nullptr;
}
int main()
{
vector<pthread_t> tids;
vector<threadData *> thread_datas;
// 创建多线程
for (int i = 0; i < NUM; i++)
{
pthread_t tid;
threadData *td = new threadData(i);
thread_datas.push_back(td);
pthread_create(&tid, nullptr, getTickets, thread_datas[i]);
tids.push_back(tid);
}
for(const auto &wait : tids)
{
pthread_join(wait, nullptr);
}
for(const auto &td : thread_datas)
{
delete td;
}
return 0;
}
运行结果:
锁的原理
- 因为锁本身就是共享资源,所有就要保证申请和释放锁是原子性操作。
- 但是连
tickets--
这种自减操作都不是原子的,锁应该如何保证申请和释放都是原子的呢?但是自减操作被编译成的每一条汇编都是原子性的。- 为了实现互斥锁,大多数体系结构(X86…)都提供了swap或exchange指令,该指令的作用把寄存器和内存单元的数据做交换,由于只有一条指令所以是原子的。
- 注:在多处理器上,访问内存的总线周期也有前有后,所以一个处理器的交换(swap)指令执行时,另一个处理器的交换指令只能等待总线周期
内存总线: 处理器和内存之间传输数据和指令的通道
lock伪代码介绍:
模拟俩线程申请锁,图解:
unlock伪代码:解锁只需要把锁还回去,不需要做别的事,下次申请锁的时候,al寄存器会被赋值为0
小结
- 锁本身就是共享资源,所以申请锁和释放锁都被设计成原子性操作
- 一个持有锁访问临界区的线程,对于其它线程来说该线程访问临界区的过程是原子性的。
注:在临界区中,线程也是会被切换的,但是是持有锁被切走的,就算该线程不在,别的线程也没有能进入临界区访问临界资源的 - 一个线程持有锁之后,其它线程再访问锁就进入挂起阻塞状态。线程对于锁的竞争力不同,离锁更近的竞争力更强
- 纯互斥环境,如果锁的分配不够合理,就容易导致其它线程的饥饿问题。因为离锁近的竞争力更强。
- 让所有线程获取锁按照一定的顺序,按照一定的顺序获取资源就是同步
⑤死锁
概念:指在多线程和多进程环境中,各个进程(或线程)均占有不会释放的资源,但又互相申请被其它进程(或线程)所占用也不会释放的资源,而处于一种永久等待的状态。
一个锁也可能产生死锁:eg:线程(或进程)申请完锁再次申请该锁,本来锁就已经在这个执行流上了,在申请肯定也就申请不到了,就会导致死锁
死锁的四个必要条件:一前提+两原则+一重要
- 互斥条件(前提):一个资源每次只能被一个执行流使用
- 请求与保持条件(原则):一个执行流因为请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件(原则):一个执行流已获得的资源,在未使用完之前,不能强行剥夺
- 循环等待条件(重要):若干执行流之间形成一种头尾相接的循环等待资源的关系
避免死锁:破坏四个必要条件中的任意一个
- 破坏请求与保持条件:例如可以使用
pthread_mutex_trylock
和pthread_mutex_lock
功能一样,也是申请锁资源,申请成功返回0,但是失败直接返回错误码,而不是进行挂起等待,这时就可以释放自身的锁,满足别的执行流 - 破坏不剥夺条件:当线程(或进程)无法获得自己需要的资源,那就直接释放这个获得不了的资源,来满足当前执行流
- 破坏循环等待条件:按照一定的顺序进行加锁
- 资源一次性分配,就不需要等待其它资源,可以直接执行了
- 前提条件,因为有互斥环境才有的互斥条件,所以可以尝试上述几种方式。
2. 同步——条件变量
在对锁进行小结时也说到,如果一个线程对锁的竞争力很强,其它线程竞争不到锁,那么其它线程就处于饥饿状态。所以就需要线程获取锁按照一定的顺序——同步
①相关概念
1. 条件变量: 当一个线程互斥的访问某一个变量的时候,在其它线程改变状态之前,该线程什么都做不了,就如同上述饥饿状态的线程,这种情况就需要条件变量
2. 同步: 在保证数据安全的前提下,让线程按照某种特定的顺序访问临界资源,从而有效的避免饥饿问题
3. 静态条件: 因为时序问题,出现的结果异常
Linux中条件变量是一种常用的同步机制,条件变量需要依赖于锁的使用,保护共享资源的访问顺序
②代码展现
通过代码介绍该机制
介绍接口:
以下介绍的条件变量的接口,头文件和返回值一样,定义条件变量
头文件:
#include <pthread.h>
返回值:
成功返回0,失败返回错误码
定义条件变量:
pthread_cond_t:库提供的数据类型
1. 条件变量的初始化和销毁:和锁的接口很相像——简要介绍
条件变量初始化:动态分配
函数声明:
int pthread_cond_init(pthread_cond_t *restrict cond,
const pthread_condattr_t *restrict attr);
参数:
1. restrict cond:指向要初始化的条件变量
2. restrict attr:设置条件变量的属性,一般置nullptr
条件变量初始化:静态分配
使用:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
条件变量的销毁:
函数声明:
int pthread_cond_destroy(pthread_cond_t *cond);
参数:
cond:指向要销毁的条件变量
2. 等待条件满足
导致线程等待条件变量,线程等待时自动释放锁,并在接收到唤醒信号时,重新获取锁并继续执行
函数声明:
int pthread_cond_wait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex);
参数:
restrict cond:指向一个条件变量,线程在这个条件变量上等待
restrict mutex:指向锁,用来释放和获取锁。注:线程不能带着锁进行等待,别的线程就没办法进来等待了
3. 唤醒等待线程
发送唤醒信号,通知等待的线程
函数声明:
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
参数:
cond:指向一个条件变量
作用:
1. pthread_cond_broadcast:发送信号通知一个正在等待条件变量的线程,只唤醒一个
2. pthread_cond_signal:发送信号通知所有正在等待条件变量的线程,全部唤醒
代码展现:
#include <iostream>
#include <unistd.h>
#include <pthread.h>
int g_val = 0; //共享资源
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
void *start_routine(void *args)
{
pthread_detach(pthread_self()); //直接分离线程,不进行等待
//注:传参的时候,我传的不是i的地址,而是强转直接拷贝的形式,因为如果主线程一瞬间跑完创建五个线程
//当新创建的线程刚想要拿这个参数时,会发现这个i是5。因为传的是地址,所以导致数据不一致问题
uint64_t number = (uint64_t)args;
std::cout << "pthread-" << number << " create success!!!" << std::endl;
while(true)
{
pthread_mutex_lock(&mutex);
//这里仅仅只是为了说明为什么 pthread_cond_wait 要在pthread_mutex_lock和pthread_mutex_unlock中间
// //假如我们规定共享资源g_val大于10,小于20这个区间时,只能由主线程访问,别的线程只能进行等待
// if(g_val > 10 && g_val < 20)
// {
// //让线程进行等待,肯定是因为某种条件不满足。这里临界资源g_val不满足条件,所以要进行等待。
// // but判断也是访问临界资源,所以判断必须在加锁之后
// pthread_cond_wait(&cond, &mutex); //线程等待的时候会自动释放锁,被唤醒时会再持有锁
// }
pthread_cond_wait(&cond, &mutex);
std::cout << "pthread-" << number << ", g_val:" << g_val++ << std::endl;
pthread_mutex_unlock(&mutex);
}
}
int main()
{
//创建五个线程
for(uint64_t i = 0; i < 5; i++) //unsigned long int ——> uint64_t
{
pthread_t tid;
pthread_create(&tid, nullptr, start_routine, (void*)i);
usleep(1000);
}
sleep(2);
std::cout << "main thread begin ctrl:" << std::endl;
while (true)
{
sleep(1);
pthread_cond_signal(&cond); //唤醒在cond中等待的线程,默认时对第一个
std::cout << "signal one thread..." << std::endl;
}
return 0;
}
运行结果:
因为我在创建线程的时候使用了usleep
所以是按照顺序创建的,也可以依次性创建,但是本质线程都可以按照某种特定的顺序访问临界资源。
有很多文字叙述就直接在代码中,借助代码描述了
3. 生产消费者模型
①理论
生产消费者模型的优点:
1. 解耦
2. 支持并发
3. 支持忙闲不均
②基于阻塞队列的实现
阻塞队列:在介绍生产消费者模型理论中有个仓库(特定结构的内存空间),其中阻塞队列是多线程编程常用于实现生产消费者模型的数据结构(特定结构的内存空间)。
理论:当阻塞队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素。当队列为满时,往队列生产元素的操作也会被阻塞,直到有元素被从队列中取出。
实现: 单线程环境 包含了四个文件BlcokQueue.hpp Task.hpp makefile main.cc
注:C++程序的源文件可以以.cc/.cxx和.cpp为后缀,.hpp后缀代表这个文件声明和定义放在一起
BlcokQueue.hpp:
#pragma once
#include <iostream>
#include <queue>
#include <pthread.h>
template<class T>
class BlockQueue
{
public:
BlockQueue(int maxcap = defaultnum)
:_maxcap(maxcap)
{
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_c_cond, nullptr);
pthread_cond_init(&_p_cond, nullptr);
}
T pop()
{
pthread_mutex_lock(&_mutex);
//判断:判断临界资源条件是否满足,也是访问临界资源。所以要在加锁之后
//while:防止线程被伪唤醒的情况
while(_q.size() == 0)
{
//伪唤醒:比如说那边生产者把队列生产满了,就要唤醒消费者线程进行消费,
//但是一不小心多唤醒了消费者线程,但是实际只有一个线程能再持有锁,然后执行
//其它被唤醒的线程就被阻塞在这里,当持有锁的线程释放了锁,而在这里等待锁的线程
//抢到了锁资源,那就不会再去执行上面临界资源是否满足的判断条件,可能就会出现问题
//所以就需要循环判断
pthread_cond_wait(&_c_cond, &_mutex);
}
//走到这里,要么线程被唤醒了,要么队列不为空,可以直接访问
T out = _q.front();
_q.pop();
//消费数据了,所以可以有空位置可以唤醒生产者生产数据了
pthread_cond_signal(&_p_cond);
pthread_mutex_unlock(&_mutex);
return out;
}
void push(const T &in)
{
pthread_mutex_lock(&_mutex);
//while:防止线程被伪唤醒的情况,上面注释对伪唤醒做了介绍
while(_q.size() == _maxcap)
{
pthread_cond_wait(&_p_cond, &_mutex);
}
//走到这里,要么线程被唤醒了,要么队列不满,可以直接访问
_q.push(in);
//生产数据了,所以可以有数据被消费,可以唤醒消费者消费数据了
pthread_cond_signal(&_c_cond);
pthread_mutex_unlock(&_mutex);
}
~BlockQueue()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_c_cond);
pthread_cond_destroy(&_p_cond);
}
private:
static const int defaultnum;
private:
std::queue<T> _q;
int _maxcap; //阻塞队列最大容量
pthread_mutex_t _mutex;
pthread_cond_t _c_cond; //消费者条件变量
pthread_cond_t _p_cond; //生产者条件变量
};
template<class T>
const int BlockQueue<T>::defaultnum = 20;
Task.hpp:
#pragma once
#include <iostream>
#include <string>
std::string opers = "+-*/%";
enum //errno
{
DIV_ZERO_ERR=1,
MOD_ZERO_ERR,
UNKNOW_ERR
};
//任务结构体
class Task
{
public:
Task(int x, int y, char oper)
:_data1(x),_data2(y),_oper(oper),_result(0), _exitcode(0)
{}
void run()
{
switch(_oper)
{
case '+':
_result = _data1 + _data2;
break;
case '-':
_result = _data1 - _data2;
break;
case '*':
_result = _data1 * _data2;
break;
case '/':
{
if(_data2 == 0)
_exitcode = DIV_ZERO_ERR;
else
_result = _data1 / _data2;
}
break;
case '%':
{
if(_data2 == 0)
_exitcode = MOD_ZERO_ERR;
else
_result = _data1 % _data2;
}
break;
default:
_exitcode = UNKNOW_ERR;
break;
}
}
//仿函数 —— 用着玩,以防忘记
void operator()()
{
run();
}
std::string GetResult()
{
std::string r = std::to_string(_data1);
r += _oper;
r += std::to_string(_data2);
r += '=';
r += std::to_string(_result);
r += ", [code:";
r += std::to_string(_exitcode);
r += "]";
return r;
}
std::string GetTask()
{
std::string r = std::to_string(_data1);
r += _oper;
r += std::to_string(_data2);
r += "=?";
return r;
}
~Task()
{}
private:
int _data1;
int _data2;
char _oper;
int _result;
int _exitcode;
};
makefile:
blockQueue:main.cc
g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:
rm blockQueue
main.cc:
#include "BlockQueue.hpp"
#include "Task.hpp"
#include <ctime>
#include <unistd.h>
void *consumer(void *args)
{
BlockQueue<Task> *bq = static_cast<BlockQueue<Task> *>(args);
while(true)
{
//获取任务 + 处理任务
Task t = bq->pop();
t();
std::cout << "处理任务:" << t.GetResult() << ", thread_id is" << pthread_self() << std::endl;
}
}
void *Producer(void *args)
{
BlockQueue<Task> *bq = static_cast<BlockQueue<Task> *>(args);
int len = opers.size();
while(true)
{
//生产数据
int data1 = rand() % 10 + 1; //范围[1, 10]
usleep(1000);
int data2 = rand() % 10; //范围[0, 9]
char op = opers[rand() % len];
Task t(data1, data2, op);
// 传递
bq->push(t);
std::cout << "生产了一个任务:" << t.GetTask() << ", thread_id is" << pthread_self() << std::endl;
sleep(1);
}
}
int main()
{
srand(time(nullptr));
BlockQueue<Task> *bq = new BlockQueue<Task>();
pthread_t c, p;
pthread_create(&p, nullptr, Producer, bq);
pthread_create(&c, nullptr, consumer, bq);
pthread_join(p, nullptr);
pthread_join(c, nullptr);
delete bq;
return 0;
}
运行结果:
实现: 多线程环境 上面四个文件只需要更改main.cc
。
int main()
{
srand(time(nullptr));
BlockQueue<Task> *bq = new BlockQueue<Task>();
pthread_t c[3], p[5];
for (int i = 0; i < 3; i++)
{
pthread_create(c + i, nullptr, consumer, bq);
}
for (int i = 0; i < 5; i++)
{
pthread_create(p + i, nullptr, Producer, bq);
}
for (int i = 0; i < 3; i++)
{
pthread_join(c[i], nullptr);
}
for (int i = 0; i < 5; i++)
{
pthread_join(p[i], nullptr);
}
delete bq;
return 0;
}
运行结果:
注:
- 伪唤醒:多线程环境比如说那边生产者把队列生产满了,就要唤醒消费者线程进行消费,但是一不小心唤醒了多个消费者线程,但是实际只有一个线程能再持有锁然后执行,其它被唤醒的线程就被阻塞在这里,当持有锁的线程释放了锁,而在这里等待锁的线程如果抢到了锁资源,那就不会再去执行上面临界资源是否满足的判断条件,可能就会出现问题。所以就需要循环判断。我在BlcokQueue.hpp文件中会引发伪唤醒的地方进行的标注。
- 发现:多线程和单线程的生产消费模型,就修改了创建线程和等待线程的部分,原因是因为就是用了一把互斥锁,三种关系中都有互斥关系,所以每次只允许一个线程访问共享内存。
③基于环形队列的实现
POSIX信号量
在前面我们讲到了System V信号量,其和POSIX信号量的功能作用和原理是相同的,都是用于同步操作,达到无冲突访问共享资源的目的。这里主要介绍POSIX信号量的接口
信号量本质是一把计数器,计数器本质就是描述资源的数量。申请信号量时,就间接对共享资源做判断了。P()操作成功,所访问的资源一定是就绪的,P()操作失败就在信号量等待
信号量和锁:
- 锁:加锁——持有锁就代表这个资源可以访问,但是只允许一个执行流访问,因为这个资源只能看作一份。上文的例子中,一般在持有锁时还要对条件变量判断一次,然后在执行下面的内容,那是因为使用这一份资源是有条件的,如果仅仅是对这份资源进行只读操作,自然不需要判断
- 信号量:二元信号量就是锁
接口介绍:
POSIX信号量提供的一套接口头文件和返回值相同。使用时Link with -pthread
头文件:
#include <semaphore.h>
返回值:
成功返回0,失败返回-1。设置错误码
1. 初始化和销毁信号量
函数声明:
int sem_init(sem_t *sem, int pshared, unsigned int value); //初始化信号量
int sem_destroy(sem_t *sem); //销毁信号量
参数:
1. sem:指向信号量的指针
2. pshared:0表示线程间共享,非0表示进程间共享
3. value:用于初始化信号量的值(用户设置)
///
2. 等待和发布信号量
函数声明:
int sem_wait(sem_t *sem); //等待信号量 ——> P()操作 将信号量的值减一
int sem_post(sem_t *sem); //发布信号量 ——> V()操作 将信号量的值加一
参数:
sem:指向信号量的指针
出错时:信号量的值不改变
代码实现
环形队列: 对循环队列有疑问的建议看一下我的这篇用C实现的简单环形队列(这篇内容使用链表进行画图分析,底层实现使用的是数组实现)
环形队列和消费者生产者结合分析:
代码实现: 包含了四个文件Main.cxx Makefile RingQueue.hpp Task.hpp
其中Task.hpp和上文中的阻塞队列代码实现是一样的,就不贴上来了
RingQueue.hpp:
#pragma once
#include <iostream>
#include <vector>
#include <string>
#include <pthread.h>
#include <unistd.h>
#include <semaphore.h>
#include <ctime>
const static int defaultcap = 5;
template <class T>
class RingQueue
{
public:
RingQueue(int cap = defaultcap)
: _ringqueue(cap), _cap(cap), _c_step(0), _p_step(0)
{
sem_init(&_cdata_sem, 0, 0);
sem_init(&_pspace_sem, 0, cap);
pthread_mutex_init(&_c_mutex, nullptr);
pthread_mutex_init(&_p_mutex, nullptr);
}
// 输入
void Push(const T &in)
{
//方案1
// Lock(_p_mutex);
// P(_pspace_sem);
//先P()后Lock的原因:更优
//1. 多线程环境中,P操作是原子的,申请成功代表有资源给该线程
// 像方案1中只有一个能申请锁,然后持有锁再申请信号量。其余线程阻塞在锁着里等待,
// 而方案2可以在持有锁的线程中执行的时候,其它线程也可以正常申请信号量,等待所资源即可
//2. 临界区的代码越少越好。P不需要保护
//所以:方案2可以让申请信号量和申请锁的时间变成并行
//方案2
P(_pspace_sem);
Lock(_p_mutex);
_ringqueue[_p_step] = in;
_p_step++;
_p_step %= _cap;
Unlock(_p_mutex);
V(_cdata_sem);
}
// 输出
void Pop(T *out)
{
P(_cdata_sem);
Lock(_c_mutex);
*out = _ringqueue[_c_step];
_c_step++;
_c_step %= _cap;
Unlock(_c_mutex);
V(_pspace_sem);
}
~RingQueue()
{
sem_destroy(&_cdata_sem);
sem_destroy(&_pspace_sem);
pthread_mutex_destroy(&_c_mutex);
pthread_mutex_destroy(&_p_mutex);
}
private:
void P(sem_t &sem)
{
sem_wait(&sem);
}
void V(sem_t &sem)
{
sem_post(&sem);
}
void Lock(pthread_mutex_t &mutex)
{
pthread_mutex_lock(&mutex);
}
void Unlock(pthread_mutex_t &mutex)
{
pthread_mutex_unlock(&mutex);
}
private:
std::vector<T> _ringqueue;
int _cap;
int _c_step; // 消费者下标
int _p_step; // 生产者下标
sem_t _cdata_sem; // 消费者关注的数据资源
sem_t _pspace_sem; // 生产者关注的空间资源
pthread_mutex_t _c_mutex;
pthread_mutex_t _p_mutex;
};
Makefile:
ringqueue:Main.cxx
g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:
rm ringqueue
Main.cxx:
#include "Task.hpp"
#include "RingQueue.hpp"
struct ThreadData
{
RingQueue<Task> *rq;
std::string threadname;
};
void *Producer(void *args)
{
ThreadData *td = static_cast<ThreadData *>(args);
RingQueue<Task> *rq = td->rq;
std::string name = td->threadname;
int len = opers.size();
while (true)
{
// 1. 获取资源
int data1 = rand() % 10 + 1;
int data2 = rand() % 10;
char oper = opers[rand() % len];
Task t(data1, data2, oper);
// 2. 生产数据
rq->Push(t);
std::cout << "Producer data done, data is:" << t.GetTask() << ", name:" << name << std::endl;
}
delete td;
return nullptr;
}
void *Consumer(void *args)
{
ThreadData *td = static_cast<ThreadData *>(args);
RingQueue<Task> *rq = td->rq;
std::string name = td->threadname;
while (true)
{
usleep(1000);
// 1. 消费数据
Task t; // 这里Task类要加一个默认构造,要不然不能构造Task类型对像,Task.hpp文件也只有这一个变化
rq->Pop(&t);
// 2. 处理数据
t();
std::cout << "Consumer get data done, data is:" << t.GetResult() << ", name:" << name << std::endl;
}
delete td;
return nullptr;
}
int main()
{
srand(time(nullptr) ^ getpid());
RingQueue<Task> *rq = new RingQueue<Task>();
pthread_t c[3], p[5];
for (int i = 0; i < 3; i++)
{
ThreadData *td = new ThreadData;
td->rq = rq;
td->threadname = "Consumer-" + std::to_string(i);
pthread_create(c + i, nullptr, Consumer, td);
}
for (int i = 0; i < 2; i++)
{
ThreadData *td = new ThreadData;
td->rq = rq;
td->threadname = "Producer-" + std::to_string(i);
pthread_create(p + i, nullptr, Producer, td);
}
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;
}
运行结果:
注:加锁和申请信号量谁前谁后,哪一个更好——方案2更优(在对应的代码区域也进行了标识)
方案1:
Lock(_p_mutex);
P(_pspace_sem);
//先P()后Lock:更优
//1. 多线程环境中,P操作是原子的,申请成功代表有资源给该线程
// 像方案1中只有一个能申请锁,然后持有锁再申请信号量。其余线程阻塞在锁着里等待,
// 而方案2可以在持有锁的线程中执行的时候,其它线程也可以正常申请信号量,等待所资源即可
//2. 临界区的代码越少越好。P不需要保护
//所以:方案2可以让申请信号量和申请锁的时间变成并行
方案2:
P(_pspace_sem);
Lock(_p_mutex);
四、池化技术——线程池
池化技术的本质以空间换时间
1. 池化技术
池化技术:提前在上层准备一批资源放在池中,在需要时直接使用而不是再去系统中申请,使用完毕再将资源还回池中,以便其它程序使用。
这并不是我们第一次谈池化技术的应用,在进程间通信——管道对进程池进行了介绍和实现。文件缓冲区也是一种数据池
池化技术的优点:
- 提高资源的重复利用率: 减少系统中频繁创建和销毁资源所带来的开销——降低系统负载,加快资源获取速度,提高系统的性能和效率
- 减少内存碎片化: 池化技术会预先分配一大块的内存块,在程序中频繁使用,避免频繁的进行内存的分配和释放,提高内存的利用率
- 控制资源使用: 可以通过资源池的大小和资源申请策略,合理分配资源
2. 线程池实现
1. 线程池的介绍:
线程过多会带来申请和调度的开销,使用线程池维护多个线程,等待监督管理者分配可并发执行的任务。避免了在处理短时间任务时,创建和销毁线程的代价。线程数量应该受多方面影响,eg:可用的并发处理器、处理器内核、网络sockets等的数量
2. 线程池的应用场景:
- 任务时间短,需要大量线程来完成任务。web服务器网页请求,单个任务小,任务量多(热门网站的点击)。反之长时间的任务(Telnet连接请求),线程池的优点就不明显了(任务时间比线程的创建时间长太多了)。
- 对性能要求苛刻的应用。eg:服务器快速相应客户请求
- 接收突发性大量请求,但不会让服务器产生大量线程的应用
3. 代码实现: 包含了四个文件Main.cc Makefile ThreadPools.hpp Task.hpp
其中Task.hpp和上文中的阻塞队列代码实现是一样的,就不贴上来了。Makefile也和上文差不多就改了以下生成的文件名
ThreadPools.hpp:
#pragma once
#include <iostream>
#include <string>
#include <vector>
#include <queue>
#include <pthread.h>
#include <unistd.h>
#include <ctime>
struct ThreadInfo
{
pthread_t tid;
std::string name;
};
static const int defaultnum = 5;
template<class T>
class ThreadPool
{
private:
void Lock()
{
pthread_mutex_lock(&_mutex);
}
void Unlock()
{
pthread_mutex_unlock(&_mutex);
}
void Threadsleep()
{
pthread_cond_wait(&_cond, &_mutex);
}
void Wakeup()
{
pthread_cond_signal(&_cond);
}
bool IsQueueEmpty()
{
return _tasks.empty();
}
std::string GetThreadName(pthread_t tid)
{
for(const auto &td : _threads)
{
if(td.tid == tid)
return td.name;
}
return "None";
}
public:
ThreadPool(int num = defaultnum)
:_threads(num)
{
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_cond, nullptr);
}
//静态成员函数没有默认传this指针,这里直接把this
static void *HandlerTask(void *args)
{
ThreadPool<T> *tp = static_cast<ThreadPool<T> *>(args);
std::string name = tp->GetThreadName(pthread_self());
while(true)
{
tp->Lock();
//循环防止伪唤醒
while(tp->IsQueueEmpty())
{
tp->Threadsleep();
}
//线程从队列取任务
T t = tp->Pop();
tp->Unlock();
//线程处理任务可以并发访问
t();
std::cout << name << " running..." << "result:" << t.GetResult() << std::endl;
}
}
void Start()
{
int num = _threads.size();
for (int i = 0; i < num; i++)
{
_threads[i].name = "thrad-" + std::to_string(i+1);
// 众所周知,this指针不能当作形参和实参传递给函数,
// 但可以通过对象地址的方式传递给线程在类中的启动函数
pthread_create(&_threads[i].tid, nullptr, HandlerTask, this);
}
}
T Pop()
{
T t = _tasks.front();
_tasks.pop();
return t;
}
void Push(const T &in)
{
//这里其实不用加锁解锁,因为只有主线程Push数据进来,当然如果多个线程Push数据需要加锁
Lock();
_tasks.push(in);
//有数据了可以消费了
Wakeup();
Unlock();
}
~ThreadPool()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_cond);
}
private:
//存放线程信息
std::vector<ThreadInfo> _threads;
//存放任务的队列
std::queue<T> _tasks;
pthread_mutex_t _mutex;
pthread_cond_t _cond;
};
Main.cc:
#include "Task.hpp"
#include "ThreadPools.hpp"
int main()
{
ThreadPool<Task> *tp = new ThreadPool<Task>(5);
tp->Start();
srand(time(nullptr) ^ getpid());
while (true)
{
//产生任务
int x = rand() % 10 + 1;
usleep(10);
int y = rand() % 5;
char op = opers[rand() % opers.size()];
Task t(x, y, op);
tp->Push(t);
//交给线程处理
std::cout << "Task:" << t.GetTask() << std::endl;
sleep(1);
}
return 0;
}
运行结果:
五、拓展
1. 线程安全的单例模式
注:这里只介绍线程安全的单例模式,不会进行实现和概念性介绍。
-
概念:
单例模式:在程序中某些类只具有一个对象(实例) -
单例模式的两种实现方式:
- 饿汉模式:单例对象在类加载时就被创建和初始化,无论是否被使用。可能造成资源浪费,尤其在实列化代价较高或资源消耗较大的情况。另外假如有A,B两个单例类,要求先创建A,再创建B,B的初始化依赖A。不能保证实例化顺序。(可以引入单例管理器,保证单例对象初始化顺序)
- 懒汉模式:核心思想延时加载。单例对象的实例化延迟至第一次被访问时才进行
饿汉模式:
伪代码:
template<class T>
class Hungry
{
public:
static T* GetInstance()
{
return &data;
}
private:
static T data;
};
- 单例模式的实现需要使用静态成员属性和方法。类中定义的静态的全局数据属于类,不属于对象。
- 静态变量和全局变量的处理方式是一样的,静态变量是全局变量的一种表现形式。毫无疑问它们要遵守程序地址空间,什么代码区,全局数据区等,所以全局变量和静态变量在程序加载时就要定义好了——程序的生命周期不释放
证明:全局变量和静态变量在程序加载时就要定义好了
#include <iostream>
#include <unistd.h>
class Init
{
public:
Init()
{
std::cout << "hello Linux.." << std::endl;
}
};
Init init; //定义成全局的
Init init1;
Init init2;
Init init3;
int main()
{
std::cout << "sleep 5" << std::endl;
sleep(5);
return 0;
}
运行结果:
懒汉模式:
伪代码:
template<class T>
class Lazy
{
public:
static T *GetInstance()
{
if(inst == nullptr)
{
//这里发生线程切换,导致多个线程到这个步骤,就会导致new出多个对象
inst = new T();
}
return inst;
}
private:
static T *inst;
};
- 静态变量在程序加载时就要定义好了,但是这个静态变量只是个指针,等其调用GetInstance()时如果不存在才会创建对象,存在直接返回已有对象的指针
- 线程安全问题,当多线程同时调用GetInstance()时然后判断对象是否被创建,判断成功线程切换又有别的线程进来,就会导致创建多份实例
线程安全的样例代码:
#include <pthread.h>
template<class T>
class Lazy
{
public:
static Lazy<T> *GetInstance()
{
//双重if判断,避免线程间不必要的锁竞争
if (nullptr == _lazyp)
{
//使用互斥锁:保证在多线程的情况下,只能new成功一次
pthread_mutex_lock(&_lock);
if(nullptr == _lazyp)
{
_lazyp = new Lazy<T>();
}
pthread_mutex_unlock(&_lock);
}
return _lazyp;
}
//要释放,所以析构要公有
~Lazy()
{}
private:
//构造函数私有
Lazy(const T &a)
:_a(a)
{}
Lazy(const Lazy<T> &) = delete; //拷贝构造
const Lazy<T> &operator=(const Lazy<T> &) = delete; //赋值运算符重载
private:
T _a;
static Lazy<T> *_lazyp;
static pthread_mutex_t _lock;
};
template<class T>
Lazy<T> *Lazy<T>::_lazyp = nullptr;
template<class T>
pthread_mutex_t Lazy<T>::_lock = PTHREAD_MUTEX_INITIALIZER;
注:这里值得注意的就是使用双if判断,避免不必要的竞争。(双if:在代码处进行了标识)
2. STL、智能指针和线程安全
- STL容器:不是线程安全的
STL容器使用动态内存分配、指针等来管理数据结构,多线程同时调用同一个容器进行读写操作,可能会导致数据结构破坏和不一致问题。STL容器在内部会使用全局变量或静态变量来维护一些数据结构,全局变量和静态变量可能导致竞态条件和数据不一致问题。- 智能指针:
- std::unique_ptr:std::unique_ptr 拥有对所管理对象的唯一所有权,由于独占所有权,std::unique_ptr 的操作是线程安全的,不需要额外的同步措施
- std::shared_ptr:多个对象需要共用一个引用计数变量。标准库实现时基于原子操作的方式保证,原子的操作引用计数,因此是线程安全的。但是需要注意保证对象本身的线程安全性。
- std::weak_ptr:是线程安全的,需要注意对象本身的线程安全性
- std::auto_ptr:不是线程安全的
3. 常见锁——自旋锁
-
悲观锁: 每次访问共享资源时,总是担心其它线程修改资源。为保证共享资源的独占性,同时阻塞其它线程访问资源,要提前加锁(eg:互斥锁,读写锁(等下介绍),信号量等)
-
乐观锁: 每次访问共享资源的时候,乐观的认为资源不会被其它的线程修改,所以不上锁。但是先尝试修改资源,然后在更新时检查是否有其它线程修改资源。若资源未被修改,则更新,否则进行回滚或重新尝试。主要采取两种方式:CAS操作和版本号机制(回滚需要修改前版本的资源状态)。常用于读操作比写操作多的场景
- 回滚:撤销之前所做的修改,恢复操作之前的状态,采取其它必要的措施或者再次尝试
-
CAS(Compare and swap)操作: 原子性操作,乐观锁的一种实现方式。需要更新数据时,判断当前数据和之前取得的值是否相等,是则更新,否则不断重试
-
自旋锁:
- 我们上面讲的互斥锁就属于挂起等待锁,多线程访问临界区资源时只有一个线程可以持有锁访问其它线程只能挂起等待持有锁的线程访问完。
- 自旋锁:它不会将线程挂起等待,而是获取锁失败时不断尝试,直到成功为止。自旋锁适用于线程在临界区执行时间很短的情况。缺点是会消耗CPU资源,优点是减少线程挂起和唤醒的操作
互斥锁系统调用接口实现自旋锁方式:
头文件:
#include <pthread.h>
函数声明:
int pthread_mutex_trylock(pthread_mutex_t *mutex);
作用:和pthread_mutex_lock功能一样,也是申请锁资源,申请成功返回0,但是失败直接返回错误码,而不是进行挂起等待
实现:while(pthread_mutex_trylock(pthread_mutex_t *mutex)){}
直接一个死循环,申请不成功一直调用,成功继续向下执行
POSIX标准定义的用于实现自旋锁的函数:
自旋锁的使用方式和互斥锁的使用方式一致,只不过一个挂起等待,一个自旋
头文件:
#include <pthread.h>
定义自旋锁:
pthread_spinlock_t spinlock;
函数声明:
//1. 初始化自旋锁
int pthread_spin_init(pthread_spinlock_t *lock, int pshared);
//2. 销毁自旋锁
int pthread_spin_destroy(pthread_spinlock_t *lock);
//3. 加锁——获取锁
int pthread_spin_lock(pthread_spinlock_t *lock);
int pthread_spin_trylock(pthread_spinlock_t *lock);
//4. 解锁——释放锁
int pthread_spin_unlock(pthread_spinlock_t *lock);
参数:
1. lock:指向定义的自旋锁
2. pshared:指定自旋锁的共享属性。
pshared为0,则自旋锁只被创建进程中的线程使用
pshared非0,则自旋锁可以被多进程共享
返回值:
成功返回0,失败返回错误码
pthread_spin_lock和pthread_spin_trylock区别:
pthread_spin_lock:尝试获取指定的自旋锁,锁已被别的线程持有,则自选,直到获取锁为止
pthread_spin_trylock:尝试获取指定的自旋锁,锁已被别的线程持有,则直接出错返回
4. 读写者问题
- 理论分析:
- 读写者问题和生产消费模型是非常相似的,接下来也用321原则进行分析
- 读写者问题描述:一个共享资源被多个读者(线程)和多个写者(线程)并发访问时遇到的问题。
- 举个例子:博主写一篇博客,会有很多人访问,写者就一个人读者有很多人,后来博主的老师看了博主写的博客,察觉有些问题。博主就把账号给了老师,让老师帮忙更改,这就是多个写者了,在这个过程中博主和博主的老师不能同时写这篇博客会出现问题,所以写者和写者是互斥关系。在写着写的过程中,读者不能来读,因为没写完可能会导致读错,读的时候也不能来写,所以写者和读者是互斥关系。博主或老师写完这篇博客肯定希望读者来读,毕竟写的那么好,同时有很多读者认为博主写的博客很好,也会催着博主赶紧更新博客,所以写者和读者也是同步关系。读者读博客,不能更改博客所以可以很多读者一起来访问,毕竟读者不会更改博客,所以读者和读者是没有关系(不需要维持这段关系)
- 在生产消费模型中消费者和消费者是互斥关系。而读写者模型中读者和读者没有关系,可以共享访问,原因是读者不能更改共享资源,只能读。而消费者把资源都拿走了,所以是互斥关系。
小结: 321原则
- 3种关系:写者和写者,读者和读者,写者和读者
- 2种角色:读者和写者(线程)
- 1个交易场所:数据交换的地点(例子的博客)
3种关系:
- 写写:互斥
- 写读:互斥+同步
- 读读:没有关系
- 读写锁接口:
头文件:
#include <pthread.h>
函数声明:
//1. 初始化和销毁读写锁
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,
const pthread_rwlockattr_t *restrict attr);
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
//2. 获取读锁
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock); //获取读锁
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock); //获取读锁的非阻塞版本
//3. 获取写锁
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock); //获取写锁的非阻塞版本
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock); //获取写锁
//4. 释放锁
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
参数:
1. rwlock:指向读写锁
2. attr:设置属性,一般nullptr
返回值:
成功返回0,失败返回错误码。可以根据错误码(宏),做相应的处理
读者优先的伪代码:
想要实现写者优先,可以定义一个write_count统计写者的数量,如果不为0,让在来的读者阻塞,等待正在读的读者读完,就可以让写者执行
5. 封装原生线程库
- C++封装线程的使用:
//测试C++线程
#include <thread>
#include <iostream>
#include <unistd.h>
void handler()
{
while(true)
{
std::cout << "thread running..." << std::endl;
sleep(1);
}
}
int main()
{
std::thread t(handler);
t.join();
return 0;
}
运行结果:
在Linux下执行C++带线程的程序:C++的线程库,本质是对Linux原生线程库的封装,所以在编译时还带上
-lpthread
选项
- 对原生线程库进行封装:
NPTL(原生线程库):Native POSIX Thread Library。我们自己简单的对原生线程库进行封装
封装代码:WrapThread.hpp
#pragma once
#include <unistd.h>
#include <pthread.h>
#include <iostream>
#include <vector>
#include <string>
#include <ctime>
typedef void (*callback_t)();
static const int num = 1;
class Thread
{
private:
static void *Routine(void *args)
{
Thread *tobjp = static_cast<Thread *>(args);
tobjp->EnteryFun();
return nullptr;
}
public:
Thread(callback_t cb)
:_cb(cb),_tid(0),_name(""), _start_timestamp(0),_IsRunning(false)
{}
void Run()
{
_name = "thread-" + std::to_string(num);
_start_timestamp = time(nullptr);
_IsRunning = true;
// 众所周知,this指针不能当作形参和实参传递给函数,
// 但可以通过对象地址的方式传递给线程在类中的启动函数
pthread_create(&_tid, nullptr, Routine, this);
}
void Join()
{
pthread_join(_tid, nullptr);
_IsRunning = false;
}
std::string Name()
{
return _name;
}
uint64_t StartTimestap()
{
return _start_timestamp;
}
bool IsRunning()
{
return _IsRunning;
}
void EnteryFun()
{
_cb();
}
~Thread()
{}
private:
pthread_t _tid;
std::string _name;
uint64_t _start_timestamp;
bool _IsRunning;
callback_t _cb;
};
测试代码:Main.cc
#include "WrapThread.hpp"
void Print()
{
while(true)
{
sleep(1);
std::cout << "wrap thread..." << std::endl;
}
}
int main()
{
std::vector<Thread> threads;
for (size_t i = 0; i < 3; i++)
{
threads.push_back(Thread(Print));
}
for (auto &t : threads)
{
t.Run();
std::cout << "name: " << t.Name() << std::endl; //获取线程名
std::cout << "time: " << t.StartTimestap() << std::endl;//获取线程启动时间戳
std::cout << "running? " << t.IsRunning() << std::endl; //判断线程是否运行
}
for(auto &t : threads)
{
t.Join();
}
return 0;
}
测试结果:
总结
本篇博文总结:
- 线程的概念和理解,再谈进程地址空间——页表部分
- 线程控制:线程创建、等待、退出和取消操作
- 原生线程库(NPTL)、线程ID和线程分离
- 互斥:线程安全和可重入、锁(互斥量)、死锁
- 同步:条件变量、POSIX信号量
- 生产消费模型和读写者问题、线程池、线程安全的单例模式和自旋锁