当我看到延迟飙升的那一刻,我就知道出问题了。我们的 FIX 引擎每秒处理数百万条消息,已经调优到了极致——至少我们是这样认为的。GC(垃圾回收)很干净。没有终结器(Finalizers)。到处都是 Span
和 stackalloc
。
然而,每处理几十万条消息,就会出现一次抖动(jitter)。仅仅 1-2 毫秒——但在高频交易(HFT)领域,这简直是永恒。这意味着亏钱。
这是一个关于我们如何将一个基于 .NET 的交易系统转变为对微秒敏感的机器的故事。这不是给胆小者看的指南。我们将深入内部——低分配(low allocations)、内存固定(pinning)、激进的对象池(aggressive pooling)、无锁结构(lock-free structures)和性能计数器(performance counters)。
每一行代码都遵循着这句箴言:微秒至关重要(microseconds matter)。
问题并非始于生产环境。它始于我的笔记本电脑。
我们当时正在测试一个市场数据接收模块。基准测试显示其 99.99% 百分位延迟(99.99 percentile latency)为 400 微秒(µs)。这很可观——直到那个峰值出现。有一个样本跳到了 1.6 毫秒(ms)。并且是持续性的。每几百万次迭代就会出现一次。
第一反应:GC。但 dotnet-counters
和 perfview
显示没有垃圾回收发生。Gen0/1/2 的回收计数都是平的。我们甚至设置了 GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency
。没用。
然后我突然意识到:我们使用了 ConcurrentQueue<T>
来缓冲传入的数据。它对吞吐量很好——但其内存布局背叛了我们。
我将其替换为一个自定义的单生产者、单消费者(single-producer, single-consumer)环形缓冲区(ring buffer)。无锁。只用原子操作(atomic ops)。我们手动将其对齐到缓存行(cache lines)以避免伪共享(false sharing)。
[StructLayout(LayoutKind.Explicit, Size = 128)]
struct PaddedLong
{
[FieldOffset(64)]
public long Value;
}
使用 Volatile.Read
和 Volatile.Write
,我们消除了大部分的停顿。抖动下降到了 800 微秒。仍然不够好。
性能分析器(profiler)显示 DateTime.UtcNow
偶尔会出现峰值。那时我才意识到:我们的时间戳记录受限于系统调用(system-call bound)。我们每秒要调用操作系统数千次。
因此,我们使用 QueryPerformanceCounter
编写了一个高分辨率时间戳缓存。由一个专用线程每 50 微秒刷新一次。消费者线程只需从内存中一个固定的位置(pinned location)读取它——零系统调用(zero syscall),接近零开销。
结果如何?亚 500 微秒(Sub-500µs)的延迟。但我们还没完成。
内存仍然是个问题。我们到处使用 Span<T>
和 Memory<T>
,但某些第三方库坚持进行内存分配。一个协议解码器在紧密循环中使用了古老的 new byte[]
模式。
我们重写了它。手动进行缓冲区切片(buffer slicing)。通过在整个管道中重用单个 ArrayPool<byte>.Shared
缓冲区来避免分配。引入了一个作用域租赁模式(scoped rental pattern):
using var buffer = PooledBuffer.Rent(4096);
// 在此代码块内安全地使用 buffer.Span
GC 的影响降得非常低,以至于性能计数器(perf counters)都无法检测到高于采样阈值的噪音。
另一个重大胜利来自于抛弃 async
。
是的,你没看错。
Async/await
会带来开销——即使很小。我们移除了关键路径(critical path)上的每一个 async
调用。市场数据处理程序是热路径(hot paths)——我们让它们变得紧凑、顺序化,并使用 ThreadAffinity
将它们固定(pinned)到专用线程上。
我们甚至更进一步:使用 ProcessThread.IdealProcessor
将进程锁定到特定的 CPU 核心。操作系统无法将线程从它们的 CPU 上抢走。L2 和 L3 缓存命中率飙升。上下文切换(Context switches)骤降。
这给了我们一致性。延迟现在变得平坦——即使在每秒 1000 万条消息的情况下也没有峰值。
但即便如此,我们还是发现了边缘情况。
我们的 JSON 日志记录器——本应只记录错误——仍在疯狂分配内存。因为有时,当你记录一个罕见错误时,格式化管道会触发一系列装箱(boxings)和字符串插值(string interpolations)。
因此,我们引入了一个无分配的结构化日志记录器(no-allocation structured logger)。它直接写入内存中预分配好的环形缓冲区(pre-allocated circular buffer)。我们在一个单独的线程上异步刷新到磁盘。没有分配。没有意外。
接着是网络栈(network stack)。
Kestrel 很快——但对 HFT 来说还不够快。我们无法承受用于内部数据交换的完整 HTTP 栈的开销。因此,我们在 SocketAsyncEventArgs
之上构建了一个自定义的 TCP 协议,并进行了激进的对象池(pooled aggressively)。
为避免内存拷贝,我们使用了零拷贝解析(zero-copy parsing)。缓冲区通过固定的内存(pinned memory)传入;我们使用 MemoryMarshal.Cast<byte, MyStruct>()
直接解析到结构体(structs)中。
我们用 Wireshark 和 Hex Editor Neo 验证了每一个字节。二进制解析很脆弱——你搞错一个偏移量(offset),一切就崩溃了。但一旦成功,它就飞起来了。
最终,我们构建了一个能在我们特定领域与 C++ 竞争的 .NET 系统。
这不是魔法。这是不懈的性能剖析(relentless profiling)。不懈的精简(relentless trimming)。像鹰一样盯着内存分配(allocations)。质疑每一个抽象(abstraction)。使用 BenchmarkDotNet 和我们自己的挂钟计时器(wall clock timers)进行基准测试。盯着 Visual Studio 中的反汇编(disassembly)问:“JIT 到底在搞什么鬼?”
欺骗我们的队列(The Queue That Lied to Us)
第一个目标:ConcurrentQueue<T>
。简洁的 API,可靠的吞吐量。但延迟?不稳定。
// 原始代码
var queue = new ConcurrentQueue<byte[]>();
// 替换方案:单生产者单消费者环形缓冲区 (Single Producer Single Consumer Ring Buffer)
public class SpscRingBuffer<T>
{
private readonly T[] _buffer;
private int _head;
private int _tail;
public SpscRingBuffer(int size)
{
_buffer = new T[size];
}
public bool TryEnqueue(T item)
{
var next = (_head + 1) % _buffer.Length;
if (next == _tail) return false; // 满了 (Full)
_buffer[_head] = item;
_head = next;
return true;
}
public bool TryDequeue(out T item)
{
if (_tail == _head)
{
item = default!;
return false; // 空了 (Empty)
}
item = _buffer[_tail];
_tail = (_tail + 1) % _buffer.Length;
return true;
}
}
基准测试结果 (BenchmarkDotNet):
[图表显示 SpscRingBuffer 的延迟分布更窄更稳定]
无系统调用的时间戳记录(Timestamping Without Syscalls)
每一次 DateTime.UtcNow
调用都是一次操作系统之旅。这拖垮了我们。
我们使用 QueryPerformanceCounter
构建了一个高精度、无锁的时间戳缓存。
public static class HighResTime
{
private static long _frequency = Stopwatch.Frequency;
private static double _tickLength = 1_000_000.0 / _frequency;
public static long NowMicroseconds => (long)(Stopwatch.GetTimestamp() * _tickLength);
}
优化前 (Before)
var ts = DateTime.UtcNow; // 每次调用约 ~120ns
优化后 (After):
var ts = HighResTime.NowMicroseconds; // 每次调用约 ~13ns
对象池,否则灭亡(Pool or Perish)
协议解码器为每条消息创建数组。
// 错误做法 (BAD): 每次都分配
byte[] buffer = new byte[1024];
stream.Read(buffer, 0, buffer.Length);
通过 ArrayPool
和 stackalloc
修复:
var buffer = ArrayPool<byte>.Shared.Rent(1024);
try
{
stream.Read(buffer, 0, 1024);
Process(buffer.AsSpan(0, 1024));
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
基准测试结果:
[图表显示使用池后分配次数和 GC 时间大幅下降]
异步的代价(The Async Tax)
异步并非免费——即使是 ValueTask
也有开销。
// 原始代码
public async Task ProcessMessageAsync() => await _service.DoWorkAsync();
// 替换方案
public void ProcessMessage() => _service.DoWork();
我们将 async
保留给非关键路径(non-critical paths)。在热循环(hot loops)中,我们在专用线程上运行代码——线程亲和(thread-affined)、CPU 绑定(CPU-bound)。
Thread thread = new(() =>
{
Thread.CurrentThread.IsBackground = false;
Thread.BeginThreadAffinity(); // 绑定到 CPU (Pin to CPU)
ProcessMarketData();
});
thread.Start();
结果: 消息分发的延迟下降了约 18%。
无锁日志记录(Lock-Free Logging)
// 优化前: 结构化日志记录涉及装箱 (Structured logging with boxing)
_logger.LogInformation("Price: {Price}, Volume: {Volume}", price, volume);
// 优化后: 无锁环形日志缓冲区 (Lock-free circular log buffer)
struct LogEntry
{
public long Timestamp;
public double Price;
public int Volume;
}
LogEntryBuffer.Write(new LogEntry
{
Timestamp = HighResTime.NowMicroseconds,
Price = price,
Volume = volume
});
没有格式化器(formatters)。没有字符串插值(string interpolation)。只有原始结构体(raw structs)。日志在后台线程刷新。
网络优化(Network Optimizations)
Kestrel 增加了不必要的负担。我们需要具有零拷贝解析(zero-copy parsing)的原始 TCP。
unsafe struct MessageHeader
{
public fixed byte Tag[4];
public int Length;
}
Span<byte> raw = ...; // 接收到的字节
ref var header = ref MemoryMarshal.AsRef<MessageHeader>(raw);
零分配(Zero allocation)。对齐的内存(Aligned memory)。无解析开销(No parsing overhead)。
基准测试结果:
[图表显示自定义 TCP 协议延迟远低于 Kestrel]
经验总结(Takeaways)
如果你想让 .NET 在 HFT 的世界里有一席之地,你需要:
.NET 已经走过了漫长的道路。借助 Span<T>
、ref struct
、Unsafe
和源生成器(source generators),你可以编写出几乎与手工调优的 C++ 一样快的代码——但更安全,生产力更高。工具链很完善。运行时很成熟。但性能不是免费的。
它是赢得的。用汗水、十六进制编辑器和无数的火焰图(flame graphs)。
而在高频交易中,这就是利润与噪音之间的区别。