目录
前言
线程池是高并发编程中常用的一种手段,今天我们来学习一下线程池如何设计以及如何使用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、锁
锁通常是导致并发服务器性能低下的一个因素。针对锁的代码,不仅不处理任何业务逻辑,还需要访问内核资源。除非没有更好的方案,否则应尽量避免锁,或者使用更小粒度的锁,如读写锁。