Skip to content

基于C++17实现的简易线程池(附代码解释和知识介绍)

Notifications You must be signed in to change notification settings

anda522/ThreadPool

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

11 Commits
 
 
 
 
 
 

Repository files navigation

基于C++17的简易线程池

所学知识参考链接:

C++11 thread, C++11 mutex:https://wyqz.top/p/2668140628.html

C++11 左右值与移动构造函数:http://avdancedu.com/a39d51f9/

任务描述

  • 实现多线程安全的任务队列,线程池使用异步操作,提交(submit)使用与thread相同。
  • 内部利用完美转发获取可调用对象的函数签名,lambda与function包装任务,使用RAII管理线程池的生命周期。

基本思路

本GItHub仓库代码的大概思路:

1 安全任务队列

  • 当访问到队列的大小时,属于读操作,可以多个线程同时读,故加共享锁
  • 当需要修改队列的大小时,属于写操作,只能一个线程进行,故加独占锁

2 线程池实现

内有线程池构造函数,提交任务函数,析构函数的实现

  • 构造函数

将线程工作类传入到n个线程,使n个线程能够同时工作。

  • 任务提交函数

参数为可变参数,传入一个线程的执行函数以及该函数所需要的所有参数。

首先先将【执行任务(函数)和对应的参数】包装成一个func函数,此函数无参数,返回值类型为执行任务的返回值类型

线程池

1 概念

  • 解决线程的创建和销毁问题(代价问题)
  • 解决CPU和IO速度不匹配的问题
  • 充分利用多核CPU资源,提高并发效率

线程池: 当进行并行的任务作业操作时,线程的建立与销毁的开销是,阻碍性能进步的关键,因此线程池,由此产生。使用多个线程,无限制循环等待队列,进行计算和操作。帮助快速降低和减少性能损耗。

2 组成

主要组成:任务队列、执行队列、管理组件

  1. 线程池管理器:初始化和创建线程,启动和停止线程,调配任务;管理线程池
  2. 工作线程:线程池中等待并执行分配的任务
  3. 任务队列:用于存放没有处理的任务,提供一种缓冲机制,同时具有调度功能,高优先级的任务放在队列前面

3 工作情况

  • 没有任务执行,缓冲队列为空
  • 队列中任务数量小于等待线程池中线程任务的数量
  • 任务数量大于等待线程池数量,缓冲队列未满
  • 任务数量大于线程池数量,缓冲队列已满

其实你线程池容量定义的大小,就是线程池的缓冲队列。

ThreadPool pool(8)那缓冲队列大小为8,等待队列也就是代码中的安全队列。

向线程池中提交任务时,会向队列中添加任务,然后唤醒一个工作线程(因为缓冲队列中为空,也就是执行的线程小于缓冲队列的大小时,有空闲的工作线程在条件变量的队列上阻塞等待,故需要进行唤醒)。

shared_ptr

参考:https://blog.csdn.net/fl2011sx/article/details/103941346

C++ 内存分四大块:

  • 全局 主函数运行前使用,初始化

  • 静态 变量第一次使用前,初始化

以上两块内存都会在程序结束后自动释放

  • 堆区 由程序员管理,C++管理方法有new delete等关键字

  • 栈区 由编译器管理,存放程序的局部变量和参数

因此我们需要关注堆区的内存管理。内存管理经常会碰到忘记释放造成的内存泄露。

在C++中引入了智能指针,有shared_ptrunique_ptrweak_ptr

使用智能指针,需要引入头文件,shared_ptr顾名思义是多个指针指向一块内存。

被管理对象有一个引用计数,这个计数记录在每个指针上,几个shared_ptr指向它,这个数字就是几,当没有任何shared_ptr指向它时,引用计数为0,这时,自动释放对象。

其功能为:当所有指针都释放(或是不再指向对象)的时候,自动释放对象

// 鼓励使用make_shared创建对象
auto ptr = make_shared<int>(); // 分配堆空间,创建智能指针

std::packaged_task

参考:https://zhuanlan.zhihu.com/p/465534313

std::packaged_task<>链接了future对象与函数(或可调用对象)。

std::packaged_task<>对象在执行任务时,会调用关联的函数(或可调用对象),把返回值保存为future的内部数据,并令future准备就绪。它可作为线程池的构件单元,亦可用于其他任务管理方案。

例如,为各个任务分别创建专属的独立运行的线程,或者在某个特定的后台线程上依次执行全部任务。若一项庞杂的操作能分解为多个子任务,则可把它们分别包装到多个std::packaged_task<>实例之中,再传递给任务调度器或线程池。这就隐藏了细节,使任务抽象化,让调度器得以专注处理std::packaged_task<>实例,无须纠缠于形形色色的任务函数。

std::packaged_task<>是类模板,其模板参数是函数签名

std::packaged_task对象是可调用对象,我们可以直接调用,还可以将其包装在std::function对象内,当作线程函数传递给std::thread对象,也可以传递给需要可调用对象的函数。

对于std::packaged_task<void()>来说,它表示任务,包装某个函数(或可调用对象),这个函数不接收参数,返回void(倘若真正的任务函数返回任何其他类型的值,则会被丢弃)。

std::unique_lock

  • 在构造函数中加锁,即定义时
  • 在析构函数中解锁,判断锁状态,如果已经解锁的话,不操作,未解锁的话解锁

std::shared_mutex

C++17共享锁

std::move

左值或右值转换为右值

std::future

  • future表示一个异步任务结果

Future模式可以理解成:我有一个任务,提交给了Future,Future替我完成这个任务。期间我自己可以去做任何想做的事情(期间future可能一直在后台执行这个任务)。一段时间之后,我就便可以从Future那儿取出结果。

Future里面有个方法 get()是个阻塞等待方法。当线程池一次性submit多个任务的时候。只有所有的任务全部完成,我们才能用GET按照任务的提交顺序依次返回结果。

std::future提供了一个重要方法就是.get(),这将阻塞主线程,直到future就绪。注意:.get()方法只能调用一次。

std::future不支持拷贝,支持移动构造。c++提供的另一个类std::shared_future支持拷贝。

可以通过下面三个方式来获得std::future

  • std::promise的get_future函数
  • std::packaged_task的get_future函数
  • std::async 函数

std::thread.join()

C++11标准

哪个线程调用这个方法,就让调用此方法的线程进入阻塞状态,等待此线程执行完毕之后,再往下执行

若当前线程未运行完毕,继续运行该线程,并阻塞主进程;直到该线程执行完毕再往下执行

条件变量

参考:https://blog.csdn.net/xhtchina/article/details/90572762

wait

条件变量是需要和一个互斥锁mutex配合使用,调用wait()之前应该先获得mutex,当线程调用 wait() 后将被阻塞,当wait陷入休眠是会自动释放mutex。直到另外某个线程调用 notify_one或notify_all唤醒了当前线程。当线程被唤醒时,此时线程是已经自动占有了mutex。

条件变量是利用线程间共享的全局变量进行同步的一种机制,主要包括两个动作:

  • 一个线程因等待"条件变量的条件成立"而挂起;
  • 另外一个线程使"条件成立",给出信号,从而唤醒被等待的线程。

为了防止竞争,条件变量的使用总是和一个互斥锁结合在一起;通常情况下这个锁是std::mutex,并且管理这个锁只能是 std::unique_lock RAII模板类。

线程的阻塞是通过成员函数wait()/wait_for()/wait_until()函数实现的。

void wait(std::unique_lock<std::mutex>& lock);
//Predicate 谓词函数,可以普通函数或者lambda表达式
template<class Predicate>
void wait(std::unique_lock<std::mutex>& lock, Predicate pred);

以上的wait函数都在会阻塞时,自动释放锁权限,即调用unique_lock的成员函数unlock(),以便其他线程能有机会获得锁。这就是条件变量只能和unique_lock一起使用的原因,否则当前线程一直占有锁,线程被阻塞。

notify_all/notify_one

  • notify_one

若任何线程在 *this 上等待,则调用 notify_one 会解阻塞(唤醒)等待线程之一。因为只唤醒等待队列中的第一个线程;不存在锁争用,所以能够立即获得锁。其余的线程不会被唤醒,需要等待再次调用notify_one()或者notify_all()

  • notify_all

会唤醒所有等待队列中阻塞的线程,存在锁争用,只有一个线程能够获得锁。其他未获得锁的线程继续尝试获得锁(类似于轮询),而不会再次阻塞。当持有锁的线程释放锁时,这些线程中的一个会获得锁。而其余的会接着尝试获得锁。

虚假唤醒

参考:https://blog.csdn.net/weixin_42108411/article/details/110138238

在正常情况下,wait类型函数返回时要么是因为被唤醒,要么是因为超时才返回,但是在实际中发现,因此操作系统的原因,wait类型在不满足条件时,它也会返回,这就导致了虚假唤醒。

完美转发

参考:http://c.biancheng.net/view/7868.html

指的是函数模板可以将自己的参数“完美”地转发给内部调用的其它函数。所谓完美,即不仅能准确地转发参数的值,还能保证被转发参数的左、右值属性不变。

C++11 标准中规定,通常情况下右值引用形式的参数只能接收右值,不能接收左值。但对于函数模板中使用右值引用语法定义的参数来说,它不再遵守这一规定,既可以接收右值,也可以接收左值,(此时的右值引用又被称为“万能引用”)。

通过将函数模板的形参类型设置为 T&&,我们可以很好地解决接收左、右值的问题。

C++11 标准的开发者已经帮我们想好了解决方案,该新标准还引入了一个模板函数 forword<T>()

RAII机制

参考:https://zhuanlan.zhihu.com/p/600337719

RAII,全称资源获取即初始化,C++语言的一种管理资源、避免泄漏的惯用法

RAII要求,资源的有效期与持有资源的对象的生命期严格绑定,即由对象的构造函数完成资源的分配(获取),同时由析构函数完成资源的释放。在这种要求

下,只要对象能正确地析构,就不会出现资源泄漏问题。

当我们在一个函数内部使用局部变量,当退出了这个局部变量的作用域时,这个变量也就别销毁了;

当这个变量是类对象时,这个时候,就会自动调用这个类的析 构函数,而这一切都是自动发生的,不要程序员显示的去调用完成。RAII就是这样去完成的。

由于系统的资源不具有自动释放的功能,而C++中的类具有自动调用析构函数的功能。如果把资源用类进行封装起来,对资源操作都封装在类的内部,在析构函数中进行释放资源。当定义的局部变量的生命结束时,它的析构函数就会自动的被调用,如此,就不用程序员显示的去调用释放资源的操作了。

代码相关问题

1 为什么条件变量要和互斥锁一起使用

wait操作之前,需要进行条件判断,而此条件属于临界资源,需要在访问前加锁,此时为保护临界资源的需要。

当条件不满足时,wait操作将执行两个步骤,并保证两个操作的原子性:

  • 将当前线程加入条件信号的等待队列
  • 解开作为参数传入的锁,解除对临界资源的锁定

2 为什么析构函数中需要提交一个空任务

主函数中,我们定义了一个线程池,当主函数运行完时,线程池将销毁,执行析构函数,那么线程池就会关闭,不管等待队列里面是否存在线程,整个线程池将会关闭,那么等待队列里面的线程将无法执行。

当我们提交一个空任务时,同时调用了get方法。get()是个阻塞等待方法,只有当任务执行完毕有返回结果后,才会取消阻塞,并得到返回结果。

而任务执行的顺序是任务的提交顺序,所以最后提交一个空任务,必须等待前面所有的任务执行完毕有返回结果,此空任务才会执行,相当于一直阻塞,直至所有提交任务执行完毕。

About

基于C++17实现的简易线程池(附代码解释和知识介绍)

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages