本文项目来源于github开源项目:Pithikos/C-Thread-Pool: A minimal but powerful thread pool in ANSI C
该项目实现了一个精悍且功能强大的线程池,代码质量很高,多处写法值得学习借鉴,适合初学者和进阶学习,是c/c++学习的优秀选择之一,建议先看第一,第三部分,在使用API时有疑惑的地方再去看第二部分对于结构的介绍
一.工作原理
线程池由线程池thpool,线程thread,工作队列jobqueue和队列中的job组成,线程池thpool内有n个创建好的线程,工作时从工作队列jobqueue的队头rear拿取任务,工作队列可以类比数据结构中的队列。

(图改自项目的design.md)
二.成员结构
任务队列 jobqueue
/* Job queue */
typedef struct jobqueue{
pthread_mutex_t rwmutex; /* used for queue r/w access */
job *front; /* pointer to front of queue */
job *rear; /* pointer to rear of queue */
bsem *has_jobs; /* flag as binary semaphore */
int len; /* number of jobs in queue */
} jobqueue;
1.第一个成员变量 rwmutex 是一个读写锁,表示队列中的数据可以同时被多个线程访问(读取)但是同一时间只允许一个线程进行修改
2.第二个成员变量 front 是队列的头指针
3.第三个成员变量 rear 是队列的尾指针
4.第四个成员变量 has_job 是一个模拟二进制信号量的变量,取值只有0/1,值为0时表示任务队列处于闲置状态,值为1时表示任务队列处于工作状态。这个信号量在线程池的实现中多处都有出现,后面会更详细地解释。
5.第五个成员变量 len 是表示队列长度(任务个数)的变量
说完了任务队列的结构,我们来看队列中任务的设计是怎么样的:
任务 job
typedef struct job{
struct job* prev; /* pointer to previous job */
void (*function)(void* arg); /* function pointer */
void* arg; /* function's argument */
} job;
1.第一个成员变量 prev 是一个job*类型的指针,指向前一个任务job
2.第二个成员变量类型看起来很复杂,但其实理解起来不难,这是一个函数指针,void表示这个函数指针指向的这个函数的返回类型是void,(*function)的*表示这是一个函数指针,function是这个函数指针的名字,(void* arg)是这个函数指针指向的函数的参数类型,为void*,第二个成员变量其实就是任务要执行的函数的指针
3.第三个成员变量 arg 是第二个成员变量的参数
注意:如果要执行的函数有多个参数,那要这些参数写成一个结构体,然后将结构体的指针传入即可(记得把类型转换成void*)
接下来是线程池的主要部分:线程thread和线程池thpool_
线程 thread
typedef struct thread{
int id; /* friendly id */
pthread_t pthread; /* pointer to actual thread */
struct thpool_* thpool_p; /* access to thpool */
} thread;
1.第一个成员变量 id 是线程的id,这里的注释是:friendly id,意思是这个是供人类识别的标识符,用于调试时识别哪个线程在运行,但是只持有这个id无法对线程进行操作
2.第二个成员变量 pthread 与第一个成员变量id有所区别,它是操作系统层面的线程标识符,可以对线程进行操作(如join,pause等等操作),它的类型pthread_t在不同的平台中有不同的定义,在windows中_mingw.h文件它的类型为unsigned long long(通常来说是64位8字节)
3.第三个成员变量 thpool_p 是一个thpool_*类型的指针,指向线程所属的线程池
线程池 thpool_
typedef struct thpool_{
thread** threads; /* pointer to threads */
volatile int num_threads_alive; /* threads currently alive */
volatile int num_threads_working; /* threads currently working */
pthread_mutex_t thcount_lock; /* used for thread count etc */
pthread_cond_t threads_all_idle; /* signal to thpool_wait */
jobqueue jobqueue; /* job queue */
} thpool_;
1.线程池的第一个成员变量是一个二级指针,可以将其理解为数组,数组中的每一个元素都是一个thread*类型的指针,指向线程池中的一个线程,如下图所示:

2.第二个和第三成员变量分别统计存活的线程数和工作中的线程数
3.第四个成员变量是改变线程池内计数变量时的锁,这是一个互斥锁,和上面任务队列的读写锁rwmutex不同
4.第五个成员变量是一个条件变量,条件变量常用于线程同步,从名字里的all_idle可以看出这个变量的作用是等待所有线程处于闲置状态,配合pthread_cond_wait()函数使用
5.第六个变量是前面提到过的任务队列,此处不在赘述
三.核心API解析:
了解完基本的结构定义后,我们来看线程池的几个API:

(图来源于项目README)
为了方便理解首先我们先看项目中给的示范代码(忽略头文件包含):
void task(void *arg){
printf("Thread #%u working on %d\n", (int)pthread_self(), (int) arg);
}
int main(){
puts("Making threadpool with 4 threads");
threadpool thpool = thpool_init(4);
puts("Adding 40 tasks to threadpool");
int i;
for (i=0; i<40; i++){
thpool_add_work(thpool, task, (void*)(uintptr_t)i);
};
thpool_wait(thpool);
puts("Killing threadpool");
thpool_destroy(thpool);
return 0;
}
概述:
首先使用 thpool_init() 函数初始化线程池,这里创建了一个含有四个线程的线程池。
接着在循环中使用thpool_add_work()函数向线程池中加入工作,这个工作为函数task。task的定义很简单,运行时使用 pthread_self() 输出线程ID,参数为循环中传入的参数i。
然后调用thpool_wait()等待所有线程结束工作,
最后使用thpool_destroy销毁线程池。
这段代码几乎涵盖了所有主要的API,我们按顺序来解析这些API是怎么工作的,为了效率起见我们只解析那些起核心作用的代码,简单的初始化或者其他代码我们简单略过或者不理会。
1. thpool_init
函数声明是 : struct thpool_* thpool_init(int num_threads)
首先初始化了两个静态全局变量,他们的类型都是static volatile int,定义在thpool.c文件的头部
threads_on_hold = 0;
threads_keepalive = 1;
static volatile int threads_keepalive;
static volatile int threads_on_hold;
这里volatile的作用是让编译器不要对变量进行优化,这样程序运行时每次都会到内存中去读取变量的值,而不是读取变量在缓存中的值,这一点的重要性在后面的解读中有所体现。threads_on_hold用于函数thread_resume() 和 threads_hold() ,表示线程池内所有线程的暂停/工作状态,threads_keepalive则表示的是线程池的存活状态,注意这里是整个线程池的存活而不仅仅是关于单个线程的状态。这两个变量在后面的API中还会多次出现。
同时可以注意到这两个全局变量限制了我们线程池的数量,也就是说我们在使用这个项目时最多智能创建一个线程池,多个线程池的情况由于这两个表示线程池状态的变量是全局的,导致牵一发而动全身,所以无法实现。
接下来是对线程池里各个成员进行初始化,前面有介绍线程池的成员,不再赘述。我们主要提一下线程的初始化:
int n;
for (n=0; n<num_threads; n++){
thread_init(thpool_p, &thpool_p->threads[n], n);
看一下thread_init的函数原型:
static int thread_init (thpool_* thpool_p, struct thread** thread_p, int id){
*thread_p = (struct thread*)malloc(sizeof(struct thread));
if (*thread_p == NULL){
err("thread_init(): Could not allocate memory for thread\n");
return -1;
}
(*thread_p)->thpool_p = thpool_p;
(*thread_p)->id = id;
pthread_create(&(*thread_p)->pthread, NULL, (void * (*)(void *)) thread_do, (*thread_p));
pthread_detach((*thread_p)->pthread);
return 0;
}
这里首先给每个线程分配他们的空间,参数thread_p的类型是thread** ,表示线程指针的指针。传入二级指针的目的是修改线程指针这个一级指针,同理如果你要在函数中修改一个外部变量的值,那么就向函数中传入它的一级指针。
然后初始化线程的成员变量,接着使用pthread_create这个函数,这个函数是pthread库的函数,函数声明是这样的:
int pthread_create(
pthread_t *thread, // 保存新线程的标识符
const pthread_attr_t *attr, // 线程属性(通常为NULL)
void *(*start_routine)(void *), // 线程要执行的函数指针
void *arg // 传递给线程函数的参数
);
第一个参数在前面介绍thread结构时有解释,第二个参数通常我们设置为NULL。
第三个和第四个参数和我们前面提及的任务job的成员function与arg很类似,但是这个函数它的返回值是 void* 。这个函数是线程的入口函数,线程创建后会立马执行这个函数。最重点的是在我们这个线程池里面作者对这个函数,也就是thread_do的实现
重点:thread_do
thread_do的实现比较长,我这里去掉那些不是很重要的部分
static void* thread_do(struct thread* thread_p){
thpool_* thpool_p = thread_p->thpool_p;
struct sigaction act;
sigemptyset(&act.sa_mask);
act.sa_flags = SA_ONSTACK;
act.sa_handler = thread_hold;
if (sigaction(SIGUSR1, &act, NULL) == -1) {
err("thread_do(): cannot handle SIGUSR1");
}
pthread_mutex_lock(&thpool_p->thcount_lock);
thpool_p->num_threads_alive += 1;
pthread_mutex_unlock(&thpool_p->thcount_lock);
while(threads_keepalive){
bsem_wait(thpool_p->jobqueue.has_jobs);
if (threads_keepalive){
pthread_mutex_lock(&thpool_p->thcount_lock);
thpool_p->num_threads_working++;
pthread_mutex_unlock(&thpool_p->thcount_lock);
/* Read job from queue and execute it */
void (*func_buff)(void*);
void* arg_buff;
job* job_p = jobqueue_pull(&thpool_p->jobqueue);
if (job_p) {
func_buff = job_p->function;
arg_buff = job_p->arg;
func_buff(arg_buff);
free(job_p);
}
pthread_mutex_lock(&thpool_p->thcount_lock);
thpool_p->num_threads_working--;
if (!thpool_p->num_threads_working) {
pthread_cond_signal(&thpool_p->threads_all_idle);
}
pthread_mutex_unlock(&thpool_p->thcount_lock);
}
}
pthread_mutex_lock(&thpool_p->thcount_lock);
thpool_p->num_threads_alive --;
pthread_mutex_unlock(&thpool_p->thcount_lock);
return NULL;
}
1.第一部分:信号处理
/* Register signal handler */
struct sigaction act;
sigemptyset(&act.sa_mask);
act.sa_flags = SA_ONSTACK;
act.sa_handler = thread_hold;
if (sigaction(SIGUSR1, &act, NULL) == -1) {
err("thread_do(): cannot handle SIGUSR1");
}
这一部分和代码和头文件<signal.h>有关,详细用法可见:C 标准库 – <signal.h> | 菜鸟教程
简单来说这部分代码先创建了一个信号结构体变量,然后用sigemptyset()初始化,设置它的处理程序 hanlder 为 thread_hold ,然后将它和信号 SIGUSR1 绑定起来,所以当线程收到SUGUSR1信号时会执行thread_hold这个函数
static void thread_hold(int sig_id) {
(void)sig_id;
threads_on_hold = 1;
while (threads_on_hold){
sleep(1);
}
}
thread函数很简单,就是把thread_on_hold置为1,然后进入循环,所以我们可以看到,如果要暂停线程池里所有线程,就只需向每个线程发送SIGUSR1信号,恢复线程时,只需将thread_on_hold置为0,这就是另外两个API 即 thread_pause 和 thread_resume 的原理
2.第二部分:任务处理
while(threads_keepalive){
bsem_wait(thpool_p->jobqueue.has_jobs);
if (threads_keepalive){
pthread_mutex_lock(&thpool_p->thcount_lock);
thpool_p->num_threads_working++;
pthread_mutex_unlock(&thpool_p->thcount_lock);
void (*func_buff)(void*);
void* arg_buff;
job* job_p = jobqueue_pull(&thpool_p->jobqueue);
if (job_p) {
func_buff = job_p->function;
arg_buff = job_p->arg;
func_buff(arg_buff);
free(job_p);
}
pthread_mutex_lock(&thpool_p->thcount_lock);
thpool_p->num_threads_working--;
if (!thpool_p->num_threads_working) {
pthread_cond_signal(&thpool_p->threads_all_idle);
}
pthread_mutex_unlock(&thpool_p->thcount_lock);
}
}
pthread_mutex_lock(&thpool_p->thcount_lock);
thpool_p->num_threads_alive --;
pthread_mutex_unlock(&thpool_p->thcount_lock);
首先是一个大的循环,循环条件是thread_keepalive的值为1,接着是一个信号等待函数bsem_wait,参数是线程池的任务队列的信号量has_job,这个函数作用是等待has_job的值被置为1,即任务队列中有任务需要处理,否则线程则阻塞在这个函数上。
然后又是一个if判断语句,这里和上面while的判断条件是相同的。两次判断的原因是防止线程池已经使用thpool_destroy销毁后还有线程收到信号继续执行任务。进入if语句后,使用jobqueue_pull()函数从任务队列里获取任务以及参数,执行任务后使用free释放空间。
完成if语句块后,将线程池的工作线程数减1,然后判断线程池内工作线程数是否为0,如果为0则使用pthread_cond_signal函数唤醒等待thread_all_idle的线程,退出while循环后说明thread_keepalive为0,将存活线程数thread_alive减1。
2.thpool_add_work
int thpool_add_work(thpool_* thpool_p, void (*function_p)(void*), void* arg_p){
job* newjob;
newjob=(struct job*)malloc(sizeof(struct job));
if (newjob==NULL){
err("thpool_add_work(): Could not allocate memory for new job\n");
return -1;
}
/* add function and argument */
newjob->function=function_p;
newjob->arg=arg_p;
/* add job to queue */
jobqueue_push(&thpool_p->jobqueue, newjob);
return 0;
}
这个函数的实现很简单,基本可以理解为对jobqueue_push()的封装,主要由两个部分组成,第一是创建一个job变量,然后初始化job的函数指针和参数,然后将这个处理好的job使用jobqueue_push()把 job 加入任务队列中。
jobqueue_push()用一个switch语句判断任务队列中的两种情况:一是队列中已经存在任务,二是任务队列里没有任务,分别处理后使用bsem_post函数唤醒至少一个等待has_job信号量的线程,
bsem_post(jobqueue_p->has_jobs);
这样有闲置线程的情况下会处理这个任务,我们可以看到这又是一个利用信号量的示例
3.thpool_wait
函数声明是: void thpool_wait(thpool_* thpool_p)
这个函数很简单,核心是一个while循环:
while (thpool_p->jobqueue.len || thpool_p->num_threads_working) {
pthread_cond_wait(&thpool_p->threads_all_idle, &thpool_p->thcount_lock);
}
thpool_wait的实现就是 用while循环内部的pthread_cond_wait等待threads_all_idle信号唤醒线程,唤醒后检查len和threads_working是否都为0,当这一条件满足时说明最后一个线程完成了工作,进而说明所有线程都是闲置状态。
值得注意的是这里是怎么考虑设置检查条件的,这里的循环条件是 a. 任务队列的len不为0 或 b. threads_working 不为0。任务队列len为0时要看还有没有线程在工作,同样没有线程工作时要看是不是还没有从任务队列里拉取任务。这两点互为补充完成了对线程池状态的完整检查,缺一不可。循环的内容是pthread_cond_wait(),这样写的好处是,如果循环内没有语句的话循环会一直高速运行,CPU占用高,这浪费了CPU资源,而pthread_cond_wait()在没收到threads_all_idle时该线程会进入休眠状态,等待信号唤醒。
4.thpool_destroy
销毁线程池主要是注意资源的回收,当心内存泄漏的问题。
第一步先把threads_keepalive置为0:
threads_keepalive = 0;
然后设置一个定时器,等待时间是1秒,在循环内部bsem_post_all通知所有等待的线程,当线程被唤醒后检查threads_keeplive为0时会停止工作,即退出thread_do的工作循环
double TIMEOUT = 1.0;
time_t start, end;
double tpassed = 0.0;
time (&start);
while (tpassed < TIMEOUT && thpool_p->num_threads_alive){
bsem_post_all(thpool_p->jobqueue.has_jobs);
time (&end);
tpassed = difftime(end,start);
}
紧接着又是一个while循环,这里是为了确保所有线程都退出
while (thpool_p->num_threads_alive){
bsem_post_all(thpool_p->jobqueue.has_jobs);
sleep(1);
}
作者对这个sleep函数做了解释:

简单来说是这样的:在测试环境中使用sleep()是为了确保在程序退出前所有线程都有足够的时间完成内存释放,从而可以检查内存泄漏。在生产环境中,操作系统会自动处理线程的资源释放,因此不需要额外的等待时间。无论是在程序结束时还是中途销毁线程池,线程都会有足够的时间终止。
函数的最后是对线程池资源的回收:
jobqueue_destroy(&thpool_p->jobqueue);
int n;
for (n=0; n < threads_total; n++){
thread_destroy(thpool_p->threads[n]);
}
free(thpool_p->threads);
free(thpool_p);
四.结束语
本文受启发于线程池原理与实现_哔哩哔哩_bilibili,加入了我自己的学习经验和理解,基本涵盖了所有重要的API,也许后面我会改写一个c++版本的,感谢观看

被折叠的 条评论
为什么被折叠?



