Linux 线程安全
Linux 线程互斥
前置概念
- 临界资源: 多线程执行流共享的资源叫做临界资源
- 临界区: 每个线程内部,访问临界资源的代码,叫做临界区
- 互斥: 任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,保护临界资源
- 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么全部完成,要么全部失败
为什么要保证互斥以及原子性
在多线程情况下,如果多个执行流都自顾自对临界资源进行操作,那么此时可能会导致数据不一致问题。解决该问题的方案就叫做互斥,互斥的作用就是,保证在任何时候有且只有一个执行流进入临界区对临界资源进行访问
例如下面我们模拟实现一个抢票系统,将票的剩余张数定义位全局变量,主线程创建五个线程让这五个线程进行抢票
#define THREAD_COUNT 5
int ticket_count = 1000;
void* Routine(void* arg) {
int thread_no = *(int*)arg;
delete (int*)arg;
while (1) {
if (ticket_count > 0) {
usleep(10000);
printf("[%d] get a ticket, ticket_num = %d\n", thread_no, ticket_count);
ticket_count -= 1;
} else {
break;
}
}
printf("thread %d quit\n", thread_no);
printf("ticket_count = %d\n", ticket_count);
return (void*)0;
}
void get_ticket_test(){
pthread_t tids[THREAD_COUNT];
for (int i = 0; i < THREAD_COUNT; i++) {
int* thread_no = new int(i);
pthread_create(tids + i, NULL, Routine, (void*)thread_no);
}
for (int i = 0; i < THREAD_COUNT; i++) {
pthread_join(tids[i], NULL);
}
}
[3] get a ticket, ticket_num = 1
thread 3 quit
ticket_count = 0
[2] get a ticket, ticket_num = 0
thread 2 quit
ticket_count = -1
[4] get a ticket, ticket_num = -1
thread 4 quit
ticket_count = -2
[0] get a ticket, ticket_num = -2
thread 0 quit
ticket_count = -3
[1] get a ticket, ticket_num = -3
thread 1 quit
ticket_count = -4
// 可以看到运行结果并不符合我们的预期,其中出现了票位负数的情况
剩余票数(ticket_count)就是临界资源,因为它被多个执行流同时访问,而判断ticket_count 是否大于0、打印剩余票数以及 ticket_count -= 1
这些代码就是临界区,因为这些代码对临界资源进行了访问。
ticket_count出现负数很可能是一个执行流if条件判断为真后,还未来得及打印就被切走了,其它线程也进入临界区店对ticket_count进行操作,导致再次切回该线程时ticket_count已经是负数了,然后该线程继续执行后面的代码继续对ticket_count进行-1操作并且打印。
Mutex 互斥量
为解决上述问题,需要保证各个线程的代码必须有互斥行为:当代码要进入临界区执行时,不允许其它线程进入临界区。如果多个线程同时要求执行临界区代码,并且临界区此时没有线程执行,那么只能允许一个线程进入临界区。如果线程不再临界区中执行,那么该线程不能阻止其它线程进入临界区
做到这三点,本质就是需要一把进入临界区的锁,Linux上提供的这把锁叫做互斥量
互斥量的接口
初始化互斥量
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr); // 动态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 静态分配
-
mutex : 需要初始化的互斥量
-
attr
(attribute 属性) : 初始化互斥量的属性,一般设置成NULL即可 -
互斥量创建成功返回0, 失败返回错误码
销毁互斥量
int pthread_mutex_destroy(pthread_mutex_t *mutex);
- mutex 需要销毁的互斥量
- 互斥量销毁成功返回0,失败返回错误码
注意:使用静态分配 也就是 PTHREAD_MUTEX_INITIALIZER 初始化的互斥量不需要销毁,不能销毁一个已经加锁了的互斥量,已经销毁的互斥量要保证后面不再有线程尝试对其加锁
互斥量加锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
- mutex : 需要加锁的互斥量
- 互斥量加锁成功返回0,失败返回错误码
lock()
和try_lock()
的区别在于lock()
是阻塞式进行等待,而trylock()
不阻塞成功返回0,失败返回错误码
互斥量解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);
- mutex : 需要加锁的互斥量
- 互斥量加锁成功返回0,失败返回错误码
互斥量的使用
我们可以使用互斥量来优化我们的抢票系统,保证业务的逻辑正确
#define THREAD_COUNT 5
int ticket_count = 1000;
pthread_mutex_t mutex; // 创建互斥量
void* Routine(void* arg) {
int thread_no = *(int*)arg;
delete (int*)arg;
while (1) {
pthread_mutex_lock(&mutex); // 对临界区代码进行加锁
if (ticket_count > 0) {
usleep(10000);
printf("[%d] get a ticket, ticket_num = %d\n", thread_no, ticket_count);
ticket_count -= 1;
pthread_mutex_unlock(&mutex); // 解锁
} else {
pthread_mutex_unlock(&mutex);
break;
}
}
printf("thread %d quit\n", thread_no);
printf("ticket_count = %d\n", ticket_count);
return (void*)0;
}
void get_ticket_test(){
pthread_t tids[THREAD_COUNT];
pthread_mutex_init(&mutex, NULL); // 初始化互斥量
for (int i = 0; i < THREAD_COUNT; i++) {
int* thread_no = new int(i);
pthread_create(tids + i, NULL, Routine, (void*)thread_no);
}
for (int i = 0; i < THREAD_COUNT; i++) {
pthread_join(tids[i], NULL);
}
pthread_mutex_destroy(&mutex); // 销毁互斥量
}
int main(){
get_ticket_test();
return 0;
}
这样在抢票过程中就不会出现剩余票数为负数的情况
- 在大部分情况下,加锁本身就是有损性能的事情,它让多执行流由并行执行变成串行执行,这几乎是不可避免的
- 我们应该在合适的场合进行加锁、解锁,这样尽可能会减少加锁带来的性能开销成本
- 进行临界资源的保护,是所有执行流都应该遵循的标准,需要程序员在编码的时候非常注意
互斥量的原理
加锁后的原子性的体现
引入互斥量后,当一个线程申请锁进入到临界区时,那么在其它线程看来该线程只有两种状态,要么该线程持有锁,要么该线程没有持有锁,只有该线程没有申请锁或已经将锁释放,对其它正在阻塞的线程才是有意义的。那么线程一访问临界区的过程就是原子的,只有其完全执行完毕,才会释放锁
临界区的线程可以进行线程切换
临界区的线程完全可以进行线程切换,一个线程持有锁,其被切走时也会将锁带走,其它线程无法拿到锁也就无法对临界区进行资源访问。
其它线程想要进入临界区,就必须等待该线程再次回来执行完临界区代码后将锁释放,才可以申请到锁,申请到锁才能进入临界区
锁是如何保护自己,并且保护临界资源的
我们说被多个执行流共享的资源叫做临界资源,访问临界资源的代码叫做临界区。所有的线程想要进入临界区前都需要竞争锁。因此锁也是被多个执行流共享的资源,所以锁本身就是临界资源,那么锁是如何保护自己的呢?
锁自己保护自己,只要保证申请锁的过程是原子的,那么就认为锁是安全的。
我们认为一条汇编指令是原子的,上述的++, --
操作由三条汇编组成,并不是原子操作会导致数据不一致问题。互斥锁的操作,大多数体系结构都提供了swap()
或exchange()
指令,该指令的作用就是将寄存器和内存单元的数据进行交换。由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期
操作系统的工作原理
操作系统一旦启动成功就是一个死循环,其依靠一个叫做时钟的硬件,时钟每隔一段时间就会向操作系统发送一个时钟中断,操作系统会根据时钟中断去执行中断向量表。中断向量表本质就是一个函数表,比如刷新磁盘的函数、检测网卡的函数、以及刷新数据的函数等。时钟不断向操作系统发送时钟中断,操作系统就会根据时钟中断不断执行相应的代码
在多处理器平台中,CPU可能有多个,但是总线只有一套。CPU和内存都是计算机中的硬件,这两个硬件之间要进行数据交互一定是要用线连接起来的,其中我们将CPU和内存连接的线叫做系统总线,把内存和外设连接的线叫做IO总线。
由于系统总线只有一套,有的时候CPU访问内存是想从内存读取指令(代码),有的时候是想从内存读取数据,所以总线总是被不同的操作种类所共享的。计算机是通过总线周期来区分此时总线中传输的是哪一种资源
lock 以及 unlock 原理
根据上述知识我们可以简单写出lock 和 unlock的代码
lock :
movb $0, %al // % al 是寄存器中的内容
xchgb %al, mutex
if (al 寄存器中的内容 > 0) {
return 0;
} else {
// 挂起等待
goto lock;
}
unlock :
movb $1, mutex
// 唤醒在等待Mutex的线程 就是被lock阻塞住的线程
return 0;
申请锁
我们可以认为mutex的初始值为1,al是计算机中的一个寄存器,当线程申请锁(pthread_mutex_lock()
)时,需要执行以下步骤:
1、先将al寄存器中的值清0.该动作可以被多个线程同时执行,因为每隔线程都有自己的一组寄存器(上下文信息)
2、然后交换al寄存器和mutex中的值,因为xchgb
时体系结构提供的交换指令,该指令可以完成寄存器和内存单元之间的数据交换
3、最后判断al寄存器中的值是否大于0,若交换前mutex中值为1则al寄存器此时应该是大于0的,那么申请锁成功,若锁已经被其它线程拿走,那么mutex中的值应该为0,交换后al寄存器中的值还是为0,则会给挂起等待,直到锁被释放后再次竞争申请锁
那么即便这个线程被因为时间片或者其它原因被切走,锁的信息也会被打包到该线程的上下文信息中一并被带走。其它线程无法拿到该锁的数据也就无法让al寄存器内部数据大于零。直到该线程重新被切回来,直到执行完临界区代码释放锁后其它线程才有可能拿到锁。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jYjNLs7D-1688649479318)(C:\Users\Lenovo\AppData\Roaming\Typora\typora-user-images\image-20230703171853538.png)]
常见锁的概念
死锁
死锁是指在一组线程中各个线程均拥有占有一份不会释放的资源的能力,但因为互相申请被其它线程所占用不会释放的资源而处于永久等待的状态
单执行流可能产生死锁吗?? 可以,如果某一个执行流连续申请了两次锁,那么该执行流会被永久挂起
查看死锁状态
我们可以通过ps
指令查看进程状态
void* Routine2(void* arg) {
pthread_mutex_lock(&mutex);
pthread_mutex_lock(&mutex); # 连续申请两次导致死锁
return (void*)0;
}
void deadlock_test(){
pthread_t tid;
pthread_mutex_init(&mutex, NULL);
pthread_create(&tid, NULL, Routine2, NULL);
pthread_join(tid, NULL);
pthread_mutex_destroy(&mutex);
}
[clx@VM-20-6-centos pthread_api]$ ps -axj | head -1 && ps -axj| grep clx_test | grep -v grep
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
1 5906 5906 5906 ? -1 Ssl 1001 0:00 /home/clx/.VimForCpp/nvim clx_test.cpp
21525 30745 30745 21525 pts/12 30745 Sl+ 1001 0:00 ./clx_test # 可以看到当前线程被锁住了
如何理解线程被阻塞住了
进程运行时是被CPU调度的,换句话说进程在调度时是需要使用到CPU资源的,每一个CPU都有都有一个运行的等待队列(run_queue),CPU在运行时就是通过从该队列中获取进程PCB来对进程进行调度的
在运行队列中的进程本质上就是在等待CPU资源,实际上不只是CPU资源如此,等待其它资源也是这样,比如锁资源,磁盘资源,网卡资源等,各种资源都有自己的等待队列
互斥量(锁)也是一种资源,如果一个线程需要锁资源,但此时锁资源被其它线程进行占用,那么此时该线程的状态就会由R状态转换为S状态。并且该进程会被移出CPU的运行等待队列,被链接到等待该锁资源的资源等待队列中,而CPU则继续调度运行等待队列中的下一个进程
直到使用锁的进程已经使用完毕,也就是锁资源已经准备就绪,此时就会从锁的资源等待队列中唤醒一个进程,将该进程的状态由S改变成R状态,并将该进程的task_struct连接到运行等待队列,等到CPU再次调度该进程时,该进程就可以使用到锁资源了
总结 站在操作系统的角度,进程等待某种资源,就是将该进程的task_struct 放入对应的等待队列中,这种情况可以称之为进程被挂起等待了。站在用户角度,用户看到就是该执行流卡住不动了。 并且这里所说的资源既可以是硬件资源也可以是软件资源,锁的本质就是一种软件资源,当我们申请锁时,锁当前可能没有就绪,可能正在被其它线程占用,此时当其它线程来申请锁的时候,就会被放到这个锁的等待队里俄中
产生死锁的四个条件
- 互斥条件 : 一个资源每次只能被一个执行流使用
- 请求与保持条件: 一个执行流因为请求资源而阻塞时,对已经获得的资源保持不妨
- 不剥夺条件: 一个执行流已经获得的资源,在未使用完全前,不能强行剥夺
- 循环等待条件: 若感执行流之间形成一种头尾相接的循环等待资源关系
线程1 线程2 # 若线程1 拿到了锁1 线程2 拿到了锁2那么线程1 那么就会陷入死锁
锁1 锁2 # 也就是不同线程加锁关系达成 1221
锁2 锁1 # 为避免死锁需要线程均保持 12 12的加锁顺序
释放1 释放2
释放2 释放1
- 破坏死锁产生的四个必要条件
- 加锁顺序保持一致
- 避免锁未释放的场景
- 资源一次性分配
除此之外还有一些避免死锁的算法,比如死锁检测法和银行家算法
Linux 线程同步
同步概念和竞态条件
同步: 在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,这就叫做同步
竞态条件:因为时序问题,导致程序异常,我们称之为竞态条件
-
首先需要明确,单纯的加锁是存在某种问题的,如果个别线程竞争能力很强,每次都能竞争到锁,那么就会导致其它大部分线程长时间拿不到锁造成饥饿问题
-
单纯的加锁可以保证线程安全(临界资源安全)但是不能保证让所有线程高效使用临界资源
条件变量
条件变量是利用线程间共享的全局变量进行同步的一种机制,条件变量是用来描述某种资源是否就绪的一种数据化描述
条件变量主要包括两个动作:
- 一个线程等待条件变量的条件成立而被挂起
- 一个线程使条件变量的条件成立后唤醒等待的线程
条件变量通常需要配合互斥锁一起使用
条件变量对应接口
初始化条件变量
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
-
cond
: 需要初始化的条件变量 -
attr
(attribute 属性) : 初始化条件变量的属性,一般设置成NULL即可 - 成功返回0,失败返回错误码
销毁条件变量
int pthread_cond_destroy(pthread_cond_t *cond);
等待条件变量满足
int pthread_cond_timedwait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex, const struct timespec *restrict abstime);
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
-
cond
: 需要等待的条件变量 - mutex : 当前线程所处的临界区对应的互斥锁
- 成功返回0,失败返回错误码
唤醒等待
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
-
pthread_cond_broadcast
函数用于唤醒等待队列的全部线程 -
pthread_cond_signal
函数用于唤醒等待队列中的首个线程
我们使用主线程创建三个新线程,让主线程控制三个新线程活动
#define THREAD_NUM 3
pthread_mutex_t mutex;
pthread_cond_t cond;
void* Routine(void* arg) {
pthread_detach(pthread_self());
std::cout << (char*)arg << " run..." << std::endl;
while (true) {
pthread_cond_wait(&cond, &mutex);
std::cout << (char*)arg << "活动..." << std::endl;
}
return (void*)0;
}
void cond_test1(){
pthread_mutex_init(&mutex, NULL);
pthread_cond_init(&cond, NULL);
pthread_t tids[THREAD_NUM];
for (int i = 0; i < THREAD_NUM; i++) {
char* buffer = (char*)malloc(64);
sprintf(buffer, "thread %d", i);
pthread_create(tids + i, NULL, Routine, (void*)buffer);
}
string msg;
while (true) {
getline(cin, msg);
pthread_cond_signal(&cond);
}
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&cond);
}
thread 0活动...
1
thread 1活动...
1
thread 2活动...
1
thread 0活动...
1
thread 1活动...
1
thread 2活动...
我们发现三个线程具有非常明显的顺序性,根本原因是当这若干个线程启动时默认会在该条件变量下去等待。而我们呢每次都唤醒的是在当前条件变量下等待的头部线程,当该线程执行完毕打印操作后就会到等待队列尾部进行wait,所以我们就可以看到周转现象
条件变量为何绑定互斥量
- 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去可能一直都无法满足,必须要由一个线程通过某些操作,改变共享变量,让原先不满足的条件变得满足,并且通知在条件变量队列上等待的线程
- 条件不会无缘无故满足,必然会牵扯到共享数据的变化,所以一定要使用互斥锁来进行保护,没有互斥锁就无法安全的获取和修改共享数据
- 当线程进入临界区需要先加锁,然后判断内部资源情况,若不满足当前线程执行条件,则需要在该条件变量下进行等待,但此时线程还是拿着锁被挂起的,也就意味着这个锁再也不会被释放了,此时会造成死锁问题,所以在调用
pthread_cond_wait()
函数时还需要将对应的互斥锁传入,此时当线程因为某些条件不满足需要在该条件变量下等待时,就会自动释放该互斥锁 - 当该线程被唤醒时,该线程会执行临界区的代码,此时要求该线程必须立马获得互斥锁。因此某个线程被唤醒时,实际会自动获取对应的互斥锁
等待的时候一般是在临界区等待,当线程进入等待自动释放锁,被唤醒自动获得锁。条件变量和互斥量一起使用,互斥量保证线程安全,条件变量保证同步
错误思路: 在临界区中发现条件不满足就解锁之,然后再条件变量下等待
pthread_mutex_lock(&mutex);
while (condition_is_false) {
pthread_mutex_unlock(&mutex);
// 若在此时条件已经满足,信号发出可能会被错过
pthread_cond_wait(&cond);
pthread_mutex_lock(&mutex);
}
pthread_mutex_unlock();
解锁和等待并非原子操作,调用解锁后在等待之前,若由其它线程已经获取到互斥量,发现此时条件满足发送了信号,那么此时pt
可重入VS线程安全
- 线程安全:多个线程并发执行同一段代码时,不会出现不同的结果。对常见全局变量或者静态变量进行操作时,并在没有锁保护的情况下,会出现线程安全的问题
- 重入:同一个函数被不同的执行流调用,当前一个流程还没执行完,就有其它执行流再次进入,我们称之为重入。一个函数重入的情况下,运行结果不会出现任何不同或者问题,则称函数为可重入函数,否则则是不可重入函数
常见线程安全的情况
- 每个线程对全局变量或者静态变量只有只读权限,没有写入权限
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
常见线程不安全的情况
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
常见的不可重入的情况
- 调用了malloc/free函数,因为malloc函数是使用全局链表来管理堆的
- 调用了标准的I/O库函数,标准I/O可以的很多实现都是以不可重入方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
常见可重入的情况
- 不使用全局变量或者静态变量
- 不使用malloc或者new开辟出的空间
- 调用不可重入函数
- 不返回静态或者全局数据,所有数据均由函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
可重入与线程安全联系
- 函数可重入,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,可能会引发线程安全问题
- 如果一个函数中有全局变量,那这个函数既不是线程安全的也是不可重入的
可重入与线程安全的区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入的函数一定是线程安全的
- 如果对临界资源访问加上锁,则这个函数是线程安全的,但如果这个重入函数的锁未释放导致死锁,因此是不可重入的
的函数文章来源:https://www.toymoban.com/news/detail-528547.html
常见的不可重入的情况
- 调用了malloc/free函数,因为malloc函数是使用全局链表来管理堆的
- 调用了标准的I/O库函数,标准I/O可以的很多实现都是以不可重入方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
常见可重入的情况
- 不使用全局变量或者静态变量
- 不使用malloc或者new开辟出的空间
- 调用不可重入函数
- 不返回静态或者全局数据,所有数据均由函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
可重入与线程安全联系
- 函数可重入,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,可能会引发线程安全问题
- 如果一个函数中有全局变量,那这个函数既不是线程安全的也是不可重入的
可重入与线程安全的区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入的函数一定是线程安全的
- 如果对临界资源访问加上锁,则这个函数是线程安全的,但如果这个重入函数的锁未释放导致死锁,因此是不可重入的
参考文章:「2021dragon」的原创文章线程安全
原文链接:https://blog.csdn.net/chenlong_cxy/article/details/122657542文章来源地址https://www.toymoban.com/news/detail-528547.html
到了这里,关于【Linux】Linux 线程安全的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!