Web Server

项目链接:WebServer by linyacool

个人代码注释: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多路复用。

image-20240323150800250

具体来说,web server分为两类线程,一类为listen thread(Main Reactor),一类为worker thread(Sub Reactor)。listen thread只有一个,它负责接收(accept)来自客户端的请求,并且将请求采用Round Robin的方式派发给worker线程进行处理;worker thread以线程池的方式管理,负责解析http请求,进行具体的数据交互。

  1. 这两类线程的内在实现原理是一致的,都是采用了epoll机制来实现IO多路复用,实现与底层os的交互。

    具体来说,它们的线程体都被抽象为一个EventLoop

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    void 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。
  2. 这两类线程通过异步的沉睡唤醒机制来实现交互。

    通过epoll机制来实现沉睡唤醒。

背景知识

Reactor模型

本webserver类型使用的是reactor模型,它的基本思想:在这个模型中,有一个reactor,它负责的是从操作系统的协议栈接收事件(读取、连接、写入等),并且将事件派发给对应的handler。还有一系列不同类型的handler,它们负责具体处理事件。

Reactor模型的工作方式是通过事件驱动的,它不断地监听事件的发生并将其分发给相应的Handler进行处理。这种模型的优势在于它能够以异步、非阻塞的方式处理大量的并发连接,提高了服务器的性能和可扩展性。

epoll

部分参考:

IO多路复用select/poll/epoll介绍

IO多路复用

epoll模型的应用很广泛,它的本质其实就是一个阻塞的监听IO数据输入输出的事件机制

演化过程

大概是说,本来是觉得一个请求一个线程:

accept():

  • 作用: accept() 用于在服务器端接受客户端的连接请求,并创建一个新的套接字用于与客户端通信。

recv():

  • 作用: recv() 用于从已连接套接字中接收数据,即从客户端接收数据。
1
2
3
4
5
6
7
8
9
10
11
12
// 伪代码描述
while(1) {
// accept阻塞
client_fd = accept(listen_fd)
// 开启线程read数据(fd增多导致线程数增多)
new Thread func() {
// recv阻塞(多线程不影响上面的accept)
if (recv(fd)) {
// logic
}
}
}

但是线程很多很多还得切换开销巨大不适合高并发场景,所以换了一个思路,用单个进程/线程来一次性处理多个请求,也即单个线程开放多个socket端口,同时开始监听,一有连接马上分别处理,这样的事件机制实现就需要内核的帮助:

image-20240322164106938

它最主要就是把fd集合扔进内核态,让内核帮忙判断哪个有数据

image-20240322164956107

poll的话,就再多封装了一步,把原来的rset那个bitmap和fd合并成了一个结构体传进去。当有数据到来,内核就会改变fd.revents位。可以看到,这个就是一个具有雏形的事件机制了。

image-20240322165444656

epoll就直接更彻底了,前面还是在外部填一个数组然后每次都通过系统调用copyin进去,这里是直接在准备阶段就通过系统调用epoll_ctl把数组写入到内核空间并返回一个fd,之后每次epoll_wait就不用再copyin一次了。然后事件通知的话也是通过写入外部的一个events数组实现。

(值得一提的是,这里的epoll fd似乎还是红黑树实现(所以也能避免用户态那么费劲维护这个庞然大物了,很漂亮的封装思想)。这段还是很有文章的,以后感兴趣可以看看)

可以看到,这一步步的核心就是减少运行时拷贝减少切换提高效率……redis、nginx、jave的NIO都是使用epoll实现的。

epoll的使用

在使用 epoll 模型进行网络通信时,主要分为以下几个阶段:

  1. 创建 Socket 和 epoll 实例:
    • 在服务器端,首先创建一个 Socket,并绑定到指定的地址和端口。
    • 创建一个 epoll 实例,用于管理多个文件描述符的事件。
  2. 监听套接字的事件:
    • 使用 epoll_ctl() 将监听套接字添加到 epoll 实例中,并注册关注的事件,一般是 EPOLLIN(可读事件)。
  3. 等待事件发生:
    • 使用 epoll_wait() 函数等待事件发生,该函数将会阻塞直到有事件发生或超时。
  4. 处理事件:
    • 当有事件发生时,epoll_wait() 返回,程序获取到就绪的文件描述符以及对应的事件类型。
    • 如果就绪的文件描述符是监听套接字,则表示有新的客户端连接请求到达,此时需要调用 accept() 函数来接受连接,创建新的已连接套接字,并将其添加到 epoll 实例中进行管理。
    • 如果就绪的文件描述符是已连接套接字,则表示有数据可以读取或写入。此时,可以调用 recv() 函数读取数据,或者调用 send() 函数发送数据。
    • 如果需要对套接字进行写操作,还需要将该套接字的事件类型设置为 EPOLLOUT。
  5. 循环处理:
    • 处理完一个事件后,程序会继续等待下一个事件的发生,重复上述过程。

API详解

事件
  1. EPOLLIN

    • 意义:表示套接字上有数据可读。
    • 使用场景:当套接字上有数据可读时,触发 EPOLLIN 事件,可以调用 recv() 等函数来读取数据。
  2. EPOLLOUT

    • 意义:表示套接字上可以写入数据。
    • 使用场景:当套接字上的写缓冲区有空间时,触发 EPOLLOUT 事件,可以调用 send() 等函数向套接字写入数据。
  3. EPOLLRDHUP

    • 意义:表示对端套接字已关闭连接或者关闭了写端
    • 使用场景:当远程端关闭连接或者关闭了写端时,触发 EPOLLRDHUP 事件。通常用于检测对端连接的关闭。
  4. EPOLLHUP

    • 意义:表示套接字出现挂起情况。
    • 使用场景:当套接字上出现挂起情况时,例如对端异常关闭连接时,触发 EPOLLHUP 事件。
  5. EPOLLERR

    • 意义:表示套接字发生错误。
    • 使用场景:当套接字发生错误时,例如连接被重置或者发生其他错误时,触发 EPOLLERR 事件。
  6. EPOLLPRI

    • 意义:表示套接字上有紧急数据可读。

    • 使用场景:当套接字上有紧急数据需要处理时,触发 EPOLLPRI 事件。紧急数据通常使用带外数据(Out-of-Band)的方式传输。

      紧急数据(Out-of-Band data)是一种特殊的数据,它可以被发送到套接字的优先级带外通道(Out-of-Band Channel)中,并且不遵循普通数据的顺序。紧急数据通常用于发送一些需要立即处理的信息,例如紧急控制信息或者重要的命令。

  7. EPOLLONESHOT

    • 意义 表示一次性触发模式。当某个套接字上的事件被 epoll_wait() 函数触发后,该事件会从 epoll 实例中被删除,需要重新注册才能再次触发。
    • 使用场景: 适用于需要确保每个事件只被一个处理器处理的情况,避免并发处理同一个事件。
  8. EPOLLET:

    • 意义 表示边缘触发模式。在边缘触发模式下,只有当套接字上的状态发生变化时才会触发事件,而不是像默认的水平触发模式一样,只要套接字上有数据可读或可写就会触发事件。
    • 使用场景: 适用于需要高效处理大量连接的场景,因为边缘触发模式可以减少不必要的触发次数,提高效率。

具体实现

运行追踪

感觉分块写有点难以下手,不如来一个我最拿手(?)的运行追踪这个思路来写吧,之后再分门别类细说。

首先,发动此命令启动服务器:

1
./WebServer

进入到了main函数,经过一系列parse arg的工作之后,来到了这里:

1
2
3
4
EventLoop mainLoop;
Server myHTTPServer(&mainLoop, threadNum, port);
myHTTPServer.start();
mainLoop.loop();

我们首先观察Server::start函数:

1
2
3
4
5
6
7
8
9
void Server::start() {
eventLoopThreadPool_->start();
acceptChannel_->setEvents(EPOLLIN | EPOLLET);
// 对于监听线程来说,有新的连接来到->EPOLLIN->read handler被触发,所以我们将handNewConn绑定到read handler上
acceptChannel_->setReadHandler(bind(&Server::handNewConn, this));
acceptChannel_->setConnHandler(bind(&Server::handThisConn, this));
// 自此以来,main loop就管理accept了
loop_->addToPoller(acceptChannel_, 0);
}

可以看到,它其实最主要还是启动了listen thread(为其绑定监听的epoll事件)和worker thread。下面分支解释这两类线程之后的举动。

listen thread

Server::start函数返回之后,继续执行main函数:

1
mainLoop.loop();

启动了listen thread的loop。从此之后,它就一直如此循环:

1
2
3
4
5
6
7
8
9
10
void EventLoop::loop() {
std::vector<SP_Channel> ret;
while (!quit_) {
ret.clear();
// 从epoll模型获取关注事件
ret = poller_->poll();
// 处理事件
for (auto& it : ret) it->handleEvents();
}
}

直到获取到了新的连接,这时候它会在handleEvents中转而通过handlerNewConn进行处理,在其中将请求通过RR方式分发给worker thread,并且注册下一次accept事件(epoll事件默认ONE_SHOT):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void Server::handNewConn() {
// 当监听套接字上触发 EPOLLIN 事件时,表示有新的连接请求到达
// 在这种情况下,通常应该调用 accept() 函数来接受新的连接
while ((accept_fd = accept(listenFd_, (struct sockaddr *)&client_addr,
&client_addr_len)) > 0) {
// RR,获取空闲worker thread
EventLoop *loop = eventLoopThreadPool_->getNextLoop();
LOG << "New connection from " << inet_ntoa(client_addr.sin_addr) << ":"
<< ntohs(client_addr.sin_port);
// 创建httpdata对象,表示请求的具体数据
shared_ptr<HttpData> req_info(new HttpData(loop, accept_fd));
// 唤醒该worker
loop->queueInLoop(std::bind(&HttpData::newEvent, req_info));
}
// 注册下一次accept事件
acceptChannel_->setEvents(EPOLLIN | EPOLLET);
}

// 注册一个epoll事件,监听EPOLLIN,worker thread用于等待数据输入
void HttpData::newEvent() {
channel_->setEvents(DEFAULT_EVENT);
loop_->addToPoller(channel_, DEFAULT_EXPIRED_TIME);
}

worker thread

Server::start函数中会调用eventLoopThreadPool_->start()启动线程池。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void EventLoopThreadPool::start() {
baseLoop_->assertInLoopThread(); // base loop 即为 listen loop
for (int i = 0; i < numThreads_; ++i) {
// 启动worker thread
std::shared_ptr<EventLoopThread> t(new EventLoopThread());
threads_.push_back(t);
loops_.push_back(t->startLoop());
}
}

// worker thread 的线程体
void EventLoopThread::threadFunc() {
EventLoop loop; // 创建一个全新的属于自己的loop!也即,**拥有自己的epoll队列**!!!
loop.loop();
}

随后进入到loop循环:

1
2
3
4
5
6
7
8
9
10
11
12
void EventLoop::loop() {
std::vector<SP_Channel> ret;
while (!quit_) {
ret.clear();
// 从epoll模型获取关注事件
ret = poller_->poll();
// 处理事件
for (auto& it : ret) it->handleEvents();
// 执行异步回调函数
doPendingFunctors();
}
}

值得注意的是,此处有一个实现很有意思的沉睡唤醒机制(详情见下个部分)。

  1. worker thread在一开始的时候会阻塞在poll函数中,
  2. 直到listen thread通过queueInLoop调用wakeup唤醒(也是通过epoll机制),worker thread才会醒来继续执行接下来的代码。
  3. 此时,doPendingFunctors会执行listen thread传入的newEvent回调,从而成功完成对数据输入的监听。
  4. 此后当数据到达的时候,就会再次从poll苏醒,然后进入真正的handleEvents之中,
  5. 等到处理完请求再次陷入沉睡,依次循环。

epoll模型

相关数据结构

Channel类:Channel是Reactor结构中的“事件”,它自始至终都属于一个EventLoop,负责一个文件描述符的IO事件,在Channel类中保存这IO事件的类型以及对应的回调函数,当IO事件发生时,最终会调用到Channel类中的回调函数。因此,程序中所有带有读写时间的对象都会和一个Channel关联,包括loop中的eventfd,listenfd,HttpData等。

Channel 可理解为一个请求的抽象,它包含了请求数据以及对应的handler指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Channel {
private:
typedef std::function<void()> CallBack;
EventLoop *loop_;
int fd_;
__uint32_t events_;
__uint32_t revents_;

private:
int parse_URI();
int parse_Headers();
int analysisRequest();

CallBack readHandler_;
CallBack writeHandler_;
CallBack errorHandler_;
CallBack connHandler_;

public:
void handleEvents() {
... // 根据revent转向不同的handler
}
void handleRead();
void handleWrite();
void handleError(int fd, int err_num, std::string short_msg);
void handleConn();
};

实现沉睡唤醒

这个确实是很值得学习,非常巧妙地运用了epoll机制。

首先,书接上文,我们可知,这个是worker thread的线程体:

1
2
3
4
5
// worker thread 的线程体
void EventLoopThread::threadFunc() {
EventLoop loop; // 创建一个全新的属于自己的loop!也即,**拥有自己的epoll队列**!!!
loop.loop();
}

我们可以去具体看看Eventloop的结构。可以注意到,eventloop对象是会自带并持续监听一个名为pwakeupChannel_的channel的,wakeupFd_是对应的eventfd

1
2
3
4
5
6
7
8
EventLoop::EventLoop()
: wakeupFd_(createEventfd()),
pwakeupChannel_(new Channel(this, wakeupFd_)) {
pwakeupChannel_->setEvents(EPOLLIN | EPOLLET);
pwakeupChannel_->setReadHandler(bind(&EventLoop::handleRead, this));
pwakeupChannel_->setConnHandler(bind(&EventLoop::handleConn, this));
poller_->epoll_add(pwakeupChannel_, 0);
}

故而,一开始,worker thread会卡在loop()poll这里动弹不得:

1
2
3
4
5
6
7
8
9
10
11
void EventLoop::loop() {
std::vector<SP_Channel> ret;
while (!quit_) {
ret.clear();
// 这里
ret = poller_->poll();
for (auto& it : ret) it->handleEvents();
// 执行异步回调函数
doPendingFunctors();
}
}

接着,listen thread会调用loop->queueInLoop

1
2
3
4
5
6
7
8
// call by listen thread
loop->queueInLoop(std::bind(&HttpData::newEvent, req_info));

void EventLoop::queueInLoop(Functor&& cb) {
pendingFunctors_.emplace_back(std::move(cb)); // add cb to pending functions
// call by listen thread
if (!isInLoopThread() || callingPendingFunctors_) wakeup();
}

接着调用wakeup

1
2
3
void EventLoop::wakeup() {
writen(wakeupFd_, (char*)(&one), sizeof uint64_t);
}

此时,由于有数据向wakeupFd_写入,故而pwakeupChannel_关注的EPOLLIN事件被触发,worker thread从poll中return,进入handleEvents来处理pwakeupChannel_的事件,最终转入handleRead进行处理:

1
2
3
4
5
6
void EventLoop::handleRead() {
// block by epoll
readn(wakeupFd_, &one, sizeof uint64_t);
// 注册监听下一次wakeup事件
pwakeupChannel_->setEvents(EPOLLIN | EPOLLET);
}

这样一来,worker thread就从epoll的阻塞中被成功唤醒,通过doPendingFunctors执行所有回调函数,也即上文listen thread在调用loop->queueInLoop时注册的newEvent

1
2
3
4
5
// 注册一个epoll事件,监听EPOLLIN,worker thread用于等待数据输入
void HttpData::newEvent() {
channel_->setEvents(DEFAULT_EVENT);
loop_->addToPoller(channel_, DEFAULT_EXPIRED_TIME);
}

等到之后处理完请求之后,就再一次陷入沉睡等待唤醒,以此类推。

可以看到,这整个流程事实上充分利用了epoll的事件机制。它的大体思路就是,在一个fd上注册一个EPOLLIN事件,worker thread阻塞等待该事件发生,listen thread通过向fd写入数据触发EPOLLIN事件从而唤醒worker thread。这样做的好处是,将沉睡唤醒机制完美兼容到了目前使用epoll实现网络通信的框架中,也即worker thread不仅监听这个沉睡事件,同时还监听请求数据到来事件,从而使得整体代码更加优雅。

连接的维护

日志机制