从0到1实现线程池(C语言版)

目录

🌤️1. 基础知识

⛅1.1 线程概述

⛅1.2 linux下线程相关函数

🌥️1.2.1 线程ID

🌥️1.2.2 线程创建

🌥️1.2.3 线程回收

🌥️1.2.4 线程分离

🌤️2. 线程池概述

⛅2.1 线程池的定义

⛅2.2 为什么要有线程池(线程池的作用)

⛅2.3 线程池的构成

⛅2.4 线程数量的确定

🌤️3、实现线程池

⛅3.1 接口设计

⛅3.2 结构体设计

⛅3.3 关键函数书写

🌥️3.3.1 __threads_create & __threads_terminate

🌥️3.3.2 __taskqueue_create

🌥️3.3.3 __add_task & __pop_task


🌤️1. 基础知识

⛅1.1 线程概述

要实现线程池,首先要了解什么是线程?要说线程就不得不提进程,以 Windows 下 QQ 为例,当我们双击打开 QQ,便打开了一个 QQ 进程,进程可以简单的认为是程序的一次执行过程,是操作系统分配基本运行资源的基本单位,可以通过任务管理器查看每一个进程的资源(如 CPU、内存、磁盘、网络)使用情况。在 QQ 中,我们可以同时打字聊天、语音通话、下载文件等,在同一个进程 QQ 中,这些同时进行的任务就是由不同的线程来执行的。线程是进程内的一个执行单元,它与其他线程共享该进程的资源,每个线程都拥有自己的执行序列、程序计数器、寄存器集和堆栈,线程是操作系统调度执行的最小单位。在 Linux 中,其实进程和线程不做区分,关于这些本质细节后面会出专门的专栏进行讲解,现在只需要知道线程就是来执行某个特定任务的。

⛅1.2 linux下线程相关函数

为了实现线程池,还需要掌握一些常见的线程函数。

🌥️1.2.1 线程ID

每一个线程都有一个唯一的线程ID,类型为pthread_t,一个无符号长整形数,可以调用如下函数获得:

pthread_t pthread_self(void);	// 返回当前线程的线程ID
🌥️1.2.2 线程创建
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                   void *(*start_routine) (void *), void *arg);
// thread:线程ID;attr: 线程属性, 一般写NULL;start_routine:函数指针;arg:函数实参
// 返回值:成功返回0

给大家举个例子就明白怎么创建一个线程了。

可以这样来理解线程创建函数:tid作为标记值管理线程,就像用fd来管理文件描述符一样,学号来管理学生一样,作为唯一不可重复的标识,线程是用来处理任务的,所以要传一个函数指针,这个任务函数可能有参数,所以还要传入参数。

🌥️1.2.3 线程回收
#include <pthread.h>
// 这是一个阻塞函数, 子线程在运行这个函数就阻塞,直到子线程退出, 函数解除阻塞, 回收对应的子线程资源
int pthread_join(pthread_t thread, void **retval);
// thread:线程ID;retval:一般写NULL
// 返回值:成功返回0

有线程创建就必有线程回收,这个函数调用之后,主线程(一般为main函数)要阻塞等待所有的子线程完成,所以效率较低。

🌥️1.2.4 线程分离

在很多情况下,主线程有属于自己的业务处理流程,调用pthread_join()只要子线程不退出主线程就会一直被阻塞。为了解决这种效率低的问题,线程库函数为我们提供了线程分离函数pthread_detach(),调用这个函数之后指定的子线程就可以和主线程分离,当子线程退出的时候,占用的内核资源就被系统的其他进程接管并回收了

#include <pthread.h>
// 参数就子线程的线程ID, 主线程就可以和这个子线程分离了
int pthread_detach(pthread_t thread);

🌤️2. 线程池概述

在正式实现线程池之前,先来了解一下线程池的基础概念,线程池是一个框架中的基础轮子,在几年前有些同学会把线程池作为简历中的一个项目,其实线程池不能是一个项目,因为并没有实现任何业务功能,线程池是用来优化一个框架或者项目的。

⛅2.1 线程池的定义

线程池就是维持管理固定数量线程的池式结构,除了线程池之外,还有大家比较熟悉的内存池、数据库连接池等池式结构,他们都有一个共同点,就是复用资源,为什么要复用,因为这些资源的创建和销毁都是比较耗时且繁琐的;什么叫维持?就是反复的去利用线程池里的线程,没有任务就休眠(就绪态),有任务就去调度执行。

⛅2.2 为什么要有线程池(线程池的作用)

当主线程在执行的过程中,遇到了耗时任务,会严重耽误主线程的运行效率的时候,主线程将这些任务发布到线程池,让线程池来完成,主线程继续执行核心业务逻辑,下面来举个生活中的例子便于大家理解。

想象一下你是一家餐厅的经理(主线程)。餐厅面对的顾客(任务)是多种多样的,包括点餐、上菜、收钱等等。如果餐厅只有你一个人来做所有的事情,显然效率会非常低下,特别是在顾客高峰期,你可能会不堪重负。这时,你可以雇佣几位员工(线程池中的线程)来帮忙。你可以将任务分配给这些员工:比如一个专门接待顾客点餐,一个负责上菜,另一个负责收银。这样,每个员工都有明确的责任,可以同时处理不同的任务,大大提高了整体的运作效率。

线程池的工作方式也类似。当有多个任务需要处理时,线程池可以提供多个线程来同时执行任务,主线程则可以继续执行其他的核心业务逻辑,而不必等待每一个任务完成,从而提高了程序的性能和响应速度。这样的机制减少了线程创建和销毁的开销,并可以有效地管理线程的生命周期。

⛅2.3 线程池的构成

有了上面的讲述,我们知道线程池需要有生产者线程在发布耗时任务,有消费者线程(线程池)来负责执行任务,然后就来思考一下生产者线程和消费者线程怎么进行交互?想一下身为经理的你怎么和员工交互呢?可以使用手机、口头表述等形式,在计算机中就需要一种数据结构,并且在线程池中该数据结构还对应着一种多进程环境,这里我们采用任务队列,或者有朋友会想,可不可以用其他数据结构?几乎在所有线程池开源代码中,队列是唯一的选择,也可以用和队列类似的结构如数组。

队列是一种先进先出的顺序数据结构,生产者线程push任务,消费者线程pop任务,并且队列加锁十分方便,不会占用太长时间,同时链表插入有序。

这里再来补充一个线程三状态转移图:

⛅2.4 线程数量的确定

首先,为什么要维护固定数量的线程呢?主要是为了避免线程频繁的创建和销毁。对于不同的程序来说,所适合维持的线程数量一般是固定的,主要由以下经验公式确认,对于 CPU 密集型程序,一般设置为 CPU 核心数较为合适,对于 IO 密集型程序,一般设置为两倍的 CPU 核心数较为合适。在实际的测试过程中,是在上述线程数量附近寻找最合适的数量。

🌤️3、实现线程池

⛅3.1 接口设计

在正式写代码之前,首先要确定要给用户暴露哪些接口,一般将接口函数放在头文件中(.h),将具体的实现放在.c文件中,用户并不需要知道线程池实现的细节,所以这里就给用户暴露必要的三个接口,分别是创建线程池、销毁线程池和提交任务。

thrdpool_t *thrdpool_create(int thrd_count);
void thrdpool_terminate(thrdpool_t * pool);
typedef void (*handler_pt)(void * /* ctx */);
int thrdpool_post(thrdpool_t *pool, handler_pt func, void *arg);

⛅3.2 结构体设计

这里一共需要三个结构体,分别是线程池、任务队列和任务,定义如下:

struct thrdpool_s { // 线程池
    task_queue_t *task_queue; // 阻塞队列,让线程状态变更的操作应该封装在队列内部
    atomic_int quit; // 是否退出的标记,原子变量
    int thrd_count; // 线程数量
    pthread_t *threads; // 线程 
};

这里要特别注意职责的划分,有不少开源线程池的实现把互斥锁这些本应该放在队列的变量放在了线程池中,应该要让线程状态变更的操作封装在队列内部,线程池结构体中包含了指向任务队列的指针,队列结构体定义如下:

typedef struct task_queue_s { // 队列
    void *head;
    void **tail; 
    int block;
    spinlock_t lock; // 可以单独加锁
    pthread_mutex_t mutex; // 也可以复用互斥锁
    pthread_cond_t cond;
} task_queue_t;

任务结构体定义如下:

typedef struct task_s { // 任务
    void *next;
    handler_pt func;
    void *arg;
} task_t;

总体结构体的关系如图所示:

队列的head指针指向队头任务,tail指针指向队尾任务的next指针,要注意这里void **(指向指针的指针)的用法,不少优秀的开源代码都喜欢这样设计,可以简化后续链表操作。其中生产者从队列头部添加任务,消费者从队列尾部取出任务。

⛅3.3 关键函数书写

🌥️3.3.1 __threads_create & __threads_terminate

首先来看看创建线程的代码,像这种复杂资源创建要使用对称式的接口设计,有创建(__threads_create )就有销毁(__threads_terminate),并且建议采用回滚式的代码书写方式,先假设成功,然后回滚,代码呈现如下图的结构:

static int __threads_create(thrdpool_t *pool, size_t thrd_count) {
    pthread_attr_t attr;
	int ret;
    ret = pthread_attr_init(&attr);
    if (ret == 0) { // 1、假设pthread_attr_init成功
        pool->threads = (pthread_t *)malloc(sizeof(pthread_t) * thrd_count);
        if (pool->threads) { // 2、假设malloc成功
            int i = 0;
            for (; i < thrd_count; i++) {
                if (pthread_create(&pool->threads[i], &attr, __thrdpool_worker, pool) != 0) {
                    break;
                }
            }
            pool->thrd_count = i;
            pthread_attr_destroy(&attr);
            if (i == thrd_count) // 3、假设pthread_create成功
                return 0; // 最终成功返回,下面都是回滚代码
            __threads_terminate(pool); // 3、回滚
            free(pool->threads); // 2、回滚
        }
        ret = -1; // 1、回滚
    }
    return ret; 
}

数字对应的地方为代码书写顺序,可以看到回滚式代码书写还是非常优雅的。

销毁线程的代码如下:

static void __threads_terminate(thrdpool_t * pool) {
    atomic_store(&pool->quit, 1);
    __nonblock(pool->task_queue);
    int i;
    for (i=0; i<pool->thrd_count; i++) {
        pthread_join(pool->threads[i], NULL);
    }
}
🌥️3.3.2 __taskqueue_create

然后来看看任务队列的创建函数,同样使用回滚式代码书写方式

static task_queue_t *__taskqueue_create() {
    int ret;
    task_queue_t *queue = (task_queue_t *)malloc(sizeof(task_queue_t));
    if (queue) { // 1、假设malloc成功
        ret = pthread_mutex_init(&queue->mutex, NULL);
        if (ret == 0) { // 2、假设pthread_mutex_init成功
            ret = pthread_cond_init(&queue->cond, NULL);
            if (ret == 0) { // 3、假设pthread_cond_init成功
                spinlock_init(&queue->lock);
                queue->head = NULL;
                queue->tail = &queue->head;
                queue->block = 1;
                return queue; // 成功返回
            }
            pthread_mutex_destroy(&queue->mutex); // 2、回滚  
        }
        free(queue); // 1、回滚
    }
    return NULL;
}
🌥️3.3.3 __add_task & __pop_task

最后来看看添加任务和取任务的函数,这两个流程逻辑函数的书写不适合用回滚式书写方式,适合用防御式代码书写方式,即先把不满足条件的 return 掉,然后写满足条件的,这两个操作函数需要有较好的链表算法能力,并且在这里会体会到之前 void **tail 的好处,__add_task 函数如下:

static inline void __add_task(task_queue_t *queue, void *task) {
    // 不限定任务类型,只要该任务的结构起始内存是一个用于链接下一个节点的指针
    void **link = (void**)task;
    *link = NULL; // task->next = NULL; 不限定链接方式 

    spinlock_lock(&queue->lock);
    *queue->tail /* 等价于 queue->tail->next */ = link;
    queue->tail = link;
    spinlock_unlock(&queue->lock);
    pthread_cond_signal(&queue->cond);
}

可以看到使用了指针的指针之后,代码清爽简洁,然后没有用 void **tail,书写的代码量会变大,并且这种方式不会用到 next 指针,不限定链接方式,适用性更强。

__pop_task 函数如下:

static inline void * __pop_task(task_queue_t *queue) {
    spinlock_lock(&queue->lock);
    if (queue->head == NULL) {
        spinlock_unlock(&queue->lock);
        return NULL;
    }
    task_t *task;
    task = queue->head;

    void **link = (void**)task;
    queue->head = *link;

    if (queue->head == NULL) {
        queue->tail = &queue->head;
    }
    spinlock_unlock(&queue->lock);
    return task;
}

🪐4. 结束语

本文较为简单的阐述了线程池的实现方法,后续还会完善代码,并用gtest做完整的测试,本人目前还是个在校生,还比较小白,也刚刚开始写 CSDN 博客不久,可能写的也不是很好,如果有任何疑问或者发现我有哪里写的不对的地方,欢迎大家留言告诉我!我都会一一改正的。

如果觉得文章对你有帮助的话,还请点赞,关注,收藏支持小占!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值