栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 软件开发 > 后端开发 > C/C++/C#

多线程之线程池设计讨论

C/C++/C# 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

多线程之线程池设计讨论

代码下载地址:
https://gitee.com/dai-jiapei/iocp/tree/master/Thread
主要下载 demo.c ,threadpool.c, threadpool.h 三个文件即可

这个一个跨系统线程池,支持Linux, windows。其中包含XP,需要将threadpool.c::COMPATIBLE_XP 这个宏定义为 1。XP 需要宏控制是因为XP没有条件变量,只能自己实现一个,当 COMPATIBLE_XP 为0时, 使用系统提供的条件变量函数。

线程池这东西,说简单也很简单,说复杂也挺复杂。简单是因为它就是一个生产者消费者模型。复杂是因为它包含了很多容错,性能上的问题。今天我们就来讨论一下线程池应该如何设计才算一个及格的线程池。

先看头文件:

#ifndef __THREAD_POOL_H__
#define __THREAD_POOL_H__

#ifdef _WIN32
#define  TPSTDCALL __stdcall
#else
//#define TPSTDCALL __attribute__((__stdcall__))
#define TPSTDCALL
#endif

#ifndef TRUE
#define TRUE 1
#endif

#ifndef FALSE
#define FALSE 0
#endif

typedef unsigned int thread_ulong_t;
typedef int thread_bool_t;

typedef void * (TPSTDCALL * queue_hook_cb)(void *, void *);
typedef void(TPSTDCALL * timer_hook_cb)(void * arg, long event);

struct __THREADPOOL;

typedef struct __THREADPOOL * THREADPOOL;
typedef unsigned int THREADTIMER;

typedef struct __HQUEUE__
{
    void * unused;
} *HQUEUE;

typedef struct __THREADPOOL_CONFIG
{
    thread_ulong_t thread_num;//常驻线程
    thread_ulong_t max_thread_num;//最大线程数
    thread_ulong_t queue_max_num;//最大队列元素个数
    thread_ulong_t max_timer_count;//最大定时器个数
    thread_ulong_t priority_count;//队列优先级个数
}THREADPOOL_CONFIG;

typedef unsigned int HQTIMER;



#ifdef __cplusplus
extern "C" {
#endif

    THREADPOOL_CONFIG * threadpool_config_new();
    void threadpool_config_free(THREADPOOL_CONFIG * config);

    THREADPOOL threadpool_create(THREADPOOL_CONFIG * config);
    thread_bool_t threadpool_destroy(THREADPOOL pool);
    int threadpool_add_job(THREADPOOL pool, int priority, thread_bool_t block_full,
        queue_hook_cb callback, void* obj, void* arg);

    THREADTIMER threadpool_set_timer(THREADPOOL pool, thread_ulong_t timeout, timer_hook_cb callback, void * arg, long event);
    thread_bool_t threadpool_kill_timer(THREADPOOL pool, THREADTIMER timer);


    //one loop per thread
    HQUEUE queue_create(unsigned int max_message);
    unsigned int queue_dispatch(HQUEUE queue);
    int queue_post(HQUEUE queue, queue_hook_cb callback, void * obj, void *arg);
    thread_bool_t queue_break(HQUEUE queue);
    thread_bool_t queue_destroy(HQUEUE queue);

    HQTIMER queue_set_timer(HQUEUE queue, thread_ulong_t timeout, timer_hook_cb callback, void * arg, long event);
    thread_bool_t queue_kill_timer(HQUEUE queue, HQTIMER timer);

#ifdef __cplusplus
};
#endif


#endif

大家先将注意力放到 threadpool_xxx 系列函数上。函数的用法应该很明显了, 先调用 threadpool_config_new 创建一个 THREADPOOL_CONFIG 结构体,这结构体用于储存线程池的环境参数,用户可根据需要自行调整。然后调用 threadpool_create 传入 THREADPOOL_CONFIG ,threadpool_create会把 THREADPOOL_CONFIG 拷贝一份,这个时候 THREADPOOL_CONFIG 对象是可以随时删除的。threadpool_add_job 负责往线程池队列抛入任务, threadpool_destroy 负责注销线程池。

THREADPOOL_CONFIG 这个结构体很关键,我们详细说说每个参数的用法。

thread_ulong_t thread_num;//常驻线程
这个变量用来控制线程池创建时,内部创建多少条任务线程,默认创建一条。而且大家要注意的是,线程并不是创建得越多越好的,过多的线程有时候还会拖垮性能。

thread_ulong_t max_thread_num;//最大线程数
最大线程数,必须大于等于常驻线程数。它的作用是,在 threadpool_add_job 函数中判断到任务队列满时,且 block_full 参数为TRUE,会判断 max_thread_num 是否大于已创建的线程数量,大于则创建一个新的临时线程,当新的线程创建完毕后,再将任务加入到队列中。而新创建的线程,则在任务队列为空后,全部注销,只留下常驻线程,等待新的任务。
要注意,这里创建新的线程,是期望能够缓解线程池的压力,并不是说开启新线程一定能够缓解线程池的压力。前面也说过,过多的线程,反而会拉垮线程池的性能。具体上,常驻线程有多少,最大线程数应该有多少,这个要看线上的日志数据,才能得出正确的结论。
这里有一个地方可能有些同学会有疑问,就是我线上的日志数据是 6 个常驻线程数性能是最高的,那么我还有必要设置最大线程数大于常驻线程数吗?这个得视情况而定。如果你的业务逻辑不再调整,且运行机器也是只有一个活跃进程(就是你自己的进程),这个时候是可以考虑不设置最大线程数大于常驻线程数的。

thread_ulong_t queue_max_num;//最大队列元素个数
大家都知道,线程池是基于生产者消费者线程模型的,模型里面有一个任务队列,线程池的任务线程会轮流从队列头部提取节点,并执行节点锁提供的回调函数。而 queue_max_num 则是控制这个任务队列的最节点数。当任务节点满时,逻辑跟 max_thread_num 成员一样,会对 block_full 参数进行判断,这里不再赘述。
另外是线程池会根据 queue_max_num 提前分配好任务节点所需的内存,而且并不仅仅是任务队列的内存是提前分配好的,其他一些资源的内存,也是提前分配好的。这样做的目的有两个,一是在高并发的场景下,减少malloc带来的时间消耗。二是在线程池创建时提前分配好需要的资源,这样在线程池运行的周期中,不再发生内存分配行为,能够更好的保证线程池的稳定性。
关于线程池不再发生内存分配行为,代码里仍有一点做得不好的地方,那就是临时线程,它是动态创建和销毁的,要知道,线程的创建也是要进行内存分配的。而且开销较大,这个设计确实会影响到线程池的性能和稳定性。
可以考虑改为提前创建好临时线程,并将其挂起限制,等到队列满且需要开启新的线程时,再将其提出使用。

thread_ulong_t max_timer_count;//最大定时器个数
定时器大家不陌生了,线程池中对应的定时器函数是threadpool_set_timer和 threadpool_kill_timer。前面也提到为了线程池的性能和稳定性,资源都是提前分配的,这也是这个参数作用的由来。有同学可能会有个疑问。有了这个变量,那么我定时器的数量不就等于被限制了吗?
是的,为了线程池的稳定性,定时器的数量确实被限制了,但是定时器的内存开销很小,所以大家设置几百上千个,也不会有太大开销。
其次是定时器逻辑问题,目前定时器的检查和回调函数,只有在常驻线程中才会执行,临时线程中是不会执行的,因为这样的控制逻辑更简单,另外线程中的定时检查,是执行一定数量的队列任务后才会执行超时检查。因为获取时间是系统函数,需要触发中断,这样可以尽量减少中断的产生。

thread_ulong_t priority_count;//队列优先级个数
设置任务队列的优先级数量,假设设置为5,即有5个优先级,如果 threadpool_add_job 函数的优先级参数 (priority) 为-1, 则将任务放到 priority_count 除以 2 的下标中(即 5除以2等于2)。也就是说-1是把任务放到中间的优先级。而任务线程在提取任务节点时,是从下标为0的节点开始提取任务。所以priority值为0的任务优先级是最高的。
所谓的优先级代码设计,它的数据结构不过就是一个数组,然后从数组下标为0 的第一个元素,一个个地将任务提取出来执行。
而不管优先级有多少个,它总的任务数量不能超过 queue_max_num (最大队列元素个数)。

THREADPOOL_CONFIG 结构体的成员介绍完了,我们再来介绍一下 threadpool_destroy 和 threadpool_add_job 函数。

threadpool_destroy 函数
threadpool_destroy 函数是一个阻塞函数,顾名思义它就是注销掉线程池的,首先它会设置一个 CLOSE 标记,threadpool_add_job 函数判断到这个标记便不能够继续添加任务。而任务线程还是会继续执行任务队列里面的任务,直到所有的任务被执行完毕,任务线程自行结束。 然后threadpool_destroy会收到通知,函数返回。
关于任务线程退出的方式,其实有两种,一种是上面说的,设置CLOSE标记,执行完所有任务后退出。另外还有一个方式是,设置EXIT标记,活动的任务线程处理完当前节点后退出,而空闲的任务线程被唤醒后直接退出,任务队列中未执行的任务全部被抛弃。
常规的方案也就这两种,目前我们只支持第一种,即CLOSE标记的处理。那可以增加QUIT标记,在threadpool_destroy 函数中判断到此标记,强行终结掉所有任务线程,然后退出吗?有一个问题,与与此问题十分相似,那就是为什么线程池都没有提供相关的线程终结函数?
是的,线程池都不提供线程终结函数,这意味着线程回调函数,最终只能通过return关键字退出。这是最安全的做法!不管是线程池还是日常开发的其他线程,都不要强行终结!原因有二,一是强行终结线程容易引起死锁和内存泄露。这还不是最可怕,最可怕的还是第二点:假设任务线程正在写文件,突然间线程终结了,文件也就只写到一半,这个时候本地数据就损坏了!
所以务必让你的任务函数通过return 关键字来退出,尽可能把一切情况都把握在可控的范围内。

threadpool_add_job 函数
threadpool_add_job 是用来添加任务到任务队列的,主要是介绍一下 block_full 这个参数 (前面已经介绍过priority参数了)。threadpool_add_job 函数中,判断到 block_full 变量为FALSE 且任务队列已满,函数就会直接返回。这里直接返回的意思就是不会将任务添加到任务队列中,也不会创建新的任务线程。如果block_full 为 TRUE 且队列已满,则先检查是否需要创建线程,再阻塞等待任务队列为空,然后将任务插入到队列中。若block_full 为 TRUE 且队列未满,则直接将任务插入到队列中。
block_full 参数是在高并发中防止线程池任务过多出现的。一些不是特别重要的消息,可以将block_full 设置为FALSE,一旦 threadpool_add_job 调用失败,直接通知客户超时,可以有效减轻线程池的负担。

定时器相关介绍:
定时器的数据结构是采用小根堆管理的,里面使用的是绝对定时器,也就是说修改系统时间,有可能让定时器变得不准确。前面也说过任务线程中,并不是每一个任务结束后,都会执行超时检查的,只有任务队列为空,或者执行了一定数量的节点任务,才会去检查是否有定时器超时。这就意味着,定时器的超时时间有可能很不准确。具体延时的差距,看你的业务逻辑而定。大概的延时公式是:常驻线程数 * 平均任务时间。假设有4条常驻线程,每个任务平均为 1 秒,则你的任务可能会比预期的延时4秒后才执行,当然了,这个是线程池处于最差情况下出现的现象。
当线程池注销时,所有的定时器对象也会跟随注销,不需要担心定时器忘记销毁的情况。

queue_xxx 系列函数是干什么的。
queue_xxx 系列函数,是对 threadpool_xxx 函数的一个异形封装。它是一个 one loop per thread 模型。何为one loop per thread?
就是在一个线程内不断循环执行任务,有点类似windows 的UI线程。UI线程在while循环获取消息和分派消息。而queue线程是不断获取任务节点,然后调用任务节点的回调函数。
要知道 queue_xxx 系列是不会创建新的线程的,它是利用 queue_dispatch (类似domodal)函数在当前线程中完成循环,可以利用 queue_post (类似postmessage)函数往里面投递任务,需要退出时可以调用 queue_break 函数,而注销 queue 对象则需要使用 queue_destroy函数 。

线程池的使用方式也参数都介绍完了,我们来做一下总结和分析:

最后特别声明一下,以上观点,为个人的一些经验和看法,如有不妥之处,还望指出。
<完>

转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/889745.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号