文章目录
- 3. Linux 线程互斥
- 3.1 相关概念
- 3.2 互斥量mutex
- 3.3 互斥量的接口
- 3.4 改进3.2中的代码
- 3.5 互斥量(锁)的原理
- 3.6 封装一下原生锁的接口,RAII风格的锁
- 3.7 可重入 和 线程安全
3. Linux 线程互斥
3.1 相关概念
- 临界资源:多线程执行流共享的资源就叫做临界资源
- 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
- 原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
3.2 互斥量mutex
- 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
- 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
- 多个线程并发的操作共享变量,会带来一些问题。 看下面的代码
struct ThreadData
{
ThreadData(int i)
{
_threadName = "Thread-" + to_string(i);
}
string _threadName;
};
// 共享资源,票
int ticket = 1000;
void* GetTicket(void* args)
{
ThreadData* data = static_cast<ThreadData*>(args);
const char* name = data->_threadName.c_str();
while(true) {
// 抢票
printf("%s get a ticket, ticket: %d\n", name, ticket);
ticket--;
if(ticket <= 0) {
// 没票了,该线程就break
printf("%s can not get ticket, done\n", name);
break;
}
usleep(1000);
}
return nullptr;
}
int main()
{
vector<pthread_t> tids;
vector<ThreadData*> datas;
for(size_t i = 1; i <= NUM; ++i) {
ThreadData* data = new ThreadData(i);
datas.push_back(data);
pthread_t tid;
pthread_create(&tid, nullptr, GetTicket, datas[i-1]);
tids.push_back(tid);
}
// 善后处理
for(const auto& t : tids) pthread_join(t, nullptr);
for(const auto& d : datas) delete d;
return 0;
}
运行后可以看到,票出现了负数,所以出现了问题
该现象叫共享资源被多线程访问后出现数据不一致问题,为什么会出现呢?
if
语句判断条件为真以后,代码可以并发的切换到其他线程usleep
这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段ticket--
操作本身就不是一个原子操作
--
操作并不是原子操作,而是对应三条汇编指令:
- load :将共享变量ticket从内存加载到寄存器中。线程在执行的时候,将共享数据加载到CPU寄存器的本质是:把数据的内容,变成自己的上下文,即以拷贝的方式,给自己单独拿了一份。
- update : 更新寄存器里面的值,执行-1操作
- store :将新值,从寄存器写回共享变量ticket的内存地址
要解决以上问题,需要做到三点:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。
3.3 互斥量的接口
初始化互斥量
方法1,静态分配:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
方法2,动态分配:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrictattr);
参数:
mutex:要初始化的互斥量
attr:不关心,暂时设置为NULL
销毁互斥量
需要注意:
- 使用
PTHREAD_ MUTEX_ INITIALIZER
初始化的互斥量不需要销毁- 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
互斥量加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号
调用 pthread_ lock 时,可能会遇到以下情况:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
注意:
加锁的本质:用时间换安全
加锁的表现:线程对临界区代码串行执行
加锁的原则:尽量保证临界区的代码越少越好
3.4 改进3.2中的代码
// 方法1,静态分配
struct ThreadData
{
ThreadData(int i)
{
_threadName = "Thread-" + to_string(i);
}
string _threadName;
};
// 共享资源,票
int ticket = 1000;
void* GetTicket(void* args)
{
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; // 也可以使用全局变量
ThreadData* data = static_cast<ThreadData*>(args);
const char* name = data->_threadName.c_str();
while(true) {
pthread_mutex_lock(&lock); // 申请锁成功,才能往后执行,不成功,线程就阻塞等待
if(ticket > 0) {
usleep(1000);
// 抢票
printf("%s get a ticket, ticket: %d\n", name, ticket);
ticket--;
pthread_mutex_unlock(&lock);
}
else {
// 没票了,该线程就break
printf("%s can not get ticket, done\n", name);
pthread_mutex_unlock(&lock);
break;
}
/* 防止这个线程抢完票后又立即去申请锁,让它sleep()一会儿,给其它正在被阻塞的线程一个机会,否则该线程迟迟分配不到资源, 会导致饥饿问题 */
usleep(15);
}
return nullptr;
}
int main()
{
vector<pthread_t> tids;
vector<ThreadData*> datas;
for(size_t i = 1; i <= NUM; ++i) {
ThreadData* data = new ThreadData(i);
datas.push_back(data);
pthread_t tid;
pthread_create(&tid, nullptr, GetTicket, datas[i-1]);
tids.push_back(tid);
}
// 善后处理
for(const auto& t : tids) pthread_join(t, nullptr);
for(const auto& d : datas) delete d;
return 0;
}
// 方法2,动态分配:
struct ThreadData
{
ThreadData(int i, pthread_mutex_t* lock)
{
_threadName = "Thread-" + to_string(i);
_lock = lock;
}
string _threadName;
pthread_mutex_t* _lock;
};
// 共享资源,票
int ticket = 1000;
void* GetTicket(void* args)
{
ThreadData* data = static_cast<ThreadData*>(args);
const char* name = data->_threadName.c_str();
while(true) {
pthread_mutex_lock(data->_lock); // 申请锁成功,才能往后执行,不成功,线程就阻塞等待
if(ticket > 0) {
usleep(1000);
// 抢票
printf("%s get a ticket, ticket: %d\n", name, ticket);
ticket--;
pthread_mutex_unlock(data->_lock);
}
else {
// 没票了,该线程就break
printf("%s can not get ticket, done\n", name);
pthread_mutex_unlock(data->_lock);
break;
}
/*防止这个线程抢完票后又立即去申请锁,让它sleep()一会儿,给其它正在被阻塞的线程一个机会,否则该线程迟迟分配不到资源, 会导致饥饿问题*/
usleep(15);
}
return nullptr;
}
int main()
{
vector<pthread_t> tids;
vector<ThreadData*> datas;
pthread_mutex_t lock;
pthread_mutex_init(&lock, nullptr);
for(size_t i = 1; i <= NUM; ++i) {
ThreadData* data = new ThreadData(i, &lock);
datas.push_back(data);
pthread_t tid;
pthread_create(&tid, nullptr, GetTicket, datas[i-1]);
tids.push_back(tid);
}
// 善后处理
for(const auto& t : tids) pthread_join(t, nullptr);
for(const auto& d : datas) delete d;
return 0;
}
看到了我们想要看到的,票数并没有出现负数
需要保证:
- 外面的线程,需要排队等待
- 已经执行完临界区的线程,不能立马申请锁,需要排到队列的尾部
- 让所有的线程获取锁,按照一定的顺序。线程按照一定的顺序性获取资源,就叫做同步问题
每个线程访问临界区之前都需要访问锁,说明锁本身也是共享资源,所以申请锁和释放锁的过程要是原子的
纯互斥环境,如果锁分配不够合理,容易导致其他线程的饥饿问题
注意:不是说只要有互斥,必有饥饿。
在临界区中,该线程可以被切换吗?
可以切换,因为在线程被切出去的时候,是持有锁被切走的,线程不在期间,其它被线程不能访问临界资源,会一直被阻塞,直到pthread_mutex_unlock()
会唤醒这些线程。这样,就保证了该线程在执行临界区的时候,对其它线程是原子的
3.5 互斥量(锁)的原理
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。 下面是lock和unlock的伪代码
lock:
最重要的是
xchgb
语句
交换的本质:线程把内存中的数据(共享),交换到CPU的寄存器中。即线程把一个共享数据交换到自己的硬件上下文中。而线程的上下文是线程独有的,这样该线程就持有了一个锁。
unlock:
将mutex制1
3.6 封装一下原生锁的接口,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);
}
private:
pthread_mutex_t* _lock;
};
class LockGuard
{
public:
LockGuard(pthread_mutex_t *lock) : _mutex(lock)
{
_mutex.Lock();
}
~LockGuard()
{
_mutex.Unlock();
}
private:
Mutex _mutex;
};
改进一下3.4中方法1的GetTicket()
void* GetTicket(void* args)
{
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; // 也可以使用全局变量
ThreadData* data = static_cast<ThreadData*>(args);
const char* name = data->_threadName.c_str();
while(true) {
{
LockGuard lockGuard(&lock);
if(ticket > 0) {
usleep(1000);
// 抢票
printf("%s get a ticket, ticket: %d\n", name, ticket);
ticket--;
pthread_mutex_unlock(&lock);
}
else {
// 没票了,该线程就break
printf("%s can not get ticket, done\n", name);
pthread_mutex_unlock(&lock);
break;
}
}
/* 防止这个线程抢完票后又立即去申请锁,让它sleep()一会儿,给其它正在被阻塞的线程一个机会,否则该线程迟迟分配不到资源, 会导致饥饿问题 */
usleep(15);
}
return nullptr;
}
这样,我们的代码中就不需要关心烦人和易忘的加锁和解锁,只要定义一个临时的LockGuard
对象,就可以自动完成。
3.7 可重入 和 线程安全
概念
- 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题
- 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数
可重入与线程安全联系
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题