线程封装(面向对象)
1.组件式的封装出一个线程类(像C++11线程库那样去管理线程)
- 我们并不想暴露出线程创建,终止,等待,分离,获取线程id等POSIX线程库的接口,我们也想像C++11那样通过面向对象的方式来玩,所以接下来我们将POSIX线程库的接口做一下封装,同样能实现像C++11线程库那样去管理我们的线程,这个类就像一个小组件似的,包含对应的.hpp文件就可以使用,使用起来很舒服。
//hpp文件
#pragma once
#include<iostream>
#include<pthread.h>
#include<string>
#include<functional>
using namespace std;
template<class T>
using fun_c = std::function<void(T)>;
template<class T>
class Thread
{
public:
Thread(const string& threadname, fun_c<T> func, T data)
:_threadname(threadname), _func(func), _data(data)
{}
~Thread()
{
;
}
//线程执行的函数
static void* ThreadRoutine(void* args) //成员函数默认里面有一个this指针,而静态成员函数中没有
{
Thread* t = static_cast<Thread*>(args);
t->_func(t->_data);
return nullptr;
}
//线程创建-》running
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 ThreadName()
{
return _threadname;
}
bool IsRunning()
{
return _isrunning;
}
private:
string _threadname;
pthread_t _tid = 0;
bool _isrunning = false;
fun_c<T> _func;
T _data;
};
//.cc文件
#include <iostream>
#include "mythread.hpp"
#include <cstdio>
#include<unistd.h>
string GetThreadName()
{
static int num = 1;
char buffer[64];
snprintf(buffer, sizeof(buffer), "thread-%d", num++);
return buffer;
}
void Print(int number)
{
while (number)
{
std::cout << "hello world: " << number-- << std::endl;
sleep(1);
}
}
int main()
{
Thread<int> t(GetThreadName(), Print, 10);
std::cout << "is thread running? " << t.IsRunning() << std::endl;
t.Start();
std::cout << "is thread running? " << t.IsRunning() << std::endl;
t.Join(); //线程阻塞等待
// Thread t(GetThreadName(), Print);
// std::cout << "is thread running? " << t.IsRunning() << std::endl;
// t.Start();
// std::cout << "is thread running? " << t.IsRunning() << std::endl;
// t.Join(); //线程阻塞等待
return 0;
}
线程互斥
多线程访问共享资源是不安全的
假设现在有一份共享资源tickets,如果我们想让多个线程都对这个资源进行操作,也就是tickets- -的操作,但下面两份代码分别出现了不同的结果,上面代码并没有出现问题,而下面代码却出现了票为负数的情况,这是怎么回事呢?
其实问题产生就是由于多线程被调度器调度的特性导致的。
int ticket = 1000; //票数
void route(string arg)
{
while (1)
{
if (ticket > 0)
{
usleep(1000);
printf("%s sells ticket:%d\n", arg.c_str(), ticket);
ticket--;
}
else
{
break;
}
}
}
int main()
{
Thread<string> t(GetThreadName(), route, GetThreadName());
Thread<string> t2(GetThreadName(), route, GetThreadName());
Thread<string> t3(GetThreadName(), route, GetThreadName());
Thread<string> t4(GetThreadName(), route, GetThreadName());
t.Start();
t2.Start();
t3.Start();
t4.Start();
t.Join();
t2.Join();
t3.Join();
t4.Join();
return 0;
}
- 了解上面的问题需要知道线程调度的特性,实际线程在被调度时它的上下文会被加载到CPU的寄存器中,而线程在被切换的时候,线程又会带着自己的上下文被切换下去,此时要进行线程的上下文保存,以便于下次该线程被切换上来的时候能够进行上下文数据的恢复。
除此之外,像tickets- -这样的操作,对应的汇编指令其实至少有三条:1.读取数据 2.修改数据 3.写回数据,而线程函数我们知道会在每个线程的私有栈都存在一份,在上面的例子中多个线程执行同一份线程函数,所以这个线程函数就绝对会处于被重入的状态,也就绝对会被多个线程执行!今天我们假设只有一个CPU(CPU就是核心,处理器芯片会集成多个核心)在调度当前进程中的线程,那么线程是CPU调度的基本单位,所以也就会出现一个线程可能执行一半的时候被切换下去了,并且该线程的上下文被保存起来(线程在切换的时候,会将自己的上下文数据保存到线程的线程控制块TCB),然后CPU又去调度进程中的另一个线程。
在知道上面的原理之后,还需要知道usleep的作用,当usleep放到if分支语句的第一行时,票数就出现了问题,出现了负数,主要是因为usleep可以将线程暂时阻塞,那么CPU就会把他切换下去,转而执行其他线程,但需要注意的是,如果被切换的线程重新调度上来时,还会从上次他执行后的语句继续向下运行。
所以会出现多个线程同时进入到分支判断语句,然后去阻塞等待的情况,假设tickets已经变成了1,然后其余的线程此时都被调度上来了,他们都开始执行tickets- -,- -之后不满足循环条件线程才会退出,那么如果我们创建出了4个线程,就会有3个线程在票数已经为0的情况下继续减减,所以就会出现票数为负数的情况。
提出解决方案:加锁(局部和静态锁的两种初始化/销毁方案)
那该如何解决上面的问题呢?多个执行流操作共享资源时,发生了数据不一致问题。
解决上面的问题实际要通过加锁来实现,但在谈论加锁的话题之前,我们需要来重新看待几个概念。
- 多个执行流总是能够共享许多资源,共享资源我们称为临界资源。
- 而多个执行流执行的函数体内部,对临界资源进行操作的代码称为临界区,需要注意的是临界区不是整个函数体内部的代码,而是指对共享资源进行操作的代码称为临界区。
- 如果我们想让多个执行流串行的访问临界资源,而不是并发或并行的访问临界资源,这样的线程调度方案就是互斥式的访问临界资源!(串行就是指只要一个线程开始执行这个任务,那么他就不能中断,必须得等这个线程执行完这个任务,你才能切换其他线程执行其他的任务,这个概念等会讲完锁之后大家就明白什么是互斥了)
- 当线程在执行一个对资源访问的操作时,要么做了这个操作,要么没有做这个操作,只要两种状态,不会出现做了一半这样的状态,我们称这样的操作是原子性的。
有了上面四组概念的铺垫之后,我们来谈谈如何对共享资源进行加锁和解锁?
首先锁实际就是一种数据类型,这个锁就像我们平常定义出来的变量或是对象一样,只不过这个锁的类型是系统给我们封装好的一种类型,进行重定义后为pthread_mutex_t。
变量或对象在定义的时候也是可以初始化的,变量初始化后,就是变量的定义,而不是声明了。变量和对象也都有自己的销毁方案,内置类型的变量或者对象销毁时,操作系统会自动回收其资源或者自动调用析构函数,而自定义对象销毁时,操作系统会调用其析构函数进行资源的回收。
锁同样也是如此,锁也有自己的初始化和销毁方案,如果你定义的是一把局部锁,就需要用pthread_mutex_init()和pthread_mutex_destroy()来进行初始化和销毁,如果你定义的是一把全局锁或静态所,则不需要用init初始化和destroy销毁,直接PTHREAD_MUTEX_INITIALIZER进行初始化即可,它有自己的初始化和销毁方案,我们无须关心静态或全局锁如何销毁。
定义好锁之后,我们就可以对某一段代码进行加锁和解锁,加锁与解锁意味着,这段代码不是一般的代码,只有申请到锁,持有锁的线程才能访问这段代码,加锁和解锁之间的代码可以称为临界区,因为想要访问这段空间必须有锁才可以访问。pthread_mutex_lock实际就是申请锁的代码和临界区的入口,如果你申请锁成功了,那么你就可以进入临界区访问临界资源,如果你并没有申请成功,比如当前这把锁已经被别的线程申请到并持有了,其他线程正持有锁在临界区访问着呢,那么你就无法进入临界区,因为你并没有持有锁,必须得在pthread_mutex_lock阻塞等待,直到你申请到锁之后,你才能进入临界区访问临界资源,这样的线程访问实际就是互斥,指的是当一个线程正在持有锁访问临界区的时候,其他线程无法进入临界区,直到持有锁的线程释放锁之后才会有可能进入临界区,注意是有可能,因为当线程释放锁之后,这把锁还需要被竞争,哪个线程竞争到这把锁,哪个线程才能持有锁的访问临界资源!
上面谈论完锁的初始化和销毁,以及如何加锁和解锁之后,我们来利用锁解决上面出现的共享资源访问不安全的问题。你不是由于多线程再进行临界资源访问时,可能由于线程切换什么的,导致非原子性式的访问临界资源吗?那我不让你这么干,我对这段临界资源进行加锁,让你当前申请到锁正在访问临界资源的线程,必须给我以原子性的访问来访问临界资源,换句话说,你必须把访问临界资源的工作做完了,才可以,要么你不要访问临界资源,要么你访问了临界资源,就必须把临界资源全部访问完了,中间不能访问一半就不访问了!所以只要对临界资源进行加锁后,临界资源就变得安全了,因为无论什么线程想要访问临界资源,都必须以原子性的方式访问完,这样的话,就不会出现在访问一半的时候,线程被切换下去了,其他线程被切换上来继续访问临界资源了,而是说如果持有锁的线程被切换下去了,这个线程会抱着他申请到的锁被切换下去,此时其他线程如果被切换上来,想要访问临界资源,那也没用,因为你没有锁啊!持有锁的线程被切换时,是抱着锁被切换的,那你现在既然访问不了临界区,CPU无法继续执行代码,那就只能等持有锁的线程重新被切换上来时,才能继续开展临界资源的访问工作,这个工作必须且只能由申请到锁的线程来完成,其他任何线程都无法完成这个工作!反过来说,这不就是原子性吗?访问临界资源的工作只要被持有锁的线程开始做了,哪怕他在做的过程中被切换下去了,也无须担心,因为别的线程做不了这个工作,所以还是得等持有锁的线程被切换上来的时候才能继续做这个工作,那是不是这个工作只要开始做了,就一定会被做完呢?会不会出现做一半,停下来了不做了,让别的线程在去访问临界资源的情况呢?当然不会!这就是锁带来的作用。
如果在加锁之后运行代码,实际可以发现他抢票的速度是要比没加锁之前慢的,原因也很简单。我来给大家解释一下,没加锁之前,线程之间是可以并发或并行执行的,我先大概说一下并发和并行是什么,后面会详细介绍这两者的区别和概念,并发你可以简单理解为,当线程运行一半被切换下去的时候,此时CPU还可以调度运行其他线程,也就是说,如果多个线程在运行的时候,每个线程都会被CPU跑一跑,那在一段时间内,所有的线程都可以被执行到,并且推进每个线程的执行过程。而并行就是在多个核心上面同一时刻跑不同的线程,比如两个同时访问临界资源的线程,在未加锁的时候,可能出现多个核心同时执行两个线程的代码,同时在访问临界资源,但实际这种情况并不常见,因为我们写出来的代码优先级并没有那么高,所以基本上都是在按照并发执行的。
然后加锁前是并发执行的,也就是说在一个线程被切换下去的时候,其他- -tickets的线程还能够被重新调度上来进行票数的- -,那么总体上来说,票数就会被一直- -。
而加锁之后就不是并发执行的了,因为我们上面说过,加锁之后即使持有锁的线程被切换下去,其他被调度到CPU上的线程也是无法进行票数- -的,因为他们没有锁,所以在持有锁的线程被切换下去的这段时间里,票数不会改变,因为线程在串行的访问临界资源,什么是串行呢?就是一个线程访问完之后,才能轮到另一个线程,就是我们前面说的,一个线程在完成他的工作之后,释放完锁之后,其他线程才有可能竞争到锁,才有可能访问临界资源,这样就是串行。
串行的执行效率肯定要比并发执行的效率底嘛,因为当多线程在执行任务的时候,我们进行并发执行,为的就是当前线程如果被切换下去了,那也没啥事,因为其他被调度上来的线程依旧可以执行这个任务。你现在加锁之后就会变成串行执行了,那当前持有锁的线程被切换下去时,其他被调度上来的线程是无法继续执行任务的,效率自然就会底一些。(效率底一点就底一点吧,毕竟现在共享资源就安全了嘛,下面运行结果你也可以看到,没有锁的时候,票数就为负数了,这种情况用户怎么可能容忍。)
局部和全局锁的两种加锁方案的代码实现
如果定义局部锁的话,我们肯定是想要将这把锁传给每个线程的,让每个线程都用这把锁来互斥式的访问共享资源,以此来保证共享资源的安全性。
//pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; //初始化一把全局的锁
int ticket = 1000; //票数
void route(pthread_mutex_t* mutex)
{
while (1)
{
pthread_mutex_lock(mutex); //上锁
if (ticket > 0)
{
usleep(1000);
printf("sells ticket:%d\n", ticket);
ticket--;
pthread_mutex_unlock(mutex);
}
else
{
pthread_mutex_unlock(mutex);
break;
}
}
}
int main()
{
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, nullptr); //初始化一把局部的锁
Thread<pthread_mutex_t*> t(GetThreadName(), route, &mutex);
Thread<pthread_mutex_t*> t2(GetThreadName(), route, &mutex);
Thread<pthread_mutex_t*> t3(GetThreadName(), route, &mutex);
Thread<pthread_mutex_t*> t4(GetThreadName(), route, &mutex);
t.Start();
t2.Start();
t3.Start();
t4.Start();
t.Join();
t2.Join();
t3.Join();
t4.Join();
pthread_mutex_destroy(&mutex); //释放锁
return 0;
}
全局的锁定义并且初始化以后,每个线程都可以看得见,因此也是共享资源
如何看待锁
完成上面对于共享资源访问不安全问题的解决之后,我们来深入的理解一下锁。
我们知道,共享资源在被多线程访问时,是不安全的,所以我们需要加锁来保护共享资源。但是我们回过头来想一想,锁本身是不是共享资源呢?所有的线程都需要申请锁和释放锁,那不就是在共同的访问锁这个资源嘛?所以锁本身不就是共享资源吗?那多个线程在访问锁这个共享资源的时候,锁本身是不是需要被保护呢?当然需要!其他的共享资源可以通过加锁来进行保护,那锁怎么办呢?
实际上,加锁和解锁的过程是原子的!也就是说只要你申请了锁,并且竞争能力恰好足够,那么你就一定能够拿到这个锁,否则你就不会拿到这个锁,不会说在申请锁申请一半的时候,线程被切换下去了,其他线程去申请锁了,不会出现这种中间态的情况!既然加锁和解锁的过程是原子的,那其实访问锁就是安全的!(但加锁解锁的过程为什么是原子的呢?我该如何理解呢?这个后面会说。)
RAII风格的封装设计锁?(构造函数加锁,析构函数解锁)
如果我们想简单的封装使用锁,那我们该如何设计呢?我们也想像之前封装设计线程那样搞出来C++式的面向对象版的创建线程和销毁线程。
实际实现起来也很简单,无非就是对原生的申请锁,加锁,解锁接口的封装!我们先定义一个互斥量的类,类中实现构造函数将锁的地址进行初始化,然后定义出加锁和解锁的两个接口,这样就可以定义出来一个内部能够进行加锁和解锁的类。
然后我们再加一层封装,实现出RAII( Resource Acquisition Is Initialization)风格的加锁,即为构造函数处进行加锁,析构函数处进行解锁!
至于锁的初始化和销毁方案,是类外面的事情,使用时需要自己先初始化好一把锁,确定初始化和销毁的方案,然后利用LockGuard.hpp这个小组件来进行加锁和解锁的过程!
可重入函数和线程安全
在多线程并发执行代码,同时访问共享资源的时候,如果某一个共享资源由于多线程访问,发生了数据不一致,共享资源不安全,并且导致其他线程运行出问题了,那么这种情况就是线程不安全的。尤其对于没有锁保护的共享资源的多线程访问的代码,很大概率出现线程不安全的情况。
而什么是可重入呢?这个话题并不陌生,我们之前谈论进程信号的时候,进程可能由于收到信号,并且在陷入内核时检测到信号,跳转到handler方法执行信号处理函数,信号处理函数中可能会出现和main执行流中执行相同的函数体,例如当时我们所说的链表的push_back在main和handler中同时执行,可能会导致某些未知错误的产生,如果出现了问题,那么我们称这个函数是不可重入函数,如果没有出现问题这个函数就是可重入函数。值得注意的是,不可重入函数说的是这个函数的属性,而不是说这个函数叫做不可重入函数,那么他就一定不能被执行流所重入,只是说,他如果被执行流重入,极大概率是要出问题的。
死锁
死锁是指一个进程中的各个线程,都持有着锁,但同时又去申请其他线程的锁,而每个线程持有的锁都是占有不会释放的,所以大家都会等着,等对方先释放锁,但是呢,大家又都不释放锁,全都占有着锁,所以大家就会处于一种永久等待的状态,也就是永久性的阻塞状态,所有执行流都不会被运行,这样的问题就是死锁!
之前抢票的代码中,多个线程使用的是同一把锁,未来有些场景一定是要使用多把锁的,在多把锁的情况下,如果某些线程持有锁不释放,还要去申请其他线程正持有的锁,而每个线程都是这样的状态,那就是死锁问题。
产生死锁的四个必要条件
1.互斥条件:一个资源每次只能被一个执行流使用,互斥其实就是加锁之后线程的串行执行。
2.请求与保持条件:一个执行流由于请求资源而阻塞时,对自己已经获得的资源保持不放。说白了就是我自己的东西不释放,我还要你的东西,你不给我就一直等,等到你给我为止。
3.不剥夺条件:一个线程在未使用完自己获得的资源之前,是不能够强行剥夺其他线程的资源的。说白了就是你先在还有资源呢,你想要别人的自由你就得等,不能强行剥夺!当你使用完自己的资源后,你可以去等待申请别人的资源。总之就是不能强行剥夺其他线程的资源,想要就必须阻塞等待别人释放资源才可以。
4.循环等待条件:若干个执行流之间,形成一种头尾相接的互相等待对方资源的关系。我们也称这样的现象为环路等待。