Linux之封装线程库和线程的互斥与同步
- 一.封装线程库
- 二.线程的互斥
- 2.1互斥量的概念
- 2.2初始化和销毁互斥量
- 2.3加锁和解锁
- 2.4互斥量的原理
- 2.5可重入和线程安全
- 2.6死锁
一.封装线程库
其实在我们C++内部也有一个线程库而C++中的线程库也是封装的原生线程库的函数,所以我们也可以自己来封装一个自己的线程库。
//mythread.hpp
#pragma once
#include <iostream>
#include <pthread.h>
#include <string>
#include <functional>
#include <unistd.h>
template<class T>
using func_t = std::function<void(T)>;
template<class T>
class Thread
{
public:
Thread(const std::string &ThreadName,func_t<T> func,T date)
:_ThreadName(ThreadName)
,_func(func)
,_date(date)
,_tid(0)
,_isrunning(false)
{}
~Thread()
{}
//注意:这是类内成员函数,所以它不止是有void* args这个参数
//还有一个this指针当作参数,所以就会造成pthread_create调用出错
//因为pthread_create函数第三个参数是一个返回值为void*参数为void*的函数指针
//所以想要解决这个问题我们可以利用static关键字将其变为全局函数
//并且再将this指针当作参数传给它,这样也就可以解决全局函数无法访问私有成员变量的问题
static void* ThreadRoutine(void* args)
{
Thread* ts = static_cast<Thread*>(args);
ts->_func(ts->_date);
return nullptr;
}
bool Start()
{
int n = pthread_create(&_tid,nullptr,ThreadRoutine,this);
if(n == 0)
{
_isrunning = true;
return true;
}
else
{
return false;
}
}
bool Join()
{
if(!_isrunning)
{
return true;
}
int n = pthread_join(_tid,nullptr);
if(n == 0)
{
_isrunning = false;
return true;
}
return false;
}
std::string Theadname()
{
return _ThreadName;
}
bool IsRunning()
{
return _isrunning;
}
private:
pthread_t _tid;
std::string _ThreadName;
bool _isrunning;
func_t<T> _func;
T _date;
};
//mythread.cc
#include "mythread.hpp"
std::string GetThreadName()
{
static int number = 1;
char name[64];
snprintf(name,sizeof(name),"Thread-%d",number++);
return name;
}
void Print(int num)
{
while(num)
{
std::cout << "i am a new thread"<< num-- << std::endl;
sleep(1);
}
}
int main()
{
Thread<int> t(GetThreadName(),Print,10);
t.Start();
t.Join();
return 0;
}
二.线程的互斥
2.1互斥量的概念
在聊线程的互斥之前我们可以通过一个实验来观察到我们多线程代码的一个问题
// mythread.cc
#include "mythread.hpp"
std::string GetThreadName()
{
static int number = 1;
char name[64];
snprintf(name, sizeof(name), "Thread-%d", number++);
return name;
}
void Print(int num)
{
while (num)
{
std::cout << "i am a new thread" << num-- << std::endl;
sleep(1);
}
}
class ThreadDate
{
public:
ThreadDate(const std::string threadname)
: _threadname(threadname)
{
}
std::string _threadname;
};
//抢票程序
int ticket = 10000;
int GetTicket(ThreadDate* td)
{
while (true)
{
if (ticket > 0)
{
//充当抢票的时间
usleep(1000);
std::cout << td->_threadname.c_str() << " get a ticket:" << ticket << std::endl;
ticket--;
}
else
{
break;
}
}
}
int main()
{
std::string name1 = GetThreadName();
ThreadDate* td1 = new ThreadDate(name1);
Thread<ThreadDate*> t1(name1,GetTicket,td1);
std::string name2 = GetThreadName();
ThreadDate* td2 = new ThreadDate(name2);
Thread<ThreadDate*> t2(name2,GetTicket,td2);
std::string name3 = GetThreadName();
ThreadDate* td3 = new ThreadDate(name3);
Thread<ThreadDate*> t3(name3,GetTicket,td3);
std::string name4 = GetThreadName();
ThreadDate* td4 = new ThreadDate(name4);
Thread<ThreadDate*> t4(name4,GetTicket,td4);
t1.Start();
t2.Start();
t3.Start();
t4.Start();
t1.Join();
t2.Join();
t3.Join();
t4.Join();
delete td1;
delete td2;
delete td3;
delete td4;
return 0;
}
正常来说我们运行这个抢票程序最后打印的应该是抢到1号票,后面就不会再继续抢票了那么事实真的像我们想的这样吗?
我们发现并不像我们所说的那样,线程居然会抢到0号,-1号,-2号票,这根本不符合我们的代码逻辑啊这是为什么呢?
这就要提到我们之前学习进程间通信的知识了
在学习进程间通信的时候我们提到过公共资源的概念而公共资源是需要保护的这是因为如果多个执行流去访问公共资源很容易产生数据不一致的情况并且数据不一致现象的出现本质上来说也是因为访问公共资源的程序不是原子性的所以公共资源是需要被保护的。那么被保护起来的公共资源就是我们说的临界资源而访问临界资源的代码就是临界区,保护的手段当时我们也提了一下分为互斥和同步。互斥简单来说就是让任何时刻只允许一个执行流去访问公共资源,而同步则是让多个执行流保持着一种顺序去访问公共资源。
那么我们可以思考一下了我们刚刚定义的全局变量ticket是否也算是一种公共资源呢?我们模拟的抢票程序不就是在访问公共资源吗?而我们并没有对ticket进行保护它不就会出现数据不一致的情况吗?
在知道了这种情况产生的原因后我们来简单阐述一下产生的过程
在Linux中我们不太好观察汇编语句我们可以用vs来观察一下。
我们老是说因为多线程访问公共资源的操作不是原子的从而导致数据不一致的情况产生但是从来没说这个情况到底是怎么产生的其中的过程到底是什么样的。接下来我结合图和文字来为大家解释。
首先我用文字来为大家描述一下数据不一致的整个过程。
假设有两个线程A和B并且此时ticket已经为1了,线程A先执行当它在进行第一步即读取ticket的值1传到自己的寄存器后,A的时间片到了CPU切换到了线程B而A就带着自己寄存器的内容即它的上下文去等待下一次的调度。现在线程B来执行这五步操作,执行的非常顺利它成功的抢到了最后一张票并将内存中ticket的减为了0。然后CPU切换回了线程A让其继续执行这五步所以线程A将自己的上下文特别是ticket的值1写入到了寄存器中,随后继续执行未完成的第二步即让CPU来判断寄存器中存储的ticket是否大于0,判断结果是大于0的。
注意:此时的线程A的判断结果其实是错误的,因为ticket的已经被线程B减到0了但是由于线程A在切换之前已经将ticket的值读取到了自己的寄存器中而CPU进行判断又是判断的是寄存器中的内容而不是此时内存中的内容。这就导致了线程A判断结果是大于0的。
在判断结果是大于0后线程A继续执行剩下的三步,先将内存中ticket的0读取到寄存器中再进行减一最后写入到内存中从而导致了线程A抢到了本不应该存在的第0号票!
而这只是两个线程并发访问的情况要知道我们刚刚可是创建了四个线程来抢票,所以更容易产生这样的问题即可能有三个线程都是ticket=1的时候读取了ticket的值到寄存器中随后在要进行第二步的时候被切换走了,之后第四个线程完成了–的操作让ticket为0。但是那三个线程在被切换回来后还是继续执行了剩下的四步导致抢到了第0号,第-1号,第-2号。
那么要如何解决这个问题呢?如果我们只允许一个线程进入临界区去访问临界资源不就不会产生这种问题了吗,这种解决办法用专业一点的术语就是加锁也就是利用互斥。而这把锁也就叫做互斥量。
那么接下来我们来介绍一下加锁的一些接口,先了解接口我们再使用并且理解。
2.2初始化和销毁互斥量
初始化互斥量有两种方法分为静态初始化互斥量和动态初始化互斥量
- 静态初始化互斥量
非常的简单,我们可以在任意地方静态初始化互斥量并且静态的互斥量不需要销毁 - 动态初始化和销毁互斥量
动态初始化互斥量中第一个参数是一个互斥量变量第二个参数是互斥量的属性通常我们设为NULL即可。
销毁互斥量只需要传入一个互斥量变量即可。
注意:不要销毁一个被加锁的互斥量并且已经被销毁的互斥量之后要确保没有线程再对其进行加锁
2.3加锁和解锁
第一个函数就是加锁,第二个函数是检测是否能加锁,第三个函数是解锁
在加锁的时候可能会遇到两种情况:
- 这个互斥量没有被加锁,那么调用lock函数就会加锁成功并且返回0
- 这个互斥量已经被加锁或者和其他线程同时申请这个互斥量时没有竞争过其他线程,那么调用lock函数就会加锁失败并且会将次线程移入到阻塞队列中进行等待直到互斥量解锁。
而如果不想让线程在申请加锁失败的情况下被阻塞的话就可以使用第二个函trylock,使用这个函数加锁失败时只会返回错误码。
那么我们来尝试使用一下互斥量吧
2.4互斥量的原理
可是就如同我们面对信号量时的问题又出来了,这么多的线程去访问互斥量那它不就变成一个公共资源了那它不也需要被保护吗?但是它肯定不能再去让别人保护它吧所以对于互斥量的保护只能从它自己入手也就是让它对加锁的操作变成原子的。这又是如何做到的呢?
我们也可以用图来解释
想要理解这部分的内容需要大家知道几个知识
寄存器在硬件中只有一份但是寄存器的内容是具有多份的这就导致每个线程都有一个属于自己的上下文。
而xchgb的作用就是将一个共享的mutex资源交换到一个线程的自己的上下文中从而让其属于线程自己进而完成加锁的工作!!!
2.5可重入和线程安全
在之前学习进程间通信的时候我们谈到过可重入函数的概念,如今我们学会了并发多线程访问公共资源就更需要关注可重入的问题了
我们先来复习一下可重入函数的概念
当同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
同时我们要注意:可与不可重入函数是不分褒贬不分好坏的,可不可以重入只是函数的一个特点而已!
在我们学习了线程后肯定要谈论到线程的安全问题
对于线程的不安全问题一般是有以下几个情况
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
那么线程的不安全问题和重入函数又有什么关系呢?
我们一般来说函数是可重入那么就是线程安全的,如果函数不可重入那就不可以让多线程去使用不然就会引发线程的安全问题。并且如果一个函数中全局变量那么整个函数既不是可重入的也不是线程安全的。
而线程安全又和可重入函数有什么区别呢?为什么要把这两个概念放在一起讲述
可重入函数是线程安全函数的一种
线程安全不一定是可重入的,但是可重入函数一定是线程安全的。
不可重入函数不一定是线程不安全的,线程不安全也不一定是不可重入函数。
线程安全与否是线程的一种特征,而函数是否可重入则是函数的一种特点。
2.6死锁
在我们了解了锁后我们现在来思考使用锁是否会带来一些问题呢?
在我们刚刚使用锁后很明显的问题的就是代码的执行速度变慢了这也很正常因为本来是多线程去访问临界资源的使用锁后变成了只能一个一个的去访问临界资源,速度当然会变慢。
但是锁的问题不仅仅出现在这里我们来假设一个场景
在这个场景中有两个线程有两把锁,A线程拥有A锁B线程拥有B锁但是于此同时呢A线程又去申请B锁B线程又去申请A锁这件造成这两个线程全部都申请失败并且进入阻塞状态从而导致整个临界区卡住了,因为两个执行流全部都阻塞住了他们俩都不愿意放下自己的锁并且还想申请别的锁。这种问题就是死锁问题。
所以死锁是指在一组进程中的各个线程均占有不会释放的资源,但因互相申请被其他线程所占用不会释放的资源而处于的一种永久等待状态。
我们上面演示的只是死锁的一种出现情况而已其实对于死锁来说有着四个必要条件:
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
这四个必要条件我们可以继续用上面的例子来进行解释:
只要你用了锁就满足了互斥条件。
像A线程那样有了A锁又去申请B锁同时还不解开A锁就满足了请求与保持条件。
A线程去申请B锁就必须在B解开锁后才能申请成功,没法直接强取豪夺了就满足了不剥夺条件。
A线程申请B锁B线程申请A锁导致A线程在等待B线程解开B锁而B线程又在等待A线程解开A锁从而满足循环等待条件。
那么想要避免死锁问题就只要破坏上面四个必要条件中的一个或者多个即可
如破坏互斥条件就只需要不用锁就行,破坏请求与保持条件就让一个线程在申请锁资源前把自己拥有的锁全部解开,破坏不剥夺条件也是类似请求与保持条件,破坏循环等待条件就是让一个线程按照顺序去申请全部的锁资源就可以。这也就是为什么多线程代码的编写难度要难的一个原因。