1 🍑线程间的互斥相关背景概念🍑
先来看看一些基本概念:
- 1️⃣临界资源:多线程执行流共享的资源就叫做临界资源。
- 2️⃣临界区:每个线程内部,访问临界资源的代码,就叫做临界区。
- 3️⃣互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
互斥量mutex:
- 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
- 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
- 多个线程并发的操作共享变量(比如全局变量),会带来一些问题。
比如一个大家熟知的栗子:售票。我们用一个全局整形变量记录票的个数,多个线程并发的去抢票,我们不难写出下面这样的代码:
cpp
int g_ticket=10000;
void* Run(void* args)
{
string name=static_cast<const char*>(args);
while(true)
{
if(g_ticket<=0)
{
break;
}
else
{
cout<<"I am "<<name<<",is running tickets"<<g_ticket<<endl;
g_ticket--;
}
usleep(2000);
}
return nullptr;
}
int main()
{
pthread_t ptids[5];
for(int i=0;i<5;++i)
{
char* name=new char[26];
snprintf(name,26,"pthread%d",i+1);
pthread_create(ptids+i,nullptr,Run,name);
}
for(int i=0;i<5;++i)
{
pthread_join(ptids[i],nullptr);
}
return 0;
}
当我们运行时:
我们发现,有多个线程抢到了同一张票,并且打印混乱。有些情况下票还有可能变成了负数,而这就是线程不安全所带来的问题,解决办法我们在下面会给出详细解释。
2 🍑用互斥锁解决线程安全问题🍑
2.1 🍎分析问题 🍎
我们来分析下上面的代码为什么会出现那样的结果?
- if 语句判断条件为真以后,代码可以并发的切换到其他线程。
- usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段。
- 减减ticket 操作本身就不是一个原子操作。
我们可以取出渐渐ticket取出ticket--部分的汇编代码:
cpp
objdump -d a.out > test.objdump
152 40064b: 8b 05 e3 04 20 00 mov 0x2004e3(%rip),%eax # 600b34 <ticket>
153 400651: 83 e8 01 sub $0x1,%eax
154 400654: 89 05 da 04 20 00 mov %eax,0x2004da(%rip) # 600b34 <ticket>
- -
操作并不是原子操作,而是对应三条汇编指令:
- load :将共享变量ticket从内存加载到寄存器中;
- update : 更新寄存器里面的值,执行-1操作;
- store :将新值,从寄存器写回共享变量ticket的内存地址。
要解决以上问题,需要做到三点:
- 1️⃣代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 2️⃣如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 3️⃣如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量
。
2.2 🍎互斥量的接口 🍎
🍋初始化互斥量🍋
初始互斥量有两种方式:
- 方法1,静态分配:
cpp
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
- 方法2,动态分配:
cpp
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
参数:
mutex:要初始化的互斥量
attr:NULL
这两种方式选择哪一种都是OK的。
🍋销毁互斥量🍋
销毁互斥量需要注意:
- 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁;
- 不要销毁一个已经加锁的互斥量;
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁。
cpp
int pthread_mutex_destroy(pthread_mutex_t *mutex);
🍋互斥量加锁和解锁🍋
cpp
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号
调用pthread_mutex_lock 时,可能会遇到以下情况:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
所以我们可以改进下上面的抢票:
cpp
int g_tictet=10000;
pthread_mutex_t mtu=PTHREAD_ADAPTIVE_MUTEX_INITIALIZER_NP;
void* Run(void* args)
{
string name=static_cast<const char*>(args);
while(true)
{
pthread_mutex_lock(&mtu);
if(g_tictet<=0)
{
pthread_mutex_unlock(&mtu);
break;
}
else
{
cout<<"I am "<<name<<",is running tickets"<<g_tictet<<endl;
g_tictet--;
}
pthread_mutex_unlock(&mtu);
usleep(2000);
}
return nullptr;
}
int main()
{
pthread_t ptids[5];
for(int i=0;i<5;++i)
{
char* name=new char[26];
snprintf(name,26,"pthread%d",i+1);
pthread_create(ptids+i,nullptr,Run,name);
}
for(int i=0;i<5;++i)
{
pthread_join(ptids[i],nullptr);
}
return 0;
}
当我们再次运行时:
我们发现不会出现多个线程抢占同一张票并且打印混乱的情况了。
代码中值得注意的事情有:加锁的策略是:选用的粒度一般是越细越好。
🍋互斥量实现原理探究🍋
搞了这么多,那么互斥量的实现原理究竟是啥捏?
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange
指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
我们可以自己实现一份lock和unlock的伪代码:
cpp
lock:
movb $0,%al
xchgb %al,mutex
if(al寄存器的内容>0)
return 0;//表示申请锁成功
else
挂起等待;
goto lock;
unlock:
movb $1,%al
唤醒等待mutex的线程;
return 0;//表示释放锁成功
通过上面的伪代码我们可以知道当初始值mutex的值为1时,假设线程1先进行申请锁,会先将寄存器中的值改为0,然后用寄存器中的0交换mutex中的1,此时1被线程1给拿到了,假设此时线程1的时间片到了,要切换线程2执行,在切换之前先保存了线程1的上下文数据,然后切换;此时线程2从头执行将寄存器中的数值改为0,然后交换,但是唯一的1已经被线程1给拿走了,所以线程而只有挂起等待;当重新切换回线程1的时候,线程1会重新恢复上下文数据,也就是寄存器的内容会被恢复到切换前,所以判断寄存器的内容>0,申请成功。此时我们发现就算是有多个线程并发的抢占锁资源时,也只有一个线程能够申请成功,其他线程在挂起等待,因为这里面的1
只有一个,并且是以交换形式进行的,可以理解这里面的1本质就是一把锁。
释放资源就更好理解了,将寄存器的值修改为1,然后唤醒等待锁的线程即可。从释放锁的那段伪代码中我们也能够看到:当多个线程申请同一把锁时,一个线程申请了锁后,虽然其他线程不能够申请了,但是却可以释放该锁。
2.3 🍎可重入VS线程安全 🍎
🍋概念🍋
- 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
- 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
🍋常见的线程不安全/安全的情况🍋
不安全情况:
- 1️⃣不保护共享变量的函数
- 2️⃣函数状态随着被调用,状态发生变化的函数
- 3️⃣返回指向静态变量指针的函数
- 4️⃣调用线程不安全函数的函数
安全情况:
- 1️⃣每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 2️⃣类或者接口对于线程来说都是原子操作
- 3️⃣多个线程之间的切换不会导致该接口的执行结果存在二义性
🍋常见不可重入/可重入的情况🍋
不可重入:
- 1️⃣调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
- 2️⃣调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 3️⃣可重入函数体内使用了静态的数据结构
可重入:
- 1️⃣不使用全局变量或静态变量
- 2️⃣不使用用malloc或者new开辟出的空间
- 3️⃣不调用不可重入函数
- 4️⃣不返回静态或全局数据,所有数据都有函数的调用者提供
- 5️⃣使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
🍋可重入与线程安全联系与区别🍋
联系:
- 1️⃣函数是可重入的,那就是线程安全的
- 2️⃣函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 3️⃣如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
区别:
- 1️⃣可重入函数是线程安全函数的一种
- 2️⃣线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 3️⃣如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
2.4 🍎死锁🍎
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
🍋死锁四个必要条件🍋
- 互斥条件:一个资源每次只能被一个执行流使用。
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺。
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。
🍋避免死锁🍋
- 破坏死锁的四个必要条件
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配
死锁避免算法有银行家算法和死锁检测算法,大家有兴趣可以自行下去研究。
3 🍑用封装使代码更加优雅 🍑
我们上面写的代码中,我们能否自己实现一个简易版本的创建线程(类似于C++11提供的线程库那样)的类呢?以及加锁和解锁能够使用RAII的思想来帮助我们完成呢?当然是可以的,我们可以自己实现一个更加优雅的代码:
mutexGuard.hpp:
cpp
#pragma once
#include<iostream>
#include<pthread.h>
using namespace std;
class mutexGurad
{
public:
mutexGurad(pthread_mutex_t* mutex)
:_mutex(mutex)
{
pthread_mutex_lock(_mutex);
}
~mutexGurad()
{
pthread_mutex_unlock(_mutex);
}
private:
pthread_mutex_t* _mutex;
};
thread.hpp:
cpp
#pragma once
#include <iostream>
#include <functional>
using namespace std;
class threadProcess
{
public:
enum stu
{
NEW,
RUNNING,
EXIT
};
template <class T>
threadProcess(int num, T exe, void *args)
: _tid(0),
_status(NEW),
_exe(exe),
_args(args)
{
char name[26];
snprintf(name, 26, "thread%d", num);
_name = name;
}
static void *runHelper(void *args)
{
threadProcess *ts = (threadProcess *)args;
(*ts)();
return nullptr;
}
void operator()() // 仿函数
{
if (_exe != nullptr)
_exe(_args);
}
void Run()
{
int n = pthread_create(&_tid, nullptr, runHelper, this);
if (n != 0)
exit(-1);
_status = RUNNING;
}
void Join()
{
int n = pthread_join(_tid, nullptr);
if (n != 0)
exit(-1);
_status = EXIT;
}
private:
string _name;
pthread_t _tid;
stu _status;
function<void *(void *)> _exe;
void *_args;
};
测试程序:
cpp
int g_tictet = 10000;
pthread_mutex_t mtu = PTHREAD_MUTEX_INITIALIZER;
void *Run(void *args)
{
string name = static_cast<const char *>(args);
while (true)
{
{
mutexGurad mutGuard(&mtu);
if (g_tictet <= 0)
{
break;
}
else
{
cout << "I am " << name << ",is running tickets" << g_tictet << endl;
g_tictet--;
}
}
usleep(1000);
}
return nullptr;
}
int main()
{
threadProcess thpro1(1, Run, (void *)"thread1");
threadProcess thpro2(2, Run, (void *)"thread2");
threadProcess thpro3(3, Run, (void *)"thread3");
thpro1.Run();
thpro2.Run();
thpro3.Run();
thpro1.Join();
thpro2.Join();
thpro3.Join();
return 0;
}
当我们运行时:
我们依旧能够得到正确的结果,并且代码写起来也好看多了。除此之外,我们还可以拿到线程的其他特性,这里我就不在测试了。