.NET 巅峰对决:微秒级优化,让高频交易系统媲美 C++ 性能

作者:微信公众号:【架构师老卢】
7-20 18:43
12

当我看到延迟飙升的那一刻,我就知道出问题了。我们的 FIX 引擎每秒处理数百万条消息,已经调优到了极致——至少我们是这样认为的。GC(垃圾回收)很干净。没有终结器(Finalizers)。到处都是 Spanstackalloc

然而,每处理几十万条消息,就会出现一次抖动(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-countersperfview 显示没有垃圾回收发生。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.ReadVolatile.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);

通过 ArrayPoolstackalloc 修复:

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 的世界里有一席之地,你需要:

  1. 将每一次内存分配视为问题。 (Treat every allocation as a problem.)
  2. 用自定义的、领域特定的数据结构替换通用数据结构。 (Replace data structures with custom, domain-specific ones.)
  3. 最小化系统调用——尤其是时间和 I/O 相关的。 (Minimise system calls — especially for time and I/O.)
  4. 在热路径上远离线程池。 (Stay off the thread pool in hot paths.)
  5. 对齐你的数据。字面意义上的。缓存行很重要。 (Align your data. Literally. Cache lines matter.)
  6. 了解 JIT 做了什么。以及何时需要智胜它。 (Learn what the JIT does. And when to outsmart it.)

.NET 已经走过了漫长的道路。借助 Span<T>ref structUnsafe 和源生成器(source generators),你可以编写出几乎与手工调优的 C++ 一样快的代码——但更安全,生产力更高。工具链很完善。运行时很成熟。但性能不是免费的。

它是赢得的。用汗水、十六进制编辑器和无数的火焰图(flame graphs)。

而在高频交易中,这就是利润与噪音之间的区别。

相关留言评论
昵称:
邮箱:
阅读排行