Hy0的WebServer

同样来自于很久之前的记录,边复习边添加了一些东西。很多细节忘了,现在再看这东西未免感觉十分…无语,但总归是感慨万分。

以下原博:

一直没有用 C++ 做过比较完整的“大”项目,所以趁着计网课设 + CSAPP 课后习题把这个经典(烂大街?)项目做一做。偷个懒,目标是 Reactor + 只有长连接 + (LT + ET)

Options

为了应对大并发,有必要对系统的一些变量进行修改。

  • /etc/sysctl.conf

    1
    2
    net.ipv4.tcp_max_syn_backlog = 40960
    net.core.somaxconn = 40960

    知识点(来源 小林网络篇-TCP半连接队列和全连接队列):

    1. (忽略tcp_syncookies)一个连接传入,依次判断半连接队列,全连接队列满否,最后如果max_syn_backlog减去当前半连接队列长度小于(max_syn_backlog >> 2),则会丢弃。

    2. 半连接队列,即收到客户端第一次 SYN 包时的连接存放队列,并向客户端回 SYN+ACK 包,等待客户端确认连接(回传 ACK 包),随后内核将该连接取出,创建一个新的完全连接放置于全连接队列。

    3. 全连接队列,即经典三握后已建立连接的连接信息队列,等待服务端通过accept取出并创建socket处理。

    4. 全连接队列长度:由int listen(int sockfd, int backlog);中的backlognet.core.somaxconn共同控制,具体为backlog = min(backlog, somaxconn)

    5. 半连接队列长度比较复杂,其理论最大值由max_qlen_log控制,具体为qlen >> max_qlen_log

      1
      2
      3
      4
      5
      6
      if max_syn_backlog > min(somaxconn, backlog) {
      max_qlen_log = min(somaxconn, backlog) * 2;
      }
      else {
      max_qlen_log = max_syn_backlog * 2;
      }

      由1可知,分为两种情况:

      1
      2
      3
      4
      5
      6
      7
      if (
      当前半连接队列 < 半连接队列理论最大值 and
      当前半连接队列 > 3/4 * max_syn_backlog
      ):
      SYN_RECV最大值 = 3/4 * max_syn_backlog
      else:
      SYN_RECV最大值 = max_syn_backlog
    6. 综上,

      • 要想避免连接因为全连接队列满而被丢弃,则适当增大全连接队列长度,即同时增大somaxconnbacklog。比如 worker 负载不过来,accept函数调用不及时的情况
      • 如果是半连接队列满的情况,常见SYN泛洪,即攻击者客户端只发送第一次握手的 SYN 包,不进行第三次握手的 ACK;或者突发高并发情况,半连接队列太小和网页应用处理效率不协调导致,方法当然也能增大半连接队列长度,即一同扩大max_syn_backlog和全连接队列长度;或者适当缩小重传未及时回包连接的次数;以及开启tcp_syncookies,在半连接队列满时不放入队列,而是第二次握手计算一个cookie值与 SYN + ACK包一同发给客户端,客户端校验连接后直接建立连接,放入全连接队列。
  • 文件描述符数量

    1
    ulimit -n 200000

    每个连接都由一个 socket 管理,Linux 中表现为一个文件描述符。这东西是有上限的,爆了的话accept就会阻塞,或者非阻塞模式直接拒绝连接。

  • 系统资源

    其实还和系统资源有关,队列连接信息存储也是需要内存的。

Begin

先是跟着 CSAPP 写了个单进程单线程处理的(忘记过Web Bench了)

当然要跟上时代,随后就研究起多线程处理。

当然要先设定一个目标(某 Server BenchMark):

1
2
3
4
5
Benchmarking: GET http://127.0.0.1:11451/8.svg (using HTTP/1.1)
1000 clients, running 10 sec.

Speed=876042 pages/min, 3168352 bytes/sec.
Requests: 146007 susceed, 0 failed.

Profiler

我想要找到评估项目可以优化的点的方法,于是了解了 Profiler 这个工具。

项目中用了 Google 的 gperftool

插桩,利用信号启停以准确分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void sigusrHandler(int signum) {
static std::atomic<bool> isStarted = false;
if (signum != SIGUSR1) return;
if (!isStarted) {
isStarted = true;
ProfilerStart("test.prof");
std::cout << "profile start" << std::endl;
}
else {
ProfilerStop();
std::cout << "profile over" << std::endl;
}
}

int main() {
if (signal(SIGPIPE, SIG_IGN) == SIG_ERR || signal(SIGUSR1, sigusrHandler) == SIG_ERR) {
exit(EXIT_FAILURE);
}

... // server loop
}

生成火焰图:

1
2
3
4
# To cbt
pprof --collapsed hylchan test.prof > 1.cbt
# To svg
~/FlameGraph/flamegraph.pl 1.cbt > 1.svg

Process

最简单的想法就是把业务函数分离,丢到async里达到多线程,当然结果惨不忍睹

(当时没控制变量,果咩)

1
2
3
4
Runing info: 5000 clients, running 30 sec.

Speed=41750 pages/min, 48708 bytes/sec.
Requests: 20875 susceed, 0 failed.

该 Profiler 登场了:

507580658

火焰图原则(来源 如何读懂火焰图-阮一峰):

y 轴表示调用栈,每一层都是一个函数。调用栈越深,火焰就越高,顶部就是正在执行的函数,下方都是它的父函数。

x 轴表示抽样数,如果一个函数在 x 轴占据的宽度越宽,就表示它被抽到的次数多,即执行的时间长。注意,x 轴不代表时间,而是所有的调用栈合并后,按字母顺序排列的。

火焰图就是看顶层的哪个函数占据的宽度最大。只要有"平顶"(plateaus),就表示该函数可能存在性能问题。

于是一眼看过去,比如http部分的的parse,以及来自async的线程调度开销。

于是写了个线程池,试了试只解析 headerLine:

1634793857

一眼Parse大锅,部分源码如下,也不知道是不是我 C++ 正则使用姿势不对(

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bool HttpContext::processRequestLine(const std::string& str) {
bool ok = false;

std::regex pattern(R"(([^ ]+)\s+([^ ]+)\s+(HTTP\/\d.\d))");
std::smatch matches;
if (std::regex_match(str, matches, pattern)) {
request_.setMethod(matches[1].str());
request_.setFullUrl(matches[2].str());
request_.setHttpVersion(matches[3].str());
ok = true;
}

return ok;
}

手写解析状态机后的效果:

2777315848

send相对来说开销还是有一定比重。

接着写完parserWrite模块直接返回 404 page,压测一下

1
2
3
4
Runing info: 1000 clients, running 10 sec.

Speed=706716 pages/min, 965886 bytes/sec.
Requests: 117786 susceed, 0 failed.

加大压力:

1
2
3
4
Runing info: 10000 clients, running 60 sec.

Speed=703550 pages/min, 961559 bytes/sec.
Requests: 703550 susceed, 0 failed.

update:

改了一下文件传输思路。

根据文件传输大小分为两种传输方法:

  • 小文件直接用mmap打到内存里做缓存
  • 大文件用上sendfile函数

首先为了防止多个线程同时操作Buffer或者文件传输管理类导致读写指针的问题,所以 EPOLL 监听事件添加 ONESHOT 保证一个客户端读写只由一个线程负责;

由于是非阻塞读写,因此有必要有个记录指针一样的东西记录传输进度,防止线程调度导致的进度缺失。mmapoffset记录读写指针;sendfile看起来倒还方便,利用文件描述符管理读写指针,发完后再reset对应文件描述表的指针。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// mmaper 是管理小文件映射的类,只要确保只有一个线程操作对象,就可以保证读写正确
ssize_t Mmaper::sendSmallFile(int client_fd, int *error) {
ssize_t n;
do {
n = send(client_fd, (char*)this->fileMap_ + this->offset_, this->nLeft, 0);
if (n == -1) {
if (errno == EINTR) {
continue;
}
*error = errno;
break;
}
this->offset_ += n;
this->nLeft -= n;
} while (this->nLeft > 0);
return this->nLeft;
}
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
// 大文件也放这了,属实不应该(
size_t Mmaper::sendBigFile(int client_fd, int *error) {
if (fileFd_ == -1) {
int fdError;
fileFd_ = openFile(&fdError);
if (fileFd_ == -1) {
return nLeft;
}
}

ssize_t n;
do {
// sendfile会自动修改offset
n = sendfile(client_fd, fileFd_, &this->offset_, 4096);
if (n == -1) {
if (errno == EINTR) {
continue;
}
*error = errno;
break;
}
nLeft -= n;
} while (n > 0);

return nLeft;
}

测试一下大文件传输速度,有点感人,和在 WSL 里面有关系?

3291095017


update2:

处理了一个奇怪的闭包问题,同时考虑到全局变量带来的并发问题,于是将发送从Mmaper解耦,将offset等发送相关的东西放到另一个类Filer中,以组合的方式存放于连接类对象中,也就是只要确保“一个线程一个连接”,那么就不会导致并发写的情况。Mmaper仍在全局中,只需要在传输文件需要读写Mmaper管理类对象时上锁。

再跑一次 WebBench

  1. smallhyl_512K

    1
    2
    3
    4
    Runing info: 1000 clients, running 10 sec.

    Speed=1514088 pages/min, 12926612 bytes/sec.
    Requests: 252348 susceed, 0 failed.
  2. smallhyl_512K_10000_clients

    1
    2
    3
    4
    Runing info: 10000 clients, running 60 sec.

    Speed=752712 pages/min, 1894325 bytes/sec.
    Requests: 752712 susceed, 0 failed.

    dps有下降

  3. bighyl_1G(太多连接内存会爆炸)

    1
    2
    3
    4
    Runing info: 100 clients, running 10 sec.

    Speed=13769826 pages/min, -85439584 bytes/sec.
    Requests: 2294971 susceed, 0 failed.

火焰图:

小文件:

1375336271

大文件:

323288854

似乎sendfile确实很可以

Final

其实这并不是第一版,第一版是课设的东糊西糊版本,自己写的Buffer还有问题,不过好在错误处理得好,没在验收的时候爆炸。

后来不甘心,想着自己再磨一个。这一版中途因为各种原因,时间跨度比较长,中途疯狂补习大量知识,回头比较一些比较早写的代码,风格有很大的差异;中途还甚至想推翻了重新写,但写着写着感觉还不如缝缝补补,终于是坚持了下来;

感慨最深的是没有先写logger,遇到问题啥头绪没有,只能单步调试(

参考了许多前辈的代码,比如最多 star 的那个(忘了也懒得找了),但是用的是裸指针管理对象,心里想着这不现代,又按照他的介绍看了看一个基于 C++11 的实现;最后根据 Profiler 自己研究一下send模块。虽然很多功能没有完善,不过可以算基本完成了 Web Server 的学习(

Test Environment

Key Value
操作系统 Windows 10
运行环境 Windows Subsystem for Ubuntu 22.04
CPU i3-12100F
内存 16 GB,3200 MHZ

Thanks

  1. TinyWebServer-qinguoyi
  2. WebServer-markparticle
  3. WebServer-linyacool
  4. muduo-chenshuo