diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7c90065..13ce24e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@
- **`StxEtxFramer` 切帧向量化**:net8+ 用 `SearchValues` 一次跳到最近的 STX/ETX 候选(不含候选的整段字节直接跳过),实测 ≈1.1 µs/帧 → ≈50 ns/帧(约 22 倍);netstandard2.0 目标保留逐字节实现,语义逐字节等价(两套独立实现,现有 StxEtx/FrameDecoder 测试全绿)。README/bench 性能数据以 2026-08-28 重跑刷新(含内置指标的真实路径)。
- 会话纪元(epoch)改为在 Connected 发布点分配(与会话编号同点),彻底闭合"发布后、任务未创建"窗口内旧纪元幽灵故障卷入新生会话的瞬态误杀(v2.3.0 评审附带发现);发布窗口注入幽灵的回归测试锁定该行为。
- demo 新增场景 5:会话感知收发(编号生命周期、整帧写完才完成、旧会话发送失效不重放、新会话照常收发)。
+- **基准矩阵扩容 + 性能文档重写**:新增内置指标开销微基准(0.5–1.0 ns/次、零分配)、裸 TCP 地板基准(框架税:小消息反而快于裸写 13–52%,64KB 约 3–4×)、会话感知/接收视图/未完成帧超时的三轮对比(SendInSessionAsync ≈2× SendAsync、+≈470 B/条;其余无可测差异);端到端吞吐参数化到 64B/1KB/64KB 两轮区间。bench/README 全量刷新(环境、噪声披露、框架税绝对+百分比并列),README 双语性能摘要同步。
### 修复
- **被动端监听韧性加固(#47,防御性)**:排查确认原始"用户 `Reconnect()` 竞速楔死监听"**无法复现**(顺序/并发两组确定性复现 + 混沌套件恢复该动作后多轮全绿,此前观察到的楔死系当时测试自身缺陷),但修复排查中识别的两个真实弱点——① 接受循环的重试延迟改为锁外等待(旧实现整个重试循环持有 `_acceptLock`,绑定/接受持续失败期间会堵死其它接受循环);② 监听 socket 设置 `SO_REUSEADDR`(服务端主动关闭后立即重绑不受 2MSL/TIME_WAIT 限制;Windows 实测宽松,Linux 上无此选项会遇 `EADDRINUSE`,一并设置保持跨平台一致)。新增确定性回归测试(8 轮服务端主动重连,端口须秒级可重新接受),混沌套件恢复用户重连动作。
diff --git a/README.en.md b/README.en.md
index 62df38a..4caade5 100644
--- a/README.en.md
+++ b/README.en.md
@@ -229,7 +229,8 @@ Measured with BenchmarkDotNet (details and how to reproduce in [bench/README.md]
- **Streaming encode**: halves per-frame heap allocation (single vs. double buffer); 25–35% faster for small payloads, on par for large ones;
- **Frame decoding**: `LengthPrefixFramer` ≈8.3 ns/frame, `StxEtxFramer` ≈50 ns/frame (≈22× faster after SearchValues vectorization on net8+) — both handle tens of millions of frames per second;
-- **End-to-end** (real TCP loopback, re-measured 2026-08-28 with built-in metrics on): one-way throughput ≈160k msgs/s (1KB messages, LengthPrefix ≈6.2 µs/msg), round-trip latency ≈51 µs; the XML codec costs 2–16 µs per message (400B–4KB) — serialization dominates, not framing. Absolute values vary by machine; see [bench/README.md](bench/README.md) to reproduce.
+- **End-to-end** (real TCP loopback, 2026-08-29 two-round ranges, built-in metrics on): one-way throughput ≈130–135k msgs/s at 1KB and ≈210–240k at 64B (LengthPrefix); round-trip latency ≈66–74 µs; the XML codec costs 2–16 µs per message (400B–4KB) — serialization dominates, not framing. **Framework tax depends on message size**: for small messages the bounded-queue pipeline is even faster than the naive serialized-NetworkStream-write baseline (13–52%); at 64KB the cost is ≈3–4× (encoding-buffer allocations dominate — recorded as an optimization direction);
+- **Cost of the new features, measured**: built-in metrics cost 0.5–1.0 ns per record with zero allocation when nobody subscribes (fine to leave on); `SendInSessionAsync` is roughly 2× the time of `SendAsync` with +≈470 B/msg allocated (the price of session semantics — use `SendAsync` when you don't need them); the receive views and the disabled incomplete-frame timeout show no measurable difference. Absolute values vary by machine; full data and noise disclosure in [bench/README.md](bench/README.md).
## Supported frameworks
@@ -252,7 +253,7 @@ The `netstandard2.0` asset is validated by the **full net48 test suite** (real T
```bash
dotnet build StreamFrame.slnx
dotnet test
-dotnet run --project samples/StreamFrame.Demo # 4-scenario end-to-end demo
+dotnet run --project samples/StreamFrame.Demo # 5-scenario end-to-end demo
dotnet run -c Release --project bench/StreamFrame.Benchmarks # benchmarks (~5-10 min)
dotnet test -f net8.0 --collect:"XPlat Code Coverage" # coverage (CI also collects & summarizes)
```
diff --git a/README.md b/README.md
index 7702cfc..b5b555f 100644
--- a/README.md
+++ b/README.md
@@ -226,7 +226,8 @@ BenchmarkDotNet 实测(详见 [bench/README.md](bench/README.md),可本地
- **流式编码**:每帧堆分配减半(单缓冲 vs 双缓冲),小负载耗时快 25–35%,大负载持平;
- **切帧吞吐**:`LengthPrefixFramer` ≈8.3 ns/帧,`StxEtxFramer` ≈50 ns/帧(net8+ SearchValues 向量化后较逐字节版提速约 22 倍)——两者均可达每秒千万帧级;
-- **端到端**(真实 TCP 回环,2026-08-28 重测,含内置指标):单向吞吐 ≈16 万条/秒(1KB 消息,LengthPrefix ≈6.2 µs/条),往返延迟 ≈51 µs;XML codec 每条报文 2–16 µs(400B–4KB)——序列化开销远大于定界层。绝对值随机器浮动,复现方式见 [bench/README.md](bench/README.md)。
+- **端到端**(真实 TCP 回环,2026-08-29 两轮区间,含内置指标):单向吞吐 1KB ≈13–13.5 万条/秒、64B ≈21–24 万条/秒(LengthPrefix);往返延迟 ≈66–74 µs;XML codec 每条报文 2–16 µs(400B–4KB)——序列化开销远大于定界层。**框架税分尺寸**:小消息下队列流水线反而快于裸 NetworkStream 逐条 Write 的对照(13–52%),64KB 大消息约 3–4×(编码缓冲分配主导,优化方向已记录);
+- **新特性成本实测**:内置指标无监听时 0.5–1.0 ns/次、零分配(可常开);`SendInSessionAsync` 约为 `SendAsync` 的 2 倍耗时、+≈470 B/条分配(会话语义的代价,不需要时用 `SendAsync`);接收视图与未完成帧超时(关闭态)均无可测差异。绝对值随机器浮动,完整数据与噪声披露见 [bench/README.md](bench/README.md)。
## 支持框架
@@ -249,7 +250,7 @@ BenchmarkDotNet 实测(详见 [bench/README.md](bench/README.md),可本地
```bash
dotnet build StreamFrame.slnx
dotnet test
-dotnet run --project samples/StreamFrame.Demo # 四场景端到端 demo
+dotnet run --project samples/StreamFrame.Demo # 五场景端到端 demo
dotnet run -c Release --project bench/StreamFrame.Benchmarks # 性能基准(约 5-10 分钟)
dotnet test -f net8.0 --collect:"XPlat Code Coverage" # 覆盖率(CI 亦自动收集并写入运行摘要)
```
diff --git a/bench/README.md b/bench/README.md
index e6aaa7a..0d90f2b 100644
--- a/bench/README.md
+++ b/bench/README.md
@@ -18,7 +18,25 @@ dotnet run -c Release --project bench/StreamFrame.Benchmarks -- --filter "*EndTo
- **Codec 基准**(`CodecBenchmarks`):官方 XML 驱动 `XmlDocumentCodec` 对典型设备报文(~400B 单值上报 / ~4KB 批量明细)的编解码开销。
- **端到端基准**(`EndToEndBenchmarks`):真实 TCP 回环上的完整连接(收发队列 + Pipe + 定界 + codec + socket)——单向吞吐(连发 1 万条 1KB 消息,双 framer)与往返延迟(逐条乒乓 2000 次)。
-## 结果(2026-08-28 重跑:StxEtx 向量化 + 内置指标已接入;Codec 表为历史数据)
+## 结果
+
+### 测量环境(2026-08-29,宏基准为两轮区间)
+
+- CPU:12th Gen Intel Core i5-12500H(笔记本);内存 32GB;Windows 10 19045;.NET 10.0.11
+- BenchmarkDotNet SimpleJob:微基准 warmup 3 / iteration 15,宏基准 warmup 2 / iteration 10;真实 TCP 回环
+- **噪声披露**:测量在长时间高负载的开发机上完成,宏基准(回环端到端)绝对值两轮漂移可达 ±30%,下表以区间标注、仅供量级参考;**关键差值(会话发送成本、接收视图、超时开销)经第三轮复核稳定**。微基准与差值结论可信度高。请以自己环境的复现为准。
+
+### 内置指标开销(微基准,无监听者的生产默认态)
+
+| 调用 | 耗时 | 分配 |
+|---|---:|---:|
+| 单次计数/直方图记录 | 0.5–1.0 ns | 0 |
+| 接收路径每消息合计(字节块 + 帧) | ≈1.6 ns | 0 |
+| 发送路径每消息合计(入队采样 + 字节块 + 帧) | ≈2.6 ns | 0 |
+
+结论:不订阅 Meter 时指标开销为**亚纳秒到个位纳秒级、零分配**——按 10 万条/秒的流量折算,发送路径合计 ≈0.026 µs/秒,完全可忽略。
+
+### 帧定界(微基准,2026-08-28 数据仍有效)
| 方法 | 负载 | 耗时 | 分配 |
|---|---|---:|---:|
@@ -40,7 +58,43 @@ dotnet run -c Release --project bench/StreamFrame.Benchmarks -- --filter "*EndTo
¹ 恰好压在 `EncodeBufferInitialSize`(默认 1024)的缓冲增长边界上:流式路径触发一次扩容租借,
抵消了省下的那次拷贝。负载远大于初始缓冲后两条路径的拷贝量趋同(几何扩容摊销)。
-### Codec(XmlDocumentCodec)
+### 端到端单向吞吐(真实 TCP 回环,1 万条/轮,两轮区间)
+
+| Framer | 64B | 1KB | 64KB |
+|---|---:|---:|---:|
+| LengthPrefix | 4.1–4.7 µs/条 ≈ 21–24 万条/秒 | 7.4–7.7 µs/条 ≈ 13–13.5 万条/秒 | 134–188 µs/条 ≈ 0.53–0.75 万条/秒 |
+| StxEtx | 5.5–6.9 µs/条 ≈ 15–18 万条/秒 | 4.3–8.7 µs/条 ≈ 12–23 万条/秒 | 151–157 µs/条 ≈ 0.64–0.66 万条/秒 |
+| 每消息堆分配(LengthPrefix) | 0.8 KB | 6.7 KB | 394–409 KB |
+
+### 裸 TCP 地板(同口径回环:预生成帧字节逐条 NetworkStream.Write,两轮区间)
+
+| 负载 | 地板 | StreamFrame(LengthPrefix) | 框架税(绝对 / 百分比并列) |
+|---|---:|---:|---|
+| 64B | 10.1–13.0 µs/条 | 4.1–4.7 µs/条 | **-6~-8 µs/条(快 48–63%)**² |
+| 1KB | 9.4–16.0 µs/条 | 7.4–7.7 µs/条 | **-2~-8 µs/条(快 13–52%)**² |
+| 64KB | 45–50 µs/条 | 134–188 µs/条 | **+89~138 µs/条(慢 198~276%)** |
+
+² 小消息下框架反而**快于**裸 NetworkStream 逐条 Write 的写法:StreamFrame 的有界队列把
+"基准线程的逐条入队"与"worker 的 socket 写出"解耦成流水线,而裸写法把每次内核写串行在
+发送线程上。框架的真实代价体现在 64KB 大消息:每条约 +0.1 ms 与 ~400 KB 分配(编码缓冲
+几何扩容 + 池租借未命中),高吞吐大报文场景是明确的优化方向(见读数要点)。
+
+### 会话感知与新特性开销(三轮,1KB LengthPrefix,同上口径)
+
+| 对比 | 基线 | 变体 | 结论 |
+|---|---:|---:|---|
+| 发送方式 | SendAsync 13.2–17.2 µs/条 | SendInSessionAsync 24.7–31.6 µs/条 | **成本 ≈ +10~18 µs/条(中位 +11)**,分配 +≈470 B/条(信封 + 完成源 + 注册表;两轮区间内 SendInSession 误差棒较大,三轮方向与量级一致) |
+| 接收视图 | GetMessages 13.7–15.8 µs/条 | GetSessionMessages 12.7–15.8 µs/条 | **无可测差异**(同一通道,信封解包平凡);分配同为 ≈3.4 KB/条 |
+| 未完成帧超时 | 关闭 13.5–20.9 µs/条 | 开启未触发 13.3–18.5 µs/条 | **无可测差异**(默认关闭为真零开销;开启后仅在半帧等待时才有计时令牌开销) |
+
+### 往返延迟(乒乓串行,两轮区间)
+
+| Framer | RTT |
+|---|---:|
+| LengthPrefix | 71.8–73.1 µs |
+| StxEtx | 65.6–73.8 µs |
+
+### Codec(XmlDocumentCodec,历史数据)
| 操作 | 报文 | 耗时 | 分配 |
|---|---|---:|---:|
@@ -49,20 +103,12 @@ dotnet run -c Release --project bench/StreamFrame.Benchmarks -- --filter "*EndTo
| Encode | ~400B | 1.9 µs | 7.8 KB |
| Encode | ~4KB | 10.9 µs | 10.4 KB |
-### 端到端(真实 TCP 回环,1KB 文本消息)
-
-| 指标 | LengthPrefix | StxEtx |
-|---|---:|---:|
-| 单向吞吐(每消息折算) | 6.21 µs ≈ **16.1 万条/秒** | 6.05 µs ≈ 16.5 万条/秒 |
-| 往返延迟(乒乓串行) | 50.8 µs | 52.4 µs |
-| 每消息堆分配(吞吐模式) | 6.5 KB | 6.9 KB |
-
-测试环境:Windows(本地开发机),.NET 8,BenchmarkDotNet 0.15.8,SimpleJob(warmup 3 / iteration 15;e2e 为 2/10)。
-数值仅供量级参考,请以自己环境的复现为准。
-
## 读数要点
-- **流式编码的实测收益主要是"每帧堆分配减半"(一个缓冲对象 vs 两个)**,小负载(≤ 百字节)耗时也快 25–35%;
- 大负载下省掉的那次 memcpy 被缓冲增长摊销,耗时基本持平。"零拷贝"指的是帧路径不再整体搬运负载,不是字面意义的零成本。
-- **切帧吞吐相差约两个数量级**:`LengthPrefixFramer` 读长度头直接跳转(≈10 ns/帧);
- `StxEtxFramer` 在 net8+ 用 `SearchValues` 向量化定位边界(≈50 ns/帧;netstandard2.0 目标回退逐字节实现)。长度前缀在超大帧上仍占优,STX/ETX 适合既有协议兼容。
+- **框架税是分尺寸的**:小消息(≤1KB)下有界队列的流水线让 StreamFrame 反而快于"裸 NetworkStream 逐条 Write"的对照写法;大消息(64KB)真实代价显现(≈3–4×、每条 ~400 KB 分配)——高吞吐大报文是后续优化方向(编码缓冲按负载预扩容 / 池化命中率)。
+- **会话感知发送的诚实成本**:`SendInSessionAsync` 约为普通发送的 2 倍耗时、+≈470 B/条分配(信封、完成源、注册表、状态字 CAS)。对协议计时器语义(T3/T6 从整帧写出起算)而言通常值得,但高频小消息场景若不需要会话语义,用 `SendAsync`。
+- **内置指标可以常开**:无监听时 0.5–1.0 ns/次、零分配,接收/发送路径每消息合计 ≈1.6/2.6 ns。
+- **新特性默认关闭即零开销**:未完成帧超时关闭时无可测差异;接收视图切换也无成本。
+- **流式编码的实测收益主要是"每帧堆分配减半"**(一个缓冲对象 vs 两个),小负载耗时也快 24–33%;大负载下省掉的那次 memcpy 被缓冲增长摊销。"零拷贝"指帧路径不再整体搬运负载,不是字面意义的零成本。
+- **切帧吞吐**:`LengthPrefixFramer` 读长度头直接跳转(≈8.3 ns/帧);`StxEtxFramer` 在 net8+ 用 `SearchValues` 向量化定位边界(≈50 ns/帧;netstandard2.0 目标回退逐字节实现)。
+- **回环数据的边界**:以上宏基准全部来自同机回环,仅代表同机进程通信的量级;真实网络的延迟/带宽会改变各分量的占比。复现命令见文首。
diff --git a/bench/StreamFrame.Benchmarks/EndToEndBenchmarks.cs b/bench/StreamFrame.Benchmarks/EndToEndBenchmarks.cs
index e120919..e812367 100644
--- a/bench/StreamFrame.Benchmarks/EndToEndBenchmarks.cs
+++ b/bench/StreamFrame.Benchmarks/EndToEndBenchmarks.cs
@@ -1,126 +1,150 @@
using System.Net;
using System.Net.Sockets;
-using System.Threading.Channels;
using BenchmarkDotNet.Attributes;
namespace StreamFrame.Benchmarks;
///
-/// 端到端管道基准(真实 TCP 回环):完整连接(收发队列 + Pipe + 定界 + codec + socket)的
-/// 单向吞吐(按 framer 分组)与往返延迟。回答"整库每秒能打多少消息、一条消息来回要多久"。
+/// 端到端管道基准(真实 TCP 回环):完整连接(收发队列 + Pipe + 定界 + codec + socket)。
+/// 原单类按关注点拆分:单向吞吐(Framer × 负载尺寸)与往返延迟(Framer)分开参数化,
+/// 避免延迟基准在尺寸参数下空转重复。
///
-[MemoryDiagnoser]
-[SimpleJob(warmupCount: 2, iterationCount: 10)]
-public class EndToEndBenchmarks
+public abstract class EndToEndBenchmarkBase
{
- private const int ThroughputMessages = 10_000;
- private const int PingPongRounds = 2_000;
-
- [Params("LengthPrefix", "StxEtx")]
- public string Framer { get; set; } = "LengthPrefix";
+ protected const int ThroughputMessages = 10_000;
+ protected const int PingPongRounds = 2_000;
- private StreamConnection _server = null!;
- private StreamConnection _client = null!;
- private CancellationTokenSource _cts = null!;
+ protected StreamConnection Server { get; private set; } = null!;
+ protected StreamConnection Client { get; private set; } = null!;
+ protected CancellationTokenSource Cts { get; private set; } = null!;
- // 完成信号:消费端收到目标条数时置位(吞吐)/客户端收到回显时置位(乒乓)
- private long _serverReceived;
- private long _clientReceived;
- private volatile TaskCompletionSource _serverDrained = NewTcs();
- private volatile TaskCompletionSource _clientDrained = NewTcs();
+ protected long ServerReceived;
+ protected long ClientReceived;
+ protected volatile TaskCompletionSource ServerDrained = NewTcs();
+ protected volatile TaskCompletionSource ClientDrained = NewTcs();
- private static TaskCompletionSource NewTcs()
+ protected static TaskCompletionSource NewTcs()
=> new(TaskCreationOptions.RunContinuationsAsynchronously);
- [GlobalSetup]
- public async Task Setup()
+ protected void SetupCore(IFramer framer)
{
var port = GetFreePort();
var options = new StreamConnectionOptions { ConnectRetryDelayMs = 200 };
StreamConnection Create(bool isActive)
- => new(
- Framer == "LengthPrefix" ? new LengthPrefixFramer() : new StxEtxFramer(),
- Utf8TextCodec.Instance,
- IPAddress.Loopback,
- port,
- isActive,
- options);
-
- _server = Create(isActive: false);
- _client = Create(isActive: true);
- _cts = new CancellationTokenSource();
+ => new(framer, Utf8TextCodec.Instance, IPAddress.Loopback, port, isActive, options);
+
+ Server = Create(isActive: false);
+ Client = Create(isActive: true);
+ Cts = new CancellationTokenSource();
// 服务端:回显(乒乓用);同时计数(吞吐用)
_ = Task.Run(async () =>
{
- await foreach (var message in _server.GetMessages(_cts.Token))
+ await foreach (var message in Server.GetMessages(Cts.Token))
{
- if (Interlocked.Increment(ref _serverReceived) == ThroughputMessages)
- _serverDrained.TrySetResult();
- await _server.SendAsync(message, _cts.Token);
+ if (Interlocked.Increment(ref ServerReceived) == ThroughputMessages)
+ ServerDrained.TrySetResult();
+ await Server.SendAsync(message, Cts.Token);
}
});
// 客户端:只计数收到的回显
_ = Task.Run(async () =>
{
- await foreach (var _ in _client.GetMessages(_cts.Token))
+ await foreach (var _ in Client.GetMessages(Cts.Token))
{
- if (Interlocked.Increment(ref _clientReceived) == PingPongRounds)
- _clientDrained.TrySetResult();
+ if (Interlocked.Increment(ref ClientReceived) == PingPongRounds)
+ ClientDrained.TrySetResult();
}
});
- _server.Start(default);
- _client.Start(default);
- await Task.WhenAll(_server.WaitForConnectedAsync(), _client.WaitForConnectedAsync());
+ Server.Start(default);
+ Client.Start(default);
+ Task.WhenAll(Server.WaitForConnectedAsync(), Client.WaitForConnectedAsync()).GetAwaiter().GetResult();
}
[GlobalCleanup]
public void Cleanup()
{
- _cts.Cancel();
- _server.DisposeAsync().AsTask().GetAwaiter().GetResult();
- _client.DisposeAsync().AsTask().GetAwaiter().GetResult();
- _cts.Dispose();
+ Cts.Cancel();
+ Server.DisposeAsync().AsTask().GetAwaiter().GetResult();
+ Client.DisposeAsync().AsTask().GetAwaiter().GetResult();
+ Cts.Dispose();
+ }
+
+ protected static IFramer CreateFramer(string name)
+ => name == "LengthPrefix" ? new LengthPrefixFramer() : new StxEtxFramer();
+
+ protected static int PayloadLength(string size)
+ => size switch
+ {
+ "64B" => 64,
+ "64KB" => 64 * 1024,
+ _ => 1024,
+ };
+
+ private static int GetFreePort()
+ {
+ var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
}
+}
+
+/// 单向吞吐:客户端连发 1 万条消息,计到服务端全部收到。Mean 已按 OperationsPerInvoke 折算为每消息。
+[MemoryDiagnoser]
+[SimpleJob(warmupCount: 2, iterationCount: 10)]
+public class OneWayThroughputBenchmarks : EndToEndBenchmarkBase
+{
+ [Params("LengthPrefix", "StxEtx")]
+ public string Framer { get; set; } = "LengthPrefix";
+
+ [Params("64B", "1KB", "64KB")]
+ public string PayloadSize { get; set; } = "1KB";
+
+ [GlobalSetup]
+ public void Setup()
+ => SetupCore(CreateFramer(Framer));
- /// 单向吞吐:客户端连发 1 万条 1KB 消息,计到服务端全部收到。Mean 已按 OperationsPerInvoke 折算为每消息。
[Benchmark(OperationsPerInvoke = ThroughputMessages)]
- public async Task OneWayThroughput_10k_1KB()
+ public async Task OneWayThroughput()
{
- Interlocked.Exchange(ref _serverReceived, 0);
- _serverDrained = NewTcs();
+ Interlocked.Exchange(ref ServerReceived, 0);
+ ServerDrained = NewTcs();
- var payload = new string('x', 1024);
+ var payload = new string('x', PayloadLength(PayloadSize));
for (var i = 0; i < ThroughputMessages; i++)
- await _client.SendAsync(payload);
+ await Client.SendAsync(payload);
- // 等全部送达(30 秒兜底,异常时让基准响亮失败而不是挂死)
- await _serverDrained.Task.WaitAsync(TimeSpan.FromSeconds(30));
+ await ServerDrained.Task.WaitAsync(TimeSpan.FromSeconds(30));
}
+}
+
+/// 往返延迟:客户端逐条发送并等回显(串行化每一条的完整 RTT)。Mean 已折算为每次往返。
+[MemoryDiagnoser]
+[SimpleJob(warmupCount: 2, iterationCount: 10)]
+public class RoundTripLatencyBenchmarks : EndToEndBenchmarkBase
+{
+ [Params("LengthPrefix", "StxEtx")]
+ public string Framer { get; set; } = "LengthPrefix";
+
+ [GlobalSetup]
+ public void Setup()
+ => SetupCore(CreateFramer(Framer));
- /// 往返延迟:客户端逐条发送并等回显(串行化每一条的完整 RTT)。Mean 已折算为每次往返。
[Benchmark(OperationsPerInvoke = PingPongRounds)]
public async Task RoundTripLatency()
{
- Interlocked.Exchange(ref _clientReceived, 0);
- _clientDrained = NewTcs();
+ Interlocked.Exchange(ref ClientReceived, 0);
+ ClientDrained = NewTcs();
for (var i = 0; i < PingPongRounds; i++)
{
- await _client.SendAsync("ping");
+ await Client.SendAsync("ping");
// 串行等待这一条的回显:单条完整往返
- while (Volatile.Read(ref _clientReceived) <= i)
+ while (Volatile.Read(ref ClientReceived) <= i)
await Task.Yield();
}
}
-
- private static int GetFreePort()
- {
- var listener = new TcpListener(IPAddress.Loopback, 0);
- listener.Start();
- var port = ((IPEndPoint)listener.LocalEndpoint).Port;
- listener.Stop();
- return port;
- }
}
diff --git a/bench/StreamFrame.Benchmarks/MetricsOverheadBenchmarks.cs b/bench/StreamFrame.Benchmarks/MetricsOverheadBenchmarks.cs
new file mode 100644
index 0000000..969e2ff
--- /dev/null
+++ b/bench/StreamFrame.Benchmarks/MetricsOverheadBenchmarks.cs
@@ -0,0 +1,91 @@
+using BenchmarkDotNet.Attributes;
+using StreamFrame;
+
+namespace StreamFrame.Benchmarks;
+
+///
+/// 内置指标(ConnectionMetrics)的无监听开销(生产默认态:无人订阅 Meter)。
+/// 回答:每次记录到底几纳秒、单条消息的收/发路径合计加多少。
+///
+[MemoryDiagnoser]
+[SimpleJob(warmupCount: 3, iterationCount: 15)]
+public class MetricsOverheadBenchmarks
+{
+ private const int Calls = 1_000;
+
+ // 无监听者(生产默认):ConnectionMetrics 的记录全部走"无订阅"短路
+ private readonly ConnectionMetrics _metrics = new("127.0.0.1:0");
+
+ [Benchmark(Baseline = true, OperationsPerInvoke = Calls)]
+ public void EmptyLoop_Calibration()
+ {
+ for (var i = 0; i < Calls; i++)
+ {
+ }
+ }
+
+ [Benchmark(OperationsPerInvoke = Calls)]
+ public void FrameSent()
+ {
+ for (var i = 0; i < Calls; i++)
+ _metrics.FrameSent();
+ }
+
+ [Benchmark(OperationsPerInvoke = Calls)]
+ public void FrameReceived()
+ {
+ for (var i = 0; i < Calls; i++)
+ _metrics.FrameReceived();
+ }
+
+ [Benchmark(OperationsPerInvoke = Calls)]
+ public void AddBytesReceived_1Chunk_1028B()
+ {
+ for (var i = 0; i < Calls; i++)
+ _metrics.AddBytesReceived(1028);
+ }
+
+ [Benchmark(OperationsPerInvoke = Calls)]
+ public void SendQueueObserved()
+ {
+ for (var i = 0; i < Calls; i++)
+ _metrics.SendQueueObserved(0);
+ }
+
+ [Benchmark(OperationsPerInvoke = Calls)]
+ public void Reconnect()
+ {
+ for (var i = 0; i < Calls; i++)
+ _metrics.Reconnect();
+ }
+
+ [Benchmark(OperationsPerInvoke = Calls)]
+ public void SessionEnded_1s()
+ {
+ for (var i = 0; i < Calls; i++)
+ _metrics.SessionEnded(1.0);
+ }
+
+ /// 单条消息的接收路径合计:一个字节块 + 一帧。
+ [Benchmark(OperationsPerInvoke = Calls)]
+ public void ReceivePath_PerMessage()
+ {
+ for (var i = 0; i < Calls; i++)
+ {
+ _metrics.AddBytesReceived(1028);
+ _metrics.FrameReceived();
+ }
+ }
+
+ /// 单条消息的发送路径合计:入队采样 + 一个字节块 + 一帧。
+ [Benchmark(OperationsPerInvoke = Calls)]
+ public void SendPath_PerMessage()
+ {
+ for (var i = 0; i < Calls; i++)
+ {
+ _metrics.SendQueueObserved(0);
+ _metrics.AddBytesSent(1028);
+ _metrics.FrameSent();
+ }
+ }
+}
diff --git a/bench/StreamFrame.Benchmarks/RawTcpFloorBenchmarks.cs b/bench/StreamFrame.Benchmarks/RawTcpFloorBenchmarks.cs
new file mode 100644
index 0000000..ed6947d
--- /dev/null
+++ b/bench/StreamFrame.Benchmarks/RawTcpFloorBenchmarks.cs
@@ -0,0 +1,99 @@
+using System.Buffers.Binary;
+using System.Net;
+using System.Net.Sockets;
+using BenchmarkDotNet.Attributes;
+
+namespace StreamFrame.Benchmarks;
+
+///
+/// 裸 TCP 回环地板值:预生成"4 字节大端长度 + 负载"的帧字节,直接在 NetworkStream 上
+/// 逐条 Write/Read(无定界、无 codec、无队列)。用于计算 StreamFrame 的框架税
+/// (绝对差值与百分比并列,口径与 OneWayThroughputBenchmarks 完全一致)。
+/// 接收循环常驻、每次调用重置计数并换完成源(与 e2e 基准同款模式——初版接收只读一轮,
+/// 第二次调用起写侧无人读导致 WriteAsync 永久阻塞,已修正)。
+///
+[MemoryDiagnoser]
+[SimpleJob(warmupCount: 2, iterationCount: 10)]
+public class RawTcpFloorBenchmarks
+{
+ private const int Messages = 10_000;
+
+ [Params("64B", "1KB", "64KB")]
+ public string PayloadSize { get; set; } = "1KB";
+
+ private TcpListener _listener = null!;
+ private TcpClient _sender = null!;
+ private TcpClient _receiver = null!;
+ private byte[] _frame = null!;
+ private long _totalBytesPerRound;
+ private long _receivedBytes;
+ private volatile TaskCompletionSource _drained = NewTcs();
+
+ private static TaskCompletionSource NewTcs()
+ => new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ [GlobalSetup]
+ public async Task Setup()
+ {
+ _listener = new TcpListener(IPAddress.Loopback, 0);
+ _listener.Start();
+ var port = ((IPEndPoint)_listener.LocalEndpoint).Port;
+
+ _sender = new TcpClient();
+ await _sender.ConnectAsync(IPAddress.Loopback, port);
+ _receiver = await _listener.AcceptTcpClientAsync();
+ _listener.Stop();
+
+ var payloadLength = PayloadSize switch
+ {
+ "64B" => 64,
+ "64KB" => 64 * 1024,
+ _ => 1024,
+ };
+ _frame = new byte[4 + payloadLength];
+ BinaryPrimitives.WriteInt32BigEndian(_frame, payloadLength);
+ for (var i = 4; i < _frame.Length; i++)
+ _frame[i] = (byte)'x';
+ _totalBytesPerRound = (4L + payloadLength) * Messages;
+
+ // 常驻接收:本轮读满目标字节数即完成当前调用(计数滚动抵扣跨轮余量)
+ _ = Task.Run(async () =>
+ {
+ var stream = _receiver.GetStream();
+ var buffer = new byte[64 * 1024];
+ while (true)
+ {
+ var n = await stream.ReadAsync(buffer);
+ if (n == 0)
+ return;
+ if (Interlocked.Add(ref _receivedBytes, n) >= Volatile.Read(ref _totalBytesPerRound))
+ {
+ Interlocked.Add(ref _receivedBytes, -Volatile.Read(ref _totalBytesPerRound));
+ _drained.TrySetResult();
+ }
+ }
+ });
+ }
+
+ [GlobalCleanup]
+ public void Cleanup()
+ {
+ _sender.Dispose();
+ _receiver.Dispose();
+ _listener.Stop();
+ }
+
+ /// 单向吞吐地板:连发 1 万条帧字节。Mean 已按 OperationsPerInvoke 折算为每消息。
+ [Benchmark(OperationsPerInvoke = Messages)]
+ public async Task OneWayThroughput()
+ {
+ Interlocked.Exchange(ref _receivedBytes, 0);
+ _drained = NewTcs();
+
+ var stream = _sender.GetStream();
+ for (var i = 0; i < Messages; i++)
+ await stream.WriteAsync(_frame);
+
+ await _drained.Task.WaitAsync(TimeSpan.FromSeconds(30));
+ }
+}
diff --git a/bench/StreamFrame.Benchmarks/SessionAwareBenchmarks.cs b/bench/StreamFrame.Benchmarks/SessionAwareBenchmarks.cs
new file mode 100644
index 0000000..23727e9
--- /dev/null
+++ b/bench/StreamFrame.Benchmarks/SessionAwareBenchmarks.cs
@@ -0,0 +1,191 @@
+using System.Net;
+using System.Net.Sockets;
+using BenchmarkDotNet.Attributes;
+using StreamFrame;
+
+namespace StreamFrame.Benchmarks;
+
+///
+/// 会话感知/新特性开销的端到端量化(LengthPrefix + 1KB,与单向吞吐同口径):
+/// ① SendInSessionAsync vs SendAsync(信封 + 完成源 + 注册表的每消息成本);
+/// ② GetSessionMessages vs GetMessages(接收视图的成本);
+/// ③ 未完成帧超时 关闭 vs 开启未触发(默认关闭路径是否零开销)。
+/// 三个关注点拆成三个基准类,避免参数交叉积膨胀。
+///
+public abstract class SessionAwareBenchmarkBase
+{
+ protected const int Messages = 10_000;
+
+ protected StreamConnection Server { get; private set; } = null!;
+ protected StreamConnection Client { get; private set; } = null!;
+ protected CancellationTokenSource Cts { get; private set; } = null!;
+ protected long ServerReceived;
+ protected volatile TaskCompletionSource ServerDrained = NewTcs();
+
+ protected static TaskCompletionSource NewTcs()
+ => new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ protected Task RunServerLoop(Func> streamFactory)
+ => Task.Run(async () =>
+ {
+ await foreach (var _ in streamFactory())
+ {
+ if (Interlocked.Increment(ref ServerReceived) == Messages)
+ ServerDrained.TrySetResult();
+ }
+ });
+
+ protected void SetupCore(StreamConnectionOptions options, Func> streamFactory)
+ {
+ var port = GetFreePort();
+ StreamConnection Create(bool isActive)
+ => new(
+ new LengthPrefixFramer(),
+ Utf8TextCodec.Instance,
+ IPAddress.Loopback,
+ port,
+ isActive,
+ options);
+
+ Server = Create(isActive: false);
+ Client = Create(isActive: true);
+ Cts = new CancellationTokenSource();
+
+ _ = RunServerLoop(streamFactory);
+
+ Server.Start(default);
+ Client.Start(default);
+ Task.WhenAll(Server.WaitForConnectedAsync(), Client.WaitForConnectedAsync()).GetAwaiter().GetResult();
+ }
+
+ [GlobalCleanup]
+ public void Cleanup()
+ {
+ Cts.Cancel();
+ Server.DisposeAsync().AsTask().GetAwaiter().GetResult();
+ Client.DisposeAsync().AsTask().GetAwaiter().GetResult();
+ Cts.Dispose();
+ }
+
+ protected static int GetFreePort()
+ {
+ var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+}
+
+/// ① 发送方式:会话绑定发送 vs 普通发送(服务端经 GetMessages 消费)。
+[MemoryDiagnoser]
+[SimpleJob(warmupCount: 2, iterationCount: 10)]
+public class SessionAwareSendBenchmarks : SessionAwareBenchmarkBase
+{
+ private long _sessionId;
+
+ [Params("SendAsync", "SendInSessionAsync")]
+ public string SendMode { get; set; } = "SendAsync";
+
+ [GlobalSetup]
+ public void Setup()
+ => SetupCore(new StreamConnectionOptions { ConnectRetryDelayMs = 200 }, () => Server.GetMessages(Cts.Token));
+
+ /// 单向吞吐:连发 1 万条 1KB。Mean 已折算为每消息。
+ [Benchmark(OperationsPerInvoke = Messages)]
+ public async Task OneWayThroughput()
+ {
+ Interlocked.Exchange(ref ServerReceived, 0);
+ ServerDrained = NewTcs();
+
+ var payload = new string('x', 1024);
+ if (SendMode == "SendInSessionAsync")
+ {
+ _sessionId = ((ISessionAwareStreamConnection)Client).CurrentSessionId;
+ for (var i = 0; i < Messages; i++)
+ await Client.SendInSessionAsync(_sessionId, payload);
+ }
+ else
+ {
+ for (var i = 0; i < Messages; i++)
+ await Client.SendAsync(payload);
+ }
+
+ await ServerDrained.Task.WaitAsync(TimeSpan.FromSeconds(30));
+ }
+}
+
+/// ② 接收视图:GetSessionMessages vs GetMessages(普通发送驱动)。
+[MemoryDiagnoser]
+[SimpleJob(warmupCount: 2, iterationCount: 10)]
+public class SessionAwareReceiveBenchmarks : SessionAwareBenchmarkBase
+{
+ [Params("GetMessages", "GetSessionMessages")]
+ public string ReceiveView { get; set; } = "GetMessages";
+
+ [GlobalSetup]
+ public void Setup()
+ => SetupCore(
+ new StreamConnectionOptions { ConnectRetryDelayMs = 200 },
+ () => ReceiveView == "GetMessages"
+ ? Server.GetMessages(Cts.Token)
+ : ((ISessionAwareStreamConnection)Server).GetSessionMessages(Cts.Token).Select(m => m.Message));
+
+ /// 单向吞吐:连发 1 万条 1KB。Mean 已折算为每消息。
+ [Benchmark(OperationsPerInvoke = Messages)]
+ public async Task OneWayThroughput()
+ {
+ Interlocked.Exchange(ref ServerReceived, 0);
+ ServerDrained = NewTcs();
+
+ var payload = new string('x', 1024);
+ for (var i = 0; i < Messages; i++)
+ await Client.SendAsync(payload);
+
+ await ServerDrained.Task.WaitAsync(TimeSpan.FromSeconds(30));
+ }
+}
+
+/// ③ 未完成帧超时:关闭(默认) vs 开启未触发(500ms,帧内不会超时)。
+[MemoryDiagnoser]
+[SimpleJob(warmupCount: 2, iterationCount: 10)]
+public class IncompleteFrameTimeoutBenchmarks : SessionAwareBenchmarkBase
+{
+ [Params("Off", "On_Untriggered")]
+ public string TimeoutMode { get; set; } = "Off";
+
+ [GlobalSetup]
+ public void Setup()
+ => SetupCore(
+ new StreamConnectionOptions
+ {
+ ConnectRetryDelayMs = 200,
+ IncompleteFrameTimeoutMs = TimeoutMode == "On_Untriggered" ? 500 : 0,
+ },
+ () => Server.GetMessages(Cts.Token));
+
+ /// 单向吞吐:连发 1 万条 1KB。Mean 已折算为每消息。
+ [Benchmark(OperationsPerInvoke = Messages)]
+ public async Task OneWayThroughput()
+ {
+ Interlocked.Exchange(ref ServerReceived, 0);
+ ServerDrained = NewTcs();
+
+ var payload = new string('x', 1024);
+ for (var i = 0; i < Messages; i++)
+ await Client.SendAsync(payload);
+
+ await ServerDrained.Task.WaitAsync(TimeSpan.FromSeconds(30));
+ }
+}
+
+file static class AsyncEnumerableLinq
+{
+ // System.Linq.Async 依赖避免:仅用于把会话视图投影回消息流
+ public static async IAsyncEnumerable Select(
+ this IAsyncEnumerable source, Func selector)
+ {
+ await foreach (var item in source)
+ yield return selector(item);
+ }
+}