线程池实现计数

线程池实现计数

​ 简介:结合学习过的有关锁的知识实现线程池,将多线程场景进行划分,使用统一的线程池管理。

引言

​ 在单服务器多用户的情景下,每个用户的请求在服务器上都需要一个线程。若出现某一时刻大量请求,会导致内存耗尽,程序崩溃;对于某些长作业,需要长时间占用某个线程,一般的即来即用的分配方式必然出现问题。为解决这些问题,同时实现任务和执行分离,引入线程池。将线程和用户请求(任务)分为两个队列,使用另外的结构进行管理。本文从多线程高并发场景出发,提出了解决该问题的线程池方法,并使用C语言实现并编译测试运行。

原理及架构

​ 在高并发情况下,将任务与执行分别使用不同的队列组织,在本文中使用双向链表。使用线程池结构体,存放任务队列和执行队列的队列指针,以及给线程加锁的互斥锁及解放互斥资源的条件变量。

​ 程序开始时,主线程对对所有进程初始化,并进入其对应回调函数,使用条件变量使得每一个线程都在等待任务,而主线程仍然运行下去。当有任务传入,将任务放入任务队列中,此时线程池检测到任务队列不为空,将分配一个线程处理任务。直到所有任务运行结束销毁线程。

代码实现

  • 分别定义任务、线程、线程池结构体。

​ 任务结构体包含该任务的方法,执行任务需要的数据,以及前向后向任务指针,因为每个任务需要一个线程执行,所以需要一个线程作为参数,因为未知将要运行的函数,使用函数指针代替,使得管理与执行分离。

//定义任务结构体
struct nTask{
    void (*task_func)(struct nTask* task);//实现任务的方法
    void* user_data;//用于实现任务的数据

    struct nTask* prev;
    struct nTask* next;
};

​ 线程结构体,需要该线程的唯一标识符、当前线程是否结束等待状态、以及对应的线程池入口指针以便于通过线程池获取任务相关参数。

//定义线程
struct nWorker{
    pthread_t threadid;//线程id用于操纵线程
    int terminate;//表示当前线程是否退出等待
    struct nManager* manager;

    struct nWorker* prev;
    struct nWorker* next;
};

​ 定义线程池结构体,用于管理,其必须有任务队列和线程队列的指针,以及用于互斥操作的互斥变量。

//定义管理结构体
typedef struct nManager{
    struct nTask* tasks;
    struct nWorker* workers;

    pthread_mutex_t mutex;
    pthread_cond_t cond;//配合条件变量来做到对mutex的操作
}ThreadPool;

​ 对于线程的操作,必须实现以下几个函数:创建线程池,每个线程对应的回调函数,线程的销毁,任务队列增加任务。

  • 创建线程池

​ 线程池作为任务队列和线程队列的管理结构,在初始化时需要将相关的属性值赋予初值,对于管理结构中的任务队列,使用任务队列增加方法实现初始化。struct nWorker* workers即线程队列,初始化需要根据作为参数传来的线程数初始化任务队列。

//按照提供的任务数量建立线程
    int i = 0;
    for(i = 0;i < numWorkers;i++){
        struct nWorker* worker =(struct nWorker*) malloc(sizeof(struct nWorker));
        if(worker == NULL){
            perror("malloc");
            return -2;
        }
        //堆上分配的数据要使用memset初始化值
        memset(worker,0,sizeof(struct nWorker));
        worker->manager = pool;//便于后续访问相关属性

        int ret = pthread_create(&worker->threadid,NULL,nThreadPoolCallback,worker);
        if(ret){
            perror("pthread_create");
            free(worker);
            return -3;
        }

        //将线程放入线程池中
        LIST_INSERT(worker,pool->workers);
    }

​ 首先使用malloc给线程结构体分配空间,因为malloc不会初始化内存,其分配的内存中有未定义数据,即需要使用memset将指定内存空间按照指定结构体的大小初始化内存。将线程结构体中的线程池指针指向线程池,使用pthread_create方法,初始化线程。

int ret = pthread_create(&worker->threadid,NULL,nThreadPoolCallback,worker);

​ 该方法自动填充worker结构体中的threadid,作为线程的唯一标识。nThreadPoolCallback,作为线程启动后的入口函数,worker作为入口函数的参数,此处传入worker是为了在回调函数中进一步对该线程的属性进行操作。最后将该线程插入到线程队列中。

  • 线程回调函数

​ 被创建的线程自动进入回调函数。在回调函数中线程一直判断任务队列中是否有任务,在等待的过程中应该对线程加锁,代表只有当前线程可以操作临界资源。使用pthread_cond_wait()方法,通过变量pthread_cond_t使当前线程在任务队列空时进入等待状态,其他线程调用pthread_cond_signalpthread_cond-broadcast该线程被唤醒。

while(1){

        //线程在等待的时候 应该加锁
        pthread_mutex_lock(&worker->manager->mutex);
        while(worker->manager->tasks == NULL){//若当前任务队列为空 循环等待
            pthread_cond_wait(&worker->manager->cond,&worker->manager->mutex);
        }

            pthread_mutex_unlock(&worker->manager->mutex);
    }

​ 当任务队列中有任务,即第二个while()循环不满足。从任务队列中取出任务,同时删除任务队列中对应的任务,执行任务自己的方法。

while(1){

        //线程在等待的时候 应该加锁
        pthread_mutex_lock(&worker->manager->mutex);
        while(worker->manager->tasks == NULL){//若当前任务队列为空 循环等待
            pthread_cond_wait(&worker->manager->cond,&worker->manager->mutex);
        }

            pthread_mutex_unlock(&worker->manager->mutex);
    
    	//若此时任务队列中有任务
        struct nTask* task = worker->manager->tasks;//取到任务队列的头指针
        LIST_REMOVE(task,worker->manager->tasks);//删除任务队列中的第一个任务
        //线程完成任务 释放资源
        pthread_mutex_unlock(&worker->manager->mutex);

        //调用task中的方法
        //在此处将task传入 在task的任务中释放task
        task->task_func(task);
    }

​ 值得注意的是,外层的while()循环似乎一直没有退出,所以需要在每一while()循环中设置退出的条件,即当线程结束时退出循环。

while(1){

        //线程在等待的时候 应该加锁
        pthread_mutex_lock(&worker->manager->mutex);
        while(worker->manager->tasks == NULL){//若当前任务队列为空 循环等待
            /*****判断当前线程是否结束 结束则退出*****/
            if(worker->terminate) break;
            /*****判断当前线程是否结束 结束则退出*****/
            pthread_cond_wait(&worker->manager->cond,&worker->manager->mutex);
        }
		
   		/*****判断当前线程是否结束 结束则退出*****/
        if(worker->terminate){
            pthread_mutex_unlock(&worker->manager->mutex);
            break;
        }
    	/*****判断当前线程是否结束 结束则退出*****/
    
        //若此时任务队列中有任务
        struct nTask* task = worker->manager->tasks;//取到任务队列的头指针
        LIST_REMOVE(task,worker->manager->tasks);//删除任务队列中的第一个任务
        //线程完成任务 释放资源
        pthread_mutex_unlock(&worker->manager->mutex);

        //调用task中的方法
        //在此处将task传入 在task的任务中释放task
        task->task_func(task);
    }

    //循环结束 释放线程
    free(worker);
  • 线程销毁函数

​ 通过线程结构体中的terminate值来标识当前线程已经结束需要销毁,随后广播将所有等待状态中的线程都唤醒,配合线程的结束terminate释放掉所有的线程。

//当要进行线程销毁时 只需要把线程的终标识置为1
    struct nWorker* worker = NULL;
    for(worker = pool->workers;worker != NULL;worker = worker->next)
        worker->terminate = 1;

    //广播将所有条件等待的信号释放
    //此处加锁就是防止出现死锁现象
    pthread_mutex_lock(&pool->mutex);
    pthread_cond_broadcast(&pool->cond);//唤醒所有条件变量
    pthread_mutex_unlock(&pool->mutex);

    //因为在回调函数中线程已经释放 分别将线程和任务队列置为空
    pool->workers = NULL;
    pool->tasks = NULL;

​ 值得注意的是,在唤醒所有线程的时候,要对该操作进行加锁,避免某处线程已被提前等待造成死锁现象。

  • 任务队列插入函数

​ 在主线程中调用本函数,往任务队列中放入一个函数,同时唤醒一个线程,同理该操作也需要加锁处理。

//往任务队列中加入任务
    pthread_mutex_lock(&pool->mutex);

    LIST_INSERT(task,pool->tasks);
    //通知线程有任务加入 唤醒一个线程
    pthread_cond_signal(&pool->cond);

    pthread_mutex_unlock(&pool->mutex);

​ 最后,实现任务对应的函数,在本文中打印传入的参数,执行解释释放空间。

void task_entry(struct nTask* task){
        //struct nTask* task = (struct nTask*)arg;
        int idx = *(int*)task->user_data;

        printf("idx: %d\n",idx);

        free(task->user_data);
        free(task);
    }
  • 主函数测试

​ 在主函数,即主线程中创建并初始化线程nThreadPoolCreate(&pool,THREADPOOL_INIT_COUNT)

//初始化线程 此时所有的线程就进入回调函数 进行等待
        nThreadPoolCreate(&pool,THREADPOOL_INIT_COUNT);

​ 此时任务队列为空,创建任务并插入任务队列中。

	int i = 0;
        for(i = 0;i < TASK_INIT_SIZE;i++){
            struct nTask* task = (struct nTask*)malloc(sizeof(struct nTask));
            if(task == NULL){
                perror("malloc");
                exit(1);
            }
            memset(task,0,sizeof(struct nTask));

            //对于每个task调用其对应的方法
            task->task_func = task_entry;
            //对用户数据赋值
            task->user_data = malloc(sizeof(int));
            *(int*)task->user_data = i;

            //将初始化完成的任务加入任务队列中
            nThreadPoolPushTask(&pool,task);
        }
  • 调试工具gdb安装与使用

​ 在linux环境下常用gdb作为命令行调试工具。使用管理员用户安装gdb

sudo apt update
sudo apt install gdb

​ 在编译程序时,在编译命令中加入-g即可进行代码的调试。对编写好的程序threadpool.c使用命令编译。

gcc -o threadpool threadpool.c -lpthread -g

​ 在命令行敲入gdb ./编译好的可执行文件即进入gdb调试界面。

​ 输入r即运行程序直到程序崩溃或断点,b 行数对程序打断点,c继续执行到下一个断点。该方法适用于小型项目,结构清晰,一般在判断或循环入口进行断点测试。

问题与解决方案

问题一:如何理解线程死锁,和发生死锁的条件;若发生死锁如何解决?

​ 死锁是当多个线程在执行过程中对一个互斥资源操作不当而发生的互相等待的现象,进而使得所有的线程都无法运行。

​ 死锁发生必须满足四个条件:有互斥资源、线程对互斥资源占有并等待、该被占有的线程只有当运行结束之后才能被其他线程使用不能强行夺取,存在线程一与线程二之间的循环等待。

​ 解决死锁即是分别从上述四个角度分别破坏死锁发生的条件。

1. 互斥资源:一次性申请所有的资源,放弃掉所有的并发性;对每个资源设置超时,若超过这个时间释放掉该资源。
2. 不可抢占:使得互斥资源可以通过一定方法被强制剥夺。
3. 循环等待:固定加锁顺序,例如对`A`先加锁`B`后加锁,则释放时一定要先释放`B`后释放`A`。

总结

​ 多线程编程可以避免对互斥资源的频繁申请而造成的程序崩溃问题,因此有必要对线程的分配进行管理。通过线程池将线程的分配效率最大化,提高程序运行的效率。

posted @ 2025-04-25 22:05  +_+0526  阅读(27)  评论(0)    收藏  举报