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

写在前面

Stream 从 .NET 1.0 延续至今,API 随异步模型和内存抽象演进:同步数组重载、TAP 异步、Span<byte> / Memory<byte>,以及面向高吞吐协议处理的 System.IO.Pipelines。这些扩展反映需求演进,不应简单归结为原始设计失败。

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

Stream 用统一抽象覆盖文件、网络、内存、加密和压缩;高吞吐协议处理还需要池化、多段缓冲区与显式背压,这正是 Pipelines 补充而非全面替代 Stream 的地方。

理解了这层张力,就能看清 .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 7+ 已经原生提供;异步重载可接收 CancellationToken:
await s.ReadExactlyAsync(buf);          // 读满,否则抛 EndOfStreamException
await s.ReadAtLeastAsync(buf, 100);     // 至少 100 字节
1
2
3
4
5
6
7
ReadExactly 的实现:
  .NET 7 引入的便利 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 7+)

1
2
3
4
5
6
7
static async Task ReadFrameAsync(
    Stream stream, Memory<byte> buffer, CancellationToken ct)
{
    await stream.ReadExactlyAsync(buffer, ct);
    await stream.ReadAtLeastAsync(
        buffer, 100, throwOnEndOfStream: false, cancellationToken: ct);
}

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
PipeReader reader = PipeReader.Create(stream);
try
{
    while (true)
    {
        ReadResult result = await reader.ReadAsync();
        ReadOnlySequence<byte> buffer = result.Buffer;
        SequencePosition consumed = ProcessProtocol(buffer);
        reader.AdvanceTo(consumed, buffer.End);

        if (result.IsCompleted && buffer.Slice(consumed).IsEmpty)
            break;
    }
}
finally
{
    await reader.CompleteAsync();
}
1
2
3
4
5
6
7
8
革命性:
  1. 不用自己分配 buffer(Pipe 内部池化)
  2. Pipe 管理缓冲区;协议解析器仍需循环并保留不完整帧
  3. 多段连续内存(ReadOnlySequence<byte>)
  4. 背压(Pipe 满了写不动)
  5. 减少应用层不必要的复制(不代表整个 I/O 路径零拷贝)

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

四、同步 vs 异步:按工作负载选择 FileStream 选项

这是另一个高频坑。

4.1 默认句柄按同步方式打开

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
实际行为:
  - 默认构造函数没有设置 `FileOptions.Asynchronous`
  - 异步重载仍可调用,但具体实现与成本因操作系统和运行时版本而异
  - 不要用“假异步”概括所有平台

为什么?
  - Windows 文件 IO 默认是同步的
  - 要真正异步需要 FILE_FLAG_OVERLAPPED + 完成端口
  - 是否值得异步打开,要看并发度、访问模式和平台

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 可使用 overlapped I/O + IOCP;Unix 实现由运行时决定
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
FileOptions.Asynchronous 的效果:
  - Windows:内部用 OVERLAPPED + IOCP
  - Linux/Unix:不能笼统声称使用 io_uring;.NET 运行时的 io_uring 支持长期仍是独立规划项
  - 实际线程占用和收益依平台、文件系统与运行时实现而变

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

  → 需要异步句柄语义时,显式使用 `FileOptions.Asynchronous` 或 `FileStreamOptions.Options`

4.3 性能对比

1
不存在脱离磁盘、文件系统、平台和并发模型的固定“6 倍”结果。请分别基准同步顺序 I/O、异步并发 I/O,并同时观察吞吐、延迟、线程池队列和 CPU。

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 唤醒开销)

// 并发 I/O 是否受益需要在目标平台测量

五、装饰器模式: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 cs = new CryptoStream(fs, aes.CreateEncryptor(), CryptoStreamMode.Write);
using var gz = new GZipStream(cs, CompressionMode.Compress);

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

// 数据流:
//   Write → gz 压缩 → cs 加密 → fs 落盘
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
图示:
  ┌─────────────────────┐
  │ GZipStream           │
  │  - 压缩              │
  └──────────┬──────────┘
  ┌─────────────────────┐
  │ CryptoStream         │
  │  - 加密              │
  └──────────┬──────────┘
  ┌─────────────────────┐
  │ 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 是否启用托管缓冲取决于构造参数和运行时实现
// NetworkStream 自身不增加托管缓冲;Socket 缓冲大小由平台和配置决定
// 是否再包 BufferedStream 应按读写粒度和目标 Stream 实测

// 对 CryptoStream / GZipStream 的大量小读写,外层缓冲可能减少下游调用
// 但装饰器顺序会改变语义,仍应按完整链路验证

六、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
内置的优势:
  - 基础实现复用池化缓冲区,降低每次调用的数组分配;异步状态和具体子类仍可能分配
  - 自适应 bufferSize
  - Stream 重写时有优化路径(如 MemoryStream → MemoryStream 直接拷贝)
  - 取消支持

6.3 FileStream → FileStream 的最优写法

1
2
3
4
5
// 通用且正确;具体 Stream 子类可以重写 CopyToAsync 优化
await src.CopyToAsync(dst);

// 不要假设 FileStream → FileStream 会自动改用 Windows CopyFileEx;
// 需要操作系统文件复制语义时,应直接选择相应文件 API 并验证元数据/覆盖行为。

七、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
这段基准使用 `MemoryStream`,主要测量 API、状态机和复制开销,不能代表磁盘或网络 I/O。不要在缺少 CPU、运行时版本、完整 BenchmarkDotNet 报告和误差时给出“典型结果”;应在目标 Stream 类型和并发模型上重新测量。

十、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[] 包装
    ReadOnlyMemory<byte> 可通过自定义 Stream 或第三方适配器暴露;BCL 没有 ReadOnlyMemoryStream 类型
    RecyclableMemoryStream - Microsoft.IO.RecyclableMemoryStream 库,池化版

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

  装饰器:
    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
服务器:
  Socket → NetworkStream → 用 PipeReader 包装
  → 高性能场景直接用 Pipelines API

客户端:
  HTTP 客户端优先使用 HttpClient;只有实现自定义 TCP 协议时才直接使用 Socket/NetworkStream

11.4 压缩 + 加密

1
2
3
写(从应用到文件):GZipStream → CryptoStream → FileStream
读(从文件到应用):FileStream → CryptoStream → GZipStream
注意 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 返回值永远不能假设"读满"——这是抽象的代价

写文件或网络代码时,先保证短读、取消、释放和所有权正确,再依据 profiling 选择 FileOptions.Asynchronous、池化缓冲区、CopyToAsync 或 Pipelines;不要把这些选项机械叠加。

参考资料