Web Server
个人代码注释:WebServer
使用方法:
开启两个shell,其中一个作为server端,另外一个作为client端。server端可以监听端口等待连接:
1
2 ./build/Debug/WebServer/WebServer -t 2 -p 60000
./WebServer [-t thread_numbers] [-p port] [-l log_file_path(should begin with '/')]client端可以发送各种请求,例如:
1
2 echo "1234" | telnet localhost 60000
curl http://localhost:60000
感觉最主要的收获还是了解到了epoll机制,以及对网络了解更深刻了一点。
总体架构
整体采用Reactor模型事件驱动实现,使用线程池提高并发度,同时使用epoll实现IO多路复用。
具体来说,web server分为两类线程,一类为listen thread(Main Reactor),一类为worker thread(Sub Reactor)。listen thread只有一个,它负责接收(accept)来自客户端的请求,并且将请求采用Round Robin的方式派发给worker线程进行处理;worker thread以线程池的方式管理,负责解析http请求,进行具体的数据交互。
这两类线程的内在实现原理是一致的,都是采用了epoll机制来实现IO多路复用,实现与底层os的交互。
具体来说,它们的线程体都被抽象为一个
EventLoop
:1
2
3
4
5
6
7
8
9
10void EventLoop::loop() {
std::vector<SP_Channel> ret;
while (!quit_) {
ret.clear();
// 从epoll模型获取关注事件
ret = poller_->poll();
// 处理事件
for (auto& it : ret) it->handleEvents();
}
}- 对于listen thread,它从epoll处获取的事件是new connection,最终会在
handleEvents
中转向handleNewConn
来建立新连接并分发任务给worker thread。 - 对于worker thread,它从epoll处获取的事件是related data,最终会在
handleEvents
中转向例如handleRead
来获取来自client的数据并进行处理和构建response。
- 对于listen thread,它从epoll处获取的事件是new connection,最终会在
这两类线程通过异步的沉睡唤醒机制来实现交互。
通过epoll机制来实现沉睡唤醒。
背景知识
Reactor模型
本webserver类型使用的是reactor模型,它的基本思想:在这个模型中,有一个reactor,它负责的是从操作系统的协议栈接收事件(读取、连接、写入等),并且将事件派发给对应的handler。还有一系列不同类型的handler,它们负责具体处理事件。
Reactor模型的工作方式是通过事件驱动的,它不断地监听事件的发生并将其分发给相应的Handler进行处理。这种模型的优势在于它能够以异步、非阻塞的方式处理大量的并发连接,提高了服务器的性能和可扩展性。
epoll
部分参考:
epoll模型的应用很广泛,它的本质其实就是一个阻塞的监听IO数据输入输出的事件机制。
演化过程
大概是说,本来是觉得一个请求一个线程:
accept():
- 作用:
accept()
用于在服务器端接受客户端的连接请求,并创建一个新的套接字用于与客户端通信。recv():
- 作用:
recv()
用于从已连接套接字中接收数据,即从客户端接收数据。
1 | // 伪代码描述 |
但是线程很多很多还得切换开销巨大不适合高并发场景,所以换了一个思路,用单个进程/线程来一次性处理多个请求,也即单个线程开放多个socket端口,同时开始监听,一有连接马上分别处理,这样的事件机制实现就需要内核的帮助:
它最主要就是把fd集合扔进内核态,让内核帮忙判断哪个有数据
poll的话,就再多封装了一步,把原来的rset那个bitmap和fd合并成了一个结构体传进去。当有数据到来,内核就会改变fd.revents位。可以看到,这个就是一个具有雏形的事件机制了。
epoll就直接更彻底了,前面还是在外部填一个数组然后每次都通过系统调用copyin进去,这里是直接在准备阶段就通过系统调用epoll_ctl
把数组写入到内核空间并返回一个fd,之后每次epoll_wait
就不用再copyin一次了。然后事件通知的话也是通过写入外部的一个events数组实现。
(值得一提的是,这里的epoll fd似乎还是红黑树实现(所以也能避免用户态那么费劲维护这个庞然大物了,很漂亮的封装思想)。这段还是很有文章的,以后感兴趣可以看看)
可以看到,这一步步的核心就是减少运行时拷贝减少切换提高效率……redis、nginx、jave的NIO都是使用epoll实现的。
epoll的使用
在使用 epoll 模型进行网络通信时,主要分为以下几个阶段:
- 创建 Socket 和 epoll 实例:
- 在服务器端,首先创建一个 Socket,并绑定到指定的地址和端口。
- 创建一个 epoll 实例,用于管理多个文件描述符的事件。
- 监听套接字的事件:
- 使用 epoll_ctl() 将监听套接字添加到 epoll 实例中,并注册关注的事件,一般是 EPOLLIN(可读事件)。
- 等待事件发生:
- 使用 epoll_wait() 函数等待事件发生,该函数将会阻塞直到有事件发生或超时。
- 处理事件:
- 当有事件发生时,epoll_wait() 返回,程序获取到就绪的文件描述符以及对应的事件类型。
- 如果就绪的文件描述符是监听套接字,则表示有新的客户端连接请求到达,此时需要调用 accept() 函数来接受连接,创建新的已连接套接字,并将其添加到 epoll 实例中进行管理。
- 如果就绪的文件描述符是已连接套接字,则表示有数据可以读取或写入。此时,可以调用 recv() 函数读取数据,或者调用 send() 函数发送数据。
- 如果需要对套接字进行写操作,还需要将该套接字的事件类型设置为 EPOLLOUT。
- 循环处理:
- 处理完一个事件后,程序会继续等待下一个事件的发生,重复上述过程。
API详解
事件
EPOLLIN:
- 意义:表示套接字上有数据可读。
- 使用场景:当套接字上有数据可读时,触发 EPOLLIN 事件,可以调用
recv()
等函数来读取数据。
EPOLLOUT:
- 意义:表示套接字上可以写入数据。
- 使用场景:当套接字上的写缓冲区有空间时,触发 EPOLLOUT 事件,可以调用
send()
等函数向套接字写入数据。
EPOLLRDHUP:
- 意义:表示对端套接字已关闭连接或者关闭了写端。
- 使用场景:当远程端关闭连接或者关闭了写端时,触发 EPOLLRDHUP 事件。通常用于检测对端连接的关闭。
EPOLLHUP:
- 意义:表示套接字出现挂起情况。
- 使用场景:当套接字上出现挂起情况时,例如对端异常关闭连接时,触发 EPOLLHUP 事件。
EPOLLERR:
- 意义:表示套接字发生错误。
- 使用场景:当套接字发生错误时,例如连接被重置或者发生其他错误时,触发 EPOLLERR 事件。
EPOLLPRI:
意义:表示套接字上有紧急数据可读。
使用场景:当套接字上有紧急数据需要处理时,触发 EPOLLPRI 事件。紧急数据通常使用带外数据(Out-of-Band)的方式传输。
紧急数据(Out-of-Band data)是一种特殊的数据,它可以被发送到套接字的优先级带外通道(Out-of-Band Channel)中,并且不遵循普通数据的顺序。紧急数据通常用于发送一些需要立即处理的信息,例如紧急控制信息或者重要的命令。
EPOLLONESHOT:
- 意义: 表示一次性触发模式。当某个套接字上的事件被 epoll_wait() 函数触发后,该事件会从 epoll 实例中被删除,需要重新注册才能再次触发。
- 使用场景: 适用于需要确保每个事件只被一个处理器处理的情况,避免并发处理同一个事件。
EPOLLET:
- 意义: 表示边缘触发模式。在边缘触发模式下,只有当套接字上的状态发生变化时才会触发事件,而不是像默认的水平触发模式一样,只要套接字上有数据可读或可写就会触发事件。
- 使用场景: 适用于需要高效处理大量连接的场景,因为边缘触发模式可以减少不必要的触发次数,提高效率。
具体实现
运行追踪
感觉分块写有点难以下手,不如来一个我最拿手(?)的运行追踪这个思路来写吧,之后再分门别类细说。
首先,发动此命令启动服务器:
1 | ./WebServer |
进入到了main函数,经过一系列parse arg的工作之后,来到了这里:
1 | EventLoop mainLoop; |
我们首先观察Server::start
函数:
1 | void Server::start() { |
可以看到,它其实最主要还是启动了listen thread(为其绑定监听的epoll事件)和worker thread。下面分支解释这两类线程之后的举动。
listen thread
Server::start
函数返回之后,继续执行main函数:
1 | mainLoop.loop(); |
启动了listen thread的loop。从此之后,它就一直如此循环:
1 | void EventLoop::loop() { |
直到获取到了新的连接,这时候它会在handleEvents
中转而通过handlerNewConn
进行处理,在其中将请求通过RR方式分发给worker thread,并且注册下一次accept事件(epoll事件默认ONE_SHOT):
1 | void Server::handNewConn() { |
worker thread
Server::start
函数中会调用eventLoopThreadPool_->start()
启动线程池。
1 | void EventLoopThreadPool::start() { |
随后进入到loop循环:
1 | void EventLoop::loop() { |
值得注意的是,此处有一个实现很有意思的沉睡唤醒机制(详情见下个部分)。
- worker thread在一开始的时候会阻塞在
poll
函数中, - 直到listen thread通过
queueInLoop
调用wakeup唤醒(也是通过epoll机制),worker thread才会醒来继续执行接下来的代码。 - 此时,
doPendingFunctors
会执行listen thread传入的newEvent
回调,从而成功完成对数据输入的监听。 - 此后当数据到达的时候,就会再次从
poll
苏醒,然后进入真正的handleEvents
之中, - 等到处理完请求再次陷入沉睡,依次循环。
epoll模型
相关数据结构
Channel类:Channel是Reactor结构中的“事件”,它自始至终都属于一个EventLoop,负责一个文件描述符的IO事件,在Channel类中保存这IO事件的类型以及对应的回调函数,当IO事件发生时,最终会调用到Channel类中的回调函数。因此,程序中所有带有读写时间的对象都会和一个Channel关联,包括loop中的eventfd,listenfd,HttpData等。
Channel 可理解为一个请求的抽象,它包含了请求数据以及对应的handler指针。
1 | class Channel { |
实现沉睡唤醒
这个确实是很值得学习,非常巧妙地运用了epoll机制。
首先,书接上文,我们可知,这个是worker thread的线程体:
1 | // worker thread 的线程体 |
我们可以去具体看看Eventloop
的结构。可以注意到,eventloop对象是会自带并持续监听一个名为pwakeupChannel_
的channel的,wakeupFd_
是对应的eventfd:
1 | EventLoop::EventLoop() |
故而,一开始,worker thread会卡在loop()
的poll
这里动弹不得:
1 | void EventLoop::loop() { |
接着,listen thread会调用loop->queueInLoop
:
1 | // call by listen thread |
接着调用wakeup
:
1 | void EventLoop::wakeup() { |
此时,由于有数据向wakeupFd_
写入,故而pwakeupChannel_
关注的EPOLLIN
事件被触发,worker thread从poll
中return,进入handleEvents
来处理pwakeupChannel_
的事件,最终转入handleRead
进行处理:
1 | void EventLoop::handleRead() { |
这样一来,worker thread就从epoll的阻塞中被成功唤醒,通过doPendingFunctors
执行所有回调函数,也即上文listen thread在调用loop->queueInLoop
时注册的newEvent
:
1 | // 注册一个epoll事件,监听EPOLLIN,worker thread用于等待数据输入 |
等到之后处理完请求之后,就再一次陷入沉睡等待唤醒,以此类推。
可以看到,这整个流程事实上充分利用了epoll的事件机制。它的大体思路就是,在一个fd上注册一个EPOLLIN事件,worker thread阻塞等待该事件发生,listen thread通过向fd写入数据触发EPOLLIN事件从而唤醒worker thread。这样做的好处是,将沉睡唤醒机制完美兼容到了目前使用epoll实现网络通信的框架中,也即worker thread不仅监听这个沉睡事件,同时还监听请求数据到来事件,从而使得整体代码更加优雅。