目录
一. 与线程互斥相关的概念
二. 线程安全问题
2.1 多个执行流访问临界区资源引发线程安全问题
2.2 可重入函数和线程安全的关系
三. 互斥锁 mutex
3.1 互斥锁功能
3.2 互斥锁的使用
3.3 互斥锁的实现原理
四. 死锁问题
四. 总结
如代码2.1所示,就是一个典型的线程不安全代码,代码2.1为一个多线程抢票程序,这段代码的目的是让多个线程并发执行,从而提高抢票的效率,我们希望tickets为0的时候就不再继续抢票。但是,如图2.1,编译并运行代码,我们发现,tickets最后居然 < 0 了,这就是不安全造成的问题。
代码2.1:线程不安全的抢票程序
#include
#include
#include
#include
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) 内部的代码还在被运行的情况,可以按照这样的链路来理解:
不光是多线程进入if内部会引发线程安全问题,--g_tickets也会存在线程不安全,按照下面假设的逻辑链,来理解--g_tickets为什么会存在线程安全问题:
上面就是线程不安全的现象,在项目开发过程中,应当采取编写可重入函数、加锁等多线程编程思想,来保证线程安全。
可重入函数、不可重入函数和线程安全的概念:
常见的不可重入函数的:
函数可重入与线程安全的关系:
如代码2.1,多个线程同时进入临界区运行,会引发线程安全问题,那么,为了避免这样的问题,引入了互斥锁,通过将临界区锁死,来避免多执行流进入。
一块被锁死的临界区,就只允许一个执行流进入,其他执行流若想进入临界区,那么就必须阻塞等待,直到解锁。
这样,被加锁的区域,就由多线程下的并发执行,变为只能串行执行。
由于互斥锁避免了多执行流进入临界区,对临界区资源进行了保护,从而避免了线程安全问题。
如3.1为互斥锁实现的功能,执行流在进入到临界区之前为并行执行,由于临界区上锁,临界区内只能串行执行,出了临界区就解锁,从而恢复并行执行。
创建互斥锁pthread_mutex_t:
互斥锁的初始化:
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 -- 销毁互斥锁
函数原型:int pthread_mutex_destroy(pthread_mutex_t *mutex);
函数参数:mutex -- 指向被销毁的互斥锁的指针。
返回值:如果成功返回0,如果失败返回非0错误码。
对临界区代码上锁:
pthread_mutex_lock -- 临界区代码上锁
函数原型:int pthread_mutex_lock(pthread_mutex_t *mutex)
函数参数:mutex -- 指向使用的锁的指针。
函数行为:成功申请到锁就进入临界区执行,否则阻塞等待,直到申请到锁。
返回值:如果成功返回0,如果失败返回非0错误码。
pthread_mutex_trylock -- 尝试对临界区代码上锁
函数原型:int pthread_mutex_trylock(pthread_mutex_t *mutex);
函数参数:mutex -- 执行使用的锁的指针。
函数行为:如果申请到锁与pthread_mutex_lock函数的行为一致,进入临界区执行,如果申请锁失败,那么pthread_mutex_trylock直接返回,而pthread_mutex_lock会阻塞等待。
返回值:成功申请到锁返回0,否则返回非0错误码。
解锁:
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
#include
#include
#include
#include
#include
#include
#include
#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 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
#include
#include
#include
#include
#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;
}
如果线程函数对一个全局变量(临界资源)做修改,那么,我们就认为,这个线程函数在访问临界资源,如果不加锁,它就是线程不安全的。但是,互斥锁也是临界资源啊,为什么多个线程使用一个互斥锁,不会出现线程安全问题呢?
这就要涉及到互斥锁上锁的底层实现了,如果上锁和解锁的操作都是原子的,不会出现中间状态,那么,这个互斥锁就是线程安全的。
站在汇编的角度,如果只执行一条汇编指令,我们认为单条汇编指令是原子的。而swap或exchange指令,可以实现将CPU寄存器中的数据和内存中的数据做交换,这个交换指令是原子的,互斥锁正是运用了这种交换,来保证上锁和解锁操作的原子性。
图3.2为上锁操作的伪代码和保证互斥锁线程安全的原理图,结合伪代码,并想象下面的场景,来理解互斥锁能保证线程安全的原因:
解锁的原理也非常简单,只需要向物理内存存储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的循环引用造成两处资源的释放互相依赖对方的释放,而谁也无法释放的问题。
死锁的产生,必须要同时具备下面四个条件:
针对死锁产生的条件,为了避免死锁,可以采取以下措施: