线程概念
什么是线程
- 在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是"一个进程内部的控制序列"
- 一个进程至少都有一个执行线程
- 线程在进程内部运行,本质是在进程地址空间内运行
- 在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化
- 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流
对上面图的解释:
是否命中:如果未命中,当前进程就会出现缺页中断的现象,进程就会从运行队列中取出,对应的故障处理程序可通过从外存加载该页的方法来排除故障,之后,再将原来的进程重新加载到运行队列中,原先引起的异常的指令就可以继续执行,而不再产生异常。
U/K:前者表示用户级,后者表示内核级,事实上,用户级页表和内核级页表也是根据这个进行区分的。
关于页表的进一步阐述(以二级页表为例):
问:上面的这种二级页表的方式有什么好处?
答:1、进程的虚拟地址管理和内存管理,通过页表+page进行解耦(当页表项的右侧部分为nullptr时,说明此时存在缺页现象,然后此时会将磁盘中的文件以页为单位加载进内存中)2、分页机制+按需创建页表可以节省内存空间(页表也是要占据内存空间的,通过上面的方式,可以实现页表的按需创建,进而节省内存空间)
注意:Linux中没有真正的线程的概念,也没有专门维护线程的数据结构,所谓的线程就是一个轻量级进程(Linux的线程是用进程的PCB模拟的),Linux中只有一个叫作执行流,进程至少拥有一个执行流。
上面这种设计的好处:
- 不用再设计TCB了
- 不用再维护PCB和TCB的关系了
- 不用再为TCB设计专门的调度算法了
重新理解进程
之前的定义:
进程 = 内核数据结构 + 进程对应的代码和数据
进程:内部只有一个单执行流的进程,也叫单执行流进程。
现在的定义:
进程 = 内核视角:承担分配系统资源的基本实体(进程的基座属性) -> 向系统申请资源的基本单位
进程:内部有多个执行流的进程,也叫多执行流进程。
线程:调度的基本单位。
线程的优点
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
线程的缺点
性能损失
- 一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
健壮性降低
- 编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
缺乏访问控制
- 进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
编程难度提高
- 编写与调试一个多线程程序比单线程程序困难得多
线程异常
- 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出
线程用途
- 合理的使用多线程,能提高CPU密集型程序的执行效率
- 合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)
Linux进程VS线程
进程和线程
- 进程是资源分配的基本单位
- 线程是调度的基本单位
- 线程共享进程数据,但也拥有自己的一部分数据:
- 线程ID
- 一组寄存器
- 栈
- errno
- 信号屏蔽字
- 调度优先级
进程的多个线程共享
同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
- 文件描述符表
- 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)(当暂停进程的时候,线程也会进入暂停状态)
- 当前工作目录
- 用户id和组id
进程和线程的关系如下图:

关于进程线程的问题
- 如何看待之前学习的单进程?具有一个线程执行流的进程
Linux线程控制
POSIX线程库
- 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以"pthread_"打头的
- 要使用这些函数库,要通过引入头文件
- 链接这些线程函数库时要使用编译器命令的"-lpthread"选项
创建线程
功能:创建一个新的线程
原型
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *
(*start_routine)(void*), void *arg);
参数
thread:返回线程ID,输出型参数
attr:设置线程的属性,attr为NULL表示使用默认属性
start_routine:是个函数指针,指向线程启动后要执行的函数
arg:传给线程启动函数的参数,即start_routine函数的参数
返回值:成功返回0;失败返回错误码
错误检查:
- 传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。
- pthreads函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做)。而是将错误代码通过返回值返回
- pthreads同样也提供了线程内的errno变量,以支持其它使用errno的代码。对于pthreads函数的错误, 建议通过返回值业判定,因为读取返回值要比读取线程内的errno变量的开销更小
注意:pthread_create不是操作系统的接口,它叫作原生线程库,所有的Linux都必须默认带有这个库。
代码练习:
cpp
#include<iostream>
#include<pthread.h>
#include<string>
#include<unistd.h>
using namespace std;
void* callback1(void* args)
{
string name = (char*)args;
while(true)
{
cout << name << endl;
sleep(1);
}
}
void* callback2(void* args)
{
string name = (char*)args;
while(true)
{
cout << name << endl;
sleep(1);
}
}
int main()
{
pthread_t tid1;
pthread_t tid2;
pthread_create(&tid1, nullptr, callback1, (void*)"thread 1");
pthread_create(&tid1, nullptr, callback2, (void*)"thread 2");
while(true)
{
cout << "我是主线程:" << getpid() << endl;
sleep(1);
}
//线程等待
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
return 0;
}
运行截图:

此时我们使用ps ajx | head -1 && ps axj| grep mythread | grep -v grep
进行查看:

我们无法查看到线程,此处我们要使用一个指令ps -aL
(-L表示查看轻量级进程)进行查看

LWP(light wight process):轻量级进程
PID和LWP相等的,即PID为3015的线程是主执行流,也就是主线程。
PID为3015,LWP为3016和3017的线程是分执行流。
线程ID及进程地址空间布局
首先先查看一下线程库以及线程库的位置:

从上面我们能够看到pthread是一个动态库,那么我们在运行程序的时候就要将动态库加载到内存空间中:

- pthread_ create函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面说的线程ID不是一回事。
- 前面讲的线程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。
- pthread_ create函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID, 属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。
- 线程库NPTL提供了pthread_ self函数,可以获得线程自身的ID:
cpp
#include <pthread.h>
pthread_t pthread_self(void);
pthread_t 到底是什么类型呢?取决于实现。对于Linux目前实现的NPTL实现而言,pthread_t类型的线程ID,本质就是一个进程地址空间上的一个地址。
从上图中可以看到,主线程的独立栈结构,用的就是地址空间中的栈结区,新线程的栈结构,用的是库中提供的栈结构。
注意:在进程地址空间中,会有我们自己的代码,库的代码,还有系统的代码,无论是谁的代码,都是在进程地址空间当中进行执行的。
线程的全部实现,并没有全部体现在OS内,而是OS提供执行流,具体的线程结构由库来进行管理
上面的线程库是用户级线程库,在Linux中,用户级线程库和内核的LWP是1:1对应的。
了解线程的局部存储
我们在程序中定义的所有的局部变量都是被所有线程所共享的,如果我们想要定义一个被每个线程各自私有的全局变量就需要用到线程的局部存储了:
cpp__thread int global_value = 100;//定义了这样的全局变量,每个线程里就都有一个各自的全局变量了,并且虚拟地址也是不一样的
如何使用gettid()?(了解即可)
注意:返回值就是线程所对应lwp的值。
cppint syscall(SYS_gettid);
代码举例:
cpp#include<iostream> #include<unistd.h> #include<pthread.h> #include<sys/types.h> #include<sys/syscall.h> using namespace std; __thread int global_value = 100; void* startRoutine(void* args) { while(1) { cout << (char*)args << " " << pthread_self() << ":global_value:" << global_value << " &global_val:" << &global_value << " Inc: " << global_value++ << " lwp " << ::syscall(SYS_gettid) << endl; cout.flush(); sleep(1); } return nullptr; } int main() { pthread_t tid1; pthread_create(&tid1, nullptr, startRoutine, (void *)"thread 1"); pthread_join(tid1, nullptr); return 0; }
运行截图:
通过比对发现是一样的。
设置线程的名字

cpp
#include <sys/prctl.h>
int prctl(int option, unsigned long arg2, unsigned long arg3,
unsigned long arg4, unsigned long arg5);
option:
PR_SET_NAME:设置线程的名字
使用举例:
cpp
int main()
{
prctl(PR_SET_NAME, "master");//将当前线程也就是主线程的名字设置为master
while(1)
{
sleep(1);
}
return 0;
}
查看截图:

两个验证
线程异常
线程异常退出,进程也会随之而整体异常退出,即线程异常 == 进程异常。
上面的这种情况也说明了线程的健壮性存在问题,因为进程具有相对独立性,所以某个进程的异常退出不会影响到其它的进程。
验证代码:
cpp
#include<iostream>
#include<cstdio>
#include<pthread.h>
#include<unistd.h>
using namespace std;
static void printTid(const char* name, pthread_t tid)
{
printf("%s 正在运行, thread id:0x%x\n", name, tid);
}
void* startRoutine(void* args)
{
const char* name = static_cast<const char*>(args);
int cnt = 5;
while(true)
{
printTid(name, pthread_self());
sleep(1);
if(!(cnt--))
{
int* p = nullptr;
*p = 0;
}
}
cout << "线程退出了" << endl;
return nullptr;
}
int main()
{
pthread_t tid;
int n = pthread_create(&tid, nullptr, startRoutine, (void*)"thread 1");//创建线程
sleep(10);
pthread_join(tid, nullptr);//等待线程
return 0;
}
运行截图:

pthread_join的第二个参数

retval是一个输出型参数,保存函数的退出码。
代码举例:
cpp
#include<iostream>
#include<cstdio>
#include<pthread.h>
#include<unistd.h>
using namespace std;
static void printTid(const char* name, pthread_t tid)
{
printf("%s 正在运行, thread id:0x%x\n", name, tid);
}
void* startRoutine(void* args)
{
const char* name = static_cast<const char*>(args);
int cnt = 5;
while(true)
{
printTid(name, pthread_self());
sleep(1);
if(!(cnt--))
{
break;
}
}
cout << "线程退出了" << endl;
return (void*)10;
}
int main()
{
pthread_t tid;
int n = pthread_create(&tid, nullptr, startRoutine, (void*)"thread 1");//创建线程
void* exitNum = nullptr;
pthread_join(tid, &exitNum);//等待线程
cout << "exit code:" << (long long)exitNum << endl;
return 0;
}
运行截图:

线程终止
如果需要只终止某个线程而不终止整个进程,可以有三种方法:
从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。
使用举例:
return (void*)10;
(假设退出码是10)线程可以调用pthread_ exit终止自己。
使用举例:
pthread_exit((void*)10);
一个线程可以调用pthread_ cancel给线程发送取消请求终止同一进程中的另一个线程。如果线程是被取消的,退出结果就是-1。
问:-1是怎么来的?
答:-1是一个宏:
pthread_exit函数
功能:线程终止
原型
void pthread_exit(void *value_ptr);
参数
value_ptr:value_ptr不要指向一个局部变量。
返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)
需要注意,pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。
注意:一旦新线程执行exit()函数,所有的线程都会退出,因为exit()函数是进程退出的函数!
pthread_cancel函数
功能:取消一个执行中的线程
原型
int pthread_cancel(pthread_t thread);
参数
thread:线程ID
返回值:成功返回0;失败返回错误码
使用举例:
cpp
#include<iostream>
#include<cstdio>
#include<pthread.h>
#include<unistd.h>
using namespace std;
static void printTid(const char* name, pthread_t tid)
{
printf("%s 正在运行, thread id:0x%x\n", name, tid);
}
void* startRoutine(void* args)
{
const char* name = static_cast<const char*>(args);
int cnt = 5;
while(true)
{
printTid(name, pthread_self());
sleep(1);
if(!(cnt--))
{}
}
cout << "线程退出了" << endl;
}
int main()
{
pthread_t tid;
int n = pthread_create(&tid, nullptr, startRoutine, (void*)"thread 1");//创建线程
(void)n;
pthread_cancel(tid);
cout << "new thread被取消了" << endl;
void* exitNum = nullptr;
pthread_join(tid, &exitNum);//等待线程
cout << "exit code:" << (long long)exitNum << endl;
return 0;
}
运行截图:

pthread_join函数(线程等待)
为什么需要线程等待?
- 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。
- 创建新的线程不会复用刚才退出线程的地址空间。
综上,如果没有特殊操作,必须要对退出的进程进行线程等待。
cpp
功能:等待线程结束
原型
int pthread_join(pthread_t thread, void **value_ptr);
参数
thread:线程ID
value_ptr:它指向一个指针,后者指向线程的返回值
返回值:成功返回0;失败返回错误码
调用该函数的线程将挂起等待,直到id为thread的线程终止。thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的,总结如下:
- 如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。
- 如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数PTHREAD_ CANCELED。
- 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参 数。
- 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数。

分离线程
- 默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
- 如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。
cpp
int pthread_detach(pthread_t thread);
可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:
cpp
pthread_detach(pthread_self());
joinable和分离是冲突的,一个线程不能既是joinable又是分离的。
问:新线程分离了,但是主线程先退出了会怎么办?
答:主线程退出了,就相当于进程退出了,此时所有的进程都会退出。
代码验证:
cpp
#include<iostream>
#include<unistd.h>
#include<pthread.h>
#include<sys/types.h>
#include<sys/syscall.h>
#include<cstring>
using namespace std;
__thread int global_value = 100;
void* startRoutine(void* args)
{
//pthread_detach(pthread_self());自己分离自己
cout << "detach success" << endl;
int cnt = 5;
while(true)
{
cout << (char*)args << " " << pthread_self() << ":global_value:"
<< global_value << " &global_val:" << &global_value << " Inc: " << global_value++
<< " lwp " << syscall(SYS_gettid) << endl;
cout.flush();
sleep(1);
cnt--;
}
return nullptr;
}
int main()
{
pthread_t tid1;
pthread_create(&tid1, nullptr, startRoutine, (void *)"thread 1");
sleep(1);//为了让线程先进行分离
pthread_detach(tid1);//主线程分离新线程
int n = pthread_join(tid1, nullptr);
cout << n << ":" << strerror(n) << endl;
return 0;
}
运行截图:

建议:让主线程去分离其它线程。
Linux 线程互斥
进程线程间的互斥相关背景概念
- 临界资源:多线程执行流共享的资源就叫做临界资源
- 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
- 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
- 上下文:CPU内的寄存器是被所有的执行流共享的,但是寄存器里面的数据是属于当前执行流的上下文数据,线程被切换的时候,需要保存上下文,线程被恢复的时候,需要恢复上下文。
互斥量mutex
- 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
- 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
- 多个线程并发的操作共享变量,会带来一些问题。
复现多线程操作引起的问题
目标:让线程尽可能多的进行切换----时间片到了,线程会在内核返回到用户态的时候做检测,创造更多的让线程阻塞的场景。
代码:
cpp
#include<iostream>
#include<unistd.h>
#include<pthread.h>
#include<sys/types.h>
#include<sys/syscall.h>
#include<cstring>
using namespace std;
//票数计数器
int tickets = 10000;//临界资源
void* getTickets(void* args)
{
const char* name = static_cast<const char*>(args);
while(true)
{
//临界区
if (tickets > 0)//非原子性的操作
{
usleep(1000);
cout << name << " 抢到了票,票的编号:" << tickets << endl;
tickets--;//非原子性的操作
}
else
{
cout << name << "已经放弃抢票了,因为没有了..." << endl;
break;
}
}
cout << tickets << endl;
return nullptr;
}
int main()
{
pthread_t tid1;
pthread_t tid2;
pthread_t tid3;
pthread_create(&tid1, nullptr, getTickets, (void *)"thread 1");
pthread_create(&tid2, nullptr, getTickets, (void *)"thread 2");
pthread_create(&tid3, nullptr, getTickets, (void *)"thread 3");
int n1 = pthread_join(tid1, nullptr);
int n2 = pthread_join(tid2, nullptr);
int n3 = pthread_join(tid3, nullptr);
return 0;
}
运行截图:

为什么可能无法获得争取结果?
-
if
语句判断条件为真以后,代码可以并发的切换到其他线程 -
usleep
这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段 -
--ticket
操作本身就不是一个原子操作取出ticket--部分的汇编代码
objdump -d a.out > test.objdump
152 40064b: 8b 05 e3 04 20 00 mov 0x2004e3(%rip),%eax # 600b34 <ticket>
153 400651: 83 e8 01 sub $0x1,%eax
154 400654: 89 05 da 04 20 00 mov %eax,0x2004da(%rip) # 600b34 <ticket>
--
操作并不是原子操作,而是对应三条汇编指令:
load
:将共享变量ticket从内存加载到寄存器中update
: 更新寄存器里面的值,执行-1操作store
:将新值,从寄存器写回共享变量ticket的内存地址
要解决以上问题,需要做到三点:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。

互斥量的接口
初始化互斥量
初始化互斥量有两种方法:
- 方法1,静态分配:
cpp
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
- 方法2,动态分配:
cpp
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);
互斥量加锁和解锁
cpp
int pthread_mutex_lock(pthread_mutex_t *mutex);//阻塞式加锁
int pthread_mutex_trylock(pthread_mutex_t *mutex);//非阻塞式加锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);//解锁
返回值:成功返回0,失败返回错误号
调用 pthread_ lock 时,可能会遇到以下情况:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量, 那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
改进之后抢票的代码:
cpp
#include<iostream>
#include<unistd.h>
#include<pthread.h>
#include<sys/types.h>
#include<sys/syscall.h>
#include<cstring>
using namespace std;
//票数计数器
int tickets = 1000;//临界资源
pthread_mutex_t mutex;
void* getTickets(void* args)
{
const char* name = static_cast<const char*>(args);
while(true)
{
//临界区
pthread_mutex_lock(&mutex);
if (tickets > 0)//非原子性的操作
{
usleep(1000);
cout << name << " 抢到了票,票的编号:" << tickets << endl;
tickets--;//非原子性的操作
pthread_mutex_unlock(&mutex);
}
else
{
cout << name << "已经放弃抢票了,因为没有了..." << endl;
pthread_mutex_unlock(&mutex);
break;
}
}
cout << tickets << endl;
return nullptr;
}
int main()
{
pthread_mutex_init(&mutex, nullptr);
pthread_t tid1;
pthread_t tid2;
pthread_t tid3;
pthread_create(&tid1, nullptr, getTickets, (void *)"thread 1");
pthread_create(&tid2, nullptr, getTickets, (void *)"thread 2");
pthread_create(&tid3, nullptr, getTickets, (void *)"thread 3");
int n1 = pthread_join(tid1, nullptr);
int n2 = pthread_join(tid2, nullptr);
int n3 = pthread_join(tid3, nullptr);
pthread_mutex_destroy(&mutex);
return 0;
}
注意:只对临界区进行加锁,而且加锁的粒度越细越好,加锁的本质是让线程执行执行临界区的代码串行化。
注意:加锁是一套规范,通过临界区对临界区资源进行访问的时候,要加锁就必须全部要加锁。
注意:竞争和申请锁的过程,都是原子性的。无论进程如何切换,都不会影响锁的申请。
问:在上面的代码中,当一个线程进入如下图中的lock()和unlock()之间的代码的时候,是否会有其它的线程也会进入lock和unlock之间的临界区代码呢?答:不会的。因为每个线程进入临界区都必须先申请锁,当前的锁已经被切走的线程申请走了(保存到切走的线程的上下文数据中),此时的线程是不能再申请到锁的。即一旦一个线程持有了锁,该线程就不会再担心任何的线程切换的问题,对于其它线程而言,线程访问临界区,只有没有访问和访问完毕两种状态,换句话说,线程访问临界区也具有一定的原子性。所以临界区内的代码应当尽量的简洁,不要夹杂太多的代码。
互斥量实现原理探究
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单 元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一 个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。 现在我们把lock和unlock的伪代码改一下:
pthread_mutex_lock():

注解:
$0:常量0
%al:CPU中的寄存器al
mutex:内存中的一个变量
问:上面的第一条语句
movb $0, %al
时,在只有一个CPU的时候是否会存在多个线程同时执行的现象?答:不会的,这条语句是具有原子性的,在只有一个CPU的时候,只有一个寄存器al(当然多核心时就更不会互相影响了),只有当线程A执行完第一条语句后,线程B才能执行第一条语句。寄存器只有一个,被所有线程共享,但是寄存器里面的数据是被每一个线程所私有的(保存在该线程的上下文数据中)。所以多个线程看起来在同时访问寄存器,但是不会互相影响。
图示:分析:申请锁的过程,本质上就是将内存中的数据交换到CPU内部,而CPU内部的数据又属于线程独有的上下文数据,所以申请锁的过程本质上就是将线程共享的锁私有化。
pthread_mutex_unlock():

RAII封装锁
cpp
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <sys/types.h>
#include <sys/syscall.h>
#include <cstring>
#include "Lock.hpp"
using namespace std;
int tickets = 1000;
Mutex mutex;
//函数本质上是一个代码块
bool getTickets(const string &name)
{
bool ret = false;
LockGuard lg(&mutex);//加锁,运用了智能指针的原理
if (tickets > 0)
{
usleep(1000);
cout << name << ":" << pthread_self() << " get a ticket: " << tickets << endl;
tickets--;
ret = true;
}
return ret;
//函数执行结束后执行LockGuard的析构函数,然后释放锁,用了
}
void *startRoutine(void *args)
{
string name = (char *)args;
while (getTickets(name))
{}
}
int main()
{
static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_t tid1;
pthread_t tid2;
pthread_t tid3;
pthread_t tid4;
pthread_create(&tid1, nullptr, startRoutine, (void *)"thread 1");
pthread_create(&tid2, nullptr, startRoutine, (void *)"thread 2");
pthread_create(&tid3, nullptr, startRoutine, (void *)"thread 3");
pthread_create(&tid4, nullptr, startRoutine, (void *)"thread 4");
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
pthread_join(tid3, nullptr);
pthread_join(tid4, nullptr);
return 0;
}
运行截图:

和之前的对比一下:
之前的:
cpp
{
lock();
cnt++;
unlock();
}
现在的:
cpp
{
LockGurad lg(&mutex);
cnt++;
}
可重入VS线程安全
概念
- 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作, 并且没有锁保护的情况下,会出现该问题。
- 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数(比如我们加锁后的买票函数),否则,是不可重入函数。
注意:一般我们使用man去查看函数的使用手册的时候,如果函数的后面带了_r就说明该函数就是可重入函数,反之,该函数就是不可重入函数。
常见的线程不安全的情况
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数(比如某个函数内有一个静态变量,每次调用这个函数该静态变量的值都会+1)
- 返回指向静态变量指针的函数(会造成多个线程指向同一个静态变量,如果在返回前被其它线程修改,主线程接收到的返回值就并不是我们想要的)
- 调用线程不安全函数的函数(比如我们刚才man的手册中不带r的函数或者STL相关的函数)
常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
常见不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的(malloc一定要调用底层申请函数:brk)
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
常见可重入的情况
-
不使用全局变量或静态变量
cppvoid Func() { int err = errno; fopen(); errno = err; }
-
不使用用malloc或者new开辟出的空间
-
不调用不可重入函数
-
不返回静态或全局数据,所有数据都有函数的调用者提供
-
使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
可重入与线程安全联系
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入与线程安全区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
常见锁概念 -- 死锁
死锁概念
- 死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
死锁的模拟:
cpp
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <sys/types.h>
#include <sys/syscall.h>
#include <cstring>
using namespace std;
pthread_mutex_t mutexA = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t mutexB = PTHREAD_MUTEX_INITIALIZER;
void *startRoutine1(void *args)
{
while (true)
{
pthread_mutex_lock(&mutexA);
sleep(1);
pthread_mutex_lock(&mutexB);
cout << "thread1 success!" << endl;
pthread_mutex_unlock(&mutexB);
pthread_mutex_unlock(&mutexA);
}
}
void *startRoutine2(void *args)
{
while (true)
{
pthread_mutex_lock(&mutexB);
sleep(1);
pthread_mutex_lock(&mutexA);
cout << "thread2 success!" << endl;
pthread_mutex_unlock(&mutexA);
pthread_mutex_unlock(&mutexB);
}
}
int main()
{
pthread_t tid1;
pthread_t tid2;
pthread_create(&tid1, nullptr, startRoutine1, nullptr);
pthread_create(&tid2, nullptr, startRoutine2, nullptr);
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
return 0;
}
运行截图:

问:一把锁是否有可能会发生死锁呢?
答:有可能的。
下面就是一把锁引发死锁的情况:
cpp
#include<iostream>
#include<pthread.h>
#include<unistd.h>
using namespace std;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int cnt = 100;
void* startRoutine(void* args)
{
const string name = static_cast<const char*>(args);
while(true)
{
if(cnt > 0)
{
pthread_mutex_lock(&mutex);
pthread_mutex_lock(&mutex);
cout << name << ": count: " << cnt-- << endl;
pthread_mutex_unlock(&mutex);
usleep(100);
}
else
{
break;
}
}
}
int main()
{
pthread_t t1, t2;
pthread_create(&t1, nullptr, startRoutine, (void*)"thread 1");
pthread_create(&t2, nullptr, startRoutine, (void*)"thread 2");
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
return 0;
}
运行截图:

死锁四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不可抢占:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
避免死锁
- 破坏死锁的四个必要条件(一般只能破坏2、3、4条件,第一个条件是无法破坏的)
- 破坏请求和保持条件:所有的进程在开始运行之前,必须一次性地申请其在整个运行过程中所需要的全部资源
- 破坏不可抢占条件:当进程新的资源未得到满足时,释放已占有的资源
- 破坏环路等待条件:系统给每类资源赋予一个序号,每个进程按编号递增的请求资源,释放则相反(也就是银行家算法的原理)
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配
避免死锁算法
- 死锁检测算法
- 银行家算法
死锁检测
两种考量标准:
- 每当有资源请求时就去检测,这种方式会占用昂贵的CPU时间
- 每隔k分钟检测一次,或者当CPU使用率降低到某个标准下去检测。考虑到CPU效率的原因,如果死锁进程达到一定数量,就没有多少进程可以运行,所以CPU会经常空闲。
死锁恢复
抢占恢复
:在某些情况下,可能会临时将某个资源从它的持有者转移到另一个进程。比如在不通知原进程的情况下,将某个资源从进程中强制取走给其他进程使用,使用完后又送回。这种恢复方式一般比较困难而且有些简单粗暴,并不可取回滚恢复
:周期性地检查进程的状态(包括请求的资源),将其写入一个文件,当发生死锁,回滚到之前的某个时间点杀死进程恢复
。最简单有效的解决方案是直接杀死一个死锁进程。但是杀死一个进程可能照样行不通,这时候就需要杀死别的资源进行恢复
鸵鸟算法
最简单的解决办法就是使用鸵鸟算法( ostrich algorithm),把头埋在沙子里,假装问题根本没有发生。每个人看待这个问题的反应都不同。数学家认为死锁是不可接受的,必须通过有效的策略来防止死锁的产生。工程师想要知道问题发生的频次,系统因为其他原因崩溃的次数和死锁带来的严重后果。如果死锁发生的频次很低,而经常会由于硬件故障、编译器错误等其他操作系统问题导致系统崩溃,那么大多数工程师不会修复死锁。
Linux线程同步
条件变量
- 当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
- 例如一个线程访问队列时,发现队列为空,它只能等待,直到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。
同步概念与竞态条件
同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步
问:为什么要使线程按照某种特定的顺序访问临界资源?
答:一是为了防止饥饿,二是为了线程协同。
竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。
条件变量函数
初始化

cpp
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict
attr);
参数:
cond:要初始化的条件变量
attr:NULL
销毁
cpp
int pthread_cond_destroy(pthread_cond_t *cond)
等待条件满足


注释:前者单位是s,后者是纳秒。
cpp
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
参数:
cond:要在这个条件变量上等待
mutex:互斥量,后面详细解释
唤醒等待

cpp
int pthread_cond_broadcast(pthread_cond_t *cond);//唤醒在条件变量下等待的所有进程
int pthread_cond_signal(pthread_cond_t *cond);//唤醒在该条件变量下等待的某个进程
代码示例
cpp
#include<iostream>
#include<pthread.h>
#include<unistd.h>
using namespace std;
//定义一个条件变量
pthread_cond_t cond;
//定义一个互斥锁
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;//当前不用,但是接口需要
int cnt = 100;
volatile bool flag = true;
void* waitCommand(void* args)
{
while(flag)
{
pthread_mutex_lock(&mutex);
// 三个线程都会进行在条件变量下排队
pthread_cond_wait(&cond, &mutex);//让对应的线程进行等待
pthread_mutex_unlock(&mutex);
// 问:为什么要在pthread_mutex_wait的前后要加上加锁和解锁?
// 答:因为wait函数在进入阻塞的情况下会释放锁,在被唤醒的时候会获得锁,但是在全部唤醒的时候,只有一个进程能够争抢到锁,在wait函数此时会加锁,加锁之后始终未解开锁,所以要在wait函数的后面加上解锁的操作,不然其它进程无法获得锁进而进入阻塞的情况,当然,只在wait函数的后面加上解锁操作也是可以的
cout << "thread id:" << pthread_self() << " run..." << endl;
}
cout << "thread id:" <<pthread_self() << " end..." << endl;
}
int main()
{
//初始化条件变量
pthread_cond_init(&cond, nullptr);
pthread_t t1, t2, t3;
pthread_create(&t1, nullptr, waitCommand, nullptr);
pthread_create(&t2, nullptr, waitCommand, nullptr);
pthread_create(&t3, nullptr, waitCommand, nullptr);
while(true)
{
char command;
sleep(1);
cout << "请输入你的command:";
cin >> command;
if(command == 'n')
{
pthread_cond_broadcast(&cond);
}
else
{
flag = false;
sleep(1);
pthread_cond_broadcast(&cond);
break;
}
}
cout << "haha" << endl;
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
pthread_join(t3, nullptr);
pthread_cond_destroy(&cond);
return 0;
}
运行截图:

为什么pthread_cond_wait
需要互斥量?
区分条件和条件变量:
条件(对应的共享资源的状态,程序员要判断资源是否满足自己操作的要求)
条件变量(条件满足或者不满足的时候,进行wait或signal一种方式)
- 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
- 条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据。

- 按照上面的说法,我们设计出如下的代码:先上锁,发现条件不满足,解锁,然后等待在条件变量上不就 行了,如下代码:
cpp
// 错误的设计
pthread_mutex_lock(&mutex);
while (condition_is_false)
{
pthread_mutex_unlock(&mutex);
//解锁之后,等待之前,条件可能已经满足,信号已经发出,但是该信号可能被错过
pthread_cond_wait(&cond);
pthread_mutex_lock(&mutex);
}
pthread_mutex_unlock(&mutex);
- 由于解锁和等待不是原子操作。调用解锁之后,
pthread_cond_wait
之前,如果已经有其他线程获取到互斥量,摒弃条件满足,发送了信号,那么pthread_cond_wait
将错过这个信号,可能会导致线程永远阻塞在这个pthread_cond_wait
。所以解锁和等待必须是一个原子操作。int pthread_cond_wait(pthread_cond_ t *cond,pthread_mutex_ t * mutex);
进入该函数后, 会去看条件量等于0不?等于,就把互斥量变成1,直到cond_ wait返回,把条件量改成1,把互斥量恢复成原样。
条件变量使用规范
- 等待条件代码
cpp
pthread_mutex_lock(&mutex);
while (条件为假)
pthread_cond_wait(cond, mutex);
修改条件
pthread_mutex_unlock(&mutex);
- 给条件发送信号代码
cpp
pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(&mutex);
生产者消费者模型
为何要使用生产者消费者模型
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。
总结:1、提高生产者和消费者的效率 2、解耦
生产者消费者模型优点
- 解耦
- 支持并发
- 支持忙闲不均

生产者之间的关系:互斥
消费者之间的关系:互斥
生产者和消费者之间的关系:互斥(不能同时访问)、同步(生产者生产了消费者才可以消费)
321模型:
生产者和生产者、消费者和消费者、生产者和消费者:三种关系
生产者和消费者:两种角色
仓库:一份临界资源
基于BlockingQueue的生产者消费者模型
BlockingQueue
在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别 在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元 素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程 操作时会被阻塞)

C++ queue模拟阻塞队列的生产消费模型
代码(单生产者,单消费者为例):
BlockQueue.hpp文件
cpp
#include <iostream>
#include <queue>
#include <unistd.h>
#include <pthread.h>
#include<cstdlib>
using namespace std;
const uint32_t gDefaultCap = 5;
template <class T>
class BlockQueue
{
public:
BlockQueue(int cap = gDefaultCap)
: _cap(cap)
{
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_proCond, nullptr);
pthread_cond_init(&_conCond, nullptr);
}
~BlockQueue()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_proCond);
pthread_cond_destroy(&_conCond);
}
//生产接口
void Push(const T &in) // const&:纯输入
{
//加锁
//判断->是否适合生产->bq是否为满->程序员视角的条件->1.满 2.不满
// if(满) 不生产,休眠
// else if(不满) 生产,唤醒消费者
//解锁
lockQueue();
while(isFull())
{
//before:在阻塞线程进行等待的时候,wait函数会自动释放锁,否则其它线程也无法唤醒
proBlockWait();//阻塞等待,等待被唤醒
//after:当阻塞结束,返回的时候,pthread_cond_wait会自动帮我们重新获得_mutex锁,然后才返回
}
//问:上面为什么用while而不用if进行条件判断呢?
//答:第一种情况,proBlockWait调用失败,但是此时是满的,继续进行push操作属于非法操作
//第二种情况,阻塞成功了,但是由于某些原因发生了伪唤醒的操作,,此时仍然是满的,push非法
//第三种情况,当多个线程的时候,由于锁只有一把,当所有线程被唤醒的时候所有线程都会继续向下执行
//但是锁只有一把,只有用有锁的才能够向下执行,此时程序就会出错
//总结:被唤醒并不等于条件被满足,当然,这个概率很小,所以要采用while进行判断,while具备条件检测功能
//条件满足,可以生产
pushCore(in);
unlockQueue();
wakeupCon();//唤醒消费者
//问:应该是在unlock的前面唤醒消费者呢?还是在unlock的后面唤醒消费者呢?
//答:放在哪里都可以,没有太大的区别。细小的差别对程序没有太大的影响
}
//消费接口
T Pop()
{
//加锁
//判断->是否适合消费->bq是否为空->程序员视角的条件->1.空 2.非空
// if(空) 不消费,休眠
// else if(非空) 消费,唤醒生产者
//解锁
lockQueue();
while(isEmpty())
{
conBlockWait();//阻塞等待,等待被唤醒
}
T tmp = popCore();
unlockQueue();
wakeupPro();//唤醒生产者
return tmp;
}
private:
void lockQueue() { pthread_mutex_lock(&_mutex); }
void unlockQueue() { pthread_mutex_unlock(&_mutex); }
bool isFull() { return _bq.size() == _cap; }
bool isEmpty() { return _bq.empty(); }
void conBlockWait() { pthread_cond_wait(&_conCond, &_mutex); }
void proBlockWait() { pthread_cond_wait(&_proCond, &_mutex); }
void wakeupCon() { pthread_cond_signal(&_conCond); };
void wakeupPro() { pthread_cond_signal(&_proCond); };
void pushCore(const T& in) { _bq.push(in); }
const T popCore() { T tmp = _bq.front(); _bq.pop(); return tmp; }
private:
uint32_t _cap; // capacity
queue<T> _bq; // blockqueue
pthread_mutex_t _mutex; //保护阻塞队列的互斥锁
pthread_cond_t _conCond; //让消费者等待的条件变量
pthread_cond_t _proCond; //让生产者等待的条件变量
};
BlockQueueTest.cpp文件
cpp
#include"BlockQueue.hpp"
#include<ctime>
void* productor(void* args)
{
BlockQueue<int> *bqPtr = static_cast<BlockQueue<int>*>(args);
while(true)
{
//1. 制作数据
int data = rand() % 10;
//2. 生产数据
bqPtr->Push(data);
cout << "productor 生产数据完成:" << data << endl;
}
}
void* consumer(void* args)
{
sleep(1);
BlockQueue<int> *bqPtr = static_cast<BlockQueue<int>*>(args);
while(true)
{
int data = bqPtr->Pop();
cout <<"consumer 消费数据完成:" << data << endl;
sleep(2);
}
}
int main()
{
srand((unsigned long)time(nullptr) ^ getpid());
// cout << "hello blockqueue" << endl;
//定义一个阻塞队列
//创建两个线程,productor,comsumer
BlockQueue<int> bq;
pthread_t c, p;
pthread_create(&c, nullptr, consumer, &bq);
pthread_create(&p, nullptr, productor, &bq);
pthread_join(c, nullptr);
pthread_join(p, nullptr);
return 0;
}
运行截图:

POSIX信号量
POSIX信号量和SystemV信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。 但POSIX可以用于线程间同步。
信号量是一个计数器,描述临界资源数量的计数器。
-- P -> 原子的 ->申请资源
++ V -> 原子的 ->归还资源
问:信号量申请成功了,就一定保证我们会拥有一部分临界资源吗?答:是的。这也是资源预定机制。
问:互斥锁也是信号量吗?答:锁也是信号量。叫二元信号量。信号量的初始值是1,p操作就是对信号量进行--,变成0,v操作就是对信号量进行++,变成1。
初始化信号量
cpp
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
参数:
pshared:0表示线程间共享,非零表示进程间共享
value:信号量初始值
销毁信号量
cpp
int sem_destroy(sem_t *sem);
等待信号量
cpp
功能:等待信号量,会将信号量的值减1
int sem_wait(sem_t *sem); //P()
发布信号量
cpp
功能:发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加1。
int sem_post(sem_t *sem);//V()
基于环形队列的生产消费模型
- 环形队列采用数组模拟,用模运算来模拟环状特性

- 环形结构起始状态和结束状态都是一样的,不好判断为空或者为满,所以可以通过加计数器或者标记位来判断满或者空。另外也可以预留一个空的位置,作为满的状态

问:什么时候两个指针会访问同一个位置?
答:只有为空或者为满的时候(互斥and同步)。其它时候,都指向的是不同的位置(并发)。所以会有以下原则:
1、空:消费者不能超过生产者 -> 生产者先运行
2、满:生产者不能把消费者套一个圈,继续在往后写入 -> 消费者先运行
cpp
//生产者最关心的是什么? ---空间(sem_t roomSem = N;) --->[N, 0]
//消费者最关心的是什么? ---数据(sem_t dataSem = 0;) --->[0, N]
//生产与消费的流程演示:
//生产线程
P(roomSem)
//生产
V(dataSem)
//消费线程
P(dataSem)
//消费
V(roomSem)
代码实现:
RingQueue.hpp文件:
cpp
#pragma once
#include<iostream>
#include<vector>
#include<string>
#include<semaphore.h>
using namespace std;
const int gCap = 10;
template<class T>
class RingQueue
{
public:
RingQueue(int cap = gCap)
:_ringqueue(cap)
,_pIndex(0)
,_cIndex(0)
{
sem_init(&_roomSem, 0, cap);
sem_init(&_dataSem, 0, 0);
pthread_mutex_init(&_cmutex, nullptr);
pthread_mutex_init(&_pmutex, nullptr);
}
//生产
void Push(const T& in)
{
sem_wait(&_roomSem);
pthread_mutex_lock(&_pmutex);
_ringqueue[_pIndex] = in;//生产的过程
sem_post(&_dataSem);
_pIndex++;//写入位置后移动
_pIndex %= _ringqueue.size();//更新下标,保证环形特征
pthread_mutex_unlock(&_pmutex);
}
//消费
T Pop()
{
pthread_mutex_lock(&_cmutex);
sem_wait(&_dataSem);
T tmp = _ringqueue[_cIndex];
sem_post(&_roomSem);
_cIndex++;
_cIndex %= _ringqueue.size();
pthread_mutex_unlock(&_cmutex);
return tmp;
}
~RingQueue()
{
sem_destroy(&_roomSem);
sem_destroy(&_dataSem);
pthread_mutex_destroy(&_cmutex);
pthread_mutex_destroy(&_pmutex);
}
private:
vector<T> _ringqueue;//环形队列
sem_t _roomSem;//衡量空间个数的计数器:productor
sem_t _dataSem;//衡量数据个数的计数器:consumer
uint32_t _pIndex;//当前生产者写入的位置,如果是多线程,_pIndex也是临界资源,所以也需要加锁保护
uint32_t _cIndex;//当前消费者读取的位置,如果是多线程,_cIndex也是临界资源,所以也需要加锁保护
pthread_mutex_t _pmutex;
pthread_mutex_t _cmutex;
};
RingQueueTest.cpp文件:
cpp
#include"RingQueue.hpp"
#include<ctime>
#include<sys/types.h>
#include<unistd.h>
#include<pthread.h>
void* productor(void* args)
{
RingQueue<int>* rqp = static_cast<RingQueue<int>*>(args);
while(true)
{
int data = rand() % 10;
rqp->Push(data);
cout << "pthread[" << pthread_self() << "]" << "生产了一个数据:" << data << endl;
}
return nullptr;
}
void* consumer(void* args)
{
RingQueue<int>* rqp = static_cast<RingQueue<int>*>(args);
sleep(1);
while(true)
{
int data = rqp->Pop();
cout << "pthread[" << pthread_self() << "]" << "消费了一个数据:" << data << endl;
sleep(2);
}
return nullptr;
}
int main()
{
srand((unsigned long)time(nullptr) ^ getpid());
pthread_t c1, c2, c3, p1, p2, p3;
RingQueue<int> rq;
pthread_create(&c1, nullptr, consumer, &rq);
pthread_create(&c2, nullptr, consumer, &rq);
pthread_create(&c3, nullptr, consumer, &rq);
pthread_create(&p1, nullptr, productor, &rq);
pthread_create(&p2, nullptr, productor, &rq);
pthread_create(&p3, nullptr, productor, &rq);
pthread_join(c1, nullptr);
pthread_join(c2, nullptr);
pthread_join(c3, nullptr);
pthread_join(p1, nullptr);
pthread_join(p2, nullptr);
pthread_join(p3, nullptr);
return 0;
}
重新理解并发和忙闲不均
代码:
task.hpp文件
cpp
#pragma once
#include <iostream>
#include <string>
using namespace std;
class Task
{
public:
Task(int one = 0, int two = 0, char op = '0')
: _elementOne(one), _elementTwo(two), _operator(op)
{}
int operator()()
{
return run();
}
int run()
{
int result = 0;
switch (_operator)
{
case '+':
result = _elementOne + _elementTwo;
break;
case '-':
result = _elementOne - _elementTwo;
break;
case '*':
result = _elementOne * _elementTwo;
break;
case '/':
{
if(_elementTwo == 0)
{
cout << "divo zero, abort" << endl;
result = -1;
}
else
result = _elementOne / _elementTwo;
break;
}
case '%':
{
if(_elementTwo == 0)
{
cout << "mod zero, abort" << endl;
result = -1;
}
else
result = _elementOne % _elementTwo;
break;
}
default:
cout << "非法操作!" << endl;
break;
}
return result;
}
void get(int* one, int* two, char* op)
{
*one = _elementOne;
*two = _elementTwo;
*op = _operator;
}
private:
int _elementOne;
int _elementTwo;
char _operator;
};
BlockQueueTest.cpp文件
cpp
#include"BlockQueue.hpp"
#include"Task.hpp"
#include<ctime>
const string ops = "+-*/%";
void* productor(void* args)
{
BlockQueue<Task> *bqPtr = static_cast<BlockQueue<Task> *>(args);
while (true)
{
// 1. 制作数据
int one = rand() % 50;
int two = rand() % 20;
int opIndex = rand()%ops.size();
char op = ops[opIndex];
// 2. 生产数据
Task task(one, two, op);
bqPtr->Push(task);
cout << "producer[" << pthread_self() << "] " << (unsigned long)time(nullptr)
<< "生产了一个任务:" << one << op << two << "=?" << endl;
}
}
void* consumer(void* args)
{
sleep(1);
BlockQueue<Task> *bqPtr = static_cast<BlockQueue<Task> *>(args);
while (true)
{
Task t = bqPtr->Pop();
int one;
int two;
char op;
t.get(&one, &two, &op);
int result = t();
cout << "consumer[" << pthread_self() << "] " << (unsigned long)time(nullptr)
<< "消费了一个任务:" << one << op << two << "=" << result << endl;
sleep(2);
}
}
int main()
{
srand((unsigned long)time(nullptr) ^ getpid());
// cout << "hello blockqueue" << endl;
//定义一个阻塞队列
//创建两个线程,productor,comsumer
BlockQueue<Task> bq;
pthread_t c, p;
pthread_create(&c, nullptr, consumer, &bq);
pthread_create(&p, nullptr, productor, &bq);
pthread_join(c, nullptr);
pthread_join(p, nullptr);
return 0;
}
运行截图:

并发:一般并不是指的是在临界资源中并发,向缓冲区中放置数据和从缓冲区中取数据都是不怎么花费时间的,同时放置数据和取数据是不可能进行并发的,因为我们用锁控制了只有一个线程可以访问临界区中的数据,而是在生产前(before Push),消费后(after Pop)对应的并发,我们一般意义上的并发是指的是制作任务和处理任务可以同时进行。
忙闲不均:生产任务和处理任务之间忙闲不均,比如生产者制作任务比较快,但是消费者处理任务比较慢,所以生产者可以提前将任务都制作出来,然后消费者慢慢处理,当消费者处理完一个任务之后,生产者可以接着生产。
线程池
cpp
/*threadpool.h*/
/* 线程池:
* 一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着
监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利
用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。
* 线程池的应用场景:
* 1. 需要大量的线程来完成任务,且完成任务的时间比较短。 WEB服务器完成网页请求这样的任务,使用线程池技
术是非常合适的。因为单个任务小,而任务数量巨大,你可以想象一个热门网站的点击次数。 但对于长时间的任务,比如一个
Telnet连接请求,线程池的优点就不明显了。因为Telnet会话时间比线程的创建时间大多了。
* 2. 对性能要求苛刻的应用,比如要求服务器迅速响应客户请求。
* 3. 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。突发性大量客户请求,在没有线程池情
况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,短时间内产生大量线程可能使内存到达极限,
出现错误.
* 线程池的种类:
* 线程池示例:
* 1. 创建固定数量线程池,循环从任务队列中获取任务对象,
* 2. 获取到任务对象后,执行任务对象中的任务接口
*/
线程池的作用:
- 降低资源消耗:通过重用已经创建的线程来降低线程创建和销毁的消耗
- 提高线程的可管理性:线程池可以统一管理、分配、调优和监控
- 降低程序的耦合程度: 提高程序的运行效率
代码:
ThpreadPool.hpp文件:
cpp
#pragma once
#include <iostream>
#include <queue>
#include <cstdlib>
#include <pthread.h>
#include <cassert>
#include <unistd.h>
#include"Task.hpp"
#include"Log.hpp"
using namespace std;
const int gThreadNum = 5;
template <class T>
class ThreadPool
{
public:
ThreadPool(int threadNum = gThreadNum)
: _isStart(false), _threadNum(threadNum)
{
assert(_threadNum > 0);
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_cond, nullptr);
}
~ThreadPool()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_cond);
}
public:
static void *threadRoutine(void *args)
{
pthread_detach(pthread_self());
ThreadPool<T> *tp = static_cast<ThreadPool<T> *>(args);
while (1)
{
tp->lockQueue();
while (!tp->haveTask())
{
tp->waitForTask();
}
//这个任务就被拿到了任务的上下文中
T t = tp->pop();
tp->unlockQueue();
//规定所有的任务都必须有一个run方法
int result = t.run();
int one;
int two;
char op;
t.get(&one, &two, &op);
Log() << "新线程完成计算任务:" << one << op << two << "=" << result << endl;
}
}
void start()
{
assert(!_isStart);
for (int i = 0; i < _threadNum; i++)
{
pthread_t tmp;
pthread_create(&tmp, nullptr, threadRoutine, this);
}
_isStart = true;
}
void push(const T &in)
{
lockQueue();
_taskQueue.push(in);
choiceThreadForHandler();
unlockQueue();
}
private:
T pop()
{
T tmp = _taskQueue.front();
_taskQueue.pop();
return tmp;
}
void lockQueue() { pthread_mutex_lock(&_mutex); };
void unlockQueue() { pthread_mutex_unlock(&_mutex); }
bool haveTask() { return !_taskQueue.empty(); }
void waitForTask() { pthread_cond_wait(&_cond, &_mutex); }
void choiceThreadForHandler() { pthread_cond_signal(&_cond); }
private:
bool _isStart; //表示该线程池是否启动
int _threadNum;
queue<T> _taskQueue;
pthread_mutex_t _mutex;
pthread_cond_t _cond;
};
ThreadPoolTest.cpp文件:
cpp
#include"ThreadPool.hpp"
#include"Task.hpp"
#include"Log.hpp"
#include<memory>
#include<ctime>
const string ops = "+-*/%";
int main()
{
srand((unsigned)time(nullptr) ^ getpid() ^ pthread_self());
unique_ptr<ThreadPool<Task>> tp(new ThreadPool<Task>());
tp->start();
while(true)
{
int one = rand() % 50;
int two = rand() % 10;
char op = ops[rand() % ops.size()];
Log() << "主线程派发计算任务:" << one << op << two << "=?" << endl;
Task t(one, two, op);
tp->push(t);
sleep(1);
}
return 0;
}
Task.hpp文件:
cpp
#pragma once
#include <iostream>
#include <string>
using namespace std;
class Task
{
public:
Task(int one = 0, int two = 0, char op = '0')
: _elementOne(one), _elementTwo(two), _operator(op)
{}
int operator()()
{
return run();
}
int run()
{
int result = 0;
switch (_operator)
{
case '+':
result = _elementOne + _elementTwo;
break;
case '-':
result = _elementOne - _elementTwo;
break;
case '*':
result = _elementOne * _elementTwo;
break;
case '/':
{
if(_elementTwo == 0)
{
cout << "divo zero, abort" << endl;
result = -1;
}
else
result = _elementOne / _elementTwo;
break;
}
case '%':
{
if(_elementTwo == 0)
{
cout << "mod zero, abort" << endl;
result = -1;
}
else
result = _elementOne % _elementTwo;
break;
}
default:
cout << "非法操作!" << endl;
break;
}
return result;
}
void get(int* one, int* two, char* op)
{
*one = _elementOne;
*two = _elementTwo;
*op = _operator;
}
private:
int _elementOne;
int _elementTwo;
char _operator;
};
Log.hpp文件:
cpp
#pragma once
#include<iostream>
#include<ctime>
#include<pthread.h>
std::ostream& Log()
{
std::cout << "For debug |" << " timestamp:" << (uint64_t)time(nullptr) << " | "
<< " Thread[" << pthread_self() << "]" << " | ";
return std::cout;
}
运行截图:

线程安全的单例模式
什么是单例模式
单例模式是一种 "经典的, 常用的, 常考的" 设计模式。
什么是设计模式
对一些经典场景的解决方案就是设计模式。
单例模式的特点
某些类, 只具有一个对象(实例), 就称之为单例.
饿汉实现方式和懒汉实现方式
饿汉方式实现单例模式
cpp
template <typename T>
class Singleton
{
static T data;
public:
static T *GetInstance()
{
return &data;
}
};
只要通过 Singleton 这个包装类来使用 T 对象, 则一个进程中只有一个 T 对象的实例
懒汉方式实现单例模式
cpp
template <typename T>
class Singleton
{
static T *inst = nullptr;
public:
static T *GetInstance()
{
if (inst == nullptr)
{
inst = new T();
}
return inst;
}
};
存在一个严重的问题, 线程不安全.
第一次调用 GetInstance 的时候, 如果两个线程同时调用, 可能会创建出两份 T 对象的实例.
但是后续再次调用, 就没有问题了.
懒汉方式实现单例模式(线程安全版本)
cpp
// 懒汉模式, 线程安全
template <typename T>
class Singleton
{
volatile static T *inst; // 需要设置 volatile 关键字, 否则可能被编译器优化.
static std::mutex lock;
public:
static T *GetInstance()
{
if (inst == NULL)
{ // 双重判定空指针, 降低锁冲突的概率, 提高性能.
lock.lock(); // 使用互斥锁, 保证多线程情况下也只调用一次 new.
if (inst == NULL)
{
inst = new T();
}
lock.unlock();
}
return inst;
}
};
注意事项:
- 加锁解锁的位置
- 双重 if 判定, 避免不必要的锁竞争
- volatile关键字防止过度优化
单例模式实现线程池
ThreadPool.hpp文件:
cpp
#include <iostream>
#include <queue>
#include <cstdlib>
#include <pthread.h>
#include <cassert>
#include <unistd.h>
#include "Task.hpp"
#include "Log.hpp"
#include "Lock.hpp"
using namespace std;
const int gThreadNum = 5;
template <class T>
class ThreadPool
{
private:
ThreadPool(int threadNum = gThreadNum)
: _isStart(false), _threadNum(threadNum)
{
assert(_threadNum > 0);
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_cond, nullptr);
}
ThreadPool(const ThreadPool<T> &) = delete;
ThreadPool &operator=(const ThreadPool<T> &) = delete;
public:
~ThreadPool()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_cond);
}
public:
static void *threadRoutine(void *args)
{
pthread_detach(pthread_self());
ThreadPool<T> *tp = static_cast<ThreadPool<T> *>(args);
while (1)
{
tp->lockQueue();
while (!tp->haveTask())
{
tp->waitForTask();
}
//这个任务就被拿到了任务的上下文中
T t = tp->pop();
tp->unlockQueue();
//规定所有的任务都必须有一个run方法
int result = t.run();
int one;
int two;
char op;
t.get(&one, &two, &op);
Log() << "新线程完成计算任务:" << one << op << two << "=" << result << endl;
}
}
void start()
{
assert(!_isStart);
for (int i = 0; i < _threadNum; i++)
{
pthread_t tmp;
pthread_create(&tmp, nullptr, threadRoutine, this);
}
_isStart = true;
}
void push(const T &in)
{
lockQueue();
_taskQueue.push(in);
choiceThreadForHandler();
unlockQueue();
}
static ThreadPool<T> *getInstance()
{
static Mutex mutex;
if (nullptr == instance) //仅仅是过滤重复的判断
{
LockGuard lockguard(&mutex); //进入代码块,加锁,退出代码块,自动解锁
if (nullptr == instance)
{
instance = new ThreadPool<T>();
}
}
return instance;
}
private:
T pop()
{
T tmp = _taskQueue.front();
_taskQueue.pop();
return tmp;
}
void lockQueue() { pthread_mutex_lock(&_mutex); };
void unlockQueue() { pthread_mutex_unlock(&_mutex); }
bool haveTask() { return !_taskQueue.empty(); }
void waitForTask() { pthread_cond_wait(&_cond, &_mutex); }
void choiceThreadForHandler() { pthread_cond_signal(&_cond); }
private:
bool _isStart; //表示该线程池是否启动
int _threadNum;
queue<T> _taskQueue;
pthread_mutex_t _mutex;
pthread_cond_t _cond;
static ThreadPool<T> *instance;
};
template <class T>
ThreadPool<T> *ThreadPool<T>::instance = nullptr;
ThreadPoolTest.cpp文件:
cpp
#include"ThreadPool.hpp"
#include"Task.hpp"
#include"Log.hpp"
#include<memory>
#include<ctime>
const string ops = "+-*/%";
int main()
{
srand((unsigned)time(nullptr) ^ getpid() ^ pthread_self());
// unique_ptr<ThreadPool<Task>> tp(new ThreadPool<Task>());
unique_ptr<ThreadPool<Task>> tp(ThreadPool<Task>::getInstance());
tp->start();
while(true)
{
int one = rand() % 50;
int two = rand() % 10;
char op = ops[rand() % ops.size()];
Log() << "主线程派发计算任务:" << one << op << two << "=?" << endl;
Task t(one, two, op);
tp->push(t);
sleep(1);
}
return 0;
}
STL 智能指针和线程安全
STL中的容器是否是线程安全的?
不是.
原因是, STL 的设计初衷是将性能挖掘到极致, 而一旦涉及到加锁保证线程安全, 会对性能造成巨大的影响. 而且对于不同的容器, 加锁方式的不同, 性能可能也不同(例如hash表的锁表和锁桶).
因此 STL 默认不是线程安全. 如果需要在多线程环境下使用, 往往需要调用者自行保证线程安全.
智能指针是否是线程安全的?
对于 unique_ptr, 由于只是在当前代码块范围内生效, 因此不涉及线程安全问题.
对于 shared_ptr, 多个对象需要共用一个引用计数变量, 所以会存在线程安全问题. 但是标准库实现的时候考虑到了这个问题, 基于原子操作(CAS)的方式保证 shared_ptr 能够高效, 原子的操作引用计数.
其他常见的各种锁
- 悲观锁:在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起。
- 乐观锁:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前, 会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。
- CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。
- 自旋锁,公平锁,非公平锁
简单了解自旋锁:

读者写者问题
读写锁
在编写多线程的时候,有一种情况是十分常见的。那就是,有些公共数据修改的机会比较少。相比较改写,它们读的机会反而高的多。通常而言,在读的过程中,往往伴随着查找的操作,中间耗时很长。给这种代码段加锁,会极大地降低我们程序的效率。那么有没有一种方法,可以专门处理这种多读少写的情况呢? 有,那就是读写锁。
首先先分析读者写者之间的关系:
三种关系:写者和写者(互斥)、读者和读者(没有关系)、读者和写者(互斥)
二种角色:读者和写者
一个场所:读写场所
问:为什么读写者和生产者消费者不同?读者作为类消费者为什么之间是没有关系的?
答:读写者和生产者和消费者之间最本质的区别就是消费者会把数据拿走,而读者不会拿走数据。

- 注意:写独占,读共享,读锁优先级高
读写者流程:
cpp
reader
加读锁
read()//读取内容
释放锁
writer
加写锁
write()//写入修改内容
释放锁
读写锁接口
设置读写优先
cppint 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 写者优先,但写者不能递归加锁 */
初始化
cppint pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,const pthread_rwlockattr_t *restrict attr);
销毁
cppint pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
加锁和解锁
cppint pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);//读加锁 int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);//写加锁 int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);//解锁
代码练习:
cpp
#include<pthread.h>
#include<iostream>
#include<unistd.h>
using namespace std;
int board = 0;
pthread_rwlock_t rw;
void* reader(void* args)
{
const char* name = static_cast<const char*>(args);
while(true)
{
pthread_rwlock_rdlock(&rw);
cout << name << " read: " << board << " tid: " << pthread_self() << endl;
sleep(10);
pthread_rwlock_unlock(&rw);
}
}
void* writer(void* args)
{
const char* name = static_cast<const char*>(args);
while(true)
{
pthread_rwlock_wrlock(&rw);
board++;
cout << "I am writer" << endl;
sleep(20);
pthread_rwlock_unlock(&rw);
}
}
int main()
{
pthread_rwlock_init(&rw, nullptr);
pthread_t r1, r2, r3, r4, r5, r6, w;
pthread_create(&r1, nullptr, reader, (void*)"reader1");
pthread_create(&r2, nullptr, reader, (void*)"reader2");
pthread_create(&r3, nullptr, reader, (void*)"reader3");
pthread_create(&r4, nullptr, reader, (void*)"reader4");
pthread_create(&r5, nullptr, reader, (void*)"reader5");
pthread_create(&r6, nullptr, reader, (void*)"reader6");
pthread_create(&w, nullptr, writer, (void*)"writer");
pthread_join(r1, nullptr);
pthread_join(r2, nullptr);
pthread_join(r3, nullptr);
pthread_join(r4, nullptr);
pthread_join(r5, nullptr);
pthread_join(r6, nullptr);
pthread_join(w, nullptr);
return 0;
}
运行截图:

从上面可以看到读者之间是没有关系的,多个读者可以并发运行。
问:系统中的读写锁是怎么做到的让读者并发运行,读者和写者互斥,写者和写者互斥的?
答:是通过类似下面的操作实现的(并不严谨,并没有将设置读者或者写者优先的设置加上):
cppstruct rwlock_t { int readers = 0; int who;//表示当前加锁的是读者还是写者 mutex_t mutex; } //读者 pthread_rwlock_rdlock(); lock(mutex); readers++; unlock(); read();//read操作 lock(mutex); readers--; unlock(mutex); pthread_rwlock_unlock(); //写者 pthread_rwlock_wrlock(); lock(mutex); while(readers > 0) { wait();//wait的同时会释放锁 } write();//write操作 unlock(); pthread_rwlock_unlock();