返回顶部

多线程:C语言 - 简易线程池的原理和实现

线程循环处理任务,线程不退出

我们都知道线程执行任务,创建和销毁线程需要额外时间开销
此时需要池化一批线程,避免多任务导致频繁的线程创建和销毁
那么不销毁线程的话,就需要让线程循环执行任务
循环执行任务的要点:

  1. 循环读取任务
  2. 任务退出不是线程退出

1.存储任务 -- 循环读取任务

需要用一种任务数据结构存储任务,这样线程池中的线程可以反复读取任务

2.函数回调 -- 函数退出,线程不退出

每次任务的执行依赖于回调,这样线程不会因为任务执行完成而退出
任务退出只是函数退出

本质是事件驱动,是生产者消费者模型

在多线程并发环境下,事件,或者说任务,发生后不能及时处理
此时就需要将事件的产生者,和事件的处理者单独列出来思考
线程池的事件就是任务,事件的产生者将任务交给线程池处理,这个过程就是消费
事件的产生者创建新的事件,这个过程就是生产

1.工作线程 -- 线程池的池化单元,任务的消费者

线程池中处理任务的线程叫做工作线程,我叫它:worker

2.线程池持有者 -- 提供多任务,任务的生产者

线程池的持有者可以向线程池生产任务(添加任务)

存储任务的数据结构

1.任务(task_t)

数据布局:

  1. 函数指针(函数引用)
  2. 参数列表
    代码实现:
struct task_s {
    void * (* func) (void* arg);
    void * arg;
};
typedef struct task_s task_t;

2.任务队列 / 阻塞队列(task_queue_t)

任务的处理需要处理顺序
在不考虑优先级的情况下,顺序是FIFO(先来先处理)
所以存储的数据结构应该是队列,不管是FIFO,还是优先级队列
基于数组的环形队列的效率应该是最高的
数据布局:

  1. 任务队列容量
  2. 当前任务队列的大小
  3. 任务队列队头,取出任务,消费
  4. 任务队列队尾,添加任务,生产
  5. 任务队列的数组

工作线程负责消费任务,线程池持有者负责生产任务

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);
}

思考线程池创建和初始化过程

需要指定什么参数呢?

  1. 工作线程数量/数组大小(核心线程数量)
  2. 任务队列容量(阻塞队列容量)

需要做的事情有哪些?

  1. malloc 线程池结构体实例
  2. malloc 任务队列
  3. malloc 工作线程数组
  4. 创建对应数量的工作线程,并将线程id赋值给数组
  5. 初始化池子的其他参数

其他注意事项:
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;
}

结语

线程池的实现总体来说,主要是:

  1. 生产者、消费者以及产品是什么
    对于线程池,产品是任务,生产者是线程池持有者
    消费者是一组工作线程
  2. 建立问题的模型之后,需要什么数据结构:
    比如任务、任务队列、线程池结构这些数据结构
  3. 生产和消费的行为:
    • 生产者行为:线程池持有者添加任务到任务队列
    • 消费者行为:循环读取任务队列,循环执行任务

通过三部曲:问题分析、数据结构、算法,很多问题都可以抽丝剥茧的解决!

posted @ 2023-07-19 23:38  你好,一多  阅读(183)  评论(0编辑  收藏  举报