【Linux系统篇】:Linux线程互斥---如何用互斥锁守护多线程程序
✨感谢您阅读本篇文章,文章内容是个人学习笔记的整理,如果哪里有误的话还请您指正噢✨
✨ 个人主页:余辉zmh–CSDN博客
✨ 文章所属专栏:Linux篇–CSDN博客
文章目录
- 互斥锁
- 1.举例引出锁
- 2.相关概念和使用
- 3.实现原理
- 4.应用--RAII风格的锁
- 5.线程安全与重入
- 6.死锁
互斥锁
1.举例引出锁
大部分情况下,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况下,变量归属于单个线程,其他线程无法获取这个变量。
但有的时候,很多变量需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
但是多线程并发访问共享数据时,会带来一些问题。
下面通过多线程模拟一轮抢票来进行测试:
#include <iostream>
#include <cstdio>
#include <cstring>
#include <vector>
#include <unistd.h>
#include <pthread.h>
#include <string>
using namespace std;#define NUM 4// 用多线程模拟一轮抢票
// tickets全局变量被所有线程共享,是一个共享数据
int tickets = 1000;class threadData{
public:threadData(int number):_threadname("thread-"+to_string(number)){}
public:string _threadname;
};void *GetTicket(void *args){threadData *td = static_cast<threadData *>(args);while(true){if(tickets>0){usleep(1000);cout << "who=" << td->_threadname << ", get a ticket: " << tickets << endl;tickets--;}else{break;}}cout << td->_threadname << " .... quit!" << endl;return nullptr;
}int main(){vector<pthread_t> tids;vector<threadData *> thread_datas;for (int i = 1; i <= NUM;i++){pthread_t tid;threadData *td = new threadData(i);thread_datas.push_back(td);pthread_create(&tid, nullptr, GetTicket, td);tids.push_back(tid);}for(auto tid : tids){pthread_join(tid, nullptr);}for(auto td : thread_datas){delete td;}return 0;
}
在上面测试的代码中,tickets
这个共享数据在被所有线程并发读写时,并没有按照预期的一样减到0停止,而是所有线程减到负数之后才停止。
上面这种情况就是共享数据在无保护的情况下被多线程并发访问进行操作而造成的数据不一致问题。
出现问题的原因,肯定是和多线程并发访问是有关系的;在上面的测试代码中,问题就是出现在ticket--
这步操作上。
在多线程并发访问情况下,对一个全局变量--/++
操作其实是不安全的。
以tickets--
为例,在语言角度来看确实只有一条语句,但是转变为汇编语句后,就是三条语句:
1.先将tickets
变量从内存读入到CPU的eax寄存器中;
2.CPU内部进行--
操作;
3.将计算结果写回内存中。
每一步都是对应一条汇编操作:
tickets-- => 1.mov [xxx] eax 2. -- 3. mov eax [xxx]
因此CPU在执行tickets--
操作时,实际上要执行三个汇编操作。
但是任何一个线程在被执行时都有可能被切换,切换时当前线程就会保存对应的上下文,等待下一次调度执行。
注意:CPU寄存器中保存的内容并不是属于寄存器的,保存的是正在被执行的线程的上下文。
所以线程在执行的时候,将共享数据加载到CPU寄存器的本质:把数据的内容,变成自己的上下文—以拷贝的方式,给自己单独拿了一份。
假如当前有两个线程,此时tickets=1000
,当线程1执行ticket--
时,执行完汇编操作一后就被切换,此时线程1的上下文中保存了tickets=1000
的内容,当轮到线程2执行时,线程2执行完ticket--
的三条汇编操作后才被切换,此时内存中的tickets
已经变成了999;当再次轮到线程1后,先将保存的上下文恢复到寄存器中,然后继续执行切换前的操作2,但是此时寄存器中的tickets=1000
,而内存中的已经是999了,继续执行就会导致数据不一致问题。
至于为什么上面的测试会出现减到负数的情况,这是因为,假如此时的数据已经等于1了,但是所有线程并发执行在本次循环都进入到了tickets>0
的判断语句中,然后在执行的tickets--
的时候,被切换,此时数据发生不一致问题,但本次循环已经进入到判断语句中了,就不再受大于0的限制,继续减减,然后就出现了负数的情况,等到再次进入循环时,就会因为不满足条件而退出,所以就有了其中一个减到0,剩余三个减到负数,但是都只有一次的现象。
根据上面的例子,已经可以明白,对于共享数据--
操作并不是安全的(实际上是没有原子性)。
所以多线程并发执行会导致共享数据被多次修改,从而导致数据不一致问题。
如何解决?
在多线程并发执行时,对共享数据的任何访问,保证任何时候只有一个执行流访问,这就是互斥!(概念一)
如何实现互斥?
通过锁来实现。
2.相关概念和使用
多线程在并发访问某种资源时,首先要保证先访问同一把锁,Linux上提供的这把锁叫做互斥锁/互斥量。
互斥锁的接口函数:
pthread_mutex_t
是原生线程库提供的一种数据类型
- 创建互斥锁
// 局部域创建,需要手动初始化和销毁
pthread_mutex_t lock;// 全局域创建,直接使用宏来定义,不用再手动初始化和销毁
pthread_mutex_t lock=PTHREAD_ADAPTIVE_MUTEX_INITIALIZER_NP;
- 锁的初始化和销毁函数
// 互斥锁的初始化函数
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);// 参数:mutex:要初始化的锁的地址;attr:设置为nullptr即可// 互斥锁的销毁函数
int pthread_mutex_destroy(pthread_mutex_t *mutex);
- 使用锁的相关函数
#include <pthread.h>// 加锁函数 参数是锁的地址
int pthread_mutex_lock(pthread_mutex_t *mutex);// 解锁函数 参数是锁的地址
int pthread_mutex_unlock(pthread_mutex_t *mutex);
对于共享资源,任何时刻只允许一个执行流访问的资源,叫做临界资源(概念二);而一个程序中只有一小部分代码才是在访问临界资源,这部分代码叫做临界区(概念三)。
加锁的本质其实是对被加锁的代码区域要让多线程串行访问,因为任何时刻只允许一个执行流执行临界区代码,去访问临界资源;加锁是用时间换取安全的。
加锁的表现:线程对于临界区代码串行访问;
加锁的原则:尽量保证临界区代码越少越好
从加锁到解锁的这段代码区就是临界区,每个执行流执行到临界区时,都要先申请锁;申请锁成功,才能往后执行;不成功,阻塞等待(执行流被挂起,等待解锁后再次申请)。
利用互斥锁对上面的抢票程序进行修改测试:
#include <iostream>
#include <cstdio>
#include <cstring>
#include <vector>
#include <unistd.h>
#include <pthread.h>
#include <string>
using namespace std;#define NUM 4// 用多线程模拟一轮抢票
int tickets = 1000;class threadData{
public:threadData(int number, pthread_mutex_t *mutex):_threadname("thread-"+to_string(number)),lock(mutex){}
public:string _threadname;pthread_mutex_t *lock;
};void *GetTicket(void *args){threadData *td = static_cast<threadData *>(args);while(true){// 加锁pthread_mutex_lock(td->lock);if (tickets > 0){usleep(1000);cout << "who=" << td->_threadname << ", get a ticket: " << tickets << endl;tickets--;// 解锁pthread_mutex_unlock(td->lock);}else{pthread_mutex_unlock(td->lock);break;}}cout << td->_threadname << " .... quit!" << endl;return nullptr;
}int main(){// 创建一个互斥锁pthread_mutex_t lock;// 初始化pthread_mutex_init(&lock, nullptr);vector<pthread_t> tids;vector<threadData *> thread_datas;for (int i = 1; i <= NUM;i++){pthread_t tid;threadData *td = new threadData(i, &lock);thread_datas.push_back(td);pthread_create(&tid, nullptr, GetTicket, td);tids.push_back(tid);}for(auto tid : tids){pthread_join(tid, nullptr);}for(auto td : thread_datas){delete td;}// 销毁互斥锁pthread_mutex_destroy(&lock);return 0;
}
加锁后再次执行,就不会出现抢票为负数的情况了:
但是仔细发现,就可以看出所有票都被其中一个执行流抢完了。这是因为在上面的代码中,抢完一张票会立即进入下一次循环抢票,可能导致某个执行流使用完后先解锁然后又立即申请锁;但是实际上多线程还要执行抢到票之后的后续动作,所以这里可以用usleep
来模拟实现:
void *GetTicket(void *args){threadData *td = static_cast<threadData *>(args);while(true){// 加锁pthread_mutex_lock(td->lock);if (tickets > 0){usleep(1000);cout << "who=" << td->_threadname << ", get a ticket: " << tickets << endl;tickets--;// 解锁pthread_mutex_unlock(td->lock);}else{pthread_mutex_unlock(td->lock);break;}usleep(10); // 模拟实现多线程抢到票之后的后续动作}cout << td->_threadname << " .... quit!" << endl;return nullptr;
}
加上usleep
语句后,就可以让线程释放完后先短暂的休眠,不会立即再申请锁,其他线程就可以申请锁了,通过休眠来模拟抢完票的后续动作,这样就可以防止上面的这种情况。
此外线程对于锁的竞争能力可能会不同,离锁越近就会越容易获取到锁资源;
在纯互斥环境下,如果锁的分配不合理,就容易导致其他线程的饥饿问题(概念四)!
注意,并不是只要有互斥,就会有饥饿问题;上面的例子中所有票被同一个执行流抢完就是这种情况。
还是以上面的抢票为例,想要使锁的分配合理,可以设置这样的规则:
1.阻塞等待锁的线程必须在队列中排队等待,不能竞争访问;
2.使用完锁的线程不能立马重新申请锁,必须加入到队列的尾部,重新排队等待申请。
根据上面的这种规则,就可以让所有的线程获取锁时,按照一定的顺序获取(上面使用队列来举例,但并不是必须用队列,只要能保证顺序性即可)。
按照一定的顺序性获取资源,这就是同步(概念五)。
多线程在并发访问某种资源时,首先要保证访问同一把锁,也就是说锁本身也是一种共享资源,也要保证自身的安全性;所以申请锁和释放锁本身就是被设计成了原子性操作。
而线程在执行临界区时,也是可以被切换的;但是线程在被切换时,锁也是和线程一起被切换走的,即使当前线程不被执行,照样没有其他线程能进入临界区访问临界资源,因为此时锁还没有被释放。
对于阻塞等待的这些线程,因为知道当前是无法申请锁的,所以只能等待锁释放后,再次申请,对于中间的过程如何其实并不关心;所以即使当前正在使用锁的这个线程被切换走,锁依然没有释放,其他线程也不关心就会继续阻塞等待,也就不能执行临界区访问临界资源。
通过加锁就可以保证当前线程在访问临界区期间,对于其他线程来讲是原子的。
如何理解原子性操作是什么,简单来说,就是汇编语句只有一条,CPU执行时只有做完或者不做两种情况,不会出现正在做的情况,所以不会被切换打断,这种就是原子性操作。
最后明白了上面的几点后,再来理解为什么临界区代码越少越好?
这是因为临界区代码越少,意味着线程在临界区执行的时间就越短,串行的比率就会降低,线程被调度切换的概率就越低,其他线程等待的时间也会变短,所以这也是为什么说加锁是利用时间换取安全的。
3.实现原理
经过上面的例子,已经可以意识到单纯的--/++
操作都不是原子的,如果不加以保护就会出现数据不一致问题,所以需要通过互斥锁来解决。
上面已经提到过,为了保证锁的安全性,申请锁和释放锁本身就是被设计成了原子性操作。
接下来就是讲解申请锁和释放锁是原子性操作的实现原理:
为了实现互斥锁的原子性操作,大多数体系结构(芯片)都提供了swap
或exchange
的指令,该指令的作用就是把寄存器和内存单元的数据相交换,由于交换操作只有一条指令,所以就保证了原子性。
根据lock(申请锁)和unlock(释放锁)的汇编操作来举例说明:
lock:// 操作1:将al寄存器中的数据置为0movb $0, %al // 操作2:交换al寄存器和内存上mutex变量的数据(交换只有一条指令)xchgb %al, mutex// 操作3:判断交换后寄存器中的数据是否不为0if(al寄存器的内容 > 0){return 0;}else挂起等待;goto lock;
unlock:// 操作1:将内存上mutex变量的数据置为1movb $1, mutex唤醒等待mutex的线程;return 0;
假设当前mutex=1
,表示只有一把锁,也就只有一个执行流可以访问共享资源;分别有两个线程,线程1和线程2;
当线程1先申请锁时,执行lock的汇编操作,当执行完操作1和操作2后,此时内存上的mutex=0
表示没有锁资源可以继续被申请,再有线程申请需要排队等待;寄存器中的mutex=1
表示当前锁被线程1申请;
在线程1执行完操作2后被切换,此时寄存器中的内容mutex=1
属于线程1的上下文,所以线程1被切换后,连带这上下文一起被切换;切换为线程2后,申请锁执行lock的汇编操作,执行操作1把寄存器中的数据置为0,然后执行操作2把寄存器和内存上的数据进行交换;继续执行操作3,此时交换后的寄存器上的数据是mutex=0
,所以执行判断语句后,要被挂起等待,也就是被切换;
线程2被挂起等待后,切换为线程1后先恢复上下文到寄存中,此时寄存器中的mutex=1
,继续执行切换前的操作3,执行判断语句大于0,然后返回,完成申请锁的整个操作。
把内存中共享的锁,交换到CPU寄存器中的本质:把一个共享的锁,让一个线程以一条汇编语句的方式,交换到自己的上下文中,因为上下文属于每个线程私有的,所以交换后,锁就成了当前线程私有的,当前线程就持有锁了!
即使申请锁后当前线程被切换,锁也会连带这线程一起被切换走,然后保存在当前线程的上下文中,后面的线程即使再申请,内存上的锁也依然没有,就只能继续阻塞等待。
所以一把共享的锁在寄存器和内存之间交换,实际上是在线程私有的上下文和内存之间交换。
lock申请锁时交换操作只有一条指令,且unlock释放锁时总共也只有一条指令;所以才保证了申请锁和释放锁的操作是具有原子性的。
4.应用–RAII风格的锁
RAII风格的锁是一种利用C++对象生命周期来管理锁资源的编程技术。
主要特点是:
1.在构造函数中申请锁;
2.在析构函数中释放锁;
3.利用对象的生命周期自动管理申请锁和释放锁
这种锁就叫做RAII
风格的锁。
1.RAII锁的基本实现:
//LockGuard.hpp文件:
#pragma once#include <pthread.h>class Mutex{
public:Mutex(pthread_mutex_t *lock):_lock(lock){}void Lock(){pthread_mutex_lock(_lock);}void UnLock(){pthread_mutex_unlock(_lock);}~Mutex(){}
private:pthread_mutex_t *_lock;
};class LockGuard{
public:LockGuard(pthread_mutex_t *lock):_mutex(lock){_mutex.Lock();}~LockGuard(){_mutex.UnLock();}private:Mutex _mutex;
};
2.RAII锁的使用方式:
修改上面的抢票代码
#include <iostream>
#include <cstdio>
#include <cstring>
#include <vector>
#include <unistd.h>
#include <pthread.h>
#include <string>
#include "LockGuard.hpp"
using namespace std;#define NUM 4// 创建一个全局锁
pthread_mutex_t lock = PTHREAD_ADAPTIVE_MUTEX_INITIALIZER_NP;// 用多线程模拟一轮抢票
int tickets = 1000;class threadData{
public:threadData(int number):_threadname("thread-"+to_string(number)){}
public:string _threadname;
};void *GetTicket(void *args){threadData *td = static_cast<threadData *>(args);while(true){// 花括号区间就是临时区{// 利用临时对象的生命周期,创建时自动调用构造函数申请锁LockGuard mutex(&lock);if (tickets > 0){usleep(1000);cout << "who=" << td->_threadname << ", get a ticket: " << tickets << endl;tickets--;}else{break;}// 区间结束时,自动调用析构函数释放锁}usleep(10); // 模拟实现多线程抢到票之后的后续动作}cout << td->_threadname << " .... quit!" << endl;return nullptr;
}int main(){vector<pthread_t> tids;vector<threadData *> thread_datas;for (int i = 1; i <= NUM;i++){pthread_t tid;threadData *td = new threadData(i/*, &lock*/);thread_datas.push_back(td);pthread_create(&tid, nullptr, GetTicket, td);tids.push_back(tid);}for(auto tid : tids){pthread_join(tid, nullptr);}for(auto td : thread_datas){delete td;}return 0;
}
5.线程安全与重入
线程安全
- 概念:
多个线程并发执行同一段代码,不会出现不同的结果,就是线程安全的;如果在没有保护情况下,对全局变量或者静态变量等进行并发访问操作,就会出现不同的结果,这就是线程不安全。
- 常见的线程安全:
1.每个线程对全局变量或者静态变量只有读取权限,而没有写入权限;
2.类或者接口对于线程来说都是原子操作;
3.多个线程之间的切换不会导致该接口的执行结果存在二义性;
- 常见的线程不安全:
1.不保护共享变量的函数;
2.函数状态随着被调用会发生变化的函数;
3.返回指向静态变量指针的函数;
4.调用线程不安全函数的函数;
重入
- 概念:
同一个函数被不同的执行流调用,当前一个执行流还没有执行完,就有其他的执行流再次进入,这就是重入;如果一个函数在重入的情况下,运行结果不会出现任何不同或者出现任何问题,则该函数就是可重入函数,反之,就是不可重入函数。
- 常见可重入:
1.不使用全局变量或静态变量;
2.不使用malloc或者new开辟出来的堆空间;
3.不调用不可重入函数;
4.不返回静态或全局数据,所有数据都由函数的调用者提供;
5.使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据;
- 常见不可重入:
1.调用了malloc/free函数;
2.调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构;
3.可重入函数体内使用了静态的数据结构;
- 可重入与线程安全的联系
1.函数是可重入的,那就是线程安全的;
2.函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题;
3.如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
- 可重入与线程安全的区别
1.可重入函数是线程安全函数的一种;
2.线程安全不一定是可重入的,而可重入函数一定是线程安全的;
3.如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
6.死锁
死锁是指在一组进程中的各个进程均占用不会释放的资源,但因互相申请其他进程所占用不会释放的资源而处于的一种永久等待状态。直观看到的现象就是程序卡住不运行了。
- 死锁四个必要条件:
1.互斥条件:一个资源每次只能被一个执行流使用;
2.请求与保持条件:一个执行流因请求资源而阻塞时,对以获得的资源保持不放;
3.不剥夺条件:一个执行流已获得的资源,在未使用之前,不能强行剥夺;
4.循环等待条件:若干执行流之间形成一种头尾相接的唤醒等待资源的关系。
- 避免死锁:
1.破环死锁的四个必要条件
2.加锁顺序一致
3.避免锁未释放的场景
4.资源一次性分配
以上就是关于Linux线程互斥部分的讲解,如果哪里有错的话,可以在评论区指正,也欢迎大家一起讨论学习,如果对你的学习有帮助的话,点点赞关注支持一下吧!!!