Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,9 @@ libuvcpp/
│ └── expand/ # Memory pool tests
├── examples/ # Runnable examples (webapp_demo)
├── doc/ # Documentation
│ ├── benchmark.md # Measured per-connection memory, throughput, stability
│ ├── ci-guide.md # CI maintenance guidelines
│ ├── http2-status.md # HTTP/2 support status
│ └── webapp-guide.md # Web app framework guide
├── cmake/ # CMake config templates
├── .github/workflows/ # CI pipeline
Expand Down Expand Up @@ -508,6 +510,12 @@ contributor, [@sercebr](https://github.com/sercebr).
blocks (`nbufs = 2`) (`1.1.28`); the variant table stores and returns handles instead of
whole bodies (`1.1.31`)

**Measured, not estimated** — see [doc/benchmark.md](doc/benchmark.md): **4.62 KiB per idle
connection** (least squares over eight tiers, R² = 0.999987 → 1 M connections ≈ 4.42 GiB),
75 k RPS on a single event loop, and a 10-minute soak at 38 M requests / 0 errors. That page
also compares the per-connection figure against [Hical](https://github.com/Hical61/Hical)'s
own report, and states the caveats that comparison carries.

### Configuration, packaging & CI

- Enable macros ship with the package, and a macro set that disagrees with the DLL is now a
Expand Down
7 changes: 7 additions & 0 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,9 @@ libuvcpp/
│ └── expand/ # 内存池测试
├── examples/ # 可运行示例(webapp_demo)
├── doc/ # 文档
│ ├── benchmark.md # 每连接内存、吞吐与稳定性实测
│ ├── ci-guide.md # CI 维护指南
│ ├── http2-status.md # HTTP/2 支持现状
│ └── webapp-guide.md # web 应用框架指南
├── cmake/ # CMake 配置模板
├── .github/workflows/ # CI 流水线
Expand Down Expand Up @@ -518,6 +520,11 @@ libuvcpp/
- 响应体零拷贝接管,并与头一起作为两块写出去(`nbufs = 2`)(`1.1.28`);
变体表按句柄存/取,不再整份拷体(`1.1.31`)

**实测数据(不是估算)** —— 见 [doc/benchmark.md](doc/benchmark.md):每条空闲连接
**4.62 KiB**(4 734 B,八档最小二乘,R² = 0.999987,外推 100 万连接 ≈ 4.42 GiB)、单事件循环
75 k RPS、10 分钟长跑 3 840 万请求 0 错误。那一页还把每连接这个数与
[Hical](https://github.com/Hical61/Hical) 自己的报告做了对照,并写明了该对照带的口径问题。

### 配置、打包与 CI

- 使能宏随包发出;宏集与 DLL 不一致从静默垃圾值改为编译期硬失败(`1.1.27`)
Expand Down
282 changes: 282 additions & 0 deletions doc/benchmark.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
# libuvcpp 每连接内存与吞吐实测

这一页是一次**可复现的靶场实测记录**,被测对象是**用本库构建出来的一个真实 HTTP 服务**,
不是微基准,也不是估算值。写它的直接动机是回答一个具体问题:

> 一台机器上一百万条空闲长连接,要吃掉多少内存?

| 项 | 值 |
|---|---|
| **测量版本** | **1.1.33**(`534e4ed`) |
| 撰写时 `master` | `1.1.35-dev`(`d46b6eb`) |
| 被测服务 | 靶场:静态文件 + 动态路由 + 上传下载 + WebSocket + TLS 的完整 webapp |
| 平台 | Windows 10 Pro 1903(18363)/ MSVC x64 / Release |
| 运行时 | 单事件循环线程(下文另注明) |

**三个读数**(方法、分档与原始数据见下文各节):

- **每连接内存 4.62 KiB**(4 734 B,最小二乘,**R² = 0.999987**)⇒ 外推 100 万连接 ≈ **4.42 GiB**
- 单核动态路由 **75 k RPS**(流水线 10–50 深可到 **88 k**);静态 **44 k RPS / 107 MB/s**
- 10 分钟长跑 **3 840 万请求、0 错误**,RSS 漂移 **0.2%**

---

## 1. 为什么这是「1.1.33 的读数」,以及它为什么对后面几版仍然成立

测量发生在 `534e4ed`(1.1.33)。写明这件事是**必要条件**:一个不带 commit 的读数值
不是结论,是传闻。

`534e4ed` 之后到本文撰写时的 `d46b6eb`(1.1.35-dev)之间,**`src/` 下只动了三个文件**:

```
src/uvcpp/uvcpp_version.h | 2 +- ← 版本号
src/web/uvcpp_http_server.cpp | 31 +++++----- ← 压缩变体表的字节账
src/web/uvcpp_http_server.h | 10 ++++++-
```

其余全是文档、CI、测试与发布打包。逐个说清它们为什么不在本文的测量路径上:

| commit | 动的东西 | 在本文测量的路径上吗 |
|---|---|---|
| `4710479` | 版本号 1.1.33 → 1.1.34 | 不涉及 |
| `d56c2a4` | 压缩变体表的字节账:把会漂移的计数字段换成从容器派生的求和,并补上「字节上限」那条腿 | **不在** —— 见下 |
| `d46b6eb` | 发布包同时提供 Debug 档动态库与 PDB(`uvcppd.dll` / `libuvcppd.so`),只改打包器与 CI 判据 | 不涉及 Release 运行时 |

`d56c2a4` 只影响「变体表超过上限时淘汰谁」,且只在**压缩缓存的冷路径**上;本文测的是连接
内存、动态/静态吞吐、长跑稳定性,都不经过那条路径(本文这一版连压缩都没开)。

**因此:下面的读数对 1.1.33 – 1.1.35-dev 这条线成立**;版本号只升不改功能时(1.2.0 属此
列)同样成立。真去动了上面那三条路径的话,这一页需要重测 —— 复现方法在第 8 节。

---

## 2. 方法:为什么是外推,而不是真的造满一百万

**真造不满。**本机在 **54 852** 条时开始出现 `ENOBUFS` —— 那是动态端口与内核缓冲到顶,
不是服务端拒绝。换平台也一样有这条墙,只是位置不同(对照项目在自己的 Linux 环境里撞到的
是同一量级的另一个数)。所以百万级只能外推,而外推要立得住,就必须有判据。

做法是**多档位建立连接 + 最小二乘回归 + R²**:每档记「建立 N 条连接后的进程 RSS」,对 N
做一次线性拟合,斜率就是每连接内存,再外推到 1e6。判据不是"斜率好看",是**R² 必须接近
1**,且每档残差要小 —— 否则那条直线根本不存在,外推就是编。

- 采样对象是**进程 RSS**(`WorkingSet64`),即**用户态**每连接开销;内核侧的 TCP 缓冲
(每连接约 4 KB 量级)不在其中,也不受本库控制 —— 第 6 节比较时会回到这一点。
- 每档建立连接后**逐条复检**该连接可服务(不是"连上就算")。
- 关闭后复检句柄数回落,确认没有把连接泄漏成别的形状。

---

## 3. 结果一:每连接内存

下表有**两列 RSS,是两个不同的量**,别混:

- **建立后 RSS** —— 连接全部建好、攥住时读的。**拟合用的就是这一列。**
- **关闭后 RSS** —— 全部断开之后再读的。它是**另一条线**,只用来佐证连接被回收干净
(本节末单说)。

八档采样(原点 + 7 个非零档):

| 目标并发 | 实际建立 | 连接失败 | 探测成功 | **建立后 RSS**(拟合用) | 关闭后 RSS / 句柄 |
|---:|---:|---:|---:|---:|---|
| 0(基线) | — | — | — | **9.402 MiB** | — |
| 8 000 | 8 000 | 0 | 8 000 | 46.277 MiB | 17.398 MiB / 184 |
| 16 000 | 16 000 | 0 | 16 000 | 82.602 MiB | 24.320 MiB / 184 |
| 24 000 | 24 000 | 0 | 24 000 | 118.477 MiB | 31.324 MiB / 184 |
| 32 000 | 32 000 | 0 | 32 000 | 154.238 MiB | 38.531 MiB / 184 |
| 40 000 | 40 000 | 0 | 40 000 | 190.652 MiB | 45.133 MiB / 184 |
| 48 000 | 48 000 | 0 | 48 000 | 226.727 MiB | 52.715 MiB / 184 |
| 55 000 | **54 852** | 148(`ENOBUFS`) | 全部 54 852 | 257.270 MiB | 59.094 MiB / **184** |

- **零连接失败**直到 48 000。
- 每一档都逐条复检过 8 000 – 54 852 条连接**全部可服务**。
- 全部关闭后句柄回落到 **184**(与基线一致,无泄漏)。
- 原点那档是**进程空转时的基线**(9 859 072 B),即 8 个点里的第 8 个。

**拟合(8 个点,y 单位 MiB)**:

```
RSS(MiB) = 0.0045143 × N + 9.954 R² = 0.9999869
= 4.62 KiB/连接 × N + 9.95 MiB
```

逐点残差(**这一列要能让人自己算**):

| N | 建立后 RSS (MiB) | 拟合预测 (MiB) | 残差 | 残差 % |
|---:|---:|---:|---:|---:|
| 0 | 9.402 | 9.954 | −0.551 | −5.86% |
| 8 000 | 46.277 | 46.068 | +0.210 | +0.45% |
| 16 000 | 82.602 | 82.182 | +0.420 | **+0.51%** |
| 24 000 | 118.477 | 118.296 | +0.180 | +0.15% |
| 32 000 | 154.238 | 154.410 | −0.172 | −0.11% |
| 40 000 | 190.652 | 190.525 | +0.128 | +0.07% |
| 48 000 | 226.727 | 226.639 | +0.088 | +0.04% |
| 54 852 | 257.270 | 257.571 | −0.301 | −0.12% |

- **除原点外每点 |残差| ≤ 0.51%**;而上表能看出这些残差**没有走向**(在零点附近交替),
不是单向偏。
- 原点那点自己偏 −5.86%:它是**空转基线**这一次单独的读数,不落在拟合线上 —— 拟合出的
截距 9.954 MiB 比它高 0.55 MiB。写出来是因为"除原点外"这句话要是没有上面那张表,读者
没法知道偏多少。
- 截距 9.95 MiB 是进程的固定开销(静态缓存、会话表、已初始化的协议层等),不是每连接的。
- **外推 100 万连接 ≈ 4.4085 GiB(斜率)+ 0.0097 GiB(截距)≈ 4.42 GiB。**
- 每连接 **4 733.6 B = 4.6226 KiB**,本文按 4.62 KiB 记。

**为什么能这么低**:本库**没有每连接常驻的读缓冲**。读是
`read_start([](uvcpp_buf* b){ … })` 的按需形状 —— 有数据才给缓冲,空闲连接上不预置任何
一块固定大小的读缓冲。这一条是第 6 节那个对照的全部原因。

### 「关闭后」那一列是另一条线,不能拿去验上面的拟合式

上表最右列(关闭后 RSS)**自己对 N 回归**,出来的是:

```
RSS(MiB) = 0.0008870 × N + 10.120 R² = 0.9997352
= 930 B/连接
```

斜率是 **5.09×** 的差(4 733.6 ÷ 930.1),R² 也不是上面那个 0.9999869。**这两个数都对,
但它们量的不是同一件事**:关闭后读到的是一条正在被回收的服务端连接表的残余占用,不是
「多一条连接要多多少内存」。**要判定容量,看的是「建立后」那条线。**

这一列自己的价值在另一头:它的**截距 10.120 MiB 与空转基线 9.402 MiB 只差 0.72 MiB**
(约 7.7%)。这才是"连接被回收干净"的证据 —— 8 000 到 54 852 条连接全关掉之后,进程回到
离基线不远的地方,且**句柄一律回到 184**。所以这一列留着,但它是**证据**,不是**斜率**。


---

## 4. 结果二:吞吐与延迟

动态路由(内存型响应),单事件循环:

| 用例 | 并发 | 流水线 | RPS | p50 | p99 | max | 错误/超时/503 |
|---|---:|---:|---:|---:|---:|---:|---:|
| json | 1 | 1 | 44 946 | 0 ms | 0 ms | 1 ms | 0 |
| json | 10 | 1 | **75 791** | 0 ms | 0 ms | 1 ms | 0 |
| json | 400 | 1 | 71 614 | 5 ms | 6 ms | 27 ms | 0 |
| json | 1 600 | 1 | 63 278 | 25 ms | 28 ms | 101 ms | 0 |
| json | 100 | **10** | **88 134** | 10 ms | 16 ms | 28 ms | 0 |
| json | 100 | **50** | 87 874 | 56 ms | 65 ms | 94 ms | 0 |

静态文件(走工作线程池):`44 379 RPS / 107 MB/s`(并发 100)、`43 154 RPS / 104 MB/s`
(并发 400)。256 KB 大响应:`1 608 RPS / 402 MB/s`。

**要点**:到并发 1 600 仍零错误、零超时、零 503,延迟线性劣化但不掉请求;流水线
(10–50 深)把单核推到 88 k RPS。

**静态服务没有并发悬崖**:1 → 200 并发共 12 档打静态文件,**每一档 503 占比都是 0%**,
成功 RPS 从 15 879 升到峰值 **45 044**(并发 12)后平在 43 563(并发 200)。作为对照,
同一台机器上纯内存路由是 44 501 → 73 480。

---

## 5. 结果三:稳定性

10 分钟持续压测(动态路由 + 连接 churn + WebSocket 混合):

| 项 | 读数 |
|---|---|
| 总请求 | **38 404 681,0 错误** |
| RPS | 64 008 |
| 延迟 | p50 **1 ms** / p90 1 ms / p99 **2 ms** / max **14 ms** |
| RSS 漂移 | **0.2%**(90.3 → 90.5 MB) |
| 句柄 | 252 → 254 |
| 连接 churn | 9 810 次建连 / **0 失败** |
| WebSocket | **7 804 条会话、23 412 帧 / 0 失败**,回收计数与建连数完全对上 |

另外单独做了**分段归因**(每段只做一件事,各自算 RSS 斜率):只发内存型请求
(每请求新建连接)**−0.01 MB/min**、只建连/断开(3 757 次)**+0.01 MB/min**、只做
WebSocket 建连/回显/断开(2 733 轮)**+0.07 MB/min**、完全不发请求 **0.00 MB/min**。
四段全平 ⇒ 没有随时间累积的增长,长跑那条 0.2% 是分配器膨胀后收敛,不是斜坡。

> 这份归因第一次跑出来是 `19.52 MB/min` 的"持续增长"。真因不在被测对象:采样是按
> **进程名**取第一条匹配,而当时机器上还跑着另外两台同名的被测进程,样本里不存 pid ⇒
> 采样在进程之间跳了。证据是那条序列呈**单次 34 MB 台阶后永久平**(跳变,不是斜坡),
> 换成**按 pid 锚定**的采样后全程 9.47 MB 不动。写在这里是因为这类装置缺陷会让读数
> 骗人,而它长得和真泄漏一模一样。

---

## 6. 与 Hical 的对照

对照对象是 **[Hical61/Hical](https://github.com/Hical61/Hical)** —— 一个 C++20/26 的
Asio 框架,同样面向「百万空闲长连接」。它自己维护了一份性能报告(该仓库
`docs/performance_report.md`),其中 **§2.8「空闲长连接内存优化」** 给出的正是同一量:
**每条空闲连接的内存**。所以两边不用在两套口径之间换算,可以直接比。

Hical 自己在 §2.8 里给的账(v2.6.4 → v2.6.5):

| 优化项 | 优化前 | 优化后 | 每连接节省 |
|---|---|---|---|
| `readBuf` 借还 | 连接常驻 8 KB `std::string` | `ReadBufferPool` 按需借还,空闲时不持有 | ~7.5 KB |
| `inputBuffer_` 懒分配 | 连接建立即分配 `PmrBuffer`(~2 KB) | `std::optional<PmrBuffer>`,第一次读才创建 | ~2 KB |
| **合计** | **~17.44 KB** | **~8 KB**(省约 55%) | |

同表给出百万连接量级:**~16.6 GB → ~7–8 GB**。
§2.9(v2.6.6)又加了一档:空闲连接 **+256 B**(协程帧栈),换来 `epoll_ctl` 调用数降 71.8%。

| 实现 | 每空闲连接 | 百万连接 |
|---|---|---|
| Hical v2.6.4(优化前) | ~17.44 KB | ~16.6 GB |
| Hical v2.6.5 / v2.6.6 | ~8 KB(v2.6.6 再 +256 B) | ~7–8 GB |
| **libuvcpp 1.1.33** | **4.62 KiB**(4 734 B) | **4.42 GiB** |

> Hical 那两格沿用**它的原文标法**(`KB` / `GB`);它实际是二进单位,见下面第一条。

- 对本库 vs Hical **优化前**:**轻约 3.8×**(每连接少约 12.8 KiB)。
- 对本库 vs Hical **优化后**:**轻约 1.7×**(4.62 KiB vs ~8 KiB)。

**⚠ 这个 1.7× 有一处口径不确定,写清楚免得被读成强结论。** Hical 在那句话里同时注明
「内核侧 TCP 缓冲 ~4 KB/连接不可优化」。**本文的 4.62 KiB 是进程 RSS 增量,只含用户态**;
如果 Hical 的 ~8 KB 里已经含了那 ~4 KB 内核缓冲,那么同一口径下它剩下的用户态部分约
4 KB,两者**接近持平** —— 差别是本库的机制里压根没有常驻读缓冲,而它需要一套
`ReadBufferPool` 借还来达到同一格。该文档没有把这个口径写明,所以两种读法都列在这里,
不单选对本库有利的那个。**优化前那一格(17.44 KB)没有这个歧义。**

**其余可比性说明**:

- 两边都是**外推**(都撞端口/内核墙),所以都是"斜率可信、截距不可跨机器比"。截距差异
明显(本机 9.95 MB,因为这台还带着静态缓存、会话表与已初始化的协议层)。**斜率才是
可比的量**,上表据此下结论。
- **单位口径其实同一个**(这里先前写成"不同",是我没查):Hical 标的是 `KB` / `GB`,但它
那几个数**只在二进读法下自洽** —— `17.44 × 1024 × 10⁶ B = 16.63 GiB`,正好对上它写的
**~16.6 GB**;按十进读成 `17.44 KB` 算出来是 `16.24`,对不上。所以它的 `KB` 是 KiB、
`GB` 是 GiB,与本文同口径,**上面那两个倍数不用折算**。
- **平台不同**:Hical 的数据出自它自己文档所述环境(Linux VM),本文是 Windows / MSVC
/ 环回。跨平台比较请带上这一条。

---

## 7. 这一页不是什么

- **不是跨平台的定论**,也不是跨项目的绝对排名 —— 见第 6 节最后三条。
- **不是单核之外的成绩**。上面所有吞吐都是**一个事件循环线程**(静态那几条走工作池)。
要横向扩就起 N 个进程。别把 75 k 当成"每核 75 k 且不可再扩"。
- **不是 TLS 读数**。TLS 的稳态代价另有一组(约 −23% ~ −30% RPS,256 KB 响应受 AES-GCM
单核吞吐限制降到 119 MB/s);那一组的基线对照**归因未闭合**,所以没有放进这一页。

---

## 8. 复现方法

测量装置是一组独立脚本(Node + Python,跑在靶场进程之外),不随库发布。方法本身足够
简单,按下面三步就能复刻:

1. **建连**:分档建立 N 条 TCP 连接(N = 0 / 8k / 16k / 24k / 32k / 40k / 48k / 55 000),
每条建好后发一个请求并读回响应 —— **逐条复检**,失败的连接不计入 N 且要单独报数。
2. **采样**:每档稳定后读被测进程的 RSS(Windows 是 `WorkingSet64`,Linux 读
`/proc/<pid>/status` 的 `VmRSS`)。**按 pid 采样,不要按进程名** —— 见第 5 节那个坑。
3. **拟合**:对 (N, RSS) 做最小二乘,报**斜率、截距、R²、每点残差**。R² 不接近 1 就说明
这条直线不成立,此时不要外推。

吞吐那一组用 `autocannon`(15 s/档,同机);长跑那组是 10 分钟的自建压测循环,同时统计
错误、延迟分位、句柄数与 RSS 漂移。

---

## 9. 出处

- **libuvcpp 侧**的全部数字:本库在 `534e4ed`(1.1.33)上构建出的靶场,方法如上。
- **Hical 侧**的数字:`Hical61/Hical` 仓库的 `docs/performance_report.md`,§2.8 / §2.9。
引用的是该项目的**自报**数据,本文没有在其平台上复跑过;其测试环境见该文档 §1。
Loading