线程池的自我修养

最近重构行情服务端的框架,其中有一部分就是重写mysql线程池,线程池是一个很独立的东西,今天就拿出来给大家分享, 怎样设计一个线程池, 以及我是怎么做的.

为什么要使用线程池

常见的线程池使用场景分为两种

  1. 大量计算, 充分利用多核

这个很好理解, 当程序需要大量计算, 单核CPU跑到100%, 这个时候可以将计算任务分解, 分多个线程计算, 如果我们有4核, 那这个时候我们可以跑到400%, 理想情况下, 可以节省3倍的时间. 当然这个不是绝对的, 具体情况要具体分析. 总而言之, 是为了让程序充分打满CPU.

  1. 同步阻塞,转异步回调

如果这个是web程序, 异步绝对是提高并发的神器. 在我们的C++服务器中, 也会有大量的阻塞任务, 可能是读取mysql, 可能是读取mongodb, 或者任意需要同步等待完成的事情, 那么在等待的时候, 我们的工作线程是完全没法做别的工作的, 这个时候我们就把等待的过程, 变成一个任务, 让线程池去做, 主线程继续处理别的工作, 等线程池完成之后, 再接管任务, 继续往下面执行.

这是两种完全不同的工作内容, 看上去都是线程池, 需要注意的细节, 是完全不一样的, 比如开启的线程数量, 大量计算的时候, 我们开的线程, 尽量是小于CPU数量的, mysql访问的时候, 线程数一定是不能高于mysql的并发数的. 这种细节很多, 不同的情景情况不一样, 不能一概而就.

线程池的自我修养

今天我要给大家分享的线程池, 抛开任务的细节, 主要讲我们应该怎样去设计一个线程池.

一图胜千言万语

不管任务多么复杂, 最终都在这个模型上. 重点可以分为下面几个:

  • 线程间的通信
  • 调度线程的设计
  • 任务的抽象

每个点的设计, 不同的人有不同的方法, 向大家分享我的方法, 主要针对的是mysql线程池的设计, 仅供大家参考.

线程间通信

线程间通信有很多种方法, 可能是信号, 可能是管道, 可能是套接字, 我比较喜欢更高级的封装zmq. 不管怎样的通信方式, 我们需要保证下面两点:

  • 全异步

不管是主工作线程与调度线程之间, 还是调度线程与线程池线程之间, 一定是异步完成, 绝对不允许同步, 任何地方有同步逻辑, 将成为整个线程池的瓶颈.

  • 一问一答

一个请求, 只能返回一次, 绝对不能一问多答, 更不能只问不答. 线程池要向主工作线程保证, 过来的请求, 一定会返回, 并且有且只有一次返回. 同时我建议, 如果线程池内部发生执行异常, 不要做二次尝试, 直接将异常标记返回.

通信模块的设计, 要保证简单高效, 给外面暴露的接口简单到只有接收任务和发送结果两个接口, 过多冗余的设计, 只是无畏的增加了复杂度.

调度线程

先上张图

调度线程需要关注的也是两点:

  • 外部消息队列

这部分我也喜欢交给zmq去做, 有任何消息的时候直接回调, 这里我将外部主线程消息与线程池消息都放在一个消息队列, 既符合先进先出的模式, 也符合单线程同步执行的逻辑.

  • 任务队列

当过来的任务超过线程池真实并发数量的时候, 我们会将任务缓存在队列, 然后当工作线程执行完任务的时候,或者有新的任务过来的时候, 我们都会去检查是否有空余的工作线程, 然后将任务分配给工作线程.

任务的抽象

将所有的工作抽象成通用的任务, 得益于C++的类型转换, 我们可以将所有的入参, 和出参都打包成一个void*, 然后将具体执行任务的过程, 使用一个静态函数, 这样打包一个通用的工作任务.

/**
 * @brief 给db层发送的参数
 */
struct DBParam
{
    DBParam():
        m_type(fund_begin),
        m_seq(0)
    {}
    //! 需要执行的sql
    std::string m_str_sql;
    //! db的类型
    db_res_type m_type;
    //! 请求的seq
    uint64_t m_seq;
};

/**
 * @brief 从db返回的数据
 */
class ResFund:
        public ResBase
{
public:
    ResFund(){}
    //! 基础数据集合
    std::vector<FundInfo> m_vec_funds;
};

//! 交给各个服务的正真执行sql的回调函数
typedef void (*DBQueryHandler)(MYSQL* con, void* param, void* res)

class DbMessage: 
    public MessageBase{
public:
    /**
     * @brief 构造函数
     */
    DbMessage();
    /**
     * @brief 析构函数
     */
    virtual ~DbMessage();
    //! 需要执行的参数
    void* m_params;
    //! 执行之后, 产生的结果信息
    void* m_msg;
    //! 执行mysql的回调
    DBQueryHandler m_handle_fun;
};

上面的代码删除了一些敏感的信息, 将主体拿出来, 大致表示我是怎么打包一个任务的. 事实上不管线程池做得多么的好, 业务千变万化, 我们很难满足的, 而我们这个任务的封装最主要的就是把业务封装到任务里面, 我们通过一个DBQueryHandler的回调函数, 主工作线程将自己的业务写到回调里面, 交由工作线程完成, 进而实现业务的千变万化.

无锁编程

看过大多线程池的实现, 很多人都喜欢用锁, 比如消息队列, 任务队列, 用各种锁来竞争, 进而实现任务的分发, 不敢说这个性能怎么样, 但是一旦扯上锁, 整个代码复杂度就上去了, 一处用锁, 到处加锁. 这个线程池的设计是完全没有任何锁的, 单线程内部完全是消息驱动, 线程间消息投递, 简单高效.

简单,简单,再简单

线程池的设计见仁见智, 不同的设计可能基于不同的需求, 没有银弹. 但是一定要把接口设计得简单, 不要有酷炫吊炸天的功能, 良好的文档, 对使用者友好, 一眼就能看懂的接口, 才是我们要追求的, 一句话, 简单,简单,再简单.

如您对我的文章感兴趣,请订阅以下公众号, 我将给您讲述, 中小企业程序员, 淘金路上的故事.



线程池的自我修养》上有1条评论

  1. 开发者头条

    感谢分享!已推荐到《开发者头条》:https://toutiao.io/posts/ln59gg 欢迎点赞支持!使用开发者头条 App 搜索 374971 即可订阅《雀观代码》

    回复

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注