关于一个 Tiny Web Server 的实现
本文最后更新于 2024年6月14日 下午
鸽了两年的 blog,我终于回来辣(
这篇 blog 是关于自己胡的一个玩具 HTTP Server。做这个项目的想法来源于 CS:APP 的 TINY Web 服务器实践。(当时感觉挺简单的,现在回头一看全是坑qwq)
友情提示:此篇 blog 废话较多。作者不是计算机专业的学生,也不从事计算机方面的工作。为节约您的宝贵时间,请酌情观看。
项目地址: https://github.com/ho-229/Network-Learn
并发模型
flowchart TB
main(("main()")) --Thread 0--> listen["WebServer::listen()"];
subgraph exec["WebServer::exec()"]
start["WebServer::start()"]
subgraph loop0["EventLoop::exec()"]
direction LR
epoll["Epoll::epoll()"]
subgraph process["EventLoop::processQueue()"]
accept["AbstractSocket::accept()"]
services["AbstractServices::process()"]
end
epoll --isListener--> accept
epoll --clientSocket--> services
process --> epoll
end
subgraph loopN["EventLoop::exec()"]
...
end
start --Thread 1--> loop0
start --Thread N--> loopN
wait["WebServer::waitForFinished()"]
loop0 --> wait
loopN --> wait
end
listen --> start
wait --> e((exit))
先来看看大致的并发模型,其思想是 one loop per thread
。在 WebServer::start() 中创建了 N 个工作线程和 EventLoop,然后将 listeners 分发到每一个 EventLoop。在 EventLoop::exec() 中循环调用 Epoll::epoll() 等待请求到达,并调用 AbstractServices::process() 处理 I/O 和协议相关的业务。
这种多线程 + I/O 多路复用 的并发模型类似于 Nginx(把进程换成了线程)。每个 EventLoop 之间相互独立,所以工作线程之间没有显式的同步代码,也没有切换进程上下文开销。
Socket 抽象及跨平台实现
classDiagram
direction LR
class AbstractSocket {
+read(buf, size)* ssize_t
+write(buf, size)* ssize_t
+close()*
+sslEnable()* bool
+isValid()* bool
+sendFile(file, offset, count)* ssize_t
+listen(hostName, port) bool
+accpet() Socket
-AbstractSocket()
#Socket m_descriptor
#bool m_isListener
}
class TcpSocket {
+TcpSocket()
}
class SslSocket {
+SslSocket()
+initializatSsl(certFile, privateKey)$ bool
+cleanUpSsl()$
+sslVersion()$ string
+isSslAvailable()$ bool
-SSL m_ssl
-SSL_CTX sslContext$
}
AbstractSocket <|-- TcpSocket : implements
AbstractSocket <|-- SslSocket : implements
AbstractSocket 的两个派生类分别是 TcpSocket 和 SslSocket,分别是 TCP
和 SSL
链接的跨平台实现。
TcpSocket 的构造函数什么也不做,而 SslSocket 的构造函数会调用 SSL_accept() 完成 SSL
握手。
AbstactScoket::read() 和 AbstractSocket::write() 是 RIO
(自动处理不足值) 跨平台实现的抽象接口。
TcpSocket 在 Linux 上使用 read() 和 write(),在 Windows 上使用 recv() 和 send();
SslSocket 使用 SSL_read() 和 SSL_write() 实现。需要注意的是某些 API 的 size 和返回值类型是 int,直接传参可能会导致整数溢出 (屑 Windows 和屑 OpenSSL)。
AbstractSocket::sendFile() 是发送文件(Handle 或 file descriptor)的 RIO
抽象接口。TcpSocket::sendFile() 在 Linux 上使用 sendfile64()(sendfile64 和其他 I/O 函数一样会返回不足值);其余平台和 SslSocket 的实现都是 mmap + AbstractSocket::write()。这里会有几个问题:
- 为什么要用 sendfile64 呢?因为它
(方便,快)是 Linux 提供的一个在两个文件描述符之间传递数据的“零拷贝”函数(也是syscall
)。在最新的实现中,它通过设置DMA
将数据从kernel buffer
直接传输到协议引擎。 - 为什么 Linux 上的 SslSocket::sendFile() 不能用 sendfile64 实现呢?因为 sendfile64 不支持对数据进行
SSL
加密。实际上在 OpenSSL 3.0.0+ 的版本中添加了 SSL_sendfile() 这个 API,但是 OpenSSL 3 尚未普及且需要kernel TLS/SSL
支持。 - 为什么其他的实现要用 mmap + AbstractSocket::write() 而不是 read() + AbstractSocket::write() 呢?主要是因为 mmap 不会改变文件描述符的 seek ,也不会受其影响;其次是 mmap 比 read() 少了一次从
kernel buffer
到user buffer
的复制。
AbstractSocket::listen() 是与协议版本无关的监听函数,具体实现可以参考 CS:APP 第三版 P662 的 open_listenfd。
AbstractSocket::accept() 会非阻塞地返回一个连接描述符(如果没有则返回错误码)。需要注意的是 Windows 的 accept() 返回的套接字会继承 listener 的(非)阻塞状态而 *nix 的 accept() 则不会。所以在 Linux 下需要调用 accept4() (Linux 的私货)将返回的连接描述符设置为非阻塞,如果是 Unix 系的话就只能在 accept() 之后再调用 fcntl() 将连接套接字设置为非阻塞。
I/O 多路复用及跨平台实现
为什么要使用 I/O 多路复用呢,一个线程一个 connection 不香么?(doge
如果你的服务器上有 10000 个链接,这就意味着你需要开 10000 个线程(C10K)。众所周知, 大量的线程就意味着大量的线程上下文切换,不仅会浪费大量 CPU 周期,还会浪费内存空间。所以我们需要一个线程能够处理多个 I/O,这就是 I/O 多路复用。
由于这个项目是跨平台项目,然而各个平台优秀的本地接口又各不相同,于是就有了 Epoll 类用来封装跨平台实现。
类 Epoll 主要负责管理 AbstractSocket 的可读和错误(链接断开)事件。Epoll::insert() 和 Epoll::erase() 分别是将套接字添加/移出 Epoll。Epoll::epoll() 负责返回可读套接字和发生错误的套接字。
// /TinyWebServer/src/core/epoll.h
class Epoll
{
public:
explicit Epoll();
~Epoll();
void insert(AbstractSocket *const socket, bool exclusive = false);
void erase(AbstractSocket *const socket);
void epoll(std::vector<AbstractSocket *> &events,
std::vector<AbstractSocket *> &errorEvents);
// snip
};
Windows(WSAPoll)
由于 WSAPoll 自身并不维护队列,所以我们要自行维护一个 pollfd 线性队列和一个 fd -> AbstractSocket*
的 Map(以便用 fd 能找到 AbstractSocket*),然后在 Epoll::insert 和 Epoll::erase 中操作它们(如果可能被异步访问还要给数据结构加锁),最后在 Epoll::epoll 中调用 WSAPoll()。
用 WSAPoll 实现会导致低效的一个原因是当调用 Epoll::erase 的时候需要遍历 pollfd 队列找到要删除的元素,然后把在它之后的元素向前移动一遍,还有一个原因是 WSAPoll 没有办法避免惊群 ㄟ( ▔, ▔ )ㄏ。
Linux(epoll)
到了 epoll 就简单了许多,因为它会维护一个红黑树。我们只需要把 AbstractSocket* 传给 epoll_event::data::ptr,在 Epoll::insert 和 Epoll::erase 中只需要把 epoll_ctl 封装一下,然后在 Epoll::epoll 中调用 epoll_wait。
接下来就是 epoll 高效的秘密了。epoll 的边缘触发(EPOLLET
)可以让一个事件在新的事件到来之前只触发一次,比起水平触发来说就少了许多冗余事件。还有一个是在 Linux 4.5+ 提供的 EPOLLEXCLUSIVE
,它能使被监听的套接字只能触发一个 epoll,完美解决了惊群问题。更低版本的 Linux 可以让每个 EventLoop 持有一组 listeners 并设置 SO_REUSEPORT
让它们能监听同一组端口。
Unix / MacOS / BSD(kqueue)
到了 kqueue 事情变得更简单了,因为它只有两个 API!kqueue() 和 kevent()。和 Epoll 一样,kqueue 也会自己维护队列,在 struct kevent 中也同样有存放用户数据结构指针的地方,不同的是 Epoll::insert、Epoll::erase 和 Epoll::epoll 都是 kevent() 的封装。
从接口设计上来说 kqueue 比 epoll 更高效。一次 kqueue 调用不仅可以同时增加/删除多个套接字,还能获取活动事件队列,不过要注意的是,更改的事件只能在下次 kqueue 调用才会生效。kqueue 提供了一个 EV_CLEAR
标志用于实现类似于 EPOLLET
的语义,它的行为是当事件被用户捕获时清除其状态。至于惊群问题就比较遗憾了,kqueue 没有提供类似 EPOLLEXCLUSIVE
的标志,而且在 MacOS 11 上用 SO_REUSEPORT
也没办法解决惊群问题。
虽然但是,kqueue 还是提供了强大的 filter,目前为止我们只使用了 kqueue 的一小部分特性。
参考资料
- 深入学习理解 IO 多路复用
- epoll在多线程中的应用-EPOLLEXCLUSIVE和REUSEPORT(一)
- Why does one NGINX worker take all the load?
EventLoop 及 Timer 实现
EventLoop
// /TinyWebServer/src/core/eventloop.cpp
void EventLoop::exec()
{
while(m_runnable && m_epoll.count())
{
m_epoll.epoll(m_queue, m_errorQueue);
if(!m_queue.empty())
this->processQueue();
if(!m_errorQueue.empty())
this->processErrorQueue();
// Clean up timeout connections
m_manager.checkout(m_errorQueue);
if(!m_errorQueue.empty())
this->processTimeoutQueue();
}
}
进入 EventLoop::exec() 后,先用 Epoll::epoll() 获取可读套接字队列和错误/已关闭套接字队列。
在 EventLoop::processQueue() 中会遍历 m_queue(可读套接字队列)。
如果 AbstractSocket 为 listener,就会循环调用 AbstractSocket::accept() 获取新的客户端的套接字描述符直到返回 INVALID_SOCKET,如果 listener 的 sslEnable() 为真会创建一个 SslSocket 对象,反之会创建 TcpSocket 对象,然后为它设置一个 timer 并调用 Epoll::insert(),最后触发 ConnectEvent::Accept;
如果 AbstractSocket 为客户端套接字,调用 AbstractServices::process() 处理业务。如果业务处理成功就调用 TimerManager::restart(),如果不成功将会调用 Epoll::erase() 和 TimerManager::destroy(),并触发 ConnectEvent::Close,最后释放套接字内存。
在 EventLoop::processErrorQueue() 中会遍历 m_errorQueue(错误/已关闭套接字队列),如果 AbstractSocket 为 listener 会发出一个 ExceptionEvent::ListenerError,反之会触发 ConnectEvent::Close 并关闭它们。
处理超时链接会调用 TimerManager::checkout() 获取不活跃套接字队列,然后调用 EventLoop::processTimeoutQueue() 关闭它们,流程和 EventLoop::processErrorQueue() 类似但不需要判断是否为 listener,因为 listener 不会被放进 TimerManager 里。
Timer
// /TinyWebServer/src/util/timermanager.h
template <typename T, typename TimeType>
class Timer
{
public:
explicit Timer(const TimeType& timeout, const T& data);
inline void reset();
inline const auto& deadline() const;
inline const T& userData() const;
inline bool operator<(const Timer<T, TimeType> &right) const;
// snip
};
class TimerManager
{
public:
using TimerItem = Timer<T, TimeType>;
using iterator = typename std::list<TimerItem>::iterator;
explicit TimerManager() {}
iterator start(const TimeType& timeout, const T& data);
void restart(iterator timerIt);
void destory(iterator it);
void checkout(std::vector<T>& list);
bool isEmpty() const;
T takeFirst();
// snip
};
在 TimerManager 中会维护一个 Timer 队列(按 deadline 从低到高)。用 std::list 实现的原因是因为它的 insert 和 erase 是常量时间开销且迭代器不会失效,而且它还提供了 std::list::splice() 可以在迭代器不失效的前提下移动位置。
调用 TimerManager::start() 时会根据 timeout 创建一个 Timer 插入到队列中并返回它的迭代器。这个迭代器会被保存在 AbstractSocket 里。当 AbstractSocket 触发错误事件时可以通过 AbstractSocket::timer() 找到它的 timer 并用 TimerManager::destory() 删除它。
TimerManager::restart() 会调用给定 Timer 的 Timer::reset() 重置它的 deadline,并将它在队列中重新排序。
在 TimerManager::checkout() 中会检查队列的第一个 Timer。如果当前时间大于 Timer::deadline() 则视为超时,此时会将 Timer::userData() 加入 list 并 pop。重复上述操作直到第一个 Timer 不再超时或队列为空。
TimerManager::takeFirst() 会返回第一个 Timer 所持有的 userData 并 pop_front。它会在 EventLoop 析构的时候被调用,以释放 AbstractSocket 所占的内存。
HTTP 实现
在谈 HttpRequest 和 HttpResponse 之前,我们先来聊聊 HTTP header
的存储(因为它俩的实现都要用到)。
众所周知,HTTP header
是不区分大小写的,如果我们想用 Map 来存储 header(name -> value),要么把查询和储存都转成大/小写,要么做忽略大小写的匹配。这里我选择了第二种处理方式,因为这种方式没有对原数据和查询数据的修改。但是 STL 中并没有这方面的支持。对于 std::map,我们需要自定义 Compare;对于 std::unordered_map,我们需要自定义 Compare 和 Hasher。
先说 Compare。实际上 std::map 和 std::unordered_map 的 Compare 是不一样的(因为一个是二叉树一个是哈希表),所以 std::map 的 Compare 是比较谁大(或谁小),而 std::unordered_map 的是比较两个值是否相等。虽然 STL 并没有提供忽略大小写比较字符串的 functor,但是在 string.h 中提供了相关函数,也就是说我们只需要做跨平台封装就行了,在 Windows 上是 _strnicmp(),在 Linux 上是 strncasecmp()。细心的你一定会发现用的都是带 size 参数的版本,这是因为带边界检查可以避免很多潜在问题,比如说比较的字符串不是以 \0 结尾…
然后就是 Hasher,这里实现是用 Key 的第一个字母的小写作为哈希值(至于碰撞嘛,能用就行。
HttpRequest
HttpRequest 主要的功能是解析及保存 HTTP
请求。
关于 parser 的细节就不过多介绍了(因为这方面我不在行,而且手写 parser 真的是噩梦),这个 parser 虽然不是用状态机实现的,但是运行起来也近似于状态机。接下来是关于 URI
的转义处理,转义标准可以参考 JavaScript
的 decodeURI,这里的实现参考 libhv/cpputil/hurl.cpp。
关于 HTTP 请求头可以参考 Http Request,关于 HTTP parser 的实现可以参考 nodejs/http-parser、nodejs/llhttp。
HttpResponse
到了 HttpResponse 就简单多了,它只负责生成 HTTP
响应头和保存响应体(或响应体的抽象)。
响应体的类型分别有 Text(文本)、Stream(标准流)、File(文件)。原本是打算用 union 实现这个多类型响应体,由于不是类型安全加上成员隐式构造函数的问题于是放弃了。然后把目光投向了 std::variant,但是 C++17
实现的 std::variant 性能不高,也放弃了。最后只好自己用 struct 手撸了一个类型安全的“联合”。
剩下的就是 HttpResponse::toRawData()。顾名思义,这个函数是将 HttpResponse 转换为完整的 HTTP
响应(或响应头部),至于实现就用 std::string::append() 按照标准把响应拼接出来就好啦。
关于 HTTP
响应头可以参考 Http Response。
HttpServices
HttpServices 是 AbstractServices 的派生类,负责保存不同的业务 handler。在 HttpServices::process() 中会调用业务 handler 和处理 HTTP
业务的相关逻辑。
首先是业务 handler 的存储。这里用的是两个 Map 嵌套的方式:
graph LR
subgraph m_services
Method
subgraph uriHandler
URI --found--> Handler
URI -.not found.-> DefaultHandler
end
Method --> URI
end
在 HttpServices::process() 中首先会调用 AbstractSocket::read() 接收请求并调用 HttpRequest::parse() 解析请求,接着调用 HttpServices::callHandler() 调用业务处理代码,然后调用 HttpResponse::toRawData() 转换成 HTTP
响应,最后调用 AbstractSocket::write()、AbstractSocket::sendStream()、AbstractSocket::sendFile() 发送响应头和响应体。
其他
关于 Epoll
关于 Epoll 的好处人尽皆知,难道它就没有问题吗?当然不是。
如果把 AbstractSocket* 放进 epoll_event::data::ptr 里,乍一看很优雅,不用外部维护一个 Map 映射 fd 和 AbstractSocket 了。当 close epoll fd 的时候 epoll_event::data::ptr 指向的内存并不会自动释放,而且我们不能遍历 epoll 内部维护的二叉树释放内存,因此造成内存泄漏。所以必须要存在外部的 holder(在这个项目中是 TimerManager) 控制 AbstractSocket 的生命周期(kqueue 亦是如此)。
其次,epoll_ctl 是 syscall
且一次只能处理一个描述符。这就意味着当 epoll 面对大量短链接的时候需要频繁陷入内核,浪费大量的 CPU 周期,导致性能下降(kevent 的设计就避免了这个问题)。
关于 Epoll 设计缺陷更完整的讨论可以看:
更多的并发模型可以看:AIO、io_uring、Go 并发模型。
亿些教训
glibc malloc()
既然用的是 C++ 就肯定少不了喜闻乐见的内存安全问题。当你遇到下面的报错会怎么办呢:
malloc(): unaligned tcache chunk detected
经过一番查找和询问,初步判断是 out of range 或 use after free。
原因大概知道了,那么如何定位呢?最开始是用 GDB 定位,结果每次都定位到 STL 的一些 construction 里面。我百思不得其解,然后挂上了 asan,结果还是令人失望,打印了几行信息之后就没反应了,后面上了 Valgrind 也是一样(毕竟是 asan 的亲爹,当然也可能是我的操作有问题qwq)。进度就这样被搁置了一个星期,我甚至开始想停掉这个项目了,因为一个不能稳定运行的 Server 没有任何意义。最后还是决定去 commit history 碰碰运气(因为之前是没问题的)。
结果还真找到了,在这个 commit 中我忽略了 Timer 被释放之后 AbstractSocket 依旧持有它的指针的问题,导致了 use after free,真的是血的教训…
从 commit history 找 bug 算是一个比较通用的手段,如果你也遇到了类似的难题不妨试试这个方法。关于 tcache 我的了解不多,下面是一些参考资料:
std::future
你以为到这就完了吗,我在早期还踩过一个大坑,这个坑是关于 std::future 的。
当时是为了实现 keep alive
。实现是在一个循环里调用 accept(),然后调用 std::async() 创建线程处理新链接的所有 HTTP
业务。结果是浏览器并不会复用这个链接反而是关闭它发起一个新链接。最后在求助我师傅 @NiceBlueChai 的时候,他在 HTTP
业务里输出线程 ID 马上发现了问题:我只创建了一个线程!而且创建线程和调用 accept 的循环被 std::future 的析构函数阻塞了! 我做梦也想不到 std::future 析构函数会阻塞。当浏览器发起第二个链接的时候会 Pending(因为服务器的 accept 不会被执行),然后它会主动断开第一个链接并重连。
在做这个项目的时候踩过的坑远不止一点点,比如说前面提到的 Windows 和 *nix 的 accept 行为不一致…
EOF
嘛,最后祝大家 Debug 一帆风顺,能够专心做自己想做的事情。