精简优秀的C/C++学习项目:线程池C-Thread-Pool

该文章已生成可运行项目,

本文项目来源于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++版本的,感谢观看

本文章已经生成可运行项目
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值