编程学习
🗒️C++多线程编程(10):线程池简单实现与工作原理解析
00 分钟
2023-11-26
2023-11-27
type
status
date
slug
summary
tags
category
icon
password
Email
🏡
我的个人主页:https://www.helloylh.com/
📕
文章首发于我的个人博客:https://blog.helloylh.com/
💖
欢迎大佬们来逛逛,有任何问题欢迎给我留言或者加我的联系方式。

💡
源代码见我的多线程编程Github仓库:
Mutithread-programming
luumodUpdated Jul 9, 2023

线程池

线程池(Thread Pool)是一种用于管理和重用线程的并发编程机制。它通常包含一个固定数量的线程,这些线程在初始化时被创建,并在整个程序的生命周期内被重复使用
线程池的核心思想是在需要执行任务时,从线程池中取出一个空闲线程来执行任务,而不是每次都创建一个新线程。
这是线程池的简单工作原理的图示:(图取自一博主)
  1. 首先由input部分定义与输入我们的任务。
  1. 把任务输入进任务队列中。
  1. 线程池中有若干个线程会从任务队列中取出线程来执行。
notion image
因此可以发现,一个典型的线程池包含这几个部分:
  1. 任务队列:有若干任务,按照先来先服务原则依次排列在任务队列中等待线程取出执行。
  1. 任务分配方式:任务是如何插入到任务队列中,并且等待线程执行的。
  1. 线程工作方式:多个线程是如何完成从任务队列中取出任务的方法。
  1. 线程管理模块:线程池的创建与管理模块,负责管理全部线程的开始与结束或其他宏观行为。

任务队列

定义此线程安全队列为我们的任务队列。同时我们需要此为线程安全的队列,使用std::mutex来确保线程安全,一个线程只访问一个队列的头部元素即可,否则会造成资源的竟态问题。

任务分配方式

若干个input的任务是如何插入到我们的任务队列中的?
我们的任务就是若干不同参数与返回类型的函数,因此如何做到一个统一的插入行为,确保可以接收任意返回类型与参数类型的函数可以插入到我们的任务队列中?
  1. 使用可变参数模板,以便我们的插入任务可以接收任意参数的函数。
  1. 使用尾返回类型推导机制来确定线程的返回类型,std::future可以帮助我们在异步操作中获取线程操作后的返回值。decltype可以推断出表达式的类型。
  1. 使用std::functionstd::bind将其此任务函数包装为一个decltype(f(args…)) 为返回类型,无参数的特殊函数,并且使用std::forward进行完美转发,保留其参数的原始类型。
  1. 使用std::packaged_task将上面包装函数打包为一个异步任务,因为此类允许我们封装任何的可调用对象,从而实现异步的操作,并且此后我们可以通过.get()来获取std::future()以便我们可以获取线程的返回值。
    1. 为什么使用std::make_shared? 因为 std::packed_task不允许进行拷贝操作,只能够进行移动操作
  1. 接着使用std::function来将此任务转换为成一个void()最普通的函数模型
  1. 将此void()函数插入到任务队列中,因此所有的不管什么返回类型与参数类型的任务函数都会转换为一个void()的函数并且插入到任务队列中。
  1. std::condition_variable通知一个线程来执行此任务。
  1. 获取线程的返回值。使用std::future(),然后使用.get().

线程管理模块

  1. 规定线程池中线程的总数。
  1. 定义一个线程安全队列,以便从队列中取出任务给某个线程。
  1. 定义条件变量std::condition_variable,用途是当任务进入任务队列后及时通知线程,表示有任务来了,可以取任务执行了。
  1. 定义互斥锁std::mutex为条件变量做阻塞的准备。
  1. 线程池关闭标记is_shutdown,为真意味着线程池被关闭,则所有的线程都无法从任务队列取任务。
ThreadPool的数据成员如下:
下面的init函数作为线程池的开始函数,表示创建指定个数的线程,并且放入std::vector中管理,因此threads就代表了存储所有的线程容器。
至于什么是ThreadWorker,下一节会讲解,它是一个线程的工作类,表示线程取出任务的过程与线程之间的同步机制。
下面的shutdown作为线程结束时的结束函数,结束标记置为真,通知所有的线程即将结束。
但是为什么会调用join呢?实际上这是一种安全机制,为了确保在程序结束的时候,保证所有的线程都进行阻塞,直到他们的任务执行完毕。
实际上join并不代表开启线程的意思,它作用是阻塞当前线程直至其所标识的线程结束其执行。
开始线程在创建线程,并且传递可调用对象的时候就已经完成了,例如:
 

线程工作方式

线程是如何完成从任务队列中取出任务的呢?
我们来定义一个线程工作类ThreadWorker。
其最主要的任务就是因为其重载了()运算符,所以它就是一个可调用的对象,即函数对象。线程std::thread在创建的时候,可以被任何可调用对象来构造,因此线程的构造器会唤醒(调用)其operator()()方法。
In C++, a std::thread object can be constructed with any Callable object, which includes function objects (objects with operator()() defined). The std::thread constructor then invokes the operator()() of the provided object.
因此在上面的init方法中,我们让std::thread通过此线程工作类来构造,则就会立刻调用此operator()()方法,在此方法中我们定义了线程的取出任务的机制。
💡
实际上,在构造线程的时候,可以直接使用其他可调用对象来创建,在此我使用了一个额外的工作类是为了清晰。
可调用对象有以下几类:
  • 函数对象:重载了operator()()的类,
  • 函数指针:调用普通函数,然后传递其指针,
  • lambda表达式。
  • std::bind绑定表达式。
来看重载的括号运算符的内容:
  1. 当is_shutdown为假的时候,则线程一直循环等待任务。
  1. 当任务队列为空的时候,wait阻塞线程。
  1. 当任务队列不为空的时候,从任务队列中取出任务,并且只有当成功取出的时候,调用std::function的operator()来执行此任务。
💡
别忘了我们的线程池中的submit函数,我们在此函数中将我们传递的任意参数与返回类型的任务函数都封装为了void()的类型的函数,并且将其enqueue进了任务队列,因此dequeue的也是void()类型的函数,得到之后便可以直接operator()来调用此任务函数。

测试

使用步骤如下:
  1. 创建线程池对象,并且规定线程总数。
  1. .init方法来启动线程池。
  1. 提交的任务并且创建std::future来保存任务的返回值,如果无返回值,则创建std::future<void>。
  1. 使用.get()来获取返回值。
  1. .shutdown来结束线程池。
输出结果

参考资料:

评论
  • Twikoo
  • Valine