线程池设计与实现

目录

前言

一、什么是线程池

二、为什么需要线程池

三、线程池的应用场景

四、如何设计线程池

五、线程池的C语言实现

5.1、线程池的三个基本组件

5.2、队列的添加,使用头插法插入链表

5.3、队列的删除

5.4、创建线程池

5.5、销毁线程池

5.6、向线程池中添加任务

5.7、线程处理函数

六、线程池优化

七、线程池应该设置多少个线程

八、如何对线程池进行监控

8.1、线程池应该包含哪些状态信息?

8.2、如果队列任务数量达到上限后怎么处理?

8.3、由谁来对线程池进行管理?

8.4、如何动态缩放线程池大小?

九、提升服务器性能的手段

9.1、硬件

9.2、软件

9.2.1、池式组件

9.2.2、高效的事件处理模式和并发模式

9.2.3、避免数据复制

9.2.4、上下文切换

9.2.5、锁


前言

线程池是高并发编程中常用的一种手段,今天我们来学习一下线程池如何设计以及如何使用C语言来实现它。


一、什么是线程池

线程池就是管理线程的池子,一组线程的集合。在程序运行初期预先创建好线程,放在一个容器中(池子)统一管理,当有任务要处理时,从池子中拿一个线程来处理任务,处理完成后,线程并不会被销毁,而是继续等待下一个任务。

二、为什么需要线程池

我们先来看一下为什么需要多线程。

不使用多线程的服务器模型

服务器运行在主线程中,使用listen监听51300端口,向epoll注册serverfd的EPOLLIN事件,然后循环阻塞在epoll_wait上等待事件到来。当有客户端请求到达时,epoll_wait阻塞返回,循环处理有事件触发的描述符。如果描述符是serverfd,说明有新的客户端连接到达,通过accept获取到客户端sokcet描述符clientfd,然后再将clientfd注册到epoll中;

如果描述符是clientfd,对clientfd进行recv、send操作,处理数据。

处理完成后,线程继续阻塞到epoll_wait上等待事件的到来。

伪代码如下:

epoll_create();
epoll_ctl(ADD,EPOLLIN,server_fd);
void eventloop()
{
    while(1)
    {
        int ReadySize = epoll_wait();
        for(int i = 0; i < ReadySize; i++)
        {
            if(eventfd == server_fd)
            {
                client_fd = accept();
                epoll_ctl(ADD,EPOLLIN,client_fd);
            }
            else
            {
                do{
                    recv(eventfd);
                }while(condition);
                process();
                send(eventfd);
            }   
        }
    }
    close(epoll_fd);
}

思考①:这种模式有什么缺点?

现在我们来分析下这种模式有什么缺点。

上边所有的操作都是在同一个线程里边顺序执行的,epoll_wait对IO事件监听、clientfd的recv、send操作是顺序执行的,当执行recv、send操作时,下一个IO事件是无法处理的,也就是说,必须把本次epoll_wait返回的所有事件都处理完成后,才能进行下一次的事件响应,如果在IO处理的中途有新的请求到达,此时是不能被立即处理的。当然,数据处理足够快的话,这种方式也没有问题,但是,如果客户端给服务器发送的数据量比较大,比如说是一张1080p的图片,网络传输完成需要100ms,服务器recv阻塞循环接收,那么接收完成就需要耗时100ms。这100毫秒内,其它客户端的数据请求就不会被服务器接收,对于TCP,如果客户端发送的数据比较频繁,很快服务器的接收缓冲区就满了,客户端的发送缓冲区也满了,这就会导致数据推送延迟以及数据丢失。服务器并发量非常低。

要解决以上问题,我们可以引入多线程模型

这种模式下,epoll运行在主线程,当有客户端请求到达时,给每一个客户端另分配一个线程,后续的处理全部在这个线程中完成,主线程只处理客户端的连接,即accept处理,不再做IO操作及数据处理。

此时,并发量是提升了,同时有10个,100个客户端连接上来,都能被处理。

伪代码如下,仅处理EPOLLIN事件,所以代码中不做事件区分:

void *taskFun(void* arg)
{
    do{
        recv(eventfd);
        process();
        send(eventfd);
    }while(condition);
}

void eventloop()
{
    epoll_create();
    epoll_ctl(ADD,EPOLLIN,server_fd);
    while(1)
    {
        int ReadySize = epoll_wait();
        for(int i = 0; i < ReadySize; i++)
        {
            if(eventfd == server_fd)
            {
                client_fd = accept();
                pthread_create(taskFun,client_fd);
            }   
        }
    }
    close(epoll_fd);
}

思考②:这种模式有没有什么缺点呢?

现在我们来分析下多线程的模式有什么缺点。

首先,第一点,客户端连接和离开,会有线程的创建和销毁操作,有性能开销,拿内存资源来说,在linux上,默认每创建一个线程大概占用10MB的调用栈(可以修改),如果有100个客户端同时连接上来,就需要开启100个线程,大概占用内存1GB,内存消耗比较大。

第二点,大量线程的调度切换需要占用CPU资源,线程上下文的保存和恢复,用户态和内核态的转换,CPU上下文的切换都需要CPU来完成。

第三点,线程的数量是有限制的,不可能无限制的创建下去。

要避免过度消耗系统资源,我们可以引入线程池

线程池在系统启动时即创建大量空闲的线程,程序将一个任务传给线程池,线程池就会启动一条线程来执行这个任务,执行结束以后,该线程并不会死亡,而是再次返回线程池中成为空闲状态,等待执行下一个任务。

伪代码如下:

void eventloop()
{
    epoll_create();
    epoll_ctl(ADD,EPOLLIN,server_fd);
    while(1)
    {
        int ReadySize = epoll_wait();
        for(int i = 0; i < ReadySize; i++)
        {
            if(eventfd == server_fd)
            {
                client_fd = accept();
                epoll_ctl(ADD,EPOLLIN,client_fd);
            }   
            else
            {
                task = client_fd;
                ThreadPoolAddJob(task);
            }
    }
    close(epoll_fd);
}

思考③:以上伪代码流程有什么缺点?

同一个fd可能被多个线程处理。上下文关联的情况下,需要确保一个fd只能被一个线程处理。

三、线程池的应用场景

上边我们 引入了线程池,那么接下来我们再分析下线程池到底有哪些应用场景。

1、对网络IO进行读写。比如我们上边的示例。

2、对磁盘IO进行读写。我们知道,磁盘的读写速度比内存读写速度慢很多倍,如果我们每次处理客户端请求都去进行磁盘操作,很可能会导致性能瓶颈,比如日志记录、数据库操作。此时,我们就需要使用线程池来进行异步解耦,即实际的磁盘操作应该在线程中异步落盘。

3、快速响应用户请求;拆解用户任务,多线程并发执行。(可以不使用队列,不排队,立即处理)

4、快速处理批量任务;(用户可能不是很关心响应速度,尽可能在单位时间内处理更多任务,可以使用队列)。

四、如何设计线程池

我们先来分析下,线程池应该包含哪些东西

第一点,我们使用多线程就是为了异步高效的处理任务,所以,肯定有一个任务队列。

第二点,处理任务的线程,构成一个执行队列,即工作线程组;

第三点,执行队列要去处理任务,必须知道任务队列是否有任务,所以,我们需要进行线程同步,即要有一个条件变量。

第四点,多个线程去操作同一个任务队列,必然产生竞争,所以,需要进行加锁,即要有一个互斥锁。

我们再接着分析下,任务都应该包含那些东西

第一点,任务肯定有需要被处理的数据。

第二点,线程池本身不应该关心任务具体应该怎么处理,且不同的任务,处理方式可能不一样,所以,我们需要提供一个回调函数,通过回调函数来处理任务。

第三点,因为下边我们计划使用C语言来实现线程池,队列使用双向链表,任务队列中,每一个任务需要知道自己的前驱和后继任务。(这点非必须)

再来看一下工作线程都应该包含那些东西

第一点,线程自己的线程ID。

第二点,执行单元需要知道自己属于哪一个线程池,需要用到互斥锁及条件变量。

第三点,因为下边我们计划使用C语言来实现线程池,队列使用双向链表,执行队列中,每一个执行单元需要知道自己的前驱和后继。(这点非必须)

我们把上边三部分关联起来,至此就分析完了线程池的基本组成单元。

接下来我们看一下线程池的基本操作

1、创建线程池。

2、销毁线程池。

3、向线程池中添加任务。

4、实现线程的执行函数,处理任务。

因为任务队列和执行队列涉及到动态变化,所以我们还需要实现添加和删除操作。为了统一处理任务队列和执行队列,我们采用宏定义来实现。

五、线程池的C语言实现

5.1、线程池的三个基本组件

执行单元

typedef struct Worker {
    pthread_t thread;
    int terminate;
    struct ThreadPool *threadPool;
    struct Worker *prev;
    struct Worker *next;
} Worker;

任务单元

typedef struct Job {
    void (*job_function)(struct Job *job);
    void *user_data;
    struct Job *prev;
    struct Job *next;
} Job;

线程池

typedef struct ThreadPool {
    struct Worker *workers;
    struct Job *waiting_jobs;
    pthread_mutex_t jobs_mtx;
    pthread_cond_t jobs_cond;
} ThreadPool;

5.2、队列的添加使用头插法插入链表

#define LINKLIST_ADD(item, list) do {   \
    item->prev = NULL;              \
    item->next = list;              \
    list = item;                    \
} while(0)

思考④:使用头插法,会不会导致最先到的任务一直得不到执行?

5.3、队列的删除

#define LINKLIST_REMOVE(item, list) do {                        \
    if (item->prev != NULL) item->prev->next = item->next;  \
    if (item->next != NULL) item->next->prev = item->prev;  \
    if (list == item) list = item->next;                    \
    item->prev = item->next = NULL;                         \
} while(0)

5.4、创建线程池

int threadPoolCreate(ThreadPool *threadPool, int numWorkers) {

    if (numWorkers < 1) numWorkers = 1;
    memset(threadPool, 0, sizeof(ThreadPool));
    
    pthread_cond_t blank_cond = PTHREAD_COND_INITIALIZER;
    memcpy(&threadPool->jobs_cond, &blank_cond, sizeof(threadPool->jobs_cond));
    
    pthread_mutex_t blank_mutex = PTHREAD_MUTEX_INITIALIZER;
    memcpy(&threadPool->jobs_mtx, &blank_mutex, sizeof(threadPool->jobs_mtx));

    int i = 0;
    for (i = 0;i < numWorkers;i ++) {
        Worker *worker = (Worker*)malloc(sizeof(Worker));
        if (worker == NULL) {
            perror("malloc");
            return 1;
        }

        memset(worker, 0, sizeof(Worker));
        worker->threadPool = threadPool;

        int ret = pthread_create(&worker->thread, NULL, workerThread, (void *)worker);
        if (ret) {
            
            perror("pthread_create");
            free(worker);

            return 1;
        }

        LINKLIST_ADD(worker, worker->threadPool->workers);
    }

    return 0;
}

5.5、销毁线程池

void threadPoolShutdown(ThreadPool *threadPool) {
    Worker *worker = NULL;

    for (worker = threadPool->workers;worker != NULL;worker = worker->next) {
        worker->terminate = 1;
    }

    pthread_mutex_lock(&threadPool->jobs_mtx);

    threadPool->workers = NULL;
    threadPool->waiting_jobs = NULL;

    pthread_cond_broadcast(&threadPool->jobs_cond);

    pthread_mutex_unlock(&threadPool->jobs_mtx);
    
}

思考⑤:

退出线程为什么要使用terminate标志而不是直接关闭呢?比如使用pthread_exit(tid)。

能否在分离模式下调用pthread_join ?

5.6、向线程池中添加任务

void ThreadPoolAddJob(ThreadPool *threadPool, Job *job) {

    pthread_mutex_lock(&threadPool->jobs_mtx);

    LINKLIST_ADD(job, threadPool->waiting_jobs);
    
    pthread_cond_signal(&threadPool->jobs_cond);
    pthread_mutex_unlock(&threadPool->jobs_mtx);
    
}

5.7、线程处理函数

static void *workerThread(void *ptr) {
    Worker *worker = (Worker*)ptr;
    
    static int a = 0;
    char threadName[16] = "ThreadPool-";
    sprintf(threadName+(strlen(threadName)),"%d",a++);
    prctl(PR_SET_NAME,threadName);
    
    while (1) {
        pthread_mutex_lock(&worker->threadPool->jobs_mtx);

        while (worker->threadPool->waiting_jobs == NULL) {
            if (worker->terminate) break;
            pthread_cond_wait(&worker->threadPool->jobs_cond, &worker->threadPool->jobs_mtx);
        }

        if (worker->terminate) {
            pthread_mutex_unlock(&worker->threadPool->jobs_mtx);
            break;
        }
        
        Job *job = worker->threadPool->waiting_jobs;
        if (job != NULL) {
            LINKLIST_REMOVE(job, worker->threadPool->waiting_jobs);
        }
        
        pthread_mutex_unlock(&worker->threadPool->jobs_mtx);

        if (job == NULL) continue;

        job->job_function(job);
    }

    free(worker);
    pthread_exit(NULL);
}

思考⑥:

上边的while能否换成if ?

不能,需要注意虚假唤醒。

以上,我们就实现了一个最简单的线程池。当然,上边的线程池是不能投入实际使用的,还有很多bug,接下来我们测试一下。

测试代码如下:

#define MAX_THREAD          10
#define COUNTER_SIZE        1000

void counterCBFun(Job *job) {

    int index = *(int*)job->user_data;

    printf("index : %d, selfid : %lu\n", index, pthread_self());
    
    free(job->user_data);
    free(job);
}



int main(int argc, char *argv[]) {

    ThreadPool pool;

    threadPoolCreate(&pool, MAX_THREAD);
    
    int i = 0;
    for (i = 0;i < COUNTER_SIZE;i ++) {
        Job *job = (Job*)malloc(sizeof(Job));
        if (job == NULL) {
            perror("malloc");
            exit(1);
        }
        
        job->job_function = counterCBFun;
        job->user_data = malloc(sizeof(int));
        *(int*)job->user_data = i;

        ThreadPoolAddJob(&pool, job);
        
    }

    getchar();
    threadPoolShutdown(&pool);
    printf("\nfinished\n");

    
}

以上,任务是在测试代码中动态申请的内存,可能存在任务队列的内存泄露问题。

gcc编译运行。

[root@localhost Desktop]# gcc -o threadPool thread_pool.c –lpthread
[root@localhost Desktop]# ./threadPool

查看进程ID

[root@localhost Desktop]# ps -aux|grep threadPool

查看进程树。

[root@localhost Desktop]# pstree -p 进程ID
threadPool(10607)─┬─{threadPool}(10608)
                  ├─{threadPool}(10609)
                  ├─{threadPool}(10610)
                  ├─{threadPool}(10611)
                  ├─{threadPool}(10612)
                  ├─{threadPool}(10613)
                  ├─{threadPool}(10614)
                  ├─{threadPool}(10615)
                  ├─{threadPool}(10616)
                  └─{threadPool}(10617)

查看线程信息。

[root@localhost Desktop]# cat /proc/线程ID/status
Name:   ThreadPool-9
State:  T (stopped)
Tgid:   10607
Pid:    10608
PPid:   10236
TracerPid:      0
Uid:    0       0       0       0
Gid:    0       0       0       0
Utrace: 0
FDSize: 256
Groups: 0
VmPeak:   108668 kB
VmSize:   108668 kB
VmLck:         0 kB
VmHWM:       732 kB
VmRSS:       732 kB
VmData:   102640 kB
VmStk:        88 kB
VmExe:         8 kB
VmLib:      1800 kB
VmPTE:        76 kB
[root@localhost Desktop]# cat /proc/进程ID/task/*/status|grep Name
Name:   threadPool
Name:   ThreadPool-9
Name:   ThreadPool-8
Name:   ThreadPool-7
Name:   ThreadPool-0
Name:   ThreadPool-1
Name:   ThreadPool-2
Name:   ThreadPool-3
Name:   ThreadPool-4
Name:   ThreadPool-5
Name:   ThreadPool-6

以下为拓展部分:

六、线程池优化

1、上边的线程池在销毁后,还能继续向池中添加任务,显然是不合理的,可以增加一个线程池状态,如果是停止状态,则不允许添加任务。

2、在销毁线程池时,主线程是否需要等待工作线程关闭呢?

最好等待一下,避免工作线程使用了已经释放的资源。

3、线程池销毁时,是否需要等待队列任务执行完成?

如果需要等待(即优雅的退出),主线程阻塞在pthread_join上,线程池继续执行任务,直到任务队列为空时,线程退出。如果不等待,即线程立即退出(不是强制关闭线程,还是需要pthread_join,只不过不等待队列是否为空),此时,任务队列可能不为空,这种情况下需要注意内存泄露,任务中是否有动态申请的内存。不管是哪种退出方式,都不要在主线程中强制退出工作线程。

4、条件变量可以考虑设置两个,一个用来通知任务队列非满,一个用来通知任务队列非空。

七、线程池应该设置多少个线程

1、线程池中的线程数量应该和CPU个数差不多。

IO密集型;2N

计算密集型。N + 1

2、动态缩放线程池大小:

八、如何对线程池进行监控

8.1、线程池应该包含哪些状态信息?

最大线程数量,即线程池中的线程数量不能超过最大线程数;

最小线程数量,用户可以指定一个最小线程数量,动态缩放的范围将在最大和最小范围之间波动。

最大任务数量,即任务队列的最大容量,超过此大小就应该做特殊处理了。

实际任务数量,即当前实际有多少任务。

最小正在等待的任务数量,设置一个阈值,如果实际任务数量超过这个阈值,则可以考虑是否应该要增加线程数量。

线程池是否开启,关闭后不能再对线程池操作。

实际存活线程数量,即当前线程池中实际有多少个线程。

等待销毁的线程数量,如果只需要4个线程,但是当前线程池中有6个线程,则等待销毁线程数即为2;工作线程在执行过程中,判断这个数量是否大于0,大于的话,则终止自己,等待销毁的线程数量减1;

忙线程数量,即当前线程中,有多少线程处于繁忙状态,处理任务前,忙线程数量加1,处理完任务后,忙线程数量减1,繁忙状态的线程数量越多,说明当前任务可能比较多,可以考虑适当的增加线程数量。

8.2、如果队列任务数量达到上限后怎么处理?

添加任务时,发现任务队列已经满了,此时,有两种策略,第一种,阻塞在pthread_cond_wait上,循环等待队列非满,直到可以插入为止。第二种,线程池拒绝服务,不再接受新的任务,并通知调用者队列已满。

8.3、由谁来对线程池进行管理?

另外启动一个管理线程,在这个线程中,定时检查线程池内线程的存活状态,工作状态等,负责动态的缩放线程池大小,保证线程池始终维持在合理高效的线程数量上。

8.4、如何动态缩放线程池大小?

增加线程:实际任务数量大于最小正在等待的任务数量,且实际存活线程数量小于最大线程数量,则可以继续增加线程,比如每次增加2个线程。举例:我们设置了一个最小正在等待的任务数量60,只要任务数量在60以内,则线程池是可以处理的,如果任务数量超过60,则认为线程池可能能力不够了,可以考虑增加线程。

减少线程:忙线程数量的2倍依然小于存活线程数量,且存活线程数量大于用户设置的最小线程数量,则可以关闭线程,比如每次关闭2个线程。处理忙绿状态的线程数量特别少,说明当前任务并不多,用不了这么多线程。

以上只是示例一种处理策略,生产环境使用时,应该结合实际情况实现缩减策略。

九、提升服务器性能的手段

9.1、硬件

CPU个数、速度,内存大小等。

9.2、软件

9.2.1、池式组件

如进程池、线程池、连接池、请求池。

9.2.2、高效的事件处理模式和并发模式

如Reactor模式、Proactor模式、半同步/半异步模式、领导者/追随者模式。

9.2.3、避免数据复制

尽量避免用户空间和内核空间的数据复制。如文件发送,可以使用零拷贝sendfile函数。

用户空间内也需要避免数据复制。如进程通信,从一个进程发送大量数据到另一个进程,应该尽量使用共享内存,而不是管道或者消息队列。

9.2.4、上下文切换

需要考虑到进程切换和线程切换的系统开销。尽量避免使用远超过CPU个数的多线程,即便是IO密集型的业务处理。

9.2.5、锁

锁通常是导致并发服务器性能低下的一个因素。针对锁的代码,不仅不处理任何业务逻辑,还需要访问内核资源。除非没有更好的方案,否则应尽量避免锁,或者使用更小粒度的锁,如读写锁。

  • 0
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

秦时小

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值