• 简单C++线程池


    简单C++线程池

    Java 中有一个很方便的 ThreadPoolExecutor,可以用做线程池。想找一下 C++ 的类似设施,尤其是能方便理解底层原理可上手的。网上找到的 demo,基本都是介绍的 projschj 的C++11线程池。这份源码最后的commit日期是2014年,现在是2021年了,本文将在阅读源码的基础上,对这份代码进行一些改造。关于线程池,目前网上讲解最好的一篇文章是这篇 Java线程池实现原理及其在美团业务中的实践,值得一读。

    改造后的源码在 https://gitee.com/zhcpku/ThreadPool 进行提供。


    更新,微软工程院大佬指出了参考代码的一些问题,并给出了自己的固定个数线程池代码,贴在文末了。感觉这部分的讨论更有价值。

    projschj 的代码

    1. 数据结构

    主要包含两个部分,一组执行线程、一个任务队列。执行线程空闲时,总是从任务队列中取出任务执行。具体执行逻辑后面会进行解释。

    class ThreadPool {
        // ...
    private:
        using task_type = std::function<void()>;
        // need to keep track of threads so we can join them
        std::vector<std::thread> workers;
        // the task queue
        std::queue<task_type> tasks;
    };
    

    2. 同步机制

    这里包括一把锁、一个条件变量,还有一个bool变量:

    • 锁用于保护任务队列、条件变量、bool变量的访问;
    • 条件变量用于唤醒线程,通知任务到来、或者线程池停用;
    • bool变量用于停用线程池;
    class ThreadPool {
        // ...
    private:
        // synchronization
        std::mutex queue_mutex;
        std::condition_variable condition;
        bool stop;
    };
    

    3. 线程池启动

    启动线程池,首先要做的是构造指定数量的线程出来,然后让每个线程开始运行。
    对于每个线程,运行逻辑是一样的:尝试从任务队列中获取任务并执行,如果拿不到任务、并且线程池没有被停用,则睡眠等待。
    这里线程等待任务使用的是条件变量,而不是信号量或者自旋锁等其他设施,是为了让线程睡眠,避免CPU空转浪费。

    // the constructor just launches some amount of workers
    inline ThreadPool::ThreadPool(size_t thread_num)
        : stop(false)
    {
        for (size_t i = 0; i < thread_num; ++i) {
            workers.emplace_back([this] {
                for (;;) {
                    task_type task;
                    {
                        std::unique_lock<std::mutex> lock(this->queue_mutex);
                        this->condition.wait(
                            lock, [this] { return this->stop || !this->tasks.empty(); });
                        if (this->stop && this->tasks.empty()) {
                            return;
                        }
                        task = std::move(this->tasks.front());
                        this->tasks.pop();
                    }
                    task();
                }
            });
        }
    }
    

    4.停用线程池

    线程的停用,需要让每一个线程停下来,并且等到每个线程都停止再退出主线程才是比较安全的操作。
    停止分三步:设置停止标识、通知到每一个线程(睡眠的线程需要唤醒)、等到每一个线程停止。

    // the destructor joins all threads
    inline ThreadPool::~ThreadPool()
    {
        {
            std::unique_lock<std::mutex> lock(queue_mutex);
            stop = true;
        }
        condition.notify_all();
        for (std::thread& worker : workers) {
            worker.join();
        }
    }
    

    5. 提交新任务

    这是整个线程池的核心,也是写的最复杂,用C++新特性最多的地方,包括但不限于:
    自动类型推导、变长模板函数、右值引用、完美转发、原地构造、智能指针、future、bind ……
    顺带提一句,要是早有变长模板参数,std::min / std::max 也不至于只能比较两个数大小,再多就得用大括号包起来作为 initialize_list 传进去了。

    这里提交任务时,由于我们的任务类型定义为一个无参无返回值的函数对象,所以需要先通过 std::bind 把函数及其参数打包成一个 对应类型的可调用对象,返回值将通过 future 异步获取。然后是要把这个任务插入任务队列末尾,因为任务队列被多线程并发访问,所以需要加锁。
    另外需要处理的两个情况,一个是线程睡眠时,新入队任务需要主要唤醒线程;另一个是线程池要停用时,入队操作是非法的。

    // add new work item to the pool
    template <class F, class... Args>
    auto ThreadPool::enqueue(F&& f, Args&&... args)
        -> std::future<typename std::result_of<F(Args...)>::type>
    {
        using return_type = typename std::result_of<F(Args...)>::type;
    
        auto task = std::make_shared<std::packaged_task<return_type()>>(
            std::bind(std::forward<F>(f), std::forward<Args>(args)...));
    
        std::future<return_type> res = task->get_future();
        {
            std::unique_lock<std::mutex> lock(queue_mutex);
    
            // don't allow enqueueing after stopping the pool
            if (stop) {
                throw std::runtime_error("enqueue on stopped ThreadPool");
            }
            tasks.emplace([task]() { (*task)(); });
        }
        condition.notify_one();
        return res;
    }
    

    改造

    以上代码已经足以阐释线程池基本原理了,以下改进主要从可靠性、易用性、使用场景等方面进行改进。

    1. non-copyable

    线程池本身应该是不可复制的,这里我们通过删除拷贝构造函数和赋值操作符,以及其对用的右值引用版本来实现:

    class ThreadPool {
      // ...
    private:
        // non-copyable
        ThreadPool(const ThreadPool&) = delete;
        ThreadPool(ThreadPool&&) = delete;
        ThreadPool& operator=(const ThreadPool&) = delete;
        ThreadPool& operator=(ThreadPool&&) = delete;
    };
    

    2. default-thread-number

    除了手动指定线程个数,更合适的做法是主动探测CPU支持的物理线程数,并以此作为执行线程个数:

    class ThreadPool {
    public:
        explicit ThreadPool(size_t thread_num = std::thread::hardware_concurrency());
        size_t ThreadCount() { return workers.size(); }
        // ...
    };
    

    3. 延迟创建线程

    线程不必一次就创建出来,可以等到任务到来的时候再创建,降低资源占用。
    // TBD

    4. 临时线程数量扩充

    线程池的应用场景主要针对的是CPU密集型应用,但是遇到IO密集型场景,也要保证可用性。如果我们的线程个数固定的话,会出现一些问题,比如:

    • 几个IO任务占据了线程,并且进入了睡眠,这个时候CPU空闲,但是后面的任务却得不到处理,任务队列越来越长;
    • 几个线程在睡眠等待某个信号或者资源,但是这个信号或资源的提供者是任务队列中的某个任务,没有空闲线程,提供者永远提供此信号或资源。
      因此我们需要一种机制,临时扩充线程数量,从线程池中的睡眠线程手中“抢回”CPU。
      其实,更好的解决办法是改造线程池,使用固定个数的线程,然后把任务打包到协程中执行,当遇到IO的时候协程主动让出CPU,这样其他任务就能上CPU运行了。毕竟,多线程擅长处理的是CPU密集型任务,多协程才是处理IO密集型任务的。…… 这不就是协程库了嘛!比如 libco、libgo 就是这种解决方案。
      // TBD

    5. 线程池停用启动

    上面的线程池,其启动停止时机分别是构造和析构的时候,还是太粗糙了。我们为其提供手动启动、停止的函数,并支持停止之后重新启动:
    // TBD


    总结

    不干了,2021年了,研究协程库去了!

    更新:微软工程师的简单线程池

    微软工程师在 用 C++ 写线程池是怎样一种体验? 这个问题下,指出之前的参考代码存在以下几个问题:

    1. 没有必要把停止标志设计为 atomic,更没有必要用 acquire-release 同步。
    2. 线程池销毁时等待所有任务执行完成,通常是没有必要的。
    3. 工作线程内部存在冗余逻辑;在尚有任务未完成时没有必要检查线程池是否停止。
    4. 添加任务时没有必要将任务封装为 std::packaged_task,因为线程池的基本职能是管理 Execution Agents; 如果一定要设计这样一个方法,那也应该保留一个直接提交任务不返回 Future 的方法;事实上,在 C++ 提案 P0443 (http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p0443r5.html)中,也有类似的设计,不返回 Future 的叫做 "one-way",返回 Future 的叫做 "two-way",很多时候需要从外部控制同步时,"one-way" 比 "two-way" 更实用。
    5. 一个 bug:"add" 方法实现中使用了 std::bind,而 std::bind 看起来并不适用于这里。我猜你设计这个方法的语义是执行 add(std::forward<F>(fcn), std::forward<Args>(args)...),从你的返回值类型上也可以看出这一点;但不幸的是,std::bind 的返回值被调用时会讲所有参数转为左值引用! 也就是说,在你的实现中,所有参数在 fnc 执行时都会被拷贝构造一份,对于不能拷贝构造的参数会直接编译出错!
    6. 另一个 bug:在线程池的析构函数中,没有对 stop_ 的赋值加锁。为什么需要对 stop_ 的赋值加锁呢?因为这个操作 必须 与工作线程对于 std::condition_variable 的 wait 检查操作互斥!具体原因如下:对于 std::condition_variable 的 wait(带 Predicate 的版本)展开的代码与下面的代码等价:
    while (!pred()) {
      cond_var.wait();
    }
    如果 pred() 不与对于状态的修改互斥的话,工作线程可能会陷入无线等待,也就导致了线程泄漏。
    

    对于为什么等待工作线程结束不必要,他的解释如下:

    跌宕的月光​2018-11-29
    线程池销毁时等待所有任务执行完成,通常是没有必要的。可否麻烦解释一下这个呢?因为比方说当main exit的时候,除非main 去等,否则detached thread就直接停掉了,出现一些任务做了一半的情况
    
    「已注销」 (作者) 回复跌宕的月光​2018-11-29
    确实有这样的问题,但我认为这属于更底层的“线程模型”的问题范畴,而非“线程池”本身的问题;即使不使用线程池,这个问题依然存在,在某些情况下我们也希望子线程运行更久些。
    解决这个问题的方法就是引入“守护线程”和“非守护线程”的概念,这个概念也广泛存在于其他编程语言(如 Java)和操作系统(参考“守护进程”)中。作为我并发库的一部分,我也自己实现过 C++ 的守护线程模型,可以参考:https://github.com/wmx16835/wang/blob/b8ecd554c4bf18cd181500e9594223e96dfede30/src/main/experimental/concurrent.h#L508-L525
    这样,创建线程的时候直接使用 thread_executor<false>::operator(F&&) 即可让新创建的线程(看上去)拥有与主线程同等的地位。
    

    至于等待任务结束,讨论的结论是,应该由任务提交方选择主动等待之后再结束线程池,而不是线程池自己来等待:

    章佳杰​2018-06-25
    你好,看了上面的代码受益良多。我有一个小问题,如果在上面这个代码的基础上要实现这样的功能:提交了一批 task,然后等这批 task 都完成,再继续提交其他 task。这个中间的「等这批 task 都完成」的操作怎么来实现比较好呢?
    
    「已注销」 (作者) 回复章佳杰​2018-06-25
    首先,如果有这样的需求,线程池可以开一个批量提交任务的接口,不是为了批量等待,而是减少多次进入临界区的开销。
    然后回到你的问题。现在普遍的做法有两种,一种是使用 Future,即将每一个任务包装为 std::packaged_task 再提交(需要考虑我回答中提及的“拷贝构造”问题),然后将这些 Future 保存起来逐一等待;另一种是使用 Latch,这样代码量会稍微增加一点,即在每个任务结束后对 Latch 执行“减一”操作,外部对所述 Latch 执行阻塞等待。
    如果使用我回答中提及的 ISO C++ 并发提案(http://www.http://open-std.org/jtc1/sc22/wg21/docs/papers/2017/p0642r1.pdf )中的解决方案,则不需要使用 Future 或 Latch,所需代码量较少,并且对于阻塞敏感的程序可以使用异步的方式避免等待的阻塞。
    

    关于第5点的bug讨论:

    郑威狄2018-06-13
    您好,针对您第五点提出的bug有没有更好的实现方法来避免这个问题呢?
    
    「已注销」 (作者) 回复郑威狄2018-06-14
    需要将可调用对象的可变参数列表中的参数全部转为右值。如果不使用第三方库的话,我见过最简单的方法就是写一个转发参数的中间层仿函数。但在我见过的大多数情形中都没有必要在这一层添加调用所需参数,所以在我提供的实现中没有 `Args&&...` 参数列表,也就没有这个 bug。
    

    参考代码

    他给出的参考代码更简单一些,对于有返回值的函数,并没有直接在 execute 时返回返回值的 future,而是统一返回void,需要返回值的话手动把 future 打包一个 packged_task 传入 execute 即可:

    #include <condition_variable>
    #include <functional>
    #include <mutex>
    #include <queue>
    #include <thread>
    
    class fixed_thread_pool {
    public:
        explicit fixed_thread_pool(size_t thread_count)
            : data_(std::make_shared<data>())
        {
            for (size_t i = 0; i < thread_count; ++i) {
                std::thread([data = data_] {
                    std::unique_lock<std::mutex> lk(data->mtx_);
                    for (;;) {
                        if (!data->tasks_.empty()) {
                            auto current = std::move(data->tasks_.front());
                            data->tasks_.pop();
                            lk.unlock();
                            current();
                            lk.lock();
                        } else if (data->is_shutdown_) {
                            break;
                        } else {
                            data->cond_.wait(lk);
                        }
                    }
                }).detach();
            }
        }
    
        fixed_thread_pool() = default;
        fixed_thread_pool(fixed_thread_pool&&) = default;
    
        ~fixed_thread_pool()
        {
            if ((bool)data_) {
                {
                    std::lock_guard<std::mutex> lk(data_->mtx_);
                    data_->is_shutdown_ = true;
                }
                data_->cond_.notify_all();
            }
        }
    
        template <class F>
        void execute(F&& task)
        {
            {
                std::lock_guard<std::mutex> lk(data_->mtx_);
                data_->tasks_.emplace(std::forward<F>(task));
            }
            data_->cond_.notify_one();
        }
    
    private:
        struct data {
            std::mutex mtx_;
            std::condition_variable cond_;
            bool is_shutdown_ = false;
            std::queue<std::function<void()>> tasks_;
        };
        std::shared_ptr<data> data_;
    };
    

    补充测试用例

    这里我补充了一个例子来说明,如何等待任务结束,以及返回值的获取:

    class count_down_latch {
    public:
        explicit count_down_latch(size_t n = 1) : cnt(n) {};
        void done() {
            std::unique_lock<std::mutex> lck(mu);
            if (--cnt <= 0) {
                cv.notify_one();
            }
        }
        void wait() {
            std::unique_lock<std::mutex> lck(mu);
            for(;;) {
                cv.wait(lck);
                mu.unlock();
                if (cnt <= 0) break;
                mu.lock();
            }
        }
    private:
        size_t cnt;
        std::mutex mu;
        std::condition_variable cv;
    };
    
    void test_lambda()
    {
        fixed_thread_pool pool(4);
        std::vector<int> results(8);
        count_down_latch latch(8);
    
        for (int i = 0; i < 8; ++i) {
            pool.execute([i, &latch, &results] {
                printf("hello %d
    ", i);
                std::this_thread::sleep_for(std::chrono::seconds(1));
                printf("world %d
    ", i);
                latch.done();
                results[i] = i * i;
            });
        }
        latch.wait();
        for (auto&& result : results) {
            printf("%d ", result);
        }
        printf("
    ");
        printf("--------------------
    ");
    }
    

    参考文献

    1. projschj 的C++11 线程池
    2. Java线程池实现原理及其在美团业务中的实践
    3. 用 C++ 写线程池是怎样一种体验? - 「已注销」的回答 - 知乎
  • 相关阅读:
    小心使用宏
    常见文件、目录、路径操作函数
    链表法页框的分配和去配
    获取调试符号文件
    Visual C++ Runtime Error 调试
    HEAP: Free Heap block XXXX modified at XXXX after it was freed
    磁盘操作 API
    【转】浅谈大型网站动态应用系统架构 Mr
    jQuery1.3.1 Tab选项卡 Mr
    spring依赖注入思想 Mr
  • 原文地址:https://www.cnblogs.com/zhcpku/p/15229339.html
Copyright © 2020-2023  润新知