欢迎来到小林的博客!!
️博客主页:✈️林 子
️博客专栏:✈️ Linux
️社区 :✈️ 进步学堂
️欢迎关注:点赞收藏✍️留言
在多线程情况下,如果有临界资源且没有保护临界资源的情况下。线程是不安全的。因为CPU的调度机制是随机的,而不是等你一个线程执行完才去执行另一个线程。可能在你这个线程执行到一半时,又切换到了另一个线程执行。
我们可以用下面这段代码来验证
#include
#include
#include
#define THREAD_MAX_NUM 5
int tickets = 10000;
void* ThreadRun(void* args)
{
int id = *(int*)args;
delete (int*)args;
//抢票逻辑
while(1)
{
if(tickets > 0)
{
usleep(1000);//延迟1000微秒
tickets--;
printf("thread %d 抢了一张票。还剩 %d 张票\n",id,tickets);
}else break;
}
return nullptr;
}
int main()
{
pthread_t tids[THREAD_MAX_NUM];
for(int i = 0 ; i < THREAD_MAX_NUM ; i++)
{
int* id = new int(i+1);
pthread_create(tids+i , nullptr,ThreadRun,(void*)id);
}
for(int i = 0 ; i < THREAD_MAX_NUM ; i++)
{
pthread_join(tids[i],nullptr);
}
return 0;
}
这段代码的逻辑就是创建5个线程,主线程等待5个线程。然后派这5个线程去抢10000张票(全局变量tickets)。 当票没了的时候跳出循环退出线程。
我们来看看运行结果:
第一次测试:
第二次测试:
第三次测试:
我们可以发现,三次测试结果。每次最后抢票都抢到了负数。这是非常危险的,如果在实际应用中,你只有100张票,却卖了105张。那么就会有5个用户没有座位,这影响是非常严重的。所以说,这个线程是不安全的。
为什么会这样呢?
我假设有2个线程,线程A和线程B。线程A先执行抢票,因为抢票的 tickets–并不是原子的。这条语句实际上是由三条汇编语句组成。 分别是: CPU加载tickets -> CPU对tickets进行减操作 -> CPU把tickets写回到内存。 而在这三个步骤中。如果在第二步完成,还没有走到第三步的时候。切换到了另一个线程B执行这段代码,线程B抢了5000票后,CPU又切到了线程A。并恢复线程A的上下文。可是在线程A的上下文中,tickets是9999。随后线程A又把9999写回到内存。 所以线程B明明已经抢了5000张票,又被线程A改回到了9999。这是非常非常不安全的。
如何保证线程安全呢?
我们只要让临界资源每次只能被一个执行流访问即可。即使是CPU调度切换,那么也要把没有访问权限的线程卡在临界资源之外。直到有访问权限的线程访问完临界资源之后。其他线程才能重新争夺这个访问权限。而这个访问权限,我们称它为锁
我们可以用互斥锁来保护一段区域,这段区域被称为临界区 。
临界区的资源每次只能被一个执行流访问!
而互斥锁是一个pthread_mutex_t
类型的变量。
通过pthread_mutex_init函数初始化,pthread_mutex_destroy销毁。
pthread_mutex_t mtx; //创建锁变量
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);//锁的初始化,返回0为成功。传入锁的地址和锁的属性
int pthread_mutex_destroy(pthread_mutex_t *mutex); //锁的销毁,返回0成功,传入锁的地址。
当然,如果你想用一个全局的,或者静态的锁。你可以用PTHREAD_MUTEX_INITIALIZER这个宏来为锁初始化。
用法:pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
锁创建和初始化之后,我们可以用pthread_mutex_lock 函数来加锁(本质是竞争锁,因为多个线程只能有一个线程持有锁)。 pthread_mutex_unlock函数来解锁。
加锁到解锁中间的区域,就是临界区。临界区只能同时被一个执行流访问!
int pthread_mutex_lock(pthread_mutex_t *mutex); //竞争锁
int pthread_mutex_unlock(pthread_mutex_t *mutex); //解锁
那么我们就用 PTHREAD_MUTEX_INITIALIZER
来初始化全局锁演示一下。
加锁后的代码:
#include
#include
#include
#define THREAD_MAX_NUM 5
int tickets = 1000;
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; //创建全局锁
void* ThreadRun(void* args)
{
int id = *(int*)args;
delete (int*)args;
//抢票逻辑
while(1)
{
//抢票这段资源为临界资源,我们为其加锁
pthread_mutex_lock(&mtx);
if(tickets > 0)
{
usleep(1000);//延迟1000微秒
tickets--;
printf("thread %d 抢了一张票。还剩 %d 张票\n",id,tickets);
pthread_mutex_unlock(&mtx); //解锁
}else
{
//这里也必须解锁。如果上面条件不成立,那么就要在这里进行解锁。
pthread_mutex_unlock(&mtx); //解锁
break;
}
}
return nullptr;
}
int main()
{
pthread_t tids[THREAD_MAX_NUM];
for(int i = 0 ; i < THREAD_MAX_NUM ; i++)
{
int* id = new int(i+1);
pthread_create(tids+i , nullptr,ThreadRun,(void*)id);
}
for(int i = 0 ; i < THREAD_MAX_NUM ; i++)
{
pthread_join(tids[i],nullptr);
}
return 0;
}
我们为这个线程加上了互斥锁。那么我们来运行看看这段代码。
第一次测试:
第二次测试:
第三次测试:
加锁之后我们可以明显的感觉到,票数不会再被买到负数了,这是能够保证线程安全的。但同样的,速度也降低了很多。没加锁时的购票速度是很快的,而加锁后的速度却变慢了很多。所以加锁也是有消耗的(主要在于临界区只能同时被单个执行流访问)。
局部互斥锁的函数上面已经介绍过了,我们直接使用即可。但是要注意的是,如果要把锁和id同时传给线程的话。我们最好指定一个ThreadData类。用来存储线程的数据。随后把这个类对象传给线程。
代码演示:
#include
#include
#include
#define THREAD_MAX_NUM 5
int tickets = 10000;
class ThreadData
{
public:
ThreadData(int uid,pthread_mutex_t* mtx):_uid(uid),_mtx(mtx){}
public:
int _uid;
pthread_mutex_t* _mtx;
};
void* ThreadRun(void* args)
{
ThreadData* data = (ThreadData*)args;
//抢票逻辑
while(1)
{
//抢票这段资源为临界资源,我们为其加锁
pthread_mutex_lock(data->_mtx);
if(tickets > 0)
{
usleep(1000);//延迟1000微秒
tickets--;
printf("thread %d 抢了一张票。还剩 %d 张票\n",data->_uid,tickets);
pthread_mutex_unlock(data->_mtx); //解锁
}else
{
//这里也必须解锁。如果上面条件不成立,那么就要在这里进行解锁。
pthread_mutex_unlock(data->_mtx); //解锁
break;
}
}
return nullptr;
}
int main()
{
//创建局部锁
pthread_mutex_t mtx;
//初始化局部锁
pthread_mutex_init(&mtx,nullptr);
pthread_t tids[THREAD_MAX_NUM];
for(int i = 0 ; i < THREAD_MAX_NUM ; i++)
{
ThreadData* data = new ThreadData(i+1,&mtx);
pthread_create(tids+i , nullptr,ThreadRun,(void*)data);
}
for(int i = 0 ; i < THREAD_MAX_NUM ; i++)
{
pthread_join(tids[i],nullptr);
}
//销毁锁
pthread_mutex_destroy(&mtx);
return 0;
}
代码的运行结果和上面的全局互斥锁是一样的。
我们都知道锁可以保证临界资源的安全。但是,锁也是被所有线程共享的。锁也是临界资源!!既然锁也是临界资源,那么锁如何保证自己是安全的?
就好比说一个1.5米的小瘦子说要保护一个2.0米的大胖子一样,小瘦子凭什么说能保证大胖子的安全?这时候小瘦子掏出了一把AK-47…大胖子才相信他能保护自己的安全…
这里有一份pthread_mutex_lock函数和pthread_mutex_unlock函数的伪代码
lock:
movb $0, %al
xchgb %al, mutex
if(al寄存器的内容 > 0)
{
return 0;
}else
挂起等待;
goto lock;
unlock:
movb $1, mutex
唤醒等待mutex的线程;
return 0;
xchgb是一条汇编指令,意思是交换两个数的值。
而互斥锁的实现原理就是用一条汇编指令,将%al寄存器的内容与mutex的内容进行交换。
接下来我将图文演示申请锁到释放锁的整个过程。
首先,看哪个线程先调用pthread_mutex_lock,假设线程B先调用。
这时候已经在线程B中调用了pthread_mutex_lock函数。执行第一句代码 movb $0, %al
,将0写入到寄存al中。
随后执行第二条指令xchgb %al, mutex
把al寄存器与mutex的内容进行交换。
突然,这时候CPU要调度线程A了。那么CPU把当前线程B运行的数据保存到线程B的上下文。也就是al寄存器的内容也会被保存到线程B的上下文。随后调度线程A。
调度线程A之后,在A调用了pthread_mutex_lock函数之后,执行第一条汇编语句 movb $0, %al
,把0写入到al寄存器中。
随后调用第二条汇编语句 xchgb %al, mutex
把寄存器的值与mutex的值进行交换。
随后线程A继续往后执行if(al寄存器的内容 > 0)
,不满足条件,保存了自己的上下文之后被CPU挂起等待。
此时CPU又切换到了线程B。
CPU切换到线程B后,线程B把保存的上下文交给了CPU,所以此时al寄存器的内容被线程B替换为了1。
随后线程B继续往后执行,if(al寄存器的内容 > 0)
条件为真,最后pthread_mutex_lock返回0。返回后执行的就是临界区的代码,也就是我们写的抢票逻辑。在这期间不管哪个线程被调度,当xchgb %al, mutex
这条指令被执行时。al的结果都不可能为1。因为mutex在第一次交换之后 ~ 锁释放之前会一直为0。所以,第一个 执行xchgb %al, mutex
这条与mutex进行交换的汇编指令的线程将会获得临界区的访问权限,也就是竞争锁成功!而竞争锁成功后,在释放锁之前,这段时间临界区只有竞争锁成功的线程可以访问。所以这就保证了线程安全。
因为 xchgb %al, mutex
的操作是原子的。所以锁也是原子的,因为只有这一句指令才是真正的竞争锁。即使在调用了 movb $0, %al
这条汇编之后,线程被切换。也无法影响什么,因为锁只有一个,只能让一个人换走。
释放锁的过程很简单,先把mutex的值恢复即可。
然后再把所有挂起的线程唤醒
注意!线程B在退出pthread_mutex_lock函数的时候,对应保存的al寄存器的上下文就已经不存在了!!不要认为线程B在执行临界资源代码时,上下文还保存着 al = 1这个字段。这个字段在退出pthread_mutex_lock函数时就已经不存在了。是保存的上下文没了,不是al寄存器没了!!!切记切记不要弄混。