【WebServer】项目总体流程
1. 项目总体流程:
- 事件处理模式:采用Epoll边沿触发的IO多路复用技术,模拟Proactor模式;
- 主线程使用epoll监听与客户端连接的socket,并在主线程中对这些socket执行数据读写;
- 读出数据后将数据放入请求队列,交给工作线程(子线程)处理业务逻辑;
- 子线程解析http请求,根据解析的结果生成不同的响应,如果请求正确则准备好数据,通过修改epoll信号通知主线程可写;
- 主线程调用函数,将数据写入socket
2. 两种事件处理模式
服务器程序通常需要处理三类事件:I/O 事件、信号及定时事件。有两种高效的事件处理模式:Reactor
和 Proactor,同步 I/O 模型通常用于实现 Reactor 模式,异步 I/O 模型通常用于实现 Proactor 模式。
Reactor模式
要求主线程(I/O处理单元)只负责监听文件描述符上是否有事件发生,有的话就立即将该事件通知工作线程(逻辑单元),将 socket 可读可写事件放入请求队列,交给工作线程处理。
除此之外,主线程不做任何其他实质性的工作。读写数据,接受新的连接,以及处理客户请求均在工作线程中完成。
使用同步 I/O(以 epoll_wait 为例)实现的 Reactor 模式的工作流程是:
-
主线程往 epoll 内核事件表中注册 socket 上的读就绪事件。
-
主线程调用 epoll_wait 等待 socket 上有数据可读。
-
当 socket 上有数据可读时, epoll_wait 通知主线程。主线程则将 socket 可读事件放入请求队列。
-
睡眠在请求队列上的某个工作线程被唤醒,它从 socket 读取数据,并处理客户请求,然后往 epoll内核事件表中注册该 socket 上的写就绪事件。
-
当主线程调用 epoll_wait 等待 socket 可写。
-
当 socket 可写时,epoll_wait 通知主线程。主线程将 socket 可写事件放入请求队列。
-
睡眠在请求队列上的某个工作线程被唤醒,它往 socket 上写入服务器处理客户请求的结果。
Reactor 模式的工作流程:
Proactor模式
Proactor 模式将所有 I/O 操作都交给主线程和内核来处理(进行读、写),工作线程仅仅负责业务逻辑。
使用异步 I/O 模型(以 aio_read 和 aio_write 为例)实现的 Proactor 模式的工作流程是:
- 主线程调用 aio_read 函数向内核注册 socket 上的读完成事件,并告诉内核用户读缓冲区的位置,
以及读操作完成时如何通知应用程序(这里以信号为例)。
-
主线程继续处理其他逻辑。
-
当 socket 上的数据被读入用户缓冲区后,内核将向应用程序发送一个信号,以通知应用程序数据已经可用。
-
应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理完客户请求后,调用 aio_write 函数向内核注册 socket 上的写完成事件,并告诉内核用户写缓冲区的位置,以及写操作完成时如何通知应用程序。
-
主线程继续处理其他逻辑。
-
当用户缓冲区的数据被写入 socket 之后,内核将向应用程序发送一个信号,以通知应用程序数据已经发送完毕。
-
应用程序预先定义好的信号处理函数选择一个工作线程来做善后处理,比如决定是否关闭 socket。
Proactor 模式的工作流程:
模拟Proactor模式(本项目采用的模式)
使用同步 I/O 方式模拟出 Proactor 模式。原理是:主线程执行数据读写操作,读写完成之后,主线程向工作线程通知这一”完成事件“。那么从工作线程的角度来看,它们就直接获得了数据读写的结果,接下来要做的只是对读写的结果进行逻辑处理。
使用同步 I/O 模型(以 epoll_wait为例)模拟出的 Proactor 模式的工作流程如下:
-
主线程往 epoll 内核事件表中注册 socket 上的读就绪事件。
-
主线程调用 epoll_wait 等待 socket 上有数据可读。
-
当 socket 上有数据可读时,epoll_wait 通知主线程。主线程从 socket 循环读取数据,直到没有更多数据可读,然后将读取到的数据封装成一个请求对象并插入请求队列。
-
睡眠在请求队列上的某个工作线程被唤醒,它获得请求对象并处理客户请求,然后往 epoll 内核事件表中注册 socket 上的写就绪事件。
-
主线程调用 epoll_wait 等待 socket 可写。
-
当 socket 可写时(即监听的文件描述符被注册了写就绪事件),epoll_wait 通知主线程。主线程往 socket 上写入服务器处理客户请求的结果。
3. epoll的两种工作模式
LT 模式 (水平触发)
假设委托内核检测读事件 -> 检测fd的读缓冲区
读缓冲区有数据 - > epoll检测到了会给用户通知
-
a.用户不读数据,数据一直在缓冲区,epoll 会一直通知
-
b.用户只读了一部分数据,epoll会通知
-
c.缓冲区的数据读完了,不通知
LT(level - triggered)是缺省的工作方式,并且同时支持 block 和 no-block socket。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的 fd 进行 IO 操作。如果你不作任何操作,内核还是会继续通知你的。
ET 模式(边沿触发)
假设委托内核检测读事件 -> 检测fd的读缓冲区
读缓冲区有数据 - > epoll检测到了会给用户通知
- a.用户不读数据,数据一致在缓冲区中,epoll下次检测的时候就不通知了
- b.用户只读了一部分数据,epoll不通知
- c.缓冲区的数据读完了,不通知
ET(edge - triggered)是高速工作方式,只支持 no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了。
但是请注意,如果一直不对这个 fd 作 IO 操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)。ET 模式在很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。epoll工作在 ET 模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
4. EPOLLONESHOT事件
即使可以使用 ET 模式,一个socket 上的某个事件还是可能被触发多次。这在并发程序中就会引起一个问题。比如一个线程在读取完某个 socket 上的数据后开始处理这些数据,而在数据的处理过程中该socket 上又有新数据可读(EPOLLIN 再次被触发),此时另外一个线程被唤醒来读取这些新的数据。于是就出现了两个线程同时操作一个 socket 的局面。
想让一个socket连接在任一时刻都只被一个线程处理,可以使用 epoll 的 EPOLLONESHOT 事件实现。
对于注册了EPOLLONESHOT 事件的文件描述符,操作系统最多触发其上注册的一个可读、可写或者异常事件,且只触发一次,除非我们使用 epoll_ctl 函数重置该文件描述符上注册的 EPOLLONESHOT 事件。这样,当一个线程在处理某个 socket 时,其他线程是不可能有机会操作该 socket 的。但反过来思考,注册了 EPOLLONESHOT 事件的 socket 一旦被某个线程处理完毕, 该线程就应该立即重置这个socket 上的 EPOLLONESHOT 事件,以确保这个 socket 下一次可读时,其 EPOLLIN 事件能被触发,进而让其他工作线程有机会继续处理这个 socket。
5. 端口复用
在C++中实现端口复用,可以使用socket编程中的setsockopt函数来设置SO_REUSEADDR选项。SO_REUSEADDR选项的作用是允许在同一端口上启动多个socket,也就是实现端口复用。数据类型为int,传1代表开启端口复用,传0则不开启。
当一个socket关闭或者服务器进程退出时,该端口就可以立即被其他socket使用,而不必等待一段时间。
以下是一个简单的示例代码:
#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
int main() {
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
std::cerr << "socket create error" << std::endl;
return -1;
}
int opt = 1;
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) {
std::cerr << "setsockopt error" << std::endl;
return -1;
}
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(8080);
if (bind(server_fd, (struct sockaddr *) &server_addr, sizeof(server_addr)) < 0) {
std::cerr << "bind error" << std::endl;
return -1;
}
if (listen(server_fd, 5) < 0) {
std::cerr << "listen error" << std::endl;
return -1;
}
while (true) {
int client_fd = accept(server_fd, nullptr, nullptr);
if (client_fd < 0) {
std::cerr << "accept error" << std::endl;
continue;
}
// do something with client_fd
close(client_fd);
}
close(server_fd);
return 0;
}
在上面的示例代码中,我们通过setsockopt函数来设置SO_REUSEADDR选项,然后通过bind函数将socket绑定到指定的IP地址和端口上。这样,即使该端口已经被其他socket占用,我们也可以成功绑定到该端口上。同时,我们使用了listen函数来监听客户端的连接请求,accept函数来接受客户端的连接。在处理完客户端的请求后,我们通过close函数关闭客户端的socket连接。
6. 主线程执行流程
1)创建线程池
threadpool< http_conn >* pool = NULL;
try {
pool = new threadpool<http_conn>;
} catch( ... ) {
return 1;
}
2)声明socket,注册epoll
//封装的http连接类,包含对连接的处理,请求解析,生成响应等功能
http_conn* users = new http_conn[ MAX_FD ];
//服务端socket
int listenfd = socket( PF_INET, SOCK_STREAM, 0 );
int ret = 0;
struct sockaddr_in address;
//INADDR_ANY是一个IPv4地址,其值为0.0.0.0
//IP地址设置为INADDR_ANY,端口号设置为一个具体的值,以便让socket监听该端口号上的所有网络接口
address.sin_addr.s_addr = INADDR_ANY;
//ipv4
address.sin_family = AF_INET;
//字节序转换,port为在终端输入的端口号
address.sin_port = htons( port );
// 端口复用,避免服务端重启后端口短时间内被占用
int reuse = 1;
setsockopt( listenfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof( reuse ) );
ret = bind( listenfd, ( struct sockaddr* )&address, sizeof( address ) );
ret = listen( listenfd, 5 );
// 创建epoll对象,和事件数组
//事件数组作为传入参数,存放epoll的就绪链表
epoll_event events[ MAX_EVENT_NUMBER ];
//epoll_create的参数目前没有意义了,以前是基于哈希实现,现在是红黑树
//大于0就行
int epollfd = epoll_create( 5 );
// 添加到epoll对象中
// addfd是http_conn封装的一个方法
addfd( epollfd, listenfd, false );
//m_epollfd为静态变量,所有socket上的事件都被注册到同一个epoll内核事件中
//m_epollfd用来保存这个epoll内核事件的fd
http_conn::m_epollfd = epollfd;
其中addfd方法代码如下:
// 向epoll中添加需要监听的文件描述符
void addfd( int epollfd, int fd, bool one_shot ) {
epoll_event event;
event.data.fd = fd;
//EPOLLRDHUP是Linux中epoll事件驱动机制中的一种事件类型,它表示对端(客户端)关闭了连接或者发送了FIN包,也就
//是对端已经断开了连接。在使用epoll进行事件驱动的时候,可以通过监听EPOLLRDHUP事件来判断对端是否已经断开连
//接,从而进行相应的处理。
event.events = EPOLLIN | EPOLLRDHUP;
if(one_shot)
{
// 防止同一个通信被不同的线程处理
event.events |= EPOLLONESHOT;
}
epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
// 设置文件描述符非阻塞
setnonblocking(fd);
}
个人觉得值得注意的点:
当调用epoll_ctl
函数将文件描述符添加到epoll
对象中时,epoll
会将epoll_event
结构体中的数据拷贝一份,存储在自己的内存空间中,并将这个拷贝的结构体作为一个节点插入到红黑树中。
这样做的好处是,当文件描述符上的事件发生时,epoll
可以直接从自己的内存空间中获取相应的事件信息,而不需要每次都去访问用户空间中的epoll_event
结构体。这样可以提高效率,减少系统调用的次数。
值得注意的是,在将文件描述符从epoll
对象中删除时,epoll
并不会自动释放之前拷贝的epoll_event
结构体,需要用户自己负责释放。
可以重用同一个epoll_event
结构体对象来注册不同的文件描述符和事件。在调用epoll_ctl
函数时,只需要将该结构体的成员变量events
和data
设置为要注册的事件和文件描述符即可。需要注意的是,每次调用epoll_ctl
函数时,都需要重新设置events
和data
成员变量。
3)调用epoll_wait处理请求
//处理请求
while(true) {
int number = epoll_wait( epollfd, events, MAX_EVENT_NUMBER, -1 );
//如果为中断则不处理
if ( ( number < 0 ) && ( errno != EINTR ) ) {
printf( "epoll failure\n" );
break;
}
for ( int i = 0; i < number; i++ ) {
int sockfd = events[i].data.fd;
//检测到有客户端连接
if( sockfd == listenfd ) {
struct sockaddr_in client_address;
socklen_t client_addrlength = sizeof( client_address );
int connfd = accept( listenfd, ( struct sockaddr* )&client_address, &client_addrlength );
if ( connfd < 0 ) {
printf( "errno is: %d\n", errno );
continue;
}
//MAX_FD: 最大的文件描述符个数
if( http_conn::m_user_count >= MAX_FD ) {
close(connfd);
continue;
}
//http_conn封装的一个函数,初始化该连接,设置端口复用以及相应参数,并将connfd加入epoll
users[connfd].init( connfd, client_address);
}
//检测到客户端关闭连接或发生错误
else if( events[i].events & ( EPOLLRDHUP | EPOLLHUP | EPOLLERR ) ) {
//关闭连接
users[sockfd].close_conn();
} else if(events[i].events & EPOLLIN) {
//当第一个if中注册到epoll中的用于通信的fd有数据到达
if(users[sockfd].read()) {
//以sockfd为数组下标,数组起始地址+sockfd指向users数组中存放http_conn对象的内存空间
//将该对象加入工作队列
//该对象已在上方的users[connfd].init( connfd, client_address);中被初始化过
pool->append(users + sockfd);
} else {
users[sockfd].close_conn();
}
} else if( events[i].events & EPOLLOUT ) {
//当子线程启动时,会调用run函数,run函数会不断从工作队列中取头结点;
//该结点为http_conn对象,run函数调用该对象的process方法
//process方法会先解析请求,再生成响应
//如果是一个正确的请求,并且能正确生成响应,子线程会注册EPOLLOUT写就绪事件到epoll
//此时主线程可以调用http_conn对象的write方法,把数据写入socket
if( !users[sockfd].write() ) {
users[sockfd].close_conn();
}
}
}
}
http_conn类的init()函数:
// 初始化连接,外部调用初始化套接字地址
void http_conn::init(int sockfd, const sockaddr_in& addr){
m_sockfd = sockfd;
//客户端socket地址
m_address = addr;
// 端口复用
int reuse = 1;
setsockopt( m_sockfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof( reuse ) );
//此处的m_epollfd在main.cpp初次创建epoll对象时,进行了初始化
addfd( m_epollfd, sockfd, true );
m_user_count++;
init();
}
void http_conn::init()
{
bytes_to_send = 0;
bytes_have_send = 0;
m_check_state = CHECK_STATE_REQUESTLINE; // 初始状态为检查请求行
m_linger = false; // 默认不保持链接 Connection : keep-alive保持连接
m_method = GET; // 默认请求方式为GET
m_url = 0;
m_version = 0;
m_content_length = 0;
m_host = 0;
m_start_line = 0;
m_checked_idx = 0;
m_read_idx = 0;
m_write_idx = 0;
bzero(m_read_buf, READ_BUFFER_SIZE);
bzero(m_write_buf, READ_BUFFER_SIZE);
bzero(m_real_file, FILENAME_LEN);
}
http_conn类的close_conn函数:
// 关闭连接
void http_conn::close_conn() {
//m_sockfd在main.cpp中的users[connfd].init( connfd, client_address);被初始化过了
if(m_sockfd != -1) {
removefd(m_epollfd, m_sockfd);
m_sockfd = -1;
m_user_count--; // 关闭一个连接,将客户总数量-1
}
}
主线程主要流程到此处理完毕,最后删除new出的空间即可。
7. 总结
主线程更详细的执行流程
-
创建socket、epoll对象、连接池、http_conn数组等资源;
-
线程池创建时,构造函数会创建工作队列,以及若干子线程,子线程会执行线程池类中的run函数;
run函数的作用是从工作队列中取出工作线程;
工作线程执行http的解析以及响应生成;
工作队列的同步与互斥由信号量+锁来完成。
-
将socket文件描述符注册到epoll,并调用epoll_wait监听,取出由变化的epoll_event对象;
-
对取到的epoll_event对象进行相应的处理;
-
如果取到的epoll_event对象为新到的客户端连接:
调用accept函数获取用于通信的文件描述符,并调用http_conn中的init函数,初始化该连接。再将该通信用的文件描述符注册到epoll对象中
- 如果取到的epoll_event对象为有数据到达:
调用http_conn中的read()函数,使用while循环的方式,将相应socket上的读缓冲区中的数据一次性读出。
并将相应的http_conn对象加入工作队列
加入工作队列后,由子线程取出并执行。
- 如果取到的epoll_event对象为写就绪事件:
主线程调用http_conn对象的write方法,把数据写入socket
- 运行结束,清理创建出的资源
仍有待详细分析的部分
1) http_conn类
- process函数:由线程池中的工作线程调用,这是处理HTTP请求的入口函数
- 各种http请求的解析函数,各类http连接的参数,以及http响应的生成函数
- 写函数以及读函数
- 有限状态机
2)定时器
- 定时器关闭超时请求的实现。本项目采用的是一个升序的单项链表,github上的WebServer项目采用的小根堆。