我的C++规范 - 线程池

线程池

线程池原理

main.cpp
复制代码
#include <iostream>
#include <mutex>
#include <condition_variable>
#include <vector>
#include <thread>
#include <queue>
#include <memory>
#include <functional>
#include <future>

#include "mclog.h"

// 线程池-可获取任务函数的返回值
template <size_t Tnum = 4>
struct Tpool
{
    // 加入并运行线程任务
    Tpool()
    {
        for (size_t i = 0; i < Tnum; i++)
        {
            _workers.emplace_back([=]() {
                while(true)
                {
                    std::function<void()> task = nullptr;
                    {
                        std::unique_lock<std::mutex> lock(_mut);                // 独占锁--获取队列任务
                        while (_tasks.empty() && _run) { _cond.wait(lock); }    // 判断假唤醒--退出且队列为空
                        if(_run == false && _tasks.empty()) { return; }         // 等待队列任务完成并退出任务
                        task = std::move(_tasks.front()); _tasks.pop();         // 取任务并弹出队列
                    }
                    if(task) { task(); }
                } 
            });
        }
    }

    // 释放线程池
    ~Tpool()
    {
        {
            std::unique_lock<std::mutex> lock(_mut);
            _run = false;
        } // 关闭运行标记
        _cond.notify_all(); // 唤醒所有线程准备退出
        for (std::thread &worker : _workers)
        {
            worker.join();
        } // 等待所有线程完成任务后释放
    }

    // 加入任务
    template <typename Tfunc, typename... Targs>
    auto push(Tfunc &&func, Targs &&...args) -> std::future<typename std::result_of<Tfunc(Targs...)>::type>
    {
        using ret_type = typename std::result_of<Tfunc(Targs...)>::type;                // 分析任务函数返回类型
        auto pack = std::bind(std::forward<Tfunc>(func), std::forward<Targs>(args)...); // 打包任务函数
        auto task = std::make_shared<std::packaged_task<ret_type()>>(pack);             // 生成可执行的任务函数指针
        auto res = task->get_future();                                                  // 提前获取执行结果声明-从传入任务的线程延迟获取结果
        {
            std::unique_lock<std::mutex> lock(_mut); // 锁住并准备将任务插入队列
            std::function<void()> func = [task]()
            { (*task)(); }; // 包装成统一可执行的任务函数
            if (_run)
            {
                _tasks.emplace(func);
            } // 加入任务到队列等待执行
        }
        _cond.notify_one(); // 通知随机一个线程去执行任务
        return res;
    }

    // internal
    bool _run = true;                         // 运行标记
    std::mutex _mut;                          // 线程池锁
    std::condition_variable _cond;            // 条件变量
    std::vector<std::thread> _workers;        // 线程容器
    std::queue<std::function<void()>> _tasks; // 任务队列
};

int main(int argc, char **argv)
{
    using namespace std::chrono;

    {
        MCLOG("多任务执行获取返回值");
        Tpool<> pool;
        std::future<int> ret1 = pool.push([=]() {
            int sum = 0;
            for(int i=0;i<10000;i++)
            {
                sum += i;
            }
            return sum; 
        });

        std::future<int> ret2 = pool.push([=]() {
            int sum = 0;
            for(int i=0;i<10000;i++)
            {
                sum = i;
            }
            return sum; 
        });

        std::future<std::string> ret3 = pool.push([=]() {
            std::string arr;
            for(int i=0;i<10000;i++)
            {
                arr += std::to_string(i);
            }
            return arr; 
        });

        MCLOG($(ret1.get()));
        MCLOG($(ret2.get()));

        // std::future 的 get 只能被调用一次,而且会阻塞当前调用的线程
        // 从返回结果截取五十个字符
        std::string str = ret3.get();
        str = std::string(str.begin(), str.begin() + 50);
        MCLOG($(str));

        MCLOG("主线程执行-会阻塞");
    }
    {
        MCLOG("\n回调式返回结果");
        Tpool<> pool;

        // 需要调用结果的处理函数
        auto fn_sum_cb = [=](int sum)
        {
            MCLOG("累加结果 " $(sum));
        };
        auto fn_str_cb = [=](std::string str)
        {
            MCLOG("字符截取 " $(str));
        };

        // 多线程处理函数
        pool.push([=]() {
            int sum = 0;
            for(int i=0;i<10000;i++)
            {
                sum += i;
            }
            fn_sum_cb(sum); 
        });

        pool.push([=]() {
            std::string arr;
            for(int i=0;i<10000;i++)
            {
                arr += std::to_string(i);
            }
            fn_str_cb( std::string(arr.begin(), arr.begin() + 50)) ; 
        });
        MCLOG("主线程执行-不会阻塞");
    }
    {
        MCLOG("\n同时只能执行的线程数");
        auto tbegin = steady_clock::now();
        Tpool<2> pool;
        pool.push([=]() {
            auto tend_ms = duration_cast<milliseconds>(steady_clock::now() - tbegin).count();
            MCLOG("进入线程-1 " $(tend_ms));
            std::this_thread::sleep_for(seconds(1));
            MCLOG("退出线程-1 "); 
        });
        pool.push([=]() {
            auto tend_ms = duration_cast<milliseconds>(steady_clock::now() - tbegin).count();
            MCLOG("进入线程-2 " $(tend_ms));
            std::this_thread::sleep_for(seconds(1));
            MCLOG("退出线程-2"); 
        });
        pool.push([=]() {
            auto tend_ms = duration_cast<milliseconds>(steady_clock::now() - tbegin).count();
            MCLOG("进入线程-3 " $(tend_ms));
            std::this_thread::sleep_for(seconds(1));
            MCLOG("退出线程-3"); 
        });
    }

    return 0;
}
打印结果
复制代码
多任务执行获取返回值 [/home/red/open/github/mcpp/example/21/main.cpp:87]
[ret1.get(): 49995000]  [/home/red/open/github/mcpp/example/21/main.cpp:116]
[ret2.get(): 9999]  [/home/red/open/github/mcpp/example/21/main.cpp:117]
[str: 01234567891011121314151617181920212223242526272829]  [/home/red/open/github/mcpp/example/21/main.cpp:123]
主线程执行-会阻塞 [/home/red/open/github/mcpp/example/21/main.cpp:125]

回调式返回结果 [/home/red/open/github/mcpp/example/21/main.cpp:128]
主线程执行-不会阻塞 [/home/red/open/github/mcpp/example/21/main.cpp:159]
累加结果 [sum: 49995000]  [/home/red/open/github/mcpp/example/21/main.cpp:134]
字符截取 [str: 01234567891011121314151617181920212223242526272829]  [/home/red/open/github/mcpp/example/21/main.cpp:138]

同时只能执行的线程数 [/home/red/open/github/mcpp/example/21/main.cpp:162]
进入线程-1 [tend_ms: 0]  [/home/red/open/github/mcpp/example/21/main.cpp:168]
进入线程-2 [tend_ms: 0]  [/home/red/open/github/mcpp/example/21/main.cpp:174]
退出线程-2 [/home/red/open/github/mcpp/example/21/main.cpp:176]
退出线程-1  [/home/red/open/github/mcpp/example/21/main.cpp:进入线程-3 [tend_ms: 170]1001]  [/home/red/open/github/mcpp/example/21/main.cpp:
180]
退出线程-3 [/home/red/open/github/mcpp/example/21/main.cpp:182]

线程池是多线程中最常用的工具之一,在C++中启动一个线程不是很方便,特别是如果你只是向运行一个简单的独立的任务时单开一个线程又会让你的代码变得复杂

这时候就是体现线程池作用的最佳时机,线程池可以提前分配数个线程,并将这些线程放到工作函数中休眠,当你需要执行一个多线程任务时,你只需要把任务丢给线程池就可以了

通常在使用多线程运算时,都是不关心返回值的,如果你需要使用返回值可以采用回调函数的方式,在子线程从执行回调函数

实际上线程池中执行的任务函数就是回调函数的一种,可以一起执行所有任务,而不需要再添加一个回调函数显得画蛇添足

打包函数和参数

从 main.cpp 文件中可以看到这个线程池的 push 函数非常复杂,他把需要运行的函数和参数打包成一个 std::function 函数,然后放入到 std::queue 任务队列中,并且推算这个 function 返回值类型,返回一个 std::future 结果,这个返回值就是整个子线程运算结束之后的结果,他和智能指针绑定

逻辑是这个逻辑,但在C++11的写法上却非常复杂,首先这个 push 函数的返回值类型是不确定的,需要使用 result_of 来推测任务函数的返回值,并打包成 future 类型,任务函数和参数需要 bind 到绑定到一个空参数的 function 中,这样才能在线程池中统一调用执行

而被打包的任务函数类型为 packaged_task 类型,这个类型描述了他由未知的函数和未知的参数组成,当你获取到 packaged_task 类型之后,声明已经成功的把函数和参数打包成了一个 function 类型,最后使用智能智能管理生命周期,并放入任务队列

当任务从队列中被取出时,function 包裹的指针指针被执行,和智能指针绑定的 future 此时变有了结果,通过 get 函数获取的值可以被返回,任务函数执行完毕之后 function 被丢弃,与之绑定的智能指针也随之释放,push 中的任务函数被释放

push 中一系列的代码其实都是C++特有的复杂模板编程才会导致代码不好理解,实际的功能却很简单,但也因为语法本身不支持一些类型推倒写起来很复杂难懂

分析返回值

push 函数中很有趣的一点是可以在主线程中绑定 future 返回值,然后通过子线程赋值给 future ,最好在主线程获取结果

future 的 get 函数会获取一个值,这个值只要存在就会返回,不存在就会阻塞,这意味这你一定获取得到这个值,或者用于卡死在那一行代码

get 函数可以在任何一个线程调用,这是因为 get 函数实际上是一个自旋锁,你总是会卡在锁上,直到被解锁,但赋值给 future 时,get 函数就会解锁,你也就可以从其他线程上获取内容

future 的实现类似条件变量,当你没有任务的时候进入休眠,存在任务时从 get 的位置醒来,这就是生产消费者模型中条件变量的作用

生产消费模型

你会发现其实线程池就是 鸡蛋工厂 的翻版,一样存在队列,一样存在任务,一样是有任务执行没任务睡觉,不一样的是线程池的任务是外部给他的

线程池是接触多线程并发编程的经典案例,如果能理解这个逻辑,那多线程代码的编写就会变得简单,因为大多数多线程任务都在遵循这个规律

你只要掌握了基本规律,就可以通过他了解更多类似的翻版实现

返回主线程

在线程池中执行的代码都是在子线程中,所以你需要注意共享数据的上锁问题,否则就会引发问题,在子线程中的数据是很难回到主线程的,因为开启子线程去处理的问题通常是非常耗时的,如果还要回到主线程那依旧会阻塞,那开始子线程就没有了意义

如果你需要回到主线程中获取结果 std::future 也许是最好的选择,但正如上面所说,意义在那里,你需要的是同步进行,而不是汇合点

任务等待

线程池通常数量是有限的,让任务太多,且非常耗时的时候,休眠的线程就会会用光,当每一个线程都被使用时,任务就会进入排队状态,他在等待其他任务的结束,然后执行正在排队的任务

如果任务很多,你不得不开启很多线程的时候,或者这个时候线程池和多线程都不是最优解,因为CPU的速度是有极限的,太多的线程会增加开销,反而降低处理速度

当你需要很多线程时,你需要分清楚是CPU密集任务还是IO密集任务,他们的区别是运行在线程上的代码到底会不会阻塞,以确认需要开启太多线程开销时采用不用逻辑

如果是IO密集型,比如读文件,读网络套接字,你需要的是多次遍历是否读写完成,可以让一个线程同时遍历和等待多个文件,循环检查他们的读写情况

如果是CPU密集型,如大量的曲线绘制点位计算,你可能需要降低线程到个位数才能增加速度,因为此时CPU是满载的,过多的切换反而导致运算速度降低

因为C++11并没有协程可以使用,所以在面对大量线程时要考虑是否需要分配一个固定数量的线程池还是要动态的新增线程任务

入门之后
复制代码
面向对象
并发编程
泛型编程
设计模式
系统调用
网络编程
工程框架

这个线程池是作为新手系列的最后一篇文章,如果你已经会了很多基础编程知识,可能还需要掌握上面列出的那些内容,你可以去搜索他们具体涵盖了那些内容,然后逐个学习吧

比如面向对象,你学习之后需要用这种思想去描述数据和分类,类与类之间的关系需要使用设计模式去拆分和解耦他们的关系,学习经典的24设计模式等,这是走向代码设计的第一步

在并发编程上,如上面的多线程的经典线程池中就能看到有很多模板在发生作用,泛型编程也是优化性能的重要工具,所以模板不仅仅是用来简化代码,它还和并发编程等高性能场景有着紧密联系,在性能优化上很难离开模板

在网络编程上,你至少需要掌握最基层的 UDP\TCP\HTTP\WEBSOCKECT 等网络传输协议,如果你尝试编写网络框架会了解到 Reactor Proactor 等网络框架的原理,那么你可能需要了解到不同操作系统的系统调用,如 socket file epoll 相关的系统调用,虽然轮子不好造,不过至少需要知道如何使用网络框架

最后,最重要的一点是你需要学习一个工程框架,那才是真正能生产软件产品的东西,就好比学会了C++等于你学会操作鼠标和键盘,但是打开电脑你却不知道能干嘛

你至少需要学会一个工程框架,就像打开电脑你需要使用PS修图,使用Office三件套处理文档,使用Steam去打游戏

在这方面我了解并不多,如桌面程序的Qt,音视频方面的FFmpeg,游戏开发的UE5等,都是值得研究的方向

告一段落

编写这个系列文章也花了不少时间,我相信如果你从第一篇文章看到这里那你的C++水平已经入门了,当然也不要高兴的太早,毕竟除了上面列出的入门之后需要补充的知识体系之外,你还有一万个C++编程细节要学习,当然这些技巧随着时间的推移你总是能遇到并学习掌握

这个系列到这里就停更了,如果你喜欢这个系列,而且希望更新更多内容请留言讨论问题,也许我会以此为灵感在这个系列后补充相关文章

下期见

项目路径

复制代码
https://github.com/HellowAmy/mcpp.git
相关推荐
独自破碎E1 小时前
【BISHI9】田忌赛马
android·java·开发语言
czy87874751 小时前
const 在 C/C++ 中的全面用法(C/C++ 差异+核心场景+实战示例)
c语言·开发语言·c++
十五年专注C++开发1 小时前
MinHook:Windows 平台下轻量级、高性能的钩子库
c++·windows·钩子技术·minhook
范纹杉想快点毕业2 小时前
实战级ZYNQ中断状态机FIFO设计
java·开发语言·驱动开发·设计模式·架构·mfc
一只小小的芙厨2 小时前
寒假集训笔记·树上背包
c++·笔记·算法·动态规划
马猴烧酒.2 小时前
【面试八股|Java集合】Java集合常考面试题详解
java·开发语言·python·面试·八股
以卿a3 小时前
C++(继承)
开发语言·c++·算法
lly2024063 小时前
XQuery 选择和过滤
开发语言
测试工程师成长之路3 小时前
Serenity BDD 框架:Java + Selenium 全面指南(2026 最新)
java·开发语言·selenium