文章目录
- 线程安全
- 互斥量
- 互斥锁的原理
- 线程安全补充
- 可重入函数
- 死锁
线程安全
由于多个线程是共享同一个地址空间的,也就是很多资源都是共享的,那么线程通信就会很方便,但是方便的同时缺乏访问控制,可能会由于一个线程的操作问题,导致其他线程异常、崩溃、逻辑不正确等问题,这就是线程安全问题
例如多个线程同时使用printf函数实际上在共享stdout资源,而stdout资源只有一个,多个线程都在使用它就可能导致打印出现乱码现象,只要涉及到全局的数据就会有线程安全问题。
下面设计一个抢票逻辑来验证一下线程安全问题
#include <iostream>
#include <pthread.h>
#include <string>
#include <unistd.h>
//抢票逻辑,1000张票,设5个线程同时抢
//tickets就是临界资源
//线程 在时间片到了、从内核态返回用户态时会进行切换
int tickets = 1000;
void* ThreadRun(void* args)
{
int id = *(int*)args;
delete (int*)args;
while(true)
{
if(tickets > 0)
{
usleep(10000);
std::cout << "线程[" << id <<"]正在抢票. . .剩余票数:" << tickets << std::endl;
tickets--;
}
else
break;
}
}
int main()
{
pthread_t tid[5];
for(size_t i = 0;i < 5;i++)
{
int* id = new int(i);
pthread_create(tid+i,nullptr,ThreadRun,(void*)id);
}
for(size_t i = 0;i < 5;i++)
pthread_join(tid[i],nullptr);
return 0;
}
上面的执行结果发现,最后抢票抢到了负数,很明显是出现了线程安全问题,实际中是绝对不能出现抢票抢到负数、两个人买到同一个票的问题
为什么会出现这种现象?
当多个进程竞争CPU的时候,CPU为了保证每个进程能公平被调度运行,采取了处理任务时间分片的机制,轮流处理多个进程,每个进程都执行一段时间后切换至下一个进程不断循环直到执行结束,由于CPU处理速度非常快,在人类的感官上认为是并行处理,实际是伪并行,同一时间只有一个任务在运行处理。
所以每个task_struct被cpu调度都是有时间片的,当线程1被cpu调度后时间片开始计时,同时cpu中的寄存器会产生线程1的上下文数据,当时间到了后,寄存器会记录线程1执行到哪,线程1会存储这些数据,下一次执行的时候再加载至寄存器中继续执行。
假如票就剩1张了,线程1刚执行完票数减的代码时间片就到了就被切换至线程2;当线程1再一次到达运行队列顶端后加载他的数据到CPU中,寄存器会根据上下文数据,接着上一次的代码结束位置继续执行,那么此时线程1认为票数剩一张执行的自减,而实际是在线程1被切换下来的时候,ticktest又被线程2自减了1次,但是线程1再次被调度是接着上一次的代码结束的位置继续执行的,所以就出现了抢到负数的现象
互斥量
上述抢票逻辑解决方案:对临界区的资源进行保护
方式:互斥
互斥:在任意时刻,只允许一个执行流访问临界区(即对临界区加互斥锁)
临界资源:像打印数据到显示器这样的就可以看作是临界资源,tickets是全局变量,就是一种临界资源
临界区:访问临界资源的代码区域
pthread库提供了互斥锁的相关函数,如下所示:
-
初始化互斥量
int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr); 方式2:使用宏初始化 pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
- pthread_mutex_t:互斥量的数据类型,需要在全局(临界区)定义一个此类型的变量
- restrict mutex:要初始化的的互斥量
- restrict attr:相关属性设置,一般设置NULL交给OS去设置
- 返回值:成功返回0,失败返回错误码
-
销毁互斥量
int pthread_mutex_destroy(pthread_mutex_t *mutex);
-
销毁互斥量
-
注意:
使用静态分配PTHREAD_MUTEX_INITIALIZER初始化的互斥量,不需要销毁
不要销毁一个已经加锁了的互斥量
已经销毁了的互斥量,要确保后面不会有线程再加锁。
-
返回值:成功返回0,失败返回错误码
-
-
int pthread_mutex_lock(pthread_mutex_t *mutex); int pthread_mutex_lock(pthread_mutex_t *mutex);
- lock:加锁,线程调用该函数让互斥量上锁,如果该互斥锁已被另一个线程锁定和拥有,则调用此函数的该线程将阻塞,直到该互斥锁变为可用为止
- unlock:解锁,解除锁定 mutex 所指向的互斥锁
- 返回值:成功返回0,失败返回错误码
上述代码变化如下:
class ticket
{
public:
ticket()
:tickets(1000)
{
pthread_mutex_init(&mtx,nullptr);//初始化锁
}
~ticket()
{
pthread_mutex_destroy(&mtx); //销毁锁
}
bool GetTicket()
{
bool ret = true; //ret不是临界资源
pthread_mutex_lock(&mtx); //加锁
if(tickets > 0)
{
usleep(1000);
std::cout << "线程[" << pthread_self() << "]正在抢票. . .剩余票数:" << tickets << std::endl;
tickets--;
}
else
{
std::cout << "剩余票空" << std::endl;
ret = false;
}
pthread_mutex_unlock(&mtx); //解锁
return ret;
}
private:
int tickets;
pthread_mutex_t mtx; //创建锁
};
void* ThreadRun(void* args)
{
ticket* id = (ticket*)args; //传入的是同一个对象,所以该对象是临界资源
while(true)
{
if(!id->GetTicket()) //票余量空则退出
break;
}
}
int main()
{
pthread_t tid[5];
ticket* id = new ticket();
//创建线程
for(size_t i = 0;i < 5;i++)
pthread_create(tid+i,nullptr,ThreadRun,(void*)id);
//线程等待
for(size_t i = 0;i < 5;i++)
pthread_join(tid[i],nullptr);
return 0;
}
上述代码在堆区申请了一个ticket类,又把首地址做为参数传给函数,所有线程访问的ticket就是同一个了(临界资源),在 pthread_mutex_lock(&mtx);[.....] pthread_mutex_unlock(&mtx);
之间的执行流就是互斥的,串行执行。
但是要注意: GetTicket函数中的bool变量不是临界资源,它是在栈区的,谁使用谁创建,函数执行完后销毁
互斥锁的原理
要访问tickets,就要先访问mtx,mtx需要被所有线程看到,那么锁也是一种临界资源,如何保证锁的安全呢?
下面用一段加锁/解锁伪代码来解释互斥锁的原理:
lock: movb $0 %al //线程A、B在CPU上运行时,情况al寄存器
xchgb %al mutex //线程A把mutex与寄存器内容交换,线程A运行时CPU的al寄存器就有个对应数据
if(al寄存器内容 > 0) //线程B运行时虽然也进行交换,但是mutex为0,交换完的al还是0
{
return 0;//加锁成功 //线程A继续往下执行时检查它在CPU运行时的al寄存器判定有锁
} //线程B继续往下执行时,检查它在CPU上运行时,al寄存器没对应数据,即无锁,将该线程
else //挂起等待(PCB被挂到等待队列)
{ //即便CPU时间片到了中途切换走:A运行时的,会把它在寄存器中相关数据存储 抱着锁走的
//挂起等待 //B也同理,所以在下一次CPU调度A或B运行时,只需要检测al寄存器的数据
} //而其他线程、后来创建的线程,无论如何也拿不到锁:
goto lock //1.mutex是全局的,已经由A在拿锁是交换为0了,在后续判断就会被判定无锁,随后被挂起
//2.A在cpu上运行的时间片到了,也会抱着锁走的(存储自己运行时寄存器产生的数据)
unlock:
movb $1 mutex //只能等A解锁,重置mutex的值,并唤醒等待的线程(处于S状态的PCB)
唤醒等待线程
return 0
-
al是寄存器,被多个线程共享,但是数据不是共享的
CPU有一组寄存器,其中就有al,al可以说是共享的,但是al的数据是不一样的;每个线程被CPU调度后,al寄存器会产生相应的数据,当线程的时间片到了以后数据会被线程存储,下个线程被调度后,又会把它的数据加载到寄存器中。所以每个线程在被调度时都会有al寄存器,但是他们的数据是私有的。
-
伪代码中的xchgb可以看成是swap或者exchange
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的 数据相交换,由于只有一条指令,保证原子性。
- 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
加锁:
线程执行加锁函数时,执行到
movb $0 %al
,把al寄存器清0之后会把内存当中的变量mutex 与 寄存器al进行数据交换(mutex一开始是1)
之后进行对寄存器进行判断,判断是否为0,不为0则表示加锁成功,为0则表示锁被占用,把当前竞争锁的线程挂起等待
中途若是时间片到了,是会把al寄存器的内容一起存储的,也就是”抱着锁被走的“,下一次被调度又会把数据重新加载到寄存器
解锁:
- 当拿到锁的线程执行解锁函数时,先把mutex重新置1,其他的线程会被唤醒等待之后会经goto跳转到第一个行重新执行
注意:
-
带锁执行是比不带锁执行要慢一些的,因为加锁解锁也需要时间
-
只要有一个线程拿到锁,其他线程就会被挂起等待,除非拿到锁的线程解锁唤,否则其他线程是无法访问临界区的,从而保证了线程安全
-
所以为什么加锁函数可以保证原子性,主要是因为,核心争锁的部分是这一条交换语句
xchgb %al mutex
,只会出现执行了这条语句和没执行这条语句的情况;- 看谁先执行这条语句,谁就拿到了锁,就算执行完后时间片到了或者被中断了那也是已经争到了锁。
而自己设置个全局变量,利用++ 、-- 赋值等操作模仿加锁是不行的,这些操作没有原子性。
- 例如++,在汇编层面需要三条语句才能全部执行完++的逻辑功能;若一个线程执行时中途发生中断,下一次接着中断位置执行完,而其他线程在中断期间执行过++了,可能全局变量的数据就异常了。
线程安全补充
多个线程并发同一段代码时,不会出现不同的结果。常见于全局变量或者静态变量进行操作,并且没有锁保护的情况下,会导致线程不安全。例如多个线程打印hello 可能会出现乱序
线程安全的情况:
-
每个线程对于全局会在静态变量只有读权限,没有写权限
-
类或者接口对于线程来说时原子的。
-
多线程切换不会导致结果出现二义性。
反之就是不安全的
可重入函数
可重入函数:同一函数被不同执行流调用,当前线程还没执行完,就有其它进程进入,我们称之为重入。一个函数在重入的情况下,运行结果不会有任何问题,则该函数称为可重入函数,否则就是不可重入函数。
不可重入函数如:malloc、new、free、io操作的相关函数等
可重入函数与线程安全的问题:
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的
死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资 源而处于的一种永久等待状态。
例如,拿了锁却没释放,之后又去申请锁,导致这个有锁的线程也挂起等待,从而变成永久等待
死锁的必要条件:
-
不可剥夺(不能改):执行流获取了互斥锁之后,除了自己主动释放锁,其他执行流不能解该互斥锁
-
循环等待:线程A等待线程B拿的锁,线程B等待线程A拿的锁
-
互斥条件(不能改):一个互斥锁,只能被一个执行流在同一时刻拥有
-
请求与保持:线程A拿着 1 锁还想请求 2 锁,线程B拿着 2 锁还想请求 1 锁
只有同时满足上述4点,才会导致死锁;破坏死锁的其中一个必要条件即可避免