目录
写在前面的话
相关背景概念
什么是互斥
互斥锁(互斥量)
互斥锁的使用
一些相关的问题
线程安全和可重入的区别
写在前面的话
本文章主要介绍了线程的互斥的相关内容,而且本文的概念也比较多,所以需要有一些前提知识作为铺垫,可以观看我的前几篇关于线程的文章,最好对线程有个基本的理解和认知后,再阅读效果会更加好。
相关背景概念
在了解Linux互斥时,我们需要先了解以下的概念:
- 临界资源:多线程执行流共享的资源就叫做临界资源(例如我们讲System V共享内存时,各进程通信的那一块区域便是临界资源)
- 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
- 原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
什么是互斥
互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
为什么要这么做呢,因为多线程的话还会存在一个问题,看下面这个例子:
多个线程 同时访问一个全局变量(所有线程共享),每个线程在执行时,都会进入一个循环,然后输出全局变量的值,每次将变量tickets--.
//如果多线程访问同一个全局变量,并进行数据计算,多线程会相互影响吗
int tickets = 10000;//在并发访问时,导致了数据不安全
void* getTickets(void* args)
{
while (true)
{
if(tickets > 0) //if判断本质也是一个计算的过程
{
usleep(1000);
printf("%p: %d\n",pthread_self(),tickets);
tickets--;
}
else
{
break;
}
}
return nullptr;
}
int main()
{
pthread_t t1,t2,t3;
pthread_create(&t1,nullptr,getTickets,nullptr);
pthread_create(&t2,nullptr,getTickets,nullptr);
pthread_create(&t3,nullptr,getTickets,nullptr);
pthread_join(t1,nullptr);
pthread_join(t2,nullptr);
pthread_join(t3,nullptr);
}
我们运行这段程序,得到了这样的结果:
出现了我们预料之外的结果-1,它是-1说明多减了一次,就像网上抢票,多售给了别人一张不存在的资源,这肯定就出问题了.
造成这种问题的原因在哪里呢? 假设有a和b两个线程,此时tickets=1. a线程在进入if语句后,刚执行完第一条语句,还没有执行tickets--,就被切换 到b线程了,由于此时tickets还没有--,所有b也依然能进入if语句,b运气好直接执行完后续的代码了,所以此时tickets=0,然后切回到a继续执行后面的代码,此时tickets又--,便造成了tickets=-1的现象。
所以多线程访问同一份共享资源是非常不安全的!
互斥锁(互斥量)
为了解决这种问题,根源就是不要让多个线程同时访问同一份资源,所以我们只需要给临界资源"上个锁",然后当一个线程进去后,其它的线程便不能再进去执行,直至进去的这个线程执行完毕,别的进程才可以进入,这个锁便是互斥锁,也称为互斥量.
初始化锁
这个锁的数据类型为pthread_mutex_t,创建出来需要进行初始化,
这里需要用到一个函数,pthread_mutex_init来初始化锁,该函数原型如下:
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
//如果定义的锁是全局的,则可以使用以下这种初始化
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
mutex
:一个指向pthread_mutex_t
类型变量的指针,用于存储初始化后的互斥锁。attr
:一个指向pthread_mutexattr_t
类型变量的指针,用于指定互斥锁的属性。可以传入NULL
使用默认属性
函数返回值:
- 成功初始化互斥锁时,返回 0。
- 若出现错误,返回一个非零的错误码
后面会讲解使用。
加锁
初始化完成之后,我们需要在临界区的前面加锁,加锁的函数为pthread_mutex_lock.
该函数原型如下:
int pthread_mutex_lock(pthread_mutex_t *mutex);
其中参数为刚开始创建的锁,代表从这里开始加锁,多个线程访问时,只允许有一个线程进入,其他线程则阻塞.
函数返回值:
- 成功加锁时,返回 0。
- 若出现错误,返回一个非零的错误码
解锁
加完锁后,需要在临界区后解锁,只有解完锁后,其他线程才可以进入,所以这里解锁的函数为pthread_mutex_unlock. 该函数原型如下:
int pthread_mutex_unlock(pthread_mutex_t *mutex);
同样地,参数是加锁的那把锁mutex。
函数返回值:
- 成功解锁时,返回 0。
- 若出现错误,返回一个非零的错误码
互斥锁的使用
学到了上面的函数,所以我们需要使用一下它们。整体流程是:我们首先要定义一把锁,然后初始化这把锁,紧接着在临界区入口处加锁,临界区出口处解锁.
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;;//pthread_mutex_t 就是原生线程库提供的一种数据类型
int tickets = 10000;//在并发访问时,导致了数据不安全
void* getTickets(void* args)
{
while (true)
{
//在临界区加锁
pthread_mutex_lock(&mtx);
if(tickets > 0)
{
usleep(1000);
printf("%s: %d\n",(char*)args,tickets);
tickets--;
//出了临界区就解锁
pthread_mutex_unlock(&mtx);
}
else
{
pthread_mutex_unlock(&mtx);
break;
}
}
return nullptr;
}
int main()
{
pthread_t t1,t2,t3;
pthread_create(&t1,nullptr,getTickets,(void*)"thread 1");
pthread_create(&t2,nullptr,getTickets,(void*)"thread 2");
pthread_create(&t3,nullptr,getTickets,(void*)"thread 3");
pthread_join(t1,nullptr);
pthread_join(t2,nullptr);
pthread_join(t3,nullptr);
}
这样变保证了这些线程不会同时访问这个临界资源,从而造成错误:
但是由于加锁和解锁,造成了效率上一定的下降.
当然可能由于sleep时间固定等各种因素,造成了只有一个线程在执行,我们可以加一个随机种子,使得sleep时间更加随机:
这样各个线程便都参与进来了:
回到最开始的初始化 ,当锁是全局变量的时候,可以直接使用PTHREAD_MUTEX_INITIALIZER进行初始化,但如果锁是局部变量的时候,那这个函数pthread_mutex_init该如何使用呢?
int main()
{
pthread_mutex_t mtx;
//初始化锁
pthread_mutex_init(&mtx,nullptr);
//...
//释放和销毁锁
pthread_mutex_destroy(&mtx);
return 0;
}
那我们发现,既然锁是局部变量,那么每个函数要用的时候怎么办,都当做参数传进去吗?
答案是必须得作为参数传入,但是这里可以有一些技巧:
要知道参数不仅仅可以是各种整型,数据类型,也可以是对象,所以我们可以在外面构造一个对象,然后每次创建线程时,将这个对象里面的内容同时也进行初始化,这样线程在调用回调函数时,直接把这个对象当做参数传入即可。
#define THREAD_NUM 5
struct ThreadData
{
public:
ThreadData(const string& n,pthread_mutex_t* pm)
:tname(n),
pmtx(pm)
{};
public:
string tname;
pthread_mutex_t* pmtx;
};
int main()
{
//....
pthread_t t[THREAD_NUM];
for(int i = 0; i < THREAD_NUM; i++)
{
string name = "thread ";
name += to_string(i);
ThreadData* td =new ThreadData(name,&mtx);
pthread_create(t+i,nullptr,getTickets,(void*)td);
}
//....
return 0;
}
一些相关的问题
Q1:加锁后 就是串执行了吗,加锁了以后,线程在临界区中会不会被系统调度切换走呢,会不会有什么问题呢,原子性体现在哪里呢?
A:
a.首先加锁后,这些线程以串行的方式执行临界区代码块。
b.加锁了以后,线程在临界区 会被调度走,但不会有问题。因为虽然被调度切换了,但是是持有着锁被切换的,所以其它线程想要进入临界区,也必须现申请锁,但是由于此时锁还没有释放,所以其他线程会申请失败,也不会进来,保证了临界区数据的一致性!
c.那原子性体现在哪里呢?在对一个没有持有锁的进程看来,最有意义的事情只有两个:
一 是某个线程没有持有锁(什么都没做);二 是某个线程释放锁(访问完临界资源了),此时我便可以访问临界资源了。这便是原子性的体现,对于某一个线程或进程,要么执行完毕,要么没有执行,不会有所谓的中间状态。
Q2:要访问临界资源,每个线程都必须申请锁,要申请锁,必须先让不同的线程看到这同一把锁,所以锁本身也是一种共享资源,那么谁来保证锁的安全?
A:为了保证锁的安全,申请和释放锁,内部操作一定是原子性的!
那么是如何保证锁内部是原子性的呢?即锁是如何实现的?
整体上说,是靠一行汇编指令实现的,汇编指令它一定是原子性的。具体如下:
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。 现在我们把lock和unlock的伪代码改一下:
以lock为例,假设就有a和b两个线程,al是一个CPU中的寄存器,a线程先进来,执行完第一条语句,将0写入到了al寄存器中,第二条语句还没有执行,b线程就来了,a必须带着自己的上下文al走了
b也是执行完第一条语句,将0写入al寄存器,然后紧接着执行第二条语句,将al中的内容0和自己内存中的mutex的值进行交换(假设mutex值是1),此时mutex的值便成为了0,al值成为了1,
紧接着a又过来把b换了,把自己的上下文填入到al寄存器中,al此时是0,然后与mutex交换,由于mutex与b线程的al=0之前进行了交换,所以此时mutex的值为0,所以和a线程中的al寄存器与mutex交换后依然是0,走到后面判读的时候,便把a挂起等待了,切到b执行,b由于此时al为1,可以正常执行,便成功申请到了锁.
线程安全和可重入的区别
首先我们要清楚这两个的概念,线程安全是针对于线程来说的,而可重入与不可重入是对于函数来说的,是函数的一种特征.
- 线程安全:当多个线程同时访问共享资源时,线程安全的代码能够保证在任何情况下都能正确地执行,并且最终保持数据的一致性。
常见线程安全情况:
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
常见的线程不安全情况:
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 可重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为可重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
常见可重入情况:
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据(例如拷贝errno)
常见不可重入函数:
- 可重入函数体内使用了静态的数据结构
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
线程安全与可重入区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
到这里,线程互斥的内容就讲完了,下一章将介绍死锁和线程同步的相关概念及使用。