深入 .NET Stream:从 byte[] 拉取到 Pipelines 推回,一次抽象的演进史

Stream 是 .NET 里"最古老又最常用"的抽象之一——从 1.0 时代就在那儿,每一版 .NET 都在给它打补丁:Read(byte[])ReadAsync(byte[])Read(Span<byte>)ReadAsync(Memory<byte>)ReadBytePipeReader。如果它真的设计好了,根本不需要这么多补丁。

把 Stream 的所有坑摊开看,背后其实就是同一个设计张力的不同投影

Stream 试图用"统一抽象"覆盖所有 IO(文件、网络、内存、加密、压缩),但同一个抽象对"高性能网络"和"简单文件读写"是天然矛盾的——前者要零拷贝、背压、内存池,后者只要简单 API。

理解了这层张力,就能看清 .NET 流式 API 二十年的演进:每一层补丁(Span、Memory、ValueTask、Pipelines)都是在重新回答"统一抽象到底要做到什么程度"。本文围绕这条主线把 Stream 从底层机制到选型陷阱彻底讲透。


一、Stream 是什么:抽象基类的雄心与代价

1.1 统一 IO 抽象

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public abstract class Stream : MarshalByRefObject, IAsyncDisposable, IDisposable
{
    public abstract bool CanRead { get; }
    public abstract bool CanWrite { get; }
    public abstract bool CanSeek { get; }
    public abstract long Length { get; }
    public abstract long Position { get; set; }

    public abstract int Read(byte[] buffer, int offset, int count);
    public abstract void Write(byte[] buffer, int offset, int count);
    public abstract long Seek(long offset, SeekOrigin origin);
    public abstract void SetLength(long value);
    public abstract void Flush();
}
1
2
3
4
5
6
7
8
9
设计意图:
  让"读文件 / 读网络 / 读内存 / 读加密"用同一套 API
  → 业务代码只依赖 Stream,不依赖具体实现
  → 可以层层包装(装饰器模式)

代价:
  → 抽象被迫提供所有方法,但每个子类都不一定能实现
  → CanRead / CanWrite / CanSeek 标志位遍布 API
  → 不支持的操作直接抛 NotSupportedException

1.2 四维能力(实际只有三维常用)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
能力            属性            典型实现
─────────────────────────────────────────────────
Read            CanRead         FileStream / NetworkStream / MemoryStream
Write           CanWrite        FileStream / NetworkStream / MemoryStream
Seek            CanSeek         FileStream ✅ / NetworkStream ❌ / MemoryStream ✅
Timeout         CanTimeout      NetworkStream ✅ / FileStream ❌

CanSeek = false 时:
  - Position / Length / SetLength / Seek 都抛 NotSupportedException
  - Position 是"游标"概念,对网络流没意义

CanTimeout = true 时:
  - ReadTimeout / WriteTimeout 控制读写超时(毫秒)
  - 实际效果因实现而异(FileStream 在 Windows 上有 native 重叠 IO)

1.3 一个 Stream 可以"什么都不支持"

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public class NullStream : Stream   // 实际叫 Stream.Null
{
    public override bool CanRead => true;
    public override bool CanWrite => true;
    public override bool CanSeek => true;
    public override long Length => 0;
    public override long Position { get; set; }

    public override int Read(byte[] buf, int off, int count) => 0;  // EOF
    public override void Write(byte[] buf, int off, int count) { /* 丢弃 */ }
    public override long Seek(long off, SeekOrigin org) => off;
    public override void Flush() { }
    public override void SetLength(long v) { }
}

Stream.Null 是"黑洞"——读立刻 EOF,写什么都不存。这种"无所不接"的抽象,让 Stream 可以做单元测试替身。


二、最坑的"暗契约":Read 不保证读满

这是新手最容易踩的雷,也是 Stream 抽象的"原罪"。

2.1 反模式:以为一次 Read 能读满

1
2
3
4
5
6
7
// ❌ 高风险写法
public async Task<byte[]> ReadNAsync(Stream s, int n)
{
    byte[] buf = new byte[n];
    int read = await s.ReadAsync(buf, 0, n);   // ⚠️ read 可能 < n
    return buf;   // ⚠️ 后半段可能是 0
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
为什么 Read 可能返回少于请求?
  - FileStream:通常能读满(除非到 EOF)
  - NetworkStream:可能 1 字节就返回!
    * TCP 是字节流,每次 Read 看到的是"当前缓冲区到达的数据"
    * 没有"凑够 N 字节"的概念
    * 如果对方写 4 字节后 sleep,下次 Read 就只有 4 字节

  - CryptoStream:可能解一帧就返回
  - GZipStream:可能解一 chunk 就返回

→ 契约:Read 返回"实际读到的字节数",可能是 0(EOF)到 count

2.2 正确做法:循环到 N 或 EOF

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public static async Task<int> ReadExactAsync(Stream s, byte[] buf, int offset, int count)
{
    int total = 0;
    while (total < count)
    {
        int read = await s.ReadAsync(buf, offset + total, count - total);
        if (read == 0) break;  // EOF
        total += read;
    }
    return total;
}

// .NET 6+ 已经原生提供:
await s.ReadExactlyAsync(buf);          // 读满,否则抛 EndOfStreamException
await s.ReadAtLeastAsync(buf, 100);     // 至少 100 字节
1
2
3
4
5
6
7
ReadExactly 的实现:
  .NET 6 引入的"长痛不如短痛"API
  替代手写循环(手写循环是 .NET 头号反模式之一)

注意:ReadExactly 在网络流上"卡住"直到读满或 EOF
  → 必须配合 CancellationToken
  → 否则恶意对端可以让调用永久阻塞

2.3 Read 返回 0 的意义

1
2
3
4
5
6
7
8
0 = EOF(流结束)
1~count = 实际读到
异常 = IO 错误

注意:
  - NetworkStream 上对端关连接 → Read 返回 0
  - 永远不会"Read 返回 0 然后又读到数据"
  - 收到 0 后再调 Read 还是 0

三、API 演进史:从 byte[] 到 PipeReader

每一代 .NET 都在补 Stream 的洞。

3.1 第一代:同步 byte[] API(.NET 1.0)

1
2
3
byte[] buf = new byte[1024];
int read = stream.Read(buf, 0, 1024);
stream.Write(buf, 0, read);
1
2
3
4
问题:
  - 同步阻塞 → 高并发下线程爆炸
  - byte[] 必须分配 → GC 压力
  - offset + count 三参数 → 容易越界

3.2 第二代:APM 异步(.NET 1.1+)

1
2
stream.BeginRead(buf, 0, 1024, callback, state);
// 在 callback 里调 EndRead 拿到字节数
1
2
3
4
问题:
  - 回调地狱
  - 状态对象装箱
  - 异常处理复杂

3.3 第三代:TAP 异步(.NET 4.5)

1
int read = await stream.ReadAsync(buf, 0, 1024);
1
2
3
问题:
  - 仍然 byte[] 分配
  - Task<int> 装箱(每个 await 一个 Task 对象)

3.4 第四代:Span / Memory(.NET Core 2.1+)

1
2
3
4
5
6
7
8
9
// 同步版本
int read = stream.Read(buf.AsSpan(0, 1024));

// 异步版本(基于 ValueTask)
int read = await stream.ReadAsync(buf.AsMemory(0, 1024));

// stackalloc 友好
Span<byte> stackBuf = stackalloc byte[256];
stream.Read(stackBuf);
1
2
3
4
5
6
7
8
改进:
  - Span 零分配
  - ValueTask<int> 避免每次 await 创建 Task
  - 可以用 stackalloc

遗留:
  - 仍然要"主动拉"(pull)
  - 仍然要循环到 N 字节

3.5 第五代:ReadExactly / ReadAtLeast(.NET 6+)

1
2
await stream.ReadExactlyAsync(buf);  // 自动循环
await stream.ReadAtLeastAsync(buf, 100, throwOnEndOfStream: false);

3.6 第六代:Pipelines(.NET Core 2.1+,命名空间 System.IO.Pipelines)

1
2
3
4
5
6
7
8
9
PipeReader reader = PipeReader.Create(stream);
ReadResult result = await reader.ReadAsync();
ReadOnlySequence<byte> buffer = result.Buffer;

// 处理 buffer(多段 Memory 拼接)
ProcessProtocol(buffer);

// 告诉 reader "我处理了多少"
reader.AdvanceTo(buffer.Start, buffer.End);
1
2
3
4
5
6
7
8
革命性:
  1. 不用自己分配 buffer(Pipe 内部池化)
  2. 不用循环到 N 字节(Pipe 自动缓冲)
  3. 多段连续内存(ReadOnlySequence<byte>)
  4. 背压(Pipe 满了写不动)
  5. 零拷贝(数据停留在 Pipe 池里)

ASP.NET Core / Kestrel / SignalR 全部用 Pipelines

四、同步 vs 异步:FileStream 的"假异步"陷阱

这是另一个高频坑。

4.1 默认 FileStream 异步是"假的"

1
2
3
4
// 看起来是异步
using var fs = new FileStream("data.bin", FileMode.Open);
byte[] buf = new byte[4096];
int read = await fs.ReadAsync(buf, 0, 4096);
1
2
3
4
5
6
7
8
9
实际行为:
  - 默认构造的 FileStream,ReadAsync 是"假异步"
  - 内部用一个线程池线程做同步 Read
  - 异步调用反而多了线程切换开销

为什么?
  - Windows 文件 IO 默认是同步的
  - 要真正异步需要 FILE_FLAG_OVERLAPPED + 完成端口
  - .NET 默认不开(兼容性、性能权衡)

4.2 真异步:FileOptions.Asynchronous

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// ✅ 真异步
using var fs = new FileStream(
    "data.bin",
    FileMode.Open,
    FileAccess.Read,
    FileShare.Read,
    bufferSize: 4096,
    options: FileOptions.Asynchronous   // ← 关键
);

int read = await fs.ReadAsync(buf, 0, 4096);
// 这才是真正的异步(Windows 用 IOCP,Linux 用 io_uring)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
FileOptions.Asynchronous 的效果:
  - Windows:内部用 OVERLAPPED + IOCP
  - Linux:内部用 io_uring(.NET 6+)
  - 不占用线程池线程
  - 大量并发 IO 时差距巨大

FileSteam 静态方法:
  File.OpenRead("...")          // 没开 Asynchronous
  File.Open("...", FileMode)    // 没开

  → 想要真异步,必须用完整 FileStream 构造

4.3 性能对比

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
场景:1 个进程同时读 1000 个文件,每文件 1MB

  默认(假异步):
    1000 个线程池线程阻塞
    CPU 跑满,实际 IO 不快
    → 12 秒

  FileOptions.Asynchronous(真异步):
    0 个线程池线程阻塞
    IOCP / io_uring 工作
    → 2 秒

→ 6 倍差距,这只是开了个开关

4.4 同步 ReadAsync 反而慢

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 真异步下,单线程顺序读:
await fs.ReadAsync(buf);
await fs.ReadAsync(buf);
await fs.ReadAsync(buf);
// 比同步:
fs.Read(buf);
fs.Read(buf);
fs.Read(buf);
// 慢一点(每次有 IOCP 唤醒开销)

// 但高并发下,真异步完胜

五、装饰器模式:Stream 的灵魂

Stream 是 GoF《设计模式》里 Decorator(装饰器)的教科书案例。

5.1 经典装饰器组合

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 三层包装:加密 + 压缩 + 文件
using var fs = new FileStream("data.gz.enc", FileMode.Create);
using var gz = new GZipStream(fs, CompressionMode.Compress);
using var cs = new CryptoStream(gz, aes.CreateEncryptor(), CryptoStreamMode.Write);

byte[] data = Encoding.UTF8.GetBytes("Hello, World!");
cs.Write(data, 0, data.Length);

// 数据流:
//   Write → cs 加密 → gz 压缩 → fs 落盘
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
图示:
  ┌─────────────────────┐
  │ CryptoStream         │
  │  - 加密              │
  └──────────┬──────────┘
  ┌─────────────────────┐
  │ GZipStream           │
  │  - 压缩              │
  └──────────┬──────────┘
  ┌─────────────────────┐
  │ BufferedStream       │
  │  - 缓冲              │
  └──────────┬──────────┘
  ┌─────────────────────┐
  │ FileStream           │
  │  - 落盘              │
  └─────────────────────┘

5.2 装饰器的"自动 dispose 链"

1
2
3
4
5
6
7
using var fs = new FileStream(...);
using var gz = new GZipStream(fs, ...);
// gz.Dispose() 内部会调用 fs.Dispose()
// 所以只 using 外层就够

// 但很多人不知道这点
// 容易在 finally 里手动 dispose 所有层

5.3 装饰器的所有权陷阱

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 假设你有一个"共享底层流"
using var fs = new FileStream(...);

// 包装两个装饰器,都接管 fs
using var cs1 = new CryptoStream(fs, enc1, Write);
using var cs2 = new CryptoStream(fs, enc2, Write);

cs1.Write(...);   // 内部调 fs.Write
cs2.Write(...);   // 也调 fs.Write!
// 实际写入是混乱的(fs 是有状态的)

// 正确做法:每个底层流只对应一个装饰器链

5.4 BufferedStream:什么时候有用

1
2
3
4
5
6
7
8
9
using var fs = new FileStream(...);
using var bs = new BufferedStream(fs, 8192);  // 8KB 缓冲

// 现代 FileStream 默认就有 bufferSize
// NetworkStream 默认 64KB Socket 缓冲
// → BufferedStream 在大多数情况下是冗余的

// 但 CryptoStream / GZipStream 没有 buffer
// → 包在它们外面有用

六、CopyTo / CopyToAsync:被低估的 API

6.1 自实现 vs 内置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// ❌ 手写循环
public async Task CopyAsync(Stream src, Stream dst)
{
    byte[] buf = new byte[8192];
    int read;
    while ((read = await src.ReadAsync(buf, 0, 8192)) > 0)
        await dst.WriteAsync(buf, 0, read);
}

// ✅ 内置(已经做了优化)
await src.CopyToAsync(dst, bufferSize: 8192);

6.2 CopyTo 的实现细节

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 简化版
public virtual async Task CopyToAsync(Stream dest, int bufferSize, CancellationToken ct)
{
    byte[] buffer = ArrayPool<byte>.Shared.Rent(bufferSize);
    try
    {
        int read;
        while ((read = await ReadAsync(buffer, ct)) > 0)
            await dest.WriteAsync(buffer.AsMemory(0, read), ct);
    }
    finally
    {
        ArrayPool<byte>.Shared.Return(buffer);
    }
}
1
2
3
4
5
内置的优势:
  - 用 ArrayPool(零分配)
  - 自适应 bufferSize
  - Stream 重写时有优化路径(如 MemoryStream → MemoryStream 直接拷贝)
  - 取消支持

6.3 FileStream → FileStream 的最优写法

1
2
3
4
5
6
7
8
9
// ❌ 慢
await src.CopyToAsync(dst);

// ✅ 快(用底层 Windows API CopyFile)
if (src is FileStream fs1 && dst is FileStream fs2)
{
    // .NET Core 内部已经针对 FileStream 优化
    // 直接调 Windows CopyFileEx
}

七、Pipelines:Stream 的接班人

7.1 为什么需要 Pipelines

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
Stream 抽象的根本问题:
  1. 应用必须分配 buffer(byte[])
  2. 应用必须循环 Read
  3. 应用必须处理"读不满"的情况
  4. 没有背压(写太快时 Stream 不阻塞)
  5. 单段 buffer(数据可能跨段)

  → 写一个高性能 HTTP 服务器,光是处理"读满 N 字节"就要 100 行代码

Pipelines 重新设计:
  1. Pipe 内部管理 buffer(池化)
  2. Pipe 自动累积数据
  3. 调用方一次拿到"已经够多"的数据
  4. Pipe 满了写不动(背压)
  5. ReadOnlySequence<byte> 支持多段

7.2 PipeReader / PipeWriter

1
2
3
4
5
6
7
8
9
var pipe = new Pipe(new PipeOptions(
    pool: ArrayPool<byte>.Shared,
    readerScheduler: PipeScheduler.ThreadPool,
    writerScheduler: PipeScheduler.ThreadPool,
    pauseWriterThreshold: 1024 * 1024,    // 写暂停阈值(背压)
    resumeWriterThreshold: 512 * 1024));   // 写恢复阈值

PipeWriter writer = pipe.Writer;
PipeReader reader = pipe.Reader;

7.3 协议解析示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 解析"4 字节长度 + N 字节 body"协议
async Task ProcessAsync(PipeReader reader)
{
    while (true)
    {
        ReadResult result = await reader.ReadAsync();
        ReadOnlySequence<byte> buffer = result.Buffer;

        while (TryReadMessage(ref buffer, out var message))
        {
            ProcessMessage(message);
        }

        // 告诉 reader:消费到哪,下次从哪开始
        reader.AdvanceTo(buffer.Start, buffer.End);

        if (result.IsCompleted) break;
    }
}

bool TryReadMessage(ref ReadOnlySequence<byte> buffer, out ReadOnlySequence<byte> message)
{
    if (buffer.Length < 4) { message = default; return false; }

    Span<byte> lengthBytes = stackalloc byte[4];
    buffer.Slice(0, 4).CopyTo(lengthBytes);
    int length = BitConverter.ToInt32(lengthBytes);

    if (buffer.Length < 4 + length) { message = default; return false; }

    message = buffer.Slice(4, length);
    buffer = buffer.Slice(4 + length);
    return true;
}
1
2
3
4
5
对比 Stream 版本:
  - 没有手动 buffer 分配
  - 没有手动"读满 4 字节"循环
  - 处理"半个消息"自动等下次数据
  - 多段 Sequence 天然支持

7.4 Stream 与 Pipe 的桥接

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 从 Stream 创建 PipeReader
PipeReader reader = PipeReader.Create(stream, new StreamPipeReaderOptions(
    bufferSize: 4096,
    leaveOpen: false));

// 从 Stream 创建 PipeWriter
PipeWriter writer = PipeWriter.Create(stream, new StreamPipeWriterOptions(
    leaveOpen: false));

// 或者反过来:把 Pipe 当 Stream 用
Stream stream = pipe.Writer.AsStream();
1
2
3
4
5
ASP.NET Core 的请求体就是 PipeReader:
  HttpContext.Request.BodyReader : PipeReader
  HttpContext.Response.BodyWriter : PipeWriter

  → 不再是 Stream,而是 Pipeline

八、常见误用清单

8.1 不 Dispose 流

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// ❌ 流泄漏
public byte[] Read(string path)
{
    var fs = new FileStream(path, FileMode.Open);
    var ms = new MemoryStream();
    fs.CopyTo(ms);
    return ms.ToArray();   // fs 和 ms 都没 Dispose
}

// ✅ using
public byte[] Read(string path)
{
    using var fs = new FileStream(path, FileMode.Open);
    using var ms = new MemoryStream();
    fs.CopyTo(ms);
    return ms.ToArray();
}

8.2 异步方法不 await

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// ❌ 火并忘记(fire and forget)
public void BadWrite(Stream s, byte[] data)
{
    s.WriteAsync(data, 0, data.Length);   // ⚠️ 没 await
    // 方法立即返回,写还没完成
}

// ✅ await 或显式处理
public async Task WriteAsync(Stream s, byte[] data)
{
    await s.WriteAsync(data);
}

8.3 不开 Asynchronous

1
2
3
4
5
6
7
8
// ❌ 假异步
using var fs = File.OpenRead("data.bin");   // 默认 options
await fs.ReadAsync(buf);   // 实际是线程池阻塞

// ✅ 真异步
using var fs = new FileStream("data.bin", FileMode.Open, FileAccess.Read,
    FileShare.Read, 4096, FileOptions.Asynchronous);
await fs.ReadAsync(buf);

8.4 不用 ArrayPool

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// ❌ 每次 new
public async Task<byte[]> ReadAsync(Stream s)
{
    byte[] buf = new byte[4096];   // ⚠️ GC 压力
    using var ms = new MemoryStream();
    int read;
    while ((read = await s.ReadAsync(buf)) > 0)
        ms.Write(buf, 0, read);
    return ms.ToArray();
}

// ✅ ArrayPool
public async Task<byte[]> ReadAsync(Stream s)
{
    byte[] buf = ArrayPool<byte>.Shared.Rent(4096);
    try
    {
        using var ms = new MemoryStream();
        int read;
        while ((read = await s.ReadAsync(buf.AsMemory(0, 4096))) > 0)
            ms.Write(buf, 0, read);
        return ms.ToArray();
    }
    finally
    {
        ArrayPool<byte>.Shared.Return(buf);
    }
}

// ✅✅ CopyToAsync(最简)
public async Task<byte[]> ReadAsync(Stream s)
{
    using var ms = new MemoryStream();
    await s.CopyToAsync(ms);
    return ms.ToArray();
}

8.5 信任 CanSeek

1
2
3
4
5
6
7
8
9
// ❌ 假设 stream 可 Seek
stream.Position = 0;
stream.Read(...);

// ✅ 检查
if (stream.CanSeek)
    stream.Position = 0;
else
    throw new InvalidOperationException("Stream is not seekable");

8.6 CryptoStream 不 FlushFinalBlock

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// ❌ 加密数据不完整
using var cs = new CryptoStream(ms, enc, CryptoStreamMode.Write);
cs.Write(data, 0, data.Length);
// 没有 cs.FlushFinalBlock() 或 Dispose → AES 填充没写

// ✅ Dispose 自动 flush
using (var cs = new CryptoStream(ms, enc, CryptoStreamMode.Write))
{
    cs.Write(data, 0, data.Length);
}   // ← Dispose 触发 FlushFinalBlock

九、性能基准

9.1 Read 模式对比

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
[MemoryDiagnoser]
public class StreamBench
{
    private MemoryStream _stream;

    [GlobalSetup]
    public void Setup()
    {
        var data = new byte[1_000_000];
        new Random().NextBytes(data);
        _stream = new MemoryStream(data);
    }

    [Benchmark]
    public int ReadAll_Array()
    {
        _stream.Position = 0;
        byte[] buf = new byte[4096];
        int total = 0;
        int read;
        while ((read = _stream.Read(buf, 0, buf.Length)) > 0) total += read;
        return total;
    }

    [Benchmark]
    public async Task<int> ReadAllAsync_Array()
    {
        _stream.Position = 0;
        byte[] buf = new byte[4096];
        int total = 0;
        int read;
        while ((read = await _stream.ReadAsync(buf, 0, buf.Length)) > 0) total += read;
        return total;
    }

    [Benchmark]
    public async Task<int> ReadAllAsync_Pooled()
    {
        _stream.Position = 0;
        byte[] buf = ArrayPool<byte>.Shared.Rent(4096);
        try
        {
            int total = 0;
            int read;
            while ((read = await _stream.ReadAsync(buf.AsMemory(0, 4096))) > 0) total += read;
            return total;
        }
        finally
        {
            ArrayPool<byte>.Shared.Return(buf);
        }
    }

    [Benchmark]
    public async Task<int> ReadAll_CopyToAsync()
    {
        _stream.Position = 0;
        using var ms = new MemoryStream();
        await _stream.CopyToAsync(ms, 4096);
        return (int)ms.Length;
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
典型结果(.NET 8):
  ReadAll_Array         : ~280 μs, 4096 B allocated
  ReadAllAsync_Array    : ~320 μs, 5 KB allocated
  ReadAllAsync_Pooled   : ~300 μs, 0 B allocated   ← 推荐
  ReadAll_CopyToAsync   : ~280 μs, 0 B allocated   ← 最简洁

观察:
  - CopyToAsync 性能与最优手写持平
  - ArrayPool 版几乎零分配
  - 普通 byte[] 分配每秒万次 → GC 噪音

十、Stream 子类速查

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
按用途分类:

  文件:
    FileStream         - 文件 IO(默认 buffered)
    FileStream(FileOptions.Asynchronous) - 真异步
    UnmanagedMemoryStream - 内存映射文件 / 非托管内存

  网络:
    NetworkStream      - Socket 包装
    SslStream          - SSL/TLS 加密层
    AuthenticatedStream - 基类,可继承做认证

  内存:
    MemoryStream       - byte[] 包装
    ReadOnlyMemoryStream - 不可变切片
    RecyclableMemoryStream - Microsoft.IO.RecyclableMemoryStream 库,池化版

  管道:
    AnonymousPipeServerStream / ClientStream - 父子进程
    NamedPipeServerStream / ClientStream     - 命名管道

  装饰器:
    BufferedStream     - 缓冲
    CryptoStream       - 加密
    GZipStream / DeflateStream / BrotliStream - 压缩

  其他:
    Stream.Null        - 黑洞
    IsolatedStorageFileStream - 沙盒存储

十一、选型决策

11.1 读文件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
默认:
  using var fs = new FileStream(path, FileMode.Open, FileAccess.Read,
      FileShare.Read, 4096, FileOptions.Asynchronous);

   读大文件 / 高并发必开 Asynchronous
   简单一次性读取:File.ReadAllBytesAsync(内部就是上面的封装)

需要 seek
   FileStream 默认支持

需要内存映射:
  MemoryMappedFile.CreateFromFile(...)

11.2 写文件

1
2
3
4
5
6
7
8
9
默认:
  using var fs = new FileStream(path, FileMode.Create, FileAccess.Write,
      FileShare.None, 4096, FileOptions.Asynchronous);

    BufferedStream 收益不大(FileStream 已有 buffer
   FileOptions.WriteThrough 用于关键数据(绕过 OS 缓存,直接落盘)

追加:
  File.AppendAllTextAsync / AppendAllBytesAsync

11.3 网络 IO

1
2
3
4
5
6
7
服务器:
  Socket → NetworkStream → 用 PipeReader 包装
  → 高性能场景直接用 Pipelines API

客户端:
  HttpClient(已经用 Pipelines 内部)
  → 不要手动 new NetworkStream

11.4 压缩 + 加密

1
2
3
写:CryptoStream → GZipStream → FileStream
读:FileStream → GZipStream → CryptoStream
注意 dispose 顺序(外层先 dispose,自动级联)

十二、小结

本文讲了 Stream 抽象从 .NET 1.0 到 9 的演进:

  • Stream 的"统一抽象"雄心:用基类覆盖所有 IO,代价是 CanXxx 标志遍布
  • 最大的暗契约:Read 不保证读满,循环到 N 字节是基本功
  • API 六代演进:同步 → APM → TAP → Span/Memory → ReadExactly → Pipelines
  • FileStream 的"假异步"陷阱:必须显式开 FileOptions.Asynchronous
  • 装饰器模式:GZipStream(CryptoStream(FileStream)) 的级联与 dispose
  • CopyToAsync 内部已经用 ArrayPool + Span 优化,优先用
  • Pipelines 是 Stream 的"接班人":池化 buffer + 背压 + 多段 Sequence
  • ASP.NET Core 内部已经从 Stream 迁移到 PipeReader/Writer
  • 6 类常见误用:不 Dispose / 不 await / 不开 Async / 不用 Pool / 信任 CanSeek / 不 Flush
  • 子类速查与选型决策
1
2
3
4
记住三句话:
  1. Stream 是"拉"模式 + "同步"基础,Pipelines 是"推"模式 + 背压原生
  2. FileStream 不开 Asynchronous 等于自废武功
  3. Stream.Read 返回值永远不能假设"读满"——这是抽象的代价

下次写文件/网络代码时,先问自己:要不要直接用 Pipelines?不需要的话至少开 FileOptions.Asynchronous + ArrayPool + CopyToAsync。性能立刻上一个台阶。