目录
一、线程池的概念
1. 线程池的概念
线程池,是一种线程使用模式。我们知道,线程过多会导致CPU需要频繁切换线程带来过多的调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待监督管理者分配可并发执行的任务。这一方式就避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。这和在容器,例如vector中,申请空间时并不是申请你所要的空间大小,而是申请2倍或1.5倍的空间大小,以减少空间扩容的代价的原理是一样的。
说简单点,线程池就是提前创建好大量线程,在程序需要使用时直接调用这个线程池内的线程即可,无需自己重新创建。
一般来讲,可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。
2. 线程池的应用场景
一般来讲,线程池有以下三个应用场景。
(1)需要大量的线程来完成任务,且完成任务的时间比较短。例如WEB服务器完成网页请求这样的任务,因为其单个任务小且任务数量巨大,所以很适合使用线程池。但对于长时间任务,例如一个Telnet连接请求,线程池的优点就不明显了。因为Telnet会话时间比线程创建时间长太多。
(2)对性能要求苛刻的应用。例如要求服务器能够在极短时间内响应客户要求。
(3)接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。突发性大量客户请求,会在短时间内使用大量的线程,此时就可以使用线程池。
二、模拟实现一个线程池
有了线程的相关知识,再对线程池有了一个初步的了解后,就可以着手自己模拟实现一个简单的线程池了。
1. 线程的简单封装
因为系统接口的线程创建、等待等操作太麻烦,所以我们可以对线程进行一个简单的封装。提供如下一个简单的线程封装:
#pragma once
#include <iostream>
#include <string>
#include <pthread.h>
#include <functional>
#include <cassert>
// 提供一个简单的线程封装
const int num = 128;
namespace ThreadNs
{
class Thread
{
typedef std::function<void *(void *)> func_t;
private:
static void *start_routine(void *args) // 调用启动函数
{
Thread *_this = static_cast<Thread *>(args);
_this->callback();
}
void *callback() // 调用传进来的启动函数
{
return _func(_args);
}
public:
Thread(func_t func, void *args = nullptr)
: _func(func), _args(args)
{
char namebuffer[num];
snprintf(namebuffer, sizeof(namebuffer), "thread-%d", threadnum++);
_name = namebuffer;
}
void start() // 创建函数
{
int n = pthread_create(&_tid, nullptr, start_routine, this);
assert(n == 0);
(void)n;
}
void join() // 等待函数
{
pthread_join(_tid, nullptr);
}
std::string& threadname()//获取线程名字
{
return _name;
}
~Thread()
{}
private:
pthread_t _tid; // 线程id
std::string _name; // 线程名字
func_t _func; // 执行函数
void *_args; // 回调参数
static int threadnum; // 线程编号
};
int Thread::threadnum = 1;
}
在这个封装中,为了方便看到是不同的线程,所以在构造函数中生成了一个线程的名字。在start函数中,就要创建一个线程。因为线程创建需要有执行函数,所以传一个start_routine函数进去,但因为这是在类里面,所以start_routine需要为静态的, 否则会有一个隐含的this指针,不符合需求。而为了让start_routnie使用类中的成员,所以将this指针传过去。在start_routine中再去调用calback函数,calback函数中就是去执行外部传进来的_func函数。
2. 锁的简单封装
为了方便使用,同样对锁进行一个简单的封装:
#pragma once
#include<pthread.h>
class Mutex
{
public:
Mutex(pthread_mutex_t *lock = nullptr)
:_lock(lock)
{}
void lock()
{
if(_lock)
pthread_mutex_lock(_lock);
}
void unlock()
{
if(_lock)
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;
};
在这个封装中,提供了两个类,Mutex类是用于提供上锁和解锁的。而LockGuard类则是用于对外部提供使用,提供RAII的锁。
3. 线程池的模拟实现
这里的这个线程池的实现,使用了上面的对线程创建的封装和锁的封装。
3.1 整体结构及头文件
在这个线程池里面,将创建的线程对象保存在一个vector中,再将要执行的任务保存在一个队列中。同时,提供锁和条件变量以供线程池的函数中的使用。
3.2 构造函数
在构造函数中,获取要形成的线程数量,并初始化锁和条件变量,将生成的线程对象放入vector中。
3.3 析构函数
释放所有的线程对象, 并销毁锁和条件变量。
3.4 线程启动
在构造函数中,我们仅仅是生成了一个线程对象,并没有正式创建线程。因为在我们自己写的Thread中,并不是在构造函数中创建线程的。所以,提供一个run函数,调用Thread中start创建线程。
因为是要调用start创建线程,所以在run中就将创建线程所需要的参数传进去。这里的handlerTask就是启动函数。
这个handlerTask函数由类中提供。因为是在类中,所以需要设置为static,以去掉隐含的this指针。但是handlerTask此时就无法调用类中的成员了。所以提供一个ThreadData类来获取this指针和线程名字:
将这个类对象的指针传给handlerTask,它就可以通过这个类对象指针来获取类成员了。为了更方便的使用,于是再提供几个函数来执行对应的操作:
在这个handlerTask启动中,当任务队列中没有数据的时候,就需要让线程进入等待。当有数据时,让线程向下执行获取任务并执行。注意,执行任务时要放在锁的外部,以免线程串行执行任务。由于这里使用的是我们自己写的RAII的锁,所以提供一个匿名域来限制锁的生效范围。
3.5 传入任务
在线程池中,要提供一个向队列传输任务的函数:
当任务传入任务队列后,就要唤醒在指定条件变量下等待的线程,让其指定对应的任务。
3.6 整体结构
根据上面的思路,就可以写出一个简单的线程池了:
#pragma once
#include"Thread.hpp"
#include"Mutex.hpp"
#include<vector>
#include<queue>
#include<unistd.h>
using namespace ThreadNs;
template<class T>
class ThreadPool;//类声明
template<class T>
struct ThreadData
{
ThreadData(ThreadPool<T> *tp, const std::string& name)
:_tp(tp), _name(name)
{}
ThreadPool<T> *_tp;
std::string _name;
};
const int gnum = 5;//默认生成5个线程
template<class T>
class ThreadPool
{
private:
static void *handlerTask(void *args)
{
ThreadData<T> *td = static_cast<ThreadData<T> *>(args);
while (true)
{
T t;
{
LockGuard lockguard(td->_tp->mutex());//使用RAII的锁
while (td->_tp->taskEmpty()) // 任务列表为空,线程就进入等待
{
td->_tp->threadWait();
}
t = td->_tp->pop(); // 获取任务
}
std::cout << td->_name << "获取任务: " << t.tostring()
<< " 处理结果为: " << t() << std::endl;//执行任务,应让线程并发处理,而不是放在临界区
}
delete td;//释放td的空间
return nullptr;
}
private:
void threadWait() {pthread_cond_wait(&_cond, &_mutex);}//等待条件变量
bool taskEmpty() {return _taskqueue.empty();}//队列判空
pthread_mutex_t* mutex() {return &_mutex;}//返回锁
T pop()//获取任务
{
T t = _taskqueue.front();//任务列表不为空,获取第一个任务
_taskqueue.pop();
return t;
}
public:
ThreadPool(const int num = gnum)
:_num(num)
{
pthread_mutex_init(&_mutex, nullptr);//初始化锁
pthread_cond_init(&_cond, nullptr);//初始化条件变量
for(int i = 0; i < _num; ++i)
{
_threads.push_back(new Thread());//生成对应个数的线程对象
}
}
void run()
{
for(auto& t : _threads)
{
ThreadData<T>* td = new ThreadData<T>(this, t->threadname());
t->start(handlerTask, (void*)td);
std::cout << t->threadname() << " start..." << std::endl;
}
}
void push(T& in)//传入任务
{
LockGuard lockguard(&_mutex);//使用RAII的锁
_taskqueue.push(in);
pthread_cond_signal(&_cond);//队列中有任务,唤醒在条件变量下等待的线程
}
~ThreadPool()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_cond);
for(const auto& t : _threads)
{
delete t;
}
}
private:
int _num;//要生成的线程个数
std::vector<Thread*> _threads;//存放线程对象
std::queue<T> _taskqueue;//存储要执行的任务
pthread_mutex_t _mutex;//锁
pthread_cond_t _cond;//条件变量
};
有了上面的线程池后,就可以写一个简单的程序进行测试:
运行该程序:
程序运行正常。
三、线程安全的单例模式
1. 单例模式的简单介绍
单例模式,就是设计模式的一种。这一内容在C++栏的C++11的“特殊类设计与类型转换”中有所讲解,这里就不过多赘述。
简单来讲,单例模式就是只允许创建一个对象的类。
要实现单例模式,有两种方法。分别是“饿汉模式”和“懒汉模式”。
饿汉模式是指在进入main函数之前就创建好一个单例对象,这个单例对象供main函数内使用。如果一个单例对象需要初始化的内容很多或者存在大量的饿汉模式的单例对象,就可能拖慢程序的启动速度。
懒汉模式则是指在需要使用的时候再创建一个单例对象。核心思想就是“延时加载”。从而实现“什么时候用就什么时候创建”,以优化服务器的启动速度。
懒汉模式相对于饿汉模式,就能够很好的提高程序的启动速度。懒汉模式的思想不仅在单例模式中有用,在内存申请中也有用。例如我们用new或用malloc申请空间时,OS并不是直接将物理内存交给你,而是给你划分了一块虚拟内存,但这块虚拟内存上并没有映射物理内存。当程序需要使用这块空间时,进程进入虚拟地址空间,通过页表映射时,就会发现这块虚拟空间没有可以映射的物理内存,此时就会触发“缺页中断”,当触发“缺页中断”后,OS才会判断是否要将这块物理内存映射到对应的虚拟内存上。
这一机制的作用其实就是为了防止某些程序或变量申请了大量的空间却不使用。例如现在你有100块钱,此时有人向你借了50块,但他并不是要立刻使用,而是要等几天才使用。于是你告诉他,这50块钱先不借你,等你需要马上用时再借你。当这个人走后,又有一个人来找你借60块钱,他说他很急,立刻就需要使用钱,于是你借给了他。此时,虽然提前有人向你借了钱,但因为他暂时不使用,所以你就先不借,而是留着借给那些需要立即使用的人。如果你在这之前就把50块钱借给了那个人不立即使用的人,那么第二个人你就没办法借给他了。此时,虽然你的总金额不变,但却可以将钱借给更多的人了。
OS中的内存也是如此。如果有大量的变量申请了内存却不使用,如果OS直接把空间给它们,就会导致OS中内存的利用率降低。通过保留内存给立即需要使用的变量的方式,就可以更好的使用内存了。
2. 修改线程池代码为单例模式
有了对于单例模式的认识,就可以将上面写的线程池修改为单例模式了。
首先,因为只能生成一个对象,所以要禁止在类外随意生成对象。因此,将构造、拷贝构造和赋值重载设置为私有:
为了让外部可以拿到一个单例对象,所以提供一个getstance函数来获取单例对象。这个函数要为static,因为类中的static成员无需对象就可以被调用。在此之前,为了方便返回同一个对象指针,所以定义一个静态的对象指针。同时因为创建对象的过程并不是线程安全的,所以要提供一个单独锁来保护创建对象的过程。但是又因为这个锁只需要在第一次创建对象时生效,所以使用“双检查”的方式来避免重复申请锁。
为了方便在类外对锁初始化,所以所以这里使用的是C++中封装后的锁。当然,这里将锁设置为静态只是为了方便getstance函数使用,如果你不想设为静态,可以使用其他方法将锁提供给getstance函数。
通过上面的修改,就可以将线程池修改为单例模式了。
四、stl和智能指针的线程安全问题
1. stl库
STL库中的容器并不是线程安全的。因为stl的设计初衷就是要将性能挖掘到极致,而一旦加锁保证线程安全,就会导致线程串行访问进而对性能造成巨大影响。
同时,对于不同容器,其加锁的方式不同,性能也可能有所不同。
基于上面的原因,stl中的容器默认不是线程安全的。如果在多线程环境下运行,就需要用户自行保证线程安全。
2. 智能指针
在智能指针中,最常用的就是unique_ptr和shared_ptr。
unique_ptr是线程安全的。因为unique_ptr只是在当前代码块范围内生效,每块空间只有一个智能指针指向,不涉及线程安全。
shared_ptr可以支持多个智能指针指向同一块空间,实现方式就是计数器,所以会存在线程安全的问题。但是在标准库中的shared_ptr考虑到了线程安全的问题, 所以它的计数器通过CAS的方式保证了其能够原子性的++、--,保证了线程安全。
五、一些常见的锁(了解)
1. 悲观锁
在每次去数据时,总是担心数据会被其他线程修改,所以在取数据前先加锁(读锁、写锁、行锁等),当其他线程想要访问数据时,被阻塞挂起。
2. 乐观锁
每次取数据时,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他线程在数据更新前有没有对数据进行修改。主要采用两种方式:版号机制和CAS操作。
3. 自旋锁
3.1 自旋锁的概念
在实际中,在之前我们所使用的一直都是阻塞等待的锁,即线程在无法拿到锁的时候就挂起等待。但是还有一种锁,那就是“自旋锁”。遇到自旋锁的线程,并不会直接将自己挂起等待,而是通过轮询的方式,不断的去申请锁。
举个例子。假设你有一个朋友,有一天你们约定好一起出去玩。你准备好后就去宿舍楼下打电话给他问他还有多久。他告诉你他现在有点事,可能要一个小时后才能来。你听了后,还要这么久,于是你告诉他,你先去外面的网吧上会儿网,等他好了之后打电话说一声,你就回来。过了一个小时后,你朋友就打电话告诉你他准备好了,让你回来,于是你就离开网吧会宿舍楼了。
第二天,你又要和朋友出去玩,同样的,当你到楼下后,他还是没有准备好。于是你打电话问他还要多久,他说大概6分钟左右。你听到后就把电话挂了,在楼下等他。过了两分钟,你有点不耐烦了,又打电话过去问他好没好,他还是说没有好。又过了两分钟,你又打电话过去,他说他马上好了。过了一会,你看他还没有下来,于是你又打了个电话过去,对方告诉你他已经到楼下了,你听了后抬头一看,就看到他已经到宿舍门口了。于是你们就一起出门玩了。
在上面的例子中,你的朋友因为要花一个小时准备,你去网吧里面等他给你打电话的过程,就是线程在没有申请到锁时,将自己挂起等待。
而你的朋友只需要几分钟准备,你在这个过程中不断给他打电话询问他准备好没的过程,就是线程在以轮询的方式查看锁是否已经被归还。
所以,自旋锁就是一个线程在没有申请到锁时,不进入挂起等待,而是不断轮询锁的过程。
使用自旋锁还是阻塞锁的依据,就是线程运行临界资源所需要的时间。这就好比你的朋友只需要几分钟就能准备好,你就没有必要去网吧上网等他了。说不定你还没有走到网吧门口,你朋友就打电话喊你回来了。线程也是如此,如果一个线程执行临界区中的代码的速度很快,那么其他线程就没有必要进入挂起等待,说不定这些线程刚进入阻塞等待,或者还没有进入阻塞等待的时候,执行临界区的线程就已经把锁归还,需要唤醒线程了。
由于自旋锁是让没有锁的线程不断申请锁,所以如果执行临界区的线程执行时间过久,就可能导致死锁。因此,自旋锁的使用要慎重,除非你明确知道线程执行临界区的速度很快,不会出现死锁问题。注意,这里的速度快是对比出来的,例如在我们的眼里,1s是很快的。但是在CPU的眼里,1s就是很慢的。有兴趣的可以看看CPU的运行速度。
在一般情况下, 阻塞锁就可以满足需求。当然,如果你想尝试自旋锁,可以对一段临界区分别使用阻塞锁和自旋锁,对比用这两个锁的运行效率如何,选择运行速度比较高的那个即可。
3.2 自旋锁的使用函数
自旋锁在linux中就已经默认提供了。自旋锁,说白了也就是一个数据类型。要使用自旋锁,首要要定义一个“pthread_spinlock_t”的变量。
(1)初始化和销毁
初始化和销毁时分别使用pthread_spin_init函数和pthread_spin_destroy函数:
(2)加锁
加锁使用pthread_spin_lock函数:
在pthread_spin_lock中,就已经把自旋的过程给我们做好了。如果你不想使用系统中设定的自旋方式,可以使用pthread_spin_trylock函数:
这个函数在申请锁失败后会返回,可以让我们自己去控制自旋方式。
(3)解锁
解锁则使用pthread_spin_unlock函数:
4. 读写锁
4.1 读写锁的作用
在多线程的场景下,有一种情况是很常见的。那就是公共数据的修改机会比较少。相比较与修改,它们被读取的概率反而高很多。比如我们自己写的博客,在博客发布后,大部分时间都是让其他人去阅读这篇博客,除非发现错误,否则我们是不会去轻易修改的。
通常而言,仅仅读取而不修改数据,是不涉及线程安全的。并且在读取的过程中,可能还伴随着查找的操作。如果给这类代码加锁,让线程串行读取,效率就会大大降低。那么有没有一种方法可以专门处理这种“多读少写”的情况呢?解决方案就是“读写锁”。
4.2 读者与写者的关系
在读写锁之间,就存在“读者”和“写者”两个角色。这两个角色之间也是有关系需要维护的。为了方便理解,这里举一个例子。
在以前,大家的学校里面每个班级的最后面都有一块黑板,这块黑板一般会用来画黑板报:
在你的班级里面,有一个叫小红的同学,她的字写的很好,画也很好。于是你们的老师就拜托小红画一下班级里的黑板报。当小红在黑板上面画画的时候,其他同学就站在后面看。当小红画了一个圆圈时,后面的同学就在激烈的讨论,有人说这为了画月亮,有人说这是想画一个飞盘。大家七嘴八舌的讨论着,结果小红最后在这个圆圈里面写了一些安全小知识。
此时你们的老师看到这种情况后,担心后面的同学打扰到小红画黑板报,也为了避免后面的同学拿着小红画一半的东西随意讨论,获得一些错误的情报。于是定了一条规定:当小红在画画时,禁止其他同学去看小红画的黑板报,只有当小红画完了才能去看。在这里,小红就是“写者”,而其他同学就是“读者”。此时,写者和读者之间的关系就是“互斥”。在同一时刻,只有一方可以去访问资源。同时,当小红画完黑板报后,就应该让其他同学来看她的黑板报,获取正确的内容。此时,读者和写者之间就是“同步”。所以,读者和写者之间的关系就是“互斥与同步”。
还是上面的例子。在小红画黑板报的时候,小黄也想来尝试,于是在小红画黑板报时,小黄就上来把小红画的黑板报擦了,想自己画。小红看见了就很生气,她辛辛苦苦画的黑板报就这样被小黄随着毁坏了,于是她告诉了老师。老师知道后,就来批评了小黄,并定下规定:在小红画黑板报时,任何人都不能去随意修改。然后老师告诉小黄,如果小黄确实想画黑板报,那等下次要重新画黑板报时,让小黄来负责画就行了。此时,小红和小黄就都是“写者”。为了防止写者之间错误的修改数据,所以它们是“互斥状态”。
那么读者和读者之间的关系呢?读者和读者之间没有任何关系。这就好比在黑板报画好后,大家都可以随意阅读黑板报,不存在说一个个排队看黑板报的情况。所以,读者和读者是可以支持并发的。
总的来讲,读写锁也遵循“321”原则。
3,指三种关系。读者和写者(互斥与同步),写者和写者(互斥),读者和读者(没有关系)。
2,指两个角色,读者和写者。
1,指一个交易场所,即读者和写者交换数据的缓冲区。
看到这里,大家可能就会有疑惑了,在线程中的生产消费模型虽然也遵循321原则,但是它的消费者之间的关系是互斥关系,那为什么这里的读写锁的读者却是没有关系,支持并发呢?原因很简单,生产消费模型中的消费者会将数据带走,涉及数据修改;但读写锁中的读者并不会拿走数据,仅仅是阅读数据,所以不存在修改数据。
4.3 读写锁的操作函数
在linux中,也为我们提供了读写锁的接口。因为读写锁也是一个数据类型,所以需要定义一个“pthread_rwlock_t”的变量。
(1)初始化和销毁
读写锁的初始化和销毁分别用pthread_rwlock_init函数和pthread_rwlock_destroy函数:
初始化函数的第二个参数是读写锁的属性,一般设置为nullptr即可。
(2)读者锁
在读写锁中,分为读锁和写锁两种。如果要用读锁,就使用pthread_rwlock_rdlock函数:
第二个函数是一个加锁失败就返回的函数。
(3)写者锁
如果要使用写锁,就要使用pthread_rwlock_wrlock函数:
(4)解锁
无论是读者锁还是写者锁,都需要使用pthread_rwlock_unlock函数解锁:
4.4 读写锁的加锁原理
(1)读锁原理
在读锁中,是存在一个计数器的。这个计数器的起始值假设为0。当有读者线程进来时,就先让读锁加锁,然后++计数器。++完后,就解开读锁。通过这一方式,保护计数器能以线程安全的方式进行++,然后在后面执行的代码中,由于计数器++后就让解开读锁,所以可以支持并发访问共享资源。当资源访问完后读者线程准备离开时,再次加上读锁,然后让线程去--计数器,--完后解开读锁,让读者线程离开。当读者线程第一次进入将计数器修改为1时,就要将写者锁加锁,以避免写者线程在有读者线程访问时进入临界区。
当有写者线程需要进入由读锁加锁的临界资源时,写者线程首先要等待所有的读者线程离开。即写者线程进入挂起等待,等读者线程全部访问完后计数器归0后,再让写者进入。虽然在这之前已经在计数器为1时加锁了,但写者线程并不会进入加锁的代码。而是向下运行,执行临界区资源。当执行完后,写者线程就需要进入一个计算器为0的判断中进行解锁。
上面只是一份伪代码,仅仅是讲解了基本的读锁逻辑,实际的读锁逻辑要比上面复杂的多。
(2)写锁原理
写锁的原理就很简单了,就是一个普通的锁,无论是读者线程还是写者线程,都需要阻塞等待。
4.5 读锁中的读者写者优先级问题
在读锁中,一个写者线程想要进入修改数据,就必须等读者线程全部结束完成访问。但如果一直频繁有读者线程进入访问数据,那么写者线程就一直无法读取数据。此时就会导致写者线程的“饥饿“问题。
出现这种情况的原因就是读锁中“读者优先”。要解决也很简单。虽然写者线程无法阻止已经在读取数据的读者线程,但可以阻止还未进入临界区的读者线程进入。即当有写者线程进来后,就禁止其他读者线程进入,等临界区中的读者线程执行完后,让写者线程进入访问数据。写者线程离开后才让其他读者线程进入。
在linux中, 为了方便用户选择读者优先还是写者优先,也提供了对应的设置读写优先的接口:
pthread_rwlockattr_setkind_np(pthread_rwlockattr_t *attr, int pref)。
在这里面,pref就是读锁的优先级设置。
PTHREAD_RWLOCK_PREFER_READER_NP(默认设置)读者优先,可能会导致写者饥饿情况。
PTHREAD_RWLOCK_PREFER_WRITER_NP写者优先,目前有BUG,导致表现行为和PTHREAD_RWLOCK_PREFER_READER_NP一致
PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP写者优先,但写者不能递归加锁
想设置读写优先,就可以用上面的宏来设置。