IBufferWriter<byte> 不是"更快的 Stream",而是另一种写入契约。本文从 GetSpan、Advance 和 BufferWriterStream 适配器出发,说明它何时能减少临时分配,何时仍然存在复制。
如果你写过一点高性能 .NET 代码,大概率见过 IBufferWriter<T> 这个接口。它在 ASP.NET Core、Kestrel、gRPC 等基础设施里经常出现,但很多业务开发者对它比较陌生。
下面直接从 Magicodes.IE.IO 的写入路径出发,说明它和 Stream、byte[] 分别解决什么问题。
先看一个缓冲写入场景
假设你要往一个缓冲区写一些字节。最常见的写法是这样:
csharp
byte[] buffer = new byte[1024];
int pos = 0;
// 每次要写东西,先确保空间,再拷贝
Array.Copy(source, 0, buffer, pos, source.Length);
pos += source.Length;
问题在哪?new byte[1024] 是每次都新建的。如果这段代码位于逐行写入的高频路径,重复分配会进入 GC 成本的一部分。你可能会说"用 ArrayPool"------对,但 ArrayPool 怎么和现有的写入 API 配合?传统的 Stream.Write(byte[], int, int) 需要调用方先准备好一块数组;现代 Stream 也提供 Span/Memory 重载,但数据仍然要由调用方准备。
IBufferWriter<byte> 就是为解决这个配合问题而生的。
IBufferWriter 的三个方法
接口只有三个成员:
csharp
public interface IBufferWriter<T>
{
void Advance(int count);
Memory<T> GetMemory(int sizeHint = 0);
Span<T> GetSpan(int sizeHint = 0);
}
关键是 GetSpan() 和 GetMemory()------它们直接把内部缓冲区的 Span<T>/Memory<T> 暴露给你 。你可以直接往这块内存上写,写完了调 Advance(count) 告诉它你写了多少。
当生产者直接取得 GetSpan() 并在目标区域写入时,可以避免为该片段额外创建中间数组。若调用方手中已经有一段源数据,复制到 writer 提供的目标区域仍然存在;IBufferWriter<byte> 解决的是目标缓冲区的所有权和复用问题,不是让所有调用链天然零拷贝。
我们的 ByteBufferWriter
Magicodes.IE.IO 里的 ByteBufferWriter 是 IBufferWriter<byte> 的一个实现。它内部用 ArrayPool<byte> 租用缓冲区,在写入管线中承担局部 XML 字节积累,并对外暴露 GetSpan:
csharp
public Span<byte> GetSpan(int sizeHint = 0)
{
EnsureCapacity(sizeHint);
return _buffer.AsSpan(_pos);
}
调用方拿到 Span<byte>,直接在上面写:
csharp
public void WriteUtf8(ReadOnlySpan<byte> utf8)
{
int len = utf8.Length;
if (_pos + len > _buffer.Length)
EnsureCapacity(len);
utf8.CopyTo(_buffer.AsSpan(_pos));
_pos += len;
}
注意这里没有 new byte[],没有 Array.Copy 创建新数组。utf8 的字节直接 CopyTo 到池化缓冲区。
为什么不直接用 Stream
Stream 抽象的是"字节流",它的 Write(byte[] buffer, int offset, int count) 要求调用方提供一个已经存在的 byte[]。这个 byte[] 要么是调用方 new 的(分配),要么是从别处拷贝过来的(拷贝)。
IBufferWriter<byte> 抽象的是"可写的缓冲区",它把目标内存交给写入方使用。直接面向这个接口写入时,可以避免为每个片段创建中间数组;但它不等于任何场景都零拷贝,写入方如果本身已经有一块输入缓冲区,仍然需要把数据复制到 writer 提供的目标区域。
Stream 由调用方提供源数据;IBufferWriter<byte> 由目标方提供可写区域,调用方提交已写长度。
把 IBufferWriter 当 Stream 用
有时候我们的代码已经写成了接受 Stream 的 API(比如 .NET 的很多标准接口都是 Stream)。这时候需要一个适配器,把 IBufferWriter<byte> 包装成一个 Stream------这就是 BufferWriterStream:
csharp
public override void Write(byte[] buffer, int offset, int count)
{
EnsureNotDisposed();
var dest = _writer.GetSpan(count); // 直接拿缓冲区 Span
buffer.AsSpan(offset, count).CopyTo(dest);
_writer.Advance(count);
}
BufferWriterStream 继承自 Stream,但对 Write 的实现是:从 IBufferWriter<byte> 拿一块 Span,把传入的数据 CopyTo 过去,然后 Advance。它的价值是兼容只接受 Stream 的上层组件,并把最终目标放在 writer 的缓冲区中;这条适配路径仍有一次复制,不能称为零拷贝。
比如 Xlsx.Write(IBufferWriter<byte> output, ...) 这个重载,就是用一个 BufferWriterStream 把用户的 IBufferWriter<byte> 包成 Stream 喂给引擎:
csharp
XlsxWritePipeline.Run(new BufferWriterStream(output), data, configure, options);
用户的 IBufferWriter<byte>(比如某个响应缓冲区)直接收到字节,不经过 MemoryStream 这种中间层。
结尾
IBufferWriter<byte> 是 .NET 高性能 I/O 的一个基础抽象。它的价值不在于三个方法本身,而在于把"取得可写区域"和"提交已写长度"这两个动作固定下来,让调用方可以复用目标缓冲区,少做临时数组和中间拷贝。
Magicodes.IE.IO 里有几层写入围绕这个接口组织:ByteBufferWriter 实现它,BufferWriterStream 负责适配,上层的 XML 拼接和 ZIP 写入最终通过这些缓冲区落地。它不显眼,但能减少写入过程中的中间物化。