目录
一. 与线程互斥相关的概念
二. 线程安全问题
2.1 多个执行流访问临界区资源引发线程安全问题
2.2 可重入函数和线程安全的关系
三. 互斥锁 mutex
3.1 互斥锁功能
3.2 互斥锁的使用
3.3 互斥锁的实现原理
四. 死锁问题
四. 总结
一. 与线程互斥相关的概念
- 临界资源:被多个执行流共享的那部分资源,称为临界资源。
- 临界区:每个线程内部访问临界资源的那部分代码,称为临界区。
- 互斥:互斥保证任何时刻只有一个执行流进入临界区访问临界资源,用于对临界区资源进行保护,从而保证线程安全。
- 原子性:一个只有两种状态的操作,要么不执行操作,执行就一定全部完成,不具有中间状态,这样的操作我们说它具有原子性。
二. 线程安全问题
2.1 多个执行流访问临界资源引发线程安全问题
如代码2.1所示,就是一个典型的线程不安全代码,代码2.1为一个多线程抢票程序,这段代码的目的是让多个线程并发执行,从而提高抢票的效率,我们希望tickets为0的时候就不再继续抢票。但是,如图2.1,编译并运行代码,我们发现,tickets最后居然 < 0 了,这就是不安全造成的问题。
代码2.1:线程不安全的抢票程序
#include <iostream>
#include <cassert>
#include <pthread.h>
#include <unistd.h>
int g_tickets = 100; // 全局变量,表示剩余票数
void *getTickets(void *args)
{
char *para = (char*)args;
while(true)
{
if(g_tickets > 0)
{
usleep(1000);
printf("%s, tickets:%d\n", para, g_tickets);
--g_tickets;
}
else
{
break;
}
}
return nullptr;
}
int main()
{
pthread_t tid[4];
// 创建线程1-4
pthread_create(tid, nullptr, getTickets, (char*)"thread 1");
pthread_create(tid + 1, nullptr, getTickets, (char*)"thread 2");
pthread_create(tid + 2, nullptr, getTickets, (char*)"thread 3");
pthread_create(tid + 3, nullptr, getTickets, (char*)"thread 4");
// 等待线程
for(int i = 0; i < 4; ++i)
{
pthread_join(tid[i], nullptr);
}
std::cout << "main thread over" << std::endl;
return 0;
}
造成上述错误的问题,正是由多个线程同时进入getTickets函数中的临界区引发的。
首先来分析,为什么会出现tickets <= 0,但 if(g_tickets > 0) 内部的代码还在被运行的情况,可以按照这样的链路来理解:
- getTickets函数中,if内部为临界区,访问临界资源g_tickets,假设有2个线程(称为线程A和线程B)判断if成立,进入了临界区。
- 假如线程A的运气很不好,在进入if后,马上就被CPU给切换走了,线程B开始被调度,在线程B中执行了g_tickets--操作,将g_tickets的值减到了0,此时线程B终止,换上线程A继续执行。
- 此时的线程A执行流已经进入到了if内部,虽然g_tickets已经变为了0,但依旧不影响if内部的代码执行,这样就输出了tickets:0。
- 如果线程A执行g_tickets--将g_tickets的值变为负数,那么其他已经进入到了if内部的线程,就会读到负数并输出,这样就造成了输出tickets<=0的问题。
不光是多线程进入if内部会引发线程安全问题,--g_tickets也会存在线程不安全,按照下面假设的逻辑链,来理解--g_tickets为什么会存在线程安全问题:
- --/++指令,翻译成汇编代码后,有三条汇编指令会被先后执行:(1). 从物理内存中读取g_tickets到CPU寄存器中 (2). 执行--/++运算 (3). 将--/++后的g_tickets值写回内存。
- --g_tickets的三条汇编指令在运行的过程中,在任何位置都可能被打断。
- 假设线程A在第二条汇编指令执行完成后就被切换了,此时CPU寄存器中记录g_tickets的值为99,这个99被存储到线程A的PCB中,线程B被换上运行。
- 再假设线程B执行多次--g_tickets后才被切换,线程B被切换走的时候,已经将g_tickets减到了10并写回了内存。
- 当内存中记录g_tickets=10时,将此前被切换走的线程A拿到CPU从上次中断的位置开始运行,然而,由于进程A的PCB中记录了g_tickets的值为上次--g_tickets但还没来的及写入内存的99,g_tickets=99被放入CPU寄存器,--g_tickets的第三条汇编指令将99写入了物理内存,前面线程B将g_tickets减到10的工作白做了!!
上面就是线程不安全的现象,在项目开发过程中,应当采取编写可重入函数、加锁等多线程编程思想,来保证线程安全。
2.2 可重入函数和线程安全的关系
可重入函数、不可重入函数和线程安全的概念:
- 可重入函数:多个执行流同时进入函数,不会影响运行结果的函数,称为可重入函数。
- 不可重入函数:多个执行流同时进入函数,可能引发异常的函数,称为不可重入函数。
- 线程安全:多个执行流并发执行同一段代码,不会出现运行结果不同的代码,我们称其是线程安全的。
常见的不可重入函数的:
- 调用了 malloc / new 动态申请内存资源的函数(内存资源是由双链表管理的)。
- 进行了IO操作的函数(涉及缓冲区,有可能存在无序输入/输出)。
- 内部更改了全局变量或者静态变量的函数。
函数可重入与线程安全的关系:
- 如果一个函数是可重入的,那么它一定是线程安全的。
- 一个线程安全的程序,可以存在不可重入的函数(使用互斥锁避免多执行流访问临界资源)
三. 互斥锁 mutex
3.1 互斥锁功能
如代码2.1,多个线程同时进入临界区运行,会引发线程安全问题,那么,为了避免这样的问题,引入了互斥锁,通过将临界区锁死,来避免多执行流进入。
一块被锁死的临界区,就只允许一个执行流进入,其他执行流若想进入临界区,那么就必须阻塞等待,直到解锁。
这样,被加锁的区域,就由多线程下的并发执行,变为只能串行执行。
由于互斥锁避免了多执行流进入临界区,对临界区资源进行了保护,从而避免了线程安全问题。
如3.1为互斥锁实现的功能,执行流在进入到临界区之前为并行执行,由于临界区上锁,临界区内只能串行执行,出了临界区就解锁,从而恢复并行执行。
3.2 互斥锁的使用
创建互斥锁pthread_mutex_t:
- 在全局或者具备,定义 pthread_mutex_t 类型的对象,就完成了互斥锁的创建。
- 创建名为mtx的互斥锁语法:pthread_mutex_t mtx
互斥锁的初始化:
- 有两种方式可以实现对互斥锁的初始化
- 对于全局的互斥锁,可以直接将互斥锁对象赋值为PTHREAD_MUTEX_INITIALIZER,实现的语法为:pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER。
- 对于局部互斥锁,可以使用pthread_mutex_init函数来初始化。
pthread_mutex_init -- 初始化互斥锁
函数原型:int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *restrict attr)
函数参数:
- mutex -- 指向被初始化的互斥锁的指针。
- attr -- 初始化互斥锁的属性,一般传nullptr表示默认属性。
返回值:如果成功返回0,如果失败返回非0错误码。
互斥锁的销毁:
- 可调用pthread_mutex_destroy函数销毁指定互斥锁。
- 对于局部互斥锁才需要人工销毁,全局互斥锁进程结束便会自动销毁。
pthread_mutex_destroy -- 销毁互斥锁
函数原型:int pthread_mutex_destroy(pthread_mutex_t *mutex);
函数参数:mutex -- 指向被销毁的互斥锁的指针。
返回值:如果成功返回0,如果失败返回非0错误码。
对临界区代码上锁:
- 使用pthread_mutex_lock函数可以实现上锁
- 从pthread_mutex_lock函数被调用开始直到解锁,它们之间的代码都只允许一个执行流进入。
- 对临界区才需要上锁,如果对非临界区上锁,会降低效率。
pthread_mutex_lock -- 临界区代码上锁
函数原型:int pthread_mutex_lock(pthread_mutex_t *mutex)
函数参数:mutex -- 指向使用的锁的指针。
返回值:如果成功返回0,如果失败返回非0错误码。
解锁:
- 使用函数pthread_mutex_unlock函数解锁。
- 一旦离开临界区,应当马上解锁,避免降低效率。
pthread_mutex_unlock -- 解锁
函数原型:int pthread_mutex_unlock(pthread_mutex_t *mutex)
函数参数:mutex -- 指向被解开的锁的指针。
返回值:如果成功返回0,如果失败返回非0错误码。
代码3.1在2.1的基础之上,定义了全局互斥锁,在进入getTicket函数内的if条件判断式之前,调用pthread_mutex_lock函数对临界区上锁,当进行完IO操作、访问完临界资源g_tickets后,马上进行解锁,这样就保证了线程安全性,不会出现输出ticket为0或为负的情况。
代码3.1:使用全局互斥锁对临界区上锁
#include <iostream>
#include <ctime>
#include <cstdlib>
#include <vector>
#include <string>
#include <cassert>
#include <pthread.h>
#include <unistd.h>
#define THREAD_NUM 5 // 子线程数量
int g_tickets = 100; // 全局变量,表示剩余票数
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 定义并初始化全局互斥锁
void *getTickets(void *args)
{
std::cout << (char*)(args) << std::endl;
while(true)
{
// 加锁
int n = pthread_mutex_lock(&mutex);
assert(n == 0);
if(g_tickets > 0)
{
usleep(1000);
printf("%s, tickets:%d\n", (char*)args, g_tickets);
--g_tickets;
n = pthread_mutex_unlock(&mutex); // 解锁
assert(n == 0);
}
else
{
n = pthread_mutex_unlock(&mutex); // 解锁
assert(n == 0);
break;
}
usleep(rand() % 2000);
}
return nullptr;
}
int main()
{
srand((unsigned int)time(nullptr) ^ getpid()); // 种下随机数种子
pthread_t tid[THREAD_NUM];
// 生成线程命名
std::vector<std::string> name(THREAD_NUM, "thread ");
for(int i = 0; i < THREAD_NUM; ++i)
{
name[i] += std::to_string(i + 1);
}
// 创建线程
for(int i = 0; i < THREAD_NUM; ++i)
{
int n = pthread_create(tid + i, nullptr, getTickets, (void*)name[i].c_str());
assert(n == 0);
}
// 主线程等到子线程退出
for(int i = 0; i < THREAD_NUM; ++i)
{
int n = pthread_join(tid[i], nullptr);
assert(n == 0);
}
return 0;
}
代码3.2则使用了在main函数中定义局部互斥锁的方式,main函数中定义的局部互斥锁,需要传递给线程函数getTicket,但是,线程函数的参数为void*类型,我们不但希望传递互斥锁,还希望传入另一个char*类型的参数,这里采用定义struct结构体的方法,在struct threadData中定义string类型和pthread_mutex_t类型指针,将指向struct threadData类型对象的指针充当参数传给线程函数,这样就实现了在局部定义互斥锁并传给线程函数。
代码3.2:在局部(main函数)中定义互斥锁
#include <iostream>
#include <string>
#include <cassert>
#include <pthread.h>
#include <unistd.h>
#define THREAD_NUM 5 // 子线程数量
int g_tickets = 100; // 全局变量,表示剩余票数
// 用于向线程函数传参的结构体
struct threadData
{
std::string _name;
pthread_mutex_t *_ptx;
threadData(const std::string& name, pthread_mutex_t *ptx)
: _name(name)
, _ptx(ptx)
{ }
};
void *getTickets(void *args)
{
threadData *pth = (threadData*)args;
while(true)
{
// 加锁
int n = pthread_mutex_lock(pth->_ptx);
assert(n == 0);
if(g_tickets > 0)
{
usleep(1000);
printf("%s, tickets:%d\n", pth->_name.c_str(), g_tickets);
--g_tickets;
n = pthread_mutex_unlock(pth->_ptx); // 解锁
assert(n == 0);
}
else
{
n = pthread_mutex_unlock(pth->_ptx); // 解锁
assert(n == 0);
break;
}
}
delete pth;
return nullptr;
}
int main()
{
// 定义并初始化局部线程锁
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, nullptr);
pthread_t tid[THREAD_NUM];
// 创建子线程
for(int i = 0; i < THREAD_NUM; ++i)
{
std::string name = "thread ";
name += std::to_string(i + 1);
struct threadData* pth = new threadData(name, &mutex);
int n = pthread_create(tid + i, nullptr, getTickets, (void*)pth);
assert(n == 0);
}
// 等待子线程
for(int i = 0; i < THREAD_NUM; ++i)
{
int n = pthread_join(tid[i], nullptr);
assert(n == 0);
}
pthread_mutex_destroy(&mutex);
return 0;
}
3.3 互斥锁的实现原理
如果线程函数对一个全局变量(临界资源)做修改,那么,我们就认为,这个线程函数在访问临界资源,如果不加锁,它就是线程不安全的。但是,互斥锁也是临界资源啊,为什么多个线程使用一个互斥锁,不会出现线程安全问题呢?
这就要涉及到互斥锁上锁的底层实现了,如果上锁和解锁的操作都是原子的,不会出现中间状态,那么,这个互斥锁就是线程安全的。
站在汇编的角度,如果只执行一条汇编指令,我们认为单条汇编指令是原子的。而swap或exchange指令,可以实现将CPU寄存器中的数据和内存中的数据做交换,这个交换指令是原子的,互斥锁正是运用了这种交换,来保证上锁和解锁操作的原子性。
图3.2为上锁操作的伪代码和保证互斥锁线程安全的原理图,结合伪代码,并想象下面的场景,来理解互斥锁能保证线程安全的原因:
- 假设线程A正在被调度,线程A向寄存器中写入了0,然后寄存器数据与物理内存中mutex数据进行了交换,执行完swap后,线程A被切走,线程B被调度。
- 线程A在被切换的时候,要带走CPU寄存器中的相关数据到其PCB中去。
- 线程B先后执行move向寄存中写入0,执行swap交换寄存器和mutex的数据,此时寄存器中的数据变为了0,if条件判断不成立,线程B挂起等待,切换回线程A继续调度。
- 线程A再次调度时要把PCB中的数据写回寄存器,此时if判断成立,线程A就可以执行临界区代码了。
- 综上,我们发现,如果线程A拿到了锁(if判断寄存器数据 > 0 成立),那么,线程B及其他任意一个线程,都无法拿到锁,也就无法进入临界区执行代码。
- 由于是通过从CPU中读取值来判断是否拿到锁,上锁是通过swap来完成的,因此最初的mutex=1中的那个1,被每个线程换来换去,但始终不会拷贝,这个1只有一份,保证了不会有两个线程同时拿到锁,也就保证了临界区在某一时刻只能有1个执行流进入。
解锁的原理也非常简单,只需要向物理内存存储mutex的区域写入1,就表示这个锁被释放掉了,再次swap的时候,调度优先级高的线程就可以拿到锁,再次进入临界区执行代码,图3.3为上锁和解锁的伪汇编代码。
四. 死锁问题
在多线程中,如果每个线程都占用一定的资源,并且不释放资源,而每个线程都需要相互申请被其它线程所占用的资源,而造成整个进程处于永久等待状态的现象,叫做死锁。
图4.1展示了一种死锁的场景,线程1和线程2需要申请锁A和锁B,其中线程1先申请锁A再申请锁B,而线程2先申请锁B再申请锁A,如果线程1申请完锁A之后就被切换去执行线程2,而线程2就会先申请锁B,当线程2申请锁A的时候,由于线程1持有锁A,线程2不能申请成功,并且当线程1再次被调度想申请锁B的时候,由于线程2占有锁B,也无法成功申请。
这样就造成了线程1和线程2互相索要对方占有的资源,而使它们处于永久等待状态的问题,这种现象被称为死锁。
死锁问题,类似于智能指针shared_ptr的循环引用造成两处资源的释放互相依赖对方的释放,而谁也无法释放的问题。
五. 总结
- 在多线程场景下,如果对临界区代码不予以加锁保护,会出现运行结果不可预期的线程安全问题,如果一个函数是可重入的,那么它一定是线程安全的。
- 对临界区代码加锁,保证同一时刻只能有一个执行流进入执行,上锁是通过swap/exchange汇编指令,来保证原子性,从而保证上锁操作不会出现线程安全问题的。
- 如果几个线程互相申请其他线程已经占用的资源,那么就会出现死锁问题,所有线程都会一直处于等待状态。