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 模式的工作流程是:

  1. 主线程往 epoll 内核事件表中注册 socket 上的读就绪事件。

  2. 主线程调用 epoll_wait 等待 socket 上有数据可读。

  3. 当 socket 上有数据可读时, epoll_wait 通知主线程。主线程则将 socket 可读事件放入请求队列。

  4. 睡眠在请求队列上的某个工作线程被唤醒,它从 socket 读取数据,并处理客户请求,然后往 epoll内核事件表中注册该 socket 上的写就绪事件。

  5. 当主线程调用 epoll_wait 等待 socket 可写。

  6. 当 socket 可写时,epoll_wait 通知主线程。主线程将 socket 可写事件放入请求队列。

  7. 睡眠在请求队列上的某个工作线程被唤醒,它往 socket 上写入服务器处理客户请求的结果。

Reactor 模式的工作流程:

Proactor模式

​ Proactor 模式将所有 I/O 操作都交给主线程和内核来处理(进行读、写),工作线程仅仅负责业务逻辑。

​ 使用异步 I/O 模型(以 aio_read 和 aio_write 为例)实现的 Proactor 模式的工作流程是:

  1. 主线程调用 aio_read 函数向内核注册 socket 上的读完成事件,并告诉内核用户读缓冲区的位置,

以及读操作完成时如何通知应用程序(这里以信号为例)。

  1. 主线程继续处理其他逻辑。

  2. 当 socket 上的数据被读入用户缓冲区后,内核将向应用程序发送一个信号,以通知应用程序数据已经可用。

  3. 应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理完客户请求后,调用 aio_write 函数向内核注册 socket 上的写完成事件,并告诉内核用户写缓冲区的位置,以及写操作完成时如何通知应用程序。

  4. 主线程继续处理其他逻辑。

  5. 当用户缓冲区的数据被写入 socket 之后,内核将向应用程序发送一个信号,以通知应用程序数据已经发送完毕。

  6. 应用程序预先定义好的信号处理函数选择一个工作线程来做善后处理,比如决定是否关闭 socket。

Proactor 模式的工作流程:

模拟Proactor模式(本项目采用的模式)

​ 使用同步 I/O 方式模拟出 Proactor 模式。原理是:主线程执行数据读写操作,读写完成之后,主线程向工作线程通知这一”完成事件“。那么从工作线程的角度来看,它们就直接获得了数据读写的结果,接下来要做的只是对读写的结果进行逻辑处理。

​ 使用同步 I/O 模型(以 epoll_wait为例)模拟出的 Proactor 模式的工作流程如下:

  1. 主线程往 epoll 内核事件表中注册 socket 上的读就绪事件。

  2. 主线程调用 epoll_wait 等待 socket 上有数据可读。

  3. 当 socket 上有数据可读时,epoll_wait 通知主线程。主线程从 socket 循环读取数据,直到没有更多数据可读,然后将读取到的数据封装成一个请求对象并插入请求队列。

  4. 睡眠在请求队列上的某个工作线程被唤醒,它获得请求对象并处理客户请求,然后往 epoll 内核事件表中注册 socket 上的写就绪事件。

  5. 主线程调用 epoll_wait 等待 socket 可写。

  6. 当 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函数时,只需要将该结构体的成员变量eventsdata设置为要注册的事件和文件描述符即可。需要注意的是,每次调用epoll_ctl函数时,都需要重新设置eventsdata成员变量。

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项目采用的小根堆。

文章项目来自牛客:https://www.nowcoder.com/courses/cover/live/504

【WebServer】项目总体流程的更多相关文章

  1. Aged-cat 的 WebServer 配置详细流程

    前言 最近看到一个不错的webserver项目,但是配置过程给的不详细,这里给出详细配置过程 项目地址:https://github.com/Aged-cat/WebServer (感谢老猫大神的项目 ...

  2. 移动APP项目研发流程及版本规划(转)

    一个移动APP项目研发规模可大可小,但都离不开以下几个成员:产品经理.ui设计师.前端开发.后端开发.测试等.如何合理安排项目成员工作.确保项目顺利进行呢?一个清晰合理的项目研发流程控制很重要. 项目 ...

  3. 使用.NET MVC框架项目开发流程(项目开发流程)

    MVC项目开发流程 整理需求,进行需求分析.项目设计. 整理数据项,建数据库做前期准备,并整理字典. 建立所需数据库表和视图和模型. 页面实现其初步功能(跳过逻辑后台代码),只是实现页面之间的跳转以及 ...

  4. ADF 项目创建流程

    ADF 项目创建流程: 1.首先建好应用 2.创建model,UI 3.创建EO,VO,AO, VL 4.设置EO的属性 5.新建lov 6.设置VO的View Accessors,并设置Attrib ...

  5. 【CC2530入门教程-01】IAR集成开发环境的建立与项目开发流程

    [引言] 本系列教程就有关CC2530单片机应用入门基础的实训案例进行分析,主要包括以下6部分的内容:1.CC2530单片机开发入门.2.通用I/O端口的输入和输出.3.外部中断初步应用.4.定时/计 ...

  6. vue项目基本流程

    一.做项目基本流程: 1.规划组件结构 Nav.vue Header.vue Home.vue..... 2.编写对应路由 vue-router 3.具体些每个组件功能 一些公共的文件jquery,j ...

  7. [GitHub]第六讲:开源项目贡献流程

    Github 是目前世界上最大的开源项目的托管交流平台.贡献开源项目的流程也是 Github 全力支持的,也一样是遵循 Github Flow,虽然跟前面团队合作流程会有一点差别.在团队内部,大家都是 ...

  8. web理论知识--网页访问过程(附有Django的web项目访问流程)

    当我们闲暇之余想上网看看新闻,或者看个电影,通常的操作是:打开电脑.打开浏览器.输入网址.浏览页面信息.点击自己感兴趣的连接......那么有没有想过,这些网页从哪里来的?过程中计算机又做了什么事情了 ...

  9. K2项目开发流程

    (自己的学习资料) K2项目开发流程: 1.在VS2013中设计流程,并在K2 Workspce中测试流程 首先是新建新建一个k2的Process文件..kprx后缀. 在里面创建所需要的流程.由于我 ...

  10. 覃超:Facebook的项目开发流程和工程师的绩效管理机制

    覃超:Facebook的项目开发流程和工程师的绩效管理机制 http://mp.weixin.qq.com/s?__biz=MjM5MDE0Mjc4MA==&mid=2650992350&am ...

随机推荐

  1. ACM-DP-数塔问题

    Description 在讲述DP算法的时候,一个经典的例子就是数塔问题,它是这样描述的: 有如下所示的数塔,要求从顶层走到底层,若每一步只能走到相邻的结点,则经过的结点的数字之和最大是多少? 已经告 ...

  2. 每日复习——static , 饿汉式方法,懒汉式方法,以及单例设计模式

    1.1.static 的使用 当我们编写一个类时,其实就是在描述其对象的属性和行为,而并没有产生实质上的对象,只有通过 new 关键字才会产生出对象,这时系统才会分配内存空间给对象,其方法才可以供外部 ...

  3. Oracle安装及各种问题

    --hsql 1:jdk 本机位置:E:\Program Files\Java\jdk1.7.0_80\ 安装教程:复制然后配置环境变量 (1)新建->变量名"JAVA_HOME&qu ...

  4. node使用node-xlsx实现excel的下载与导入,保证你看的明明白白

    需求简介 很多时候,我们都会有这样一个业务. 将列表中的数据导出为excel. 这样做的目的是为了方便查看,同时可以保存在本地归档. 还可以将导出的Excel后的数据进行加工. node-xlsx 的 ...

  5. 如何在 DevOps 中进行 API 全生命周期管理?

    随着 DevOps 理念在中国企业当中的普及和发展,中国企业 DevOps 落地成熟度不断提升,根据中国信通院的数据已有近 6 成企业向全生命周期管理迈进. 而在研发全生命周期管理之中,API 管理的 ...

  6. 今天能恢复我的Django吗——恢复了!

    今天能用两小时恢复我的Django吗 实在是累了,昨天和队友改bug的时候为了能在我的电脑上实现他的程序就在datagrip中删了我django建的表.没想到啊,这一删就全是报错!! 不说了,今天看看 ...

  7. 【故障公告】被放出的 Bing 爬虫,又被爬宕机的园子

    这些巨头爬虫们现在怎么了?记忆中2022年之前的十几年,园子没有遇到过被巨头爬虫们爬宕机的情况,巨头们都懂得爱护,都懂得控制节奏,都懂得在爬网时控制并发连接数以免给目标网站造成过大压力. 从去年开始, ...

  8. 基于 Github 平台的 .NET 开源项目模板. 嘎嘎实用!

    简介 大家好,为了使开源项目的维护和管理更方便一些,出于个人需求写了一款开源项目的模板,该模板基于 Github 平台,并使用 .NET 来实现管道功能. 在接受过实战检验后, 于今天开源, 项目地址 ...

  9. JS Bom(window)对象

    window 是客户端浏览器对象模型的基类,window 对象是客户端 JavaScript 的全局对象.一个 window 对象实际上就是一个独立的窗口,对于框架页面来说,浏览器窗口每个框架都包含一 ...

  10. YOLO4论文中文版

    文章目录 YOLO4论文中文版 摘要 1.介绍 2.相关工作 2.1.目标检测模型 2.2.Bag of freebies 2.3.Bag of specials 3.方法 3.1.架构选择 3.2. ...