多线程:C语言 - 简易线程池的原理和实现
目录
线程循环处理任务,线程不退出
我们都知道线程执行任务,创建和销毁线程需要额外时间开销
此时需要池化一批线程,避免多任务导致频繁的线程创建和销毁
那么不销毁线程的话,就需要让线程循环执行任务
循环执行任务的要点:
- 循环读取任务
- 任务退出不是线程退出
1.存储任务 -- 循环读取任务
需要用一种任务数据结构存储任务,这样线程池中的线程可以反复读取任务
2.函数回调 -- 函数退出,线程不退出
每次任务的执行依赖于回调,这样线程不会因为任务执行完成而退出
任务退出只是函数退出
本质是事件驱动,是生产者消费者模型
在多线程并发环境下,事件,或者说任务,发生后不能及时处理
此时就需要将事件的产生者,和事件的处理者单独列出来思考
线程池的事件就是任务,事件的产生者将任务交给线程池处理,这个过程就是消费
事件的产生者创建新的事件,这个过程就是生产
1.工作线程 -- 线程池的池化单元,任务的消费者
线程池中处理任务的线程叫做工作线程,我叫它:worker
2.线程池持有者 -- 提供多任务,任务的生产者
线程池的持有者可以向线程池生产任务(添加任务)
存储任务的数据结构
1.任务(task_t)
数据布局:
- 函数指针(函数引用)
- 参数列表
代码实现:
struct task_s {
void * (* func) (void* arg);
void * arg;
};
typedef struct task_s task_t;
2.任务队列 / 阻塞队列(task_queue_t)
任务的处理需要处理顺序
在不考虑优先级的情况下,顺序是FIFO(先来先处理)
所以存储的数据结构应该是队列,不管是FIFO,还是优先级队列
而基于数组的环形队列的效率应该是最高的
数据布局:
- 任务队列容量
- 当前任务队列的大小
- 任务队列队头,取出任务,消费
- 任务队列队尾,添加任务,生产
- 任务队列的数组
工作线程负责消费任务,线程池持有者负责生产任务
struct task_queue_s {
int task_queue_cap; // 容量
int task_queue_size; // 当前任务队列大小
int task_queue_head; // 队头 出任务
int task_queue_tail; // 队尾 入任务
task_t tasks[]; // 任务数组(C99柔性数组)
};
3.线程池结构
首先存储一定数量的池内工作线程,也就是工作线程数组
需要指定这个数组大小(初始化时指定)
还需要指定任务队列、池关闭标志位、互斥锁
// 线程池结构体
struct fixed_thread_pool_s {
// 线程池是否关闭
int is_shutdown;
// 工作者线程数组容量
int worker_arr_cap;
// 工作者线程数组
pthread_t * worker_id_arr;
// todo 因为不需要扩容线程池工作线程,所以不需要 管理者和 监控工作线程忙数量
/*
//工作者线程 忙数量
//__attribute__((unused)) int workker_busy_num;
// 工作者线程 忙数量 锁
//__attribute__((unused)) pthread_mutex_t mutex_busy_num; // 可以用cas锁优化
// 管理者线程
//__attribute__((unused)) pthread_t manager_id ;
*/
// 线程池锁
pthread_mutex_t mutex_pool;
// 任务队列
task_queue_t * task_queue;
// 条件变量:任务队列是否满
pthread_cond_t tq_is_full;
// 条件变量:任务队列是否空
pthread_cond_t tq_is_empty;
};
// shutdown标志位
#define POOL_ACTIVE 0
#define POOL_SHUTDOWN 1
// 池默认工作线程数组初始化大小
#define DEFAULT_THREAD_POOL_WORKER_SIZE 8
// 池默认任务队列容量
#define DEFAULT_TASK_QUEUE_CAP 16
// 暂无线程池扩容,创建时线程数量是多少就是多少线程
typedef struct fixed_thread_pool_s fixed_thread_pool_t;
伪代码实现工作线程不退出
由浅入深,先结合数据结构,想象一下如何不退出:
while(true){
// 先加锁,从任务队列中获取任务
pthread_mutex_lock(&mutex_pool);
// 判断 任务队列 和 线程池 的状态
while(task_queue_size == 0
&& is_shutdown == POOL_ACTIVE){
// 用 任务队列空条件 阻塞自己
pthread_cond_wait(&tq_is_empty, &mutex_pool);
}
// 此时队列非空,可以获取任务
// 但是不知道阻塞期间,线程池是不是被关闭了
if(is_shutdown == POOL_SHUTDOWN){
// 池关闭了,此时解锁, 防止死锁
pthread_mutex_unlock(&mutex_pool);
}
// 模拟从任务队列获取任务
task_t task = func; // 获取函数指针
void* arg = {...}; // 获取参数列表
// 获取任务完毕,解锁
pthread_mutex_unlock(&pool->mutex_pool);
// 执行任务
printf(INFO "线程:%ld 开始执行任务\n", self_id);
func(arg);
printf(INFO "线程:%ld 任务执行完成\n", self_id);
}
思考线程池创建和初始化过程
需要指定什么参数呢?
- 工作线程数量/数组大小(核心线程数量)
- 任务队列容量(阻塞队列容量)
需要做的事情有哪些?
- malloc 线程池结构体实例
- malloc 任务队列
- malloc 工作线程数组
- 创建对应数量的工作线程,并将线程id赋值给数组
- 初始化池子的其他参数
其他注意事项:
1.发生任何异常都要将之前申请的堆内存 free 掉
思考如何添加任务给线程池
先给线程池加锁,之后将函数指针和参数列表指针包装成task_t,传入任务队列
之后解锁
实例:简易线程池完整代码
thread_pool.h
#pragma once
#include <malloc.h>
#include <string.h>
#include <pthread.h>
#include <stdint-gcc.h>
// 任务结构体
typedef struct task_s task_t;
// 任务队列结构体
typedef struct task_queue_s task_queue_t;
// 线程池结构体
// shutdown标志位
#define POOL_ACTIVE 0
#define POOL_SHUTDOWN 1
// 池默认工作线程数组容量
#define DEFAULT_THREAD_POOL_WORKER_SIZE 8
// 池默认任务队列容量
#define DEFAULT_TASK_QUEUE_CAP 16
// 暂无线程池扩容,创建时线程数量是多少就是多少线程
typedef struct fixed_thread_pool_s fixed_thread_pool_t;
/**
*线程池函数
*/
// 创建线程池
fixed_thread_pool_t * create_fixed_thread_pool(int task_queue_cap, int worker_arr_size);
// 工作线程,循环消费任务队列
// todo 消费
void * worker(void * arg);
// 线程池持有者,生产任务到任务队列
// todo 生产
void thread_pool_task_add(fixed_thread_pool_t * pool, void*(* func)(void*), void * arg);
// 关闭线程池
void pool_shutdown(fixed_thread_pool_t * pool);
thread_pool.c
#include "thread_pool.h"
#include "exception.h"
// 任务结构体
struct task_s {
void * (* func) (void* arg);
void * arg;
};
// 任务队列结构体
struct task_queue_s {
int task_queue_cap; // 容量
int task_queue_size; // 当前任务队列大小
int task_queue_head; // 队头 出任务
int task_queue_tail; // 队尾 入任务
task_t tasks[]; // C99 柔性数组
};
// 线程池结构体
struct fixed_thread_pool_s {
// 线程池是否关闭
int is_shutdown;
// 工作者线程数量
int worker_arr_cap;
// 工作者线程数组
pthread_t * worker_id_arr;
// todo 因为不需要扩容线程池工作线程,所以不需要 管理者和 监控工作线程忙数量
/*
//工作者线程 忙数量
__attribute__((unused)) int workker_busy_num;
// 工作者线程 忙数量 锁
__attribute__((unused)) pthread_mutex_t mutex_busy_num; // 可以用cas锁优化
// 管理者线程
__attribute__((unused)) pthread_t manager_id ;
*/
// 线程池锁
pthread_mutex_t mutex_pool;
// 任务队列
task_queue_t * task_queue;
// 条件变量:任务队列是否满
pthread_cond_t tq_is_full;
// 条件变量:任务队列是否空
pthread_cond_t tq_is_empty;
};
fixed_thread_pool_t * create_fixed_thread_pool(int worker_arr_cap, int task_queue_cap){
fixed_thread_pool_t * pool = NULL; // 线程池
task_queue_t * task_queue = NULL; // 任务队列
pthread_t * worker_id_arr = NULL; // 工作线程数组
TRY_BEGIN:
// 1. 线程池 的 创建和初始化
pool = malloc(sizeof(fixed_thread_pool_t));
if(pool == NULL){
fprintf(stderr, "线程池 堆内存分配失败");
CATCH_EXCEPTION;
}
pool->is_shutdown = POOL_ACTIVE;
pool->worker_arr_cap = worker_arr_cap;
// 2. 任务队列 的 创建和初始化
task_queue = malloc(sizeof(task_queue_t) + task_queue_cap * sizeof(task_t));
if(task_queue == NULL){
fprintf(stderr, "任务队列 堆内存分配失败");
CATCH_EXCEPTION;
}
pool->task_queue = task_queue;
memset(task_queue, 0, sizeof(task_queue_t));
task_queue->task_queue_cap = task_queue_cap;
// 3. 工作线程 的创建
worker_id_arr = malloc(worker_arr_cap * sizeof(pthread_t));
if(worker_id_arr == NULL){
fprintf(stderr, "工作线程数组 堆内存分配失败");
CATCH_EXCEPTION;
}
pool->worker_id_arr = worker_id_arr;
for(int i = 0; i < worker_arr_cap; i++){
pthread_create(&pool->worker_id_arr[i], NULL, worker, pool);
}
return pool;
TRY_END;
free(pool);
free(task_queue);
free(worker_id_arr);
return NULL;
}
/* __attribute__((unused)) void * manager(void * arg){
* } // 暂时不需要
*/
void * worker(void * arg){
// 获取自己的id
pthread_t self_id = pthread_self();
// 获取线程池实例
fixed_thread_pool_t * pool = (fixed_thread_pool_t*) arg;
while(1) {
// todo 1 使用线程池,加锁
pthread_mutex_lock(&pool->mutex_pool);
// todo 2 判断 任务队列 和 线程池 的状态
while(pool->task_queue->task_queue_size == 0 && pool->is_shutdown == POOL_ACTIVE){
// 用 任务队列空条件 阻塞自己
pthread_cond_wait(&pool->tq_is_empty, &pool->mutex_pool);
}
// 此时队列非空,可以获取任务
// todo 3 但是不知道阻塞期间,线程池是不是被关闭了
if(pool->is_shutdown == POOL_SHUTDOWN){
// 池关闭了,此时解锁, 防止死锁
pthread_mutex_unlock(&pool->mutex_pool);
}
// todo 4 获取任务
task_queue_t * task_queue = pool->task_queue;
int queue_head = task_queue->task_queue_head;
task_t task;
// 取出 任务队列头节点任务
task.func = task_queue->tasks[queue_head].func;
task.arg = task_queue->tasks[queue_head].arg;
// 移动 任务队列头结点,任务队列当前大小 - 1
task_queue->task_queue_head = (queue_head + 1) % task_queue->task_queue_cap;
--task_queue->task_queue_size;
// todo 5 用完线程池,解锁
pthread_mutex_unlock(&pool->mutex_pool);
// todo 6 执行任务
printf(INFO "线程:%ld 开始执行任务\n", self_id);
(* task.func)(task.arg); // 管理者的任务 应该是传入的堆内存,否则不能 free
//free(task.arg);
//task.arg = NULL;
printf(INFO "线程:%ld 任务执行完成\n", self_id);
}
}
void thread_pool_task_add(fixed_thread_pool_t * pool, void* (* func)(void*), void * arg){
printf(INFO "添加任务到线程池:\n");
// todo 线程池加锁
pthread_mutex_lock(&pool->mutex_pool);
task_queue_t * tq = pool->task_queue;
// todo 判断 任务队列 和 线程池 的状态
while (tq->task_queue_size == tq->task_queue_cap && pool->is_shutdown == POOL_ACTIVE){
pthread_cond_wait(&pool->tq_is_full, &pool->mutex_pool);
}
// 任务队列有空余,再看看是不是线程池已经关闭
if(pool->is_shutdown){
pthread_mutex_unlock(&pool->mutex_pool);
return;
}
// todo 添加任务
// 访问队尾,移动尾指针,任务队列个数修改
task_t * tasks = tq->tasks;
tasks[tq->task_queue_tail].func = func;
tasks[tq->task_queue_tail].arg = arg;
tq->task_queue_tail = (tq->task_queue_tail + 1) % tq->task_queue_cap;
tq->task_queue_size ++;
// todo 唤醒阻塞的消费者
// 当任务队列为空, worker线程 使用 任务队列空条件 阻塞自己
// 所以需要唤醒
pthread_cond_signal(&pool->tq_is_empty);
// todo 任务添加完毕,解锁线程池
pthread_mutex_unlock(&pool->mutex_pool);
}
void pool_shutdown(fixed_thread_pool_t * pool){
pool->is_shutdown = POOL_SHUTDOWN;
}
结语
线程池的实现总体来说,主要是:
- 生产者、消费者以及产品是什么
对于线程池,产品是任务,生产者是线程池持有者
消费者是一组工作线程 - 建立问题的模型之后,需要什么数据结构:
比如任务、任务队列、线程池结构这些数据结构 - 生产和消费的行为:
- 生产者行为:线程池持有者添加任务到任务队列
- 消费者行为:循环读取任务队列,循环执行任务
通过三部曲:问题分析、数据结构、算法,很多问题都可以抽丝剥茧的解决!