线程池的C++实现(一)
现代的软件一般都使用了多线程技术,在有些软件里面,一个线程被创建出来执行了仅仅一个任务,然后就被销毁了。线程的创建与销毁是需要消耗资源,这样为了执行单一任务而被创建出来的线程越多,性能也就越差。如果能意识到线程仅仅是负责指令流的执行,并重复利用同一个线程去执行多个函数,将线程的创建和销毁的次数控制在有限次内,频繁创建与摧毁线程这种不必要的开销就能够有效避免。
线程池就是这样一种将线程的创建与摧毁控制在一定次数内,并利用同一线程反复执行不同人任务的技术,当然,其中的线程数不止一条。线程池中线程的个数一般和硬件线程数量一致时(暂时不考虑当前线程和线程池以外的线程数),因为当进程中的线程数与硬件线程数一致时能达到最佳并发,C++语言中,硬件线程数可以由函数std::thread::hardware_concurrency()的返回值得到,函数返回的非0即为硬件线程数。
线程池内的线程一般在初始化时一起创建,在指定退出时一起退出。其中要执行任务的线程与外部线程通过一个线程安全的容器来实现交互,外部线程作为生产者往容器中添加任务,池中的线程作为消费者拿出线程中的任务去执行。当容器中没有任务时,池中的线程会阻塞直到得到通知并且容器中存放了任务(阻塞的线程不会得到系统调度器的调度)。
说了这么多,来看看线程池怎么实现的吧。先来看看头文件ThreadPool.h:
#ifndef _THREADPOOL_H_ #define _THREADPOOL_H_ #include <functional> #include <mutex> #include <atomic> #include <list> constexpr bool ThreadExit = true; class InterfaceTaskWithArgCommand //继承自此接口便可使用命令模式版本线程池 { public: virtual void Execute() = 0; virtual void DestoryMyself() = 0; virtual ~InterfaceTaskWithArgCommand() = 0; }; struct TaskArgType { void *pArg; int iArgLen; TaskArgType() : pArg(nullptr), iArgLen(0) { } TaskArgType(void *pData, int iLen) : pArg(pData), iArgLen(iLen) { } }; struct ThreadTask final { enum ThreadTaskType { ExecWithNothing, ExecWithArg, ExecByCommandPattern, Exit }; ThreadTaskType TaskType; std::function<void()> CB; //只执行不保证被操作对象生命周期 std::function<void(TaskArgType)> ArgCB; //被操作对象的生命周期由线程池托管,需在执行函数中释放,否则造成内存泄漏 TaskArgType Arg; InterfaceTaskWithArgCommand *TaskCommand; //命令模式版本 ThreadTask() { } ThreadTask(ThreadTaskType type) : TaskType(type) { } ThreadTask(std::function<void()> f) : CB(f), TaskType(ThreadTaskType::ExecWithNothing) { } ThreadTask(std::function<void(TaskArgType)> f, void *p, int size) : ArgCB(f), Arg(p, size), TaskType(ThreadTaskType::ExecWithNothing) { } ThreadTask(InterfaceTaskWithArgCommand *Command) : TaskCommand(Command), TaskType(ThreadTaskType::ExecByCommandPattern) { } bool Execute() { bool bExit = false; switch(TaskType) { case ThreadTask::ThreadTaskType::ExecWithNothing: CB(); break; case ThreadTask::ThreadTaskType::ExecWithArg: ArgCB(Arg); break; case ThreadTask::ThreadTaskType::ExecByCommandPattern: TaskCommand->Execute(); break; case ThreadTask::ThreadTaskType::Exit: default: bExit = ThreadExit; break; } return bExit; } }; //线程的各种个数最好由配置文件决定,配置文件根据具体场景配置 class ThreadPool { public: ThreadPool(); //考虑到此类可能存在多个实例的可能(如线程池隔离的情况,不同的线程池做不同类型的事情),构造函数设计为公有 ThreadPool(int iInitThreadCount, int iHoldThreadCount, int iMaxThreadCount); ~ThreadPool() = default; void Start(); void Stop(); void AddTask(std::function<void()> Task); void AddTask(InterfaceTaskWithArgCommand *Command); void AddTask(std::function<void(TaskArgType)> Task, void *pData, int iLen); int GetCurTaskCount(); int GetCurThreadCount(); void SetHoldThreadCount(int iCount); void SetMaxThreadCount(int iCount); private: void AddThread(); void AddThreadSuitably(); void ReduceThread(); void Run(); void MontorIdle(); void SetStop(); ThreadPool(const ThreadPool& rhs) = delete; ThreadPool(ThreadPool&& rhs) = delete; ThreadPool& operator=(const ThreadPool& rhs) = delete; ThreadPool& operator=(ThreadPool&& rhs) = delete; private: int m_iInitThreadCount; //初始个数 std::atomic<int> m_aiHoldThreadCount; //保持个数 std::atomic<int> m_aiMaxThreadCount; //最大允许个数 std::atomic<int> m_aiCurThreadCount; //当前个数 std::atomic<bool> m_abStopFlag; std::mutex m_mLock; std::list<ThreadTask> m_listTask; std::condition_variable m_ConditionVariable; }; #endif // !_THREADPOOL_H_
在头文件里,我定义了三种可执行的类型:命令模式类型,可携带固定参数的无返回值function类型,无参数无返回值function类型,这三个类型都是任务ThreadTask的执行方式。ThreadTask中还有多种构造方式,使用不同的构造方式,指示其可执行的类型的枚举变量也将不同,这会使得ThreadTask的Execute根据不同的类型执行不同的分支。
从ThreadPool开始,就是线程池本身了。
在类中,我没有将线程池设为单例模式,是考虑到在某些情况下需要线程池隔离,比如将负责网络IO与CPU密集型计算的线程池分开,避免相互影响。在线程池内,将不需要的拷贝/移动构造、拷贝/移动赋值声明为删除。m_iInitThreadCount是初始个数代表着线程池刚刚被创建出来时候池中线程的个数,这个数目不需要在运行中改变,所以可以是基本的int类型,m_aiHoldThreadCount与m_aiMaxThreadCount和m_aiCurThreadCount分别是保持线程个数,最大线程个数和当前线程个数,这三个在运行中可改变且可能同时被多个线程访问所以使用了原子的方式。这个线程池的线程数量在一定范围(m_aiMaxThreadCount)内可以动态增加以应对大量的突发任务,但是过多的线程会带来过多的调度的可能,从而影响性能,所以使用m_aiHoldThreadCount变量来指示在绝大多数情况下线程池内应该保有的线程个数。m_listTask和m_mLock是存放任务的容器和保护容易的锁,m_ConditionVariable用于实现如前所述的"当容器中没有任务时,池中的线程会阻塞直到得到通知并且容器中存放了任务"。
接下来就是源文件了ThreadPool.cpp,一起来看一下:
#include <thread> #include "ThreadPool.h" ThreadPool::ThreadPool() : m_iInitThreadCount(4), m_aiHoldThreadCount(4), m_aiMaxThreadCount(4), m_aiCurThreadCount(0), m_abStopFlag(true) { } ThreadPool::ThreadPool(int iInitThreadCount, int iHoldThreadCount, int iMaxThreadCount) : m_iInitThreadCount(iInitThreadCount), m_aiHoldThreadCount(iHoldThreadCount), m_aiMaxThreadCount(iMaxThreadCount), m_aiCurThreadCount(0), m_abStopFlag(true) { } void ThreadPool::AddThread() { std::thread th(&ThreadPool::Run, this); th.detach(); m_aiCurThreadCount++; } void ThreadPool::AddThreadSuitably() { if(m_aiCurThreadCount < m_aiMaxThreadCount && m_aiCurThreadCount <= GetCurTaskCount()) { AddThread(); } } void ThreadPool::ReduceThread() { { std::lock_guard<std::mutex> lk(m_mLock); m_listTask.emplace_back(ThreadTask::ThreadTaskType::Exit); } m_ConditionVariable.notify_one(); } void ThreadPool::Run() { bool bExit = false; while(!m_abStopFlag && !bExit) { std::unique_lock<std::mutex> lk(m_mLock); while(m_listTask.empty()) //防止虚假唤醒 { m_ConditionVariable.wait(lk); if(m_abStopFlag) { m_aiCurThreadCount--; return; } } ThreadTask Task = m_listTask.front(); m_listTask.pop_front(); lk.unlock(); bExit = Task.Execute(); } m_aiCurThreadCount--; } void ThreadPool::Start() { m_abStopFlag = false; for(int i = 0; i < m_iInitThreadCount; ++i) { AddThread(); } } //阻塞直到全部线程停止,不提供异步停止线程的方式 //停止线程的场景一般在程序结束的时候,此时如果是异步停止的话,难以保证线程池中的线程操作的目标生命周期还未结束 void ThreadPool::Stop() { do { SetStop(); } while(0 != GetCurThreadCount()); //线程池里可能跑的长函数,通知的时候可能没在等待条件变量 } void ThreadPool::AddTask(std::function<void()> Task) { if(!Task) return; bool bNeedCall = false; AddThreadSuitably(); { //此范围解锁是考虑到,wait条件变量的线程在被唤醒之后会尝试去加锁,如果还未被解锁的话,被唤醒的线程将再阻塞直到通知线程解锁,这引发了系统额外一次的调度 std::lock_guard<std::mutex> lk(m_mLock); //添加任务的线程同时可能不止一条线程 if(m_listTask.empty()) bNeedCall = true; m_listTask.emplace_back(Task); } if(bNeedCall) m_ConditionVariable.notify_one(); //条件标量会导致系统调用,导致用户态与内核态之前的切换,避免掉不必要的开销 } void ThreadPool::AddTask(InterfaceTaskWithArgCommand *Command) { if(nullptr == Command) return; bool bNeedCall = false; AddThreadSuitably(); { std::lock_guard<std::mutex> lk(m_mLock); //添加任务的线程同时可能不止一条线程 if(m_listTask.empty()) bNeedCall = true; m_listTask.emplace_back(Command); } if(bNeedCall) m_ConditionVariable.notify_one(); } void ThreadPool::AddTask(std::function<void(TaskArgType)> Task, void *pData, int iLen) { if(!Task || nullptr == pData || iLen < 1) return; bool bNeedCall = false; AddThreadSuitably(); { std::lock_guard<std::mutex> lk(m_mLock); //添加任务的线程同时可能不止一条线程 if(m_listTask.empty()) bNeedCall = true; m_listTask.emplace_back(Task, pData, iLen); } if(bNeedCall) m_ConditionVariable.notify_one(); } int ThreadPool::GetCurTaskCount() { std::lock_guard<std::mutex> lk(m_mLock); return m_listTask.size(); } int ThreadPool::GetCurThreadCount() { return m_aiCurThreadCount; } void ThreadPool::MontorIdle() { if(m_aiHoldThreadCount < m_aiCurThreadCount) { //利用定时器控制线程池里空闲线程的个数 ReduceThread(); } } void ThreadPool::SetStop() { if(!m_abStopFlag) { m_abStopFlag = true; m_ConditionVariable.notify_all(); } } void ThreadPool::SetHoldThreadCount(int iCount) { m_aiHoldThreadCount = iCount; } void ThreadPool::SetMaxThreadCount(int iCount) { m_aiMaxThreadCount = iCount; }
先说说线程的添加部分吧。线程池中线程的创建是靠函数AddThread()来实现的,此函数做的东西很简单,就是启动一条执行Run()的线程并分离它然后对当前线程数计数。Run()则只要没有收到停止命令就一直尝试取出容器中存放的任务来执行,当容器没有任务时,条件变量就会等待wait(),直到其他存放任务的线程通知notify_one()它。可能有些朋友不太明白,条件变量为什么需要锁,我在这里解释一下,因为是多线程环境,所以取任务的容器需要有锁m_mLock保护,先说结论,条件变量在等待的时候则会释放当前的锁来给其他的线程机会去往容器存任务,我们假设下如果不释放锁的会怎样,如果不释放锁其他线程永远也没有机会往容器里存放任务了,同样等待条件变量的线程也将没有机会得其他线程的通知,而等待的线程将会因为收不到唤醒通知而永远等待,这是个死锁的局面...囧...。所以记住条件变量在等待时会释放锁来给其他线程机会,并等待其他线程通知。接着,在条件变量被唤醒后它又会尝试获取当前的锁m_mLock来得到访问容器的机会, 考虑到锁的粒度对并发性的影响,主动释放来控制粒度以获取更大的并发性lk.unlock();。这样,池里的多条线程就可以同时执行任务。函数中有对容器是否为空的循环判断并在符合条件时等待while(m_listTask.empty()),一方面这能避免虚假唤醒,另一方面,当容器中有任务时也可以避免等待,直接去取出任务来执行,直到容器中没有任务,这也能避免其他线程的不必要的通知notify_one(),这个一会说。
接下来说说怎么向容器中添加任务的。我重载了函数AddTask()来实现往容器中添加不同类型的可执行任务,在函数的一开始,先判断下任务的合法性,空任务会引出崩溃问题,所以一定要提前处理。前面提过这个线程池可以动态增加线程数来应对突发任务的情况,这正是函数AddThreadSuitably()所做的。由于取任务的时候的做法是只要容器中存在任务,就不停取除非容器任务被全部取出,所以在存放任务时,我添加了一个标志bNeedCall来确定是否需要通知去唤醒池中的线程,很明显,当容器中还存在任务时,可以不用通知。这个函数中利用了块来控制锁的粒度,正如我的注释中所描述,是为了避免一次额外的调度。
还是应该说说关于池中线程数量的控制,前文说过,进程中线程的个数最好和硬件线程数一致来获取最佳并发。然而在处理完突发情况之后,线程池中线程个数会根据我们设定的范围增加,所以我们应该控制线程池中线程数量的控制。利用定时器并设定合理的值,延迟销毁池中一定的空闲线程的方式比起直接销毁空闲线程来有更大的概率在销毁线程之前在遇到一次突发情况。对于线程的控制这部分,由于依赖于定时器,暂时还未能实现,后面出定时器篇的时候再补上。
最后,来说说线程的停止Stop()部分吧,这里也有东西可以说说,由于线程池使用的是条件变量而不是信号量,就导致在通知notify_all()的时候需要池中的线程在wait()才算是有效的通知,换言之,只有在等待中的线程才会响应通知并被唤醒,所以这里我们需要循环通知while(0 != GetCurThreadCount());,正好,同步的循环通知也能保证池中还在执行的线程访问的对象生命周期有效,当然,保证这一点的条件是,线程池在对象生命周期停止前同步调用Stop()函数。
接下来就是测试代码啦,头文件test.h很简单,就是一些函数声明
#ifndef TEST_H_ #define TEST_H_ void doTest(); void doTestThreadPool(); #endif // !TEST_H_
源文件test.cpp如下
#include <memory> #include <vector> #include <iostream> #include <thread> #include "ThreadPool.h" #include "test.h" static std::vector<std::function<void()>> PrepareForThreadPoolTask() { int a = 0; std::vector<std::function<void()>> vecTask(10000); for(auto& elem : vecTask) { elem = std::bind([](int a){ std::cout << "************************" << a << "************************" << std::endl; },a++); } return vecTask; //RVO } void doTestThreadPool() { std::unique_ptr<ThreadPool> pThreadPool(new ThreadPool(1,1,1)); pThreadPool->Start(); pThreadPool->SetMaxThreadCount(std::thread::hardware_concurrency()); std::vector<std::function<void()>> vecTask = PrepareForThreadPoolTask(); for(auto & elem : vecTask) { pThreadPool->AddTask(elem); } do { std::this_thread::sleep_for(std::chrono::seconds(1)); } while(pThreadPool->GetCurTaskCount()); pThreadPool->Stop(); std::system("pause"); } void doTest() { doTestThreadPool(); }
运行结果如下:
好了,今天的线程池就到这里吧,后面会持续对线程池更新,比如增加线程池中还未执行任务的取消功能,增加优先级功能,对线程池不足之处的改进,完善未完成部分。。。。。如果你喜欢的话,请持续关注我的博客园。