如果该部分阅读有障碍可以先看下面的各部分结构定义及联系。
初始化
-
读取配置。
-
绑定地址与监听(LISTENQ = 1024)。
-
创建epoll并添加监听描述符。
-
初始化线程池:
-
初始化线程池各参数。
-
创建线程。
-
调用threadpool_worker函数(每个worker线程循环执行以下几步):
-
每个worker线程进入线程池时都先对线程池互斥锁。
-
通过条件变量判断是否有待处理任务,只要没有任务则阻塞并打开互斥锁。
-
如有任务(通过threadpool_add添加),则取出队列中第一个节点。
-
对线程池开锁,之后其他线程可访问线程池。
-
执行每个任务对应的操作并删除该任务。
注:这里先开锁后执行对应任务,因为在此情况下已经完成对该worker线程任务的分配,不可能将其他任务再分配给此线程,其他线程的并发操作不会对其有任何影响。另外,如果将开锁过程置于任务执行之后,则线程池会被加锁至前一个任务执行结束,整个线程池处于被锁定状态。任务会退化成顺序执行,所以这里先开锁非常重要。
-
-
-
初始化定时器:
-
初始化定时器优先队列。
-
更新当前时间。
-
任务处理
-
请求连接(事件:有连接请求):
-
接受连接并返回连接描述符。
-
向epoll中注册新描述符。
-
新增时间戳信息,新时间戳被加入到优先队列:
- 最早超时节点在优先队列头。
-
-
响应任务(事件:请求到服务器):
-
将新建的任务加入到线程池中,处理函数为do_request,参数为请求结构(tk_request_t)。
-
完成tk_task_t节点初始化,任务数量queue_size加1。
-
将新任务挂在线程池task队列。
-
调用pthread_cond_signal激活一个等待该条件的线程(只激活一个,避免惊群效应)。在初始化时,线程池中多个线程因为任务队列为空,所以调用pthread_cond_wait被休眠。一旦有新任务被加入线程池,条件变量条件(任务队列非空)即可被满足,此时会唤醒工作线程来处理该任务。
-
-
某一具体任务被添加进task队列中就会激活一个worker线程去处理请求,入口函数为do_request。
-
删除该请求定时器:
因为该任务已经响应,且未超时,不再需要定时器去处理该连接的超时情况,接下来在定时器队列中删除该请求的定时器。这也就是为什么tk_request_t和tk_timer_t结构中互有指向彼此指针的原因。
-
读取用户请求:
-
数据读取发生错误(errno非EAGAIN)则关闭连接,释放相应数据结构。
-
若为errno为EAGAIN:
-
跳出循环(外层使用循环主要是为了触发EAGAIN条件)。
-
将该请求重设定时器并与于epoll中重新注册。
-
结束do_request函数,释放worker线程控制权(TCP未断开)。
-
-
-
解析请求并填充tk_request_t各变量。
-
获取用户请求文件名,判断默认目录下该文件权限等基本信息:
- 如果有错误信息返回错误响应体。
-
返回响应体:
-
若为长连接,则不关闭TCP连接并重新回到循环中。若为短连接则断开连接。
关于线程池首先需要了解TKeed中线程池结构体的定义:
typedef struct threadpool{
pthread_mutex_t lock; // 互斥锁
pthread_cond_t cond; // 条件变量
pthread_t *threads; // 线程
tk_task_t *head; // 任务链表
int thread_count; // 线程数
int queue_size; // 任务队列长
int shutdown; // 关机方式
int started;
}tk_threadpool_t;
除了线程的锁机制,线程池还有指向任务队列头节点的head指针。
-
互斥锁lock
互斥锁lock在每次访问临界区时都需要先检查互斥锁,第一个执行pthread_mutex_lock()的线程会得到互斥锁,其他线程会一直等待直到第一个线程执行pthread_mutex_unlock()才会释放。为了保证每个任务的原子性(同一个task不会被多个线程获取并执行),线程池中线程数虽然大于1,但某一具体时刻只能有一个线程在取任务。(对应生产者 - 消费者模型中只能有一个消费者线程取任务)
-
条件变量cond
多线程情况下,如果某个线程已经进入临界区,其他线程会一直检查是否已经开锁,但这是在浪费时间和系统资源,于是就设置条件变量cond来解决这种忙等的问题。某个线程一旦监测到有线程已经得到互斥锁就是进程进入休眠状态,一旦满足条件就会唤醒休眠的线程,之后被唤醒的线程再去检查互斥锁。
-
线程数组threads
初始化时根据配置文件中设定的worker线程数分配线程缓冲池。
-
任务列表头head
所有任务以链表形式组织,head指针指向任务队列的首节点。具体每个任务节点的变量会在下面分析到tk_task_t结构时补充。
-
线程数thread_count
初始化时从用户配置中获得,用于标识线程池中worker线程数量。
-
任务队列大小queue_size
用于标识当前未处理的任务数,设置其目的是为了快速判断是否任务队列已经为空。(也可以判断通过"head->next == NULL"来判断,但不够直观)
-
关机方式shutdown
有立即关机(immediate_shutdown)和平滑关机(graceful_shutdown)两种模式。
上述线程池中,其他均为"原子变量",只有head变量是tk_task_t是复合型的,tk_task_t的定义如下:
typedef struct tk_task{
void (*func)(void *); // 处理函数的函数指针
void *arg; // 函数变量
struct tk_task *next; // 任务链表(下一节点指针)
}tk_task_t;
这里使用无类型函数指针和无类型变量指针也是为了程序扩展性。
-
处理函数指针func
每个任务创建时设置函数指针func,func为该任务的执行函数。
-
函数参数指针arg
函数参数指针指向任务处理函数func的变量。
-
链表下一节点指针next
所有新增的任务以链表形式组织,next指向下一个任务节点。
所有HTTP请求解析的参数都以tk_request_t结构定义,上述arg指针会被强制转为tk_request_t类型的指针
typedef struct tk_http_request{
char* root; // 配置目录
int fd; // 描述符(监听、连接)
int epoll_fd; // epoll描述符
char buff[MAX_BUF]; // 用户缓冲
int method; // 请求方法
int state; // 请求头解析状态
void *request_start;
void *method_end;
void *uri_start;
void *uri_end;
void *path_start;
void *path_end;
void *timer; // 指向时间戳结构
.....
.....
}tk_http_request_t;
本结构用作任务中用户请求处理函数的参数,之后timer节点为非原子结构。
-
默认文件目录指针root
root指向默认文件目录。该目录在读取配置后被设置。
-
连接描述符fd
服务器接受请求创建连接后返回的客户端连接描述符,该描述符在向客户机发回响应文件时候会被用到。
-
epoll描述符epoll_fd
epoll描述符。
-
用户缓冲buff
用户请求的到达客户端后,需要从内核缓冲区读出,读到用户缓冲区buff中。请求行等均会被先读入buff中,之后再进行解析操作。
-
用户请求方法method
解析buff中用户请求行时填入method,如GET、POST等。
-
解析状态state
使用状态机解析用户请求行,state用于记录每个解析状态。
-
解析指针
解析用户请求行时,设置各指针指向buff中某一部分收尾部位,比如uri_start指向uri首地址,uri_end指向uri最后一个字节。设置指针方式是为了操作方便。
-
时间结构timer
用于各个请求时间相关数据,具体描述下面会说。
该结构记录各请求时间戳信息。
typedef struct tk_timer{
size_t key; // 标记超时时间
int deleted; // 标记是否被删除
timer_handler_pt handler; // 超时处理
tk_http_request_t *request; // 指向对应的request请求
} tk_timer_t;
-
超时时间key
添加请求时先更新当前时间,key的值为当前时间加上超时时间(默认timeout为500ms)。
-
标记是否被删除deleted
标记该请求是否需要被关闭。每次删除时并不是直接删除该请求,而是先置deleted为1,之后在检查超时时会统一处理,实现惰性删除。
-
超时回调函数handler
发生超时需要处理时,处理方法为调用回调函数handler。这里这么处理也是为了扩展性考虑。
-
请求节点指针request
指向tk_thread_t节点的指针,每个timer和请求节点一一对应。