C#处理高并发文件写入 C#如何设计一个高吞吐量的文件写入队列

5次阅读

并发FileStream.write 崩溃主因是内核句柄竞争、i/o 阻塞与磁盘寻道放大;streamwriter 非线程安全易致异常或数据错乱;推荐 concurrentqueue + 后台线程批量刷盘解耦生产消费,兼顾性能与稳定性。

C#处理高并发文件写入 C#如何设计一个高吞吐量的文件写入队列

为什么直接 FileStream.Write 在高并发下会崩

多个线程同时调用 FileStream.Write 写同一个文件,哪怕加了 lock,也会因频繁的 I/O 阻塞、内核句柄竞争和磁盘寻道放大延迟,吞吐迅速跌穿 1MB/s。更糟的是,StreamWriter 默认带缓冲但非线程安全,跨线程写入可能触发 ObjectDisposedException 或数据错乱。

  • windows 上对同一文件的并发 WriteFile 调用会被串行化,本质仍是单点瓶颈
  • 每次写入都触发一次系统调用,上下文切换成本在万级 QPS 下不可忽略
  • 小块写入(如每条日志几十字节)会激增磁盘 I/O 次数,SSD 寿命和延迟双受损

ConcurrentQueue + 后台写入线程是最稳的起点

核心思路是解耦“生产”和“消费”:业务线程只往线程安全队列投递数据,由单一后台线程批量刷盘。这避免锁争用,也天然聚合小写请求。

  • ConcurrentQueue<string></string>ConcurrentQueue<byte></byte> 存原始数据,比 BlockingCollection 更轻量(后者含额外同步开销)
  • 后台线程用 Thread.Sleep(1)SpinWait.SpinOnce() 等待,别用 BlockingCollection.Take() —— 它在空时仍会进内核态,增加延迟
  • 攒够阈值(如 8KB 或 100 条)再刷盘,或每 10ms 强制 flush 一次,防消息积压过久
var queue = new ConcurrentQueue<byte[]>(); Task.Run(() => {     var buffer = new List<byte[]>(128);     while (!cancellationToken.IsCancellationRequested) {         if (queue.TryDequeue(out var item)) {             buffer.Add(item);             if (buffer.Sum(b => b.Length) >= 8192) Flush(buffer);         } else if (buffer.Count > 0) {             Flush(buffer);         } else {             Thread.Sleep(1); // 低频轮询,避免 CPU 空转         }     } });

MemoryMappedFile 适合超大吞吐但代价高

当单机写入持续超过 500MB/s(如金融行情快照),纯队列模型可能因内存拷贝和 GC 压力成为瓶颈。MemoryMappedFile 可绕过 .NET ,让多线程直接写共享内存页,再由独立线程调用 FlushViewOfFile 刷盘。

  • 必须用 FileOptions.RandomAccess | FileOptions.WriteThrough 创建映射,禁用系统缓存,避免 double-buffering
  • 需自行管理写位置指针long 类型原子变量),并处理跨页边界——写到页尾要手动切到下一页
  • 崩溃恢复难:映射文件不保证落盘顺序,断电可能丢最后几页数据,必须配 CRC 校验和 checkpoint 机制

别忽略 FileOptions.AsynchronousWriteAsync

如果写入目标是网络文件系统(如 SMB 共享)或云存储网关,同步 I/O 会卡死整个线程池。此时应改用异步流,但要注意陷阱:

  • FileStream 必须用 FileOptions.Asynchronous 构造,否则 WriteAsync 内部仍走同步路径
  • 不要在 async void 方法里写日志,异常会静默丢失;统一用 async Task 并 await
  • 并发 WriteAsync 到同一文件仍需锁(如 SemaphoreSlim),但锁粒度可缩小到单次 write 调用,而非整条消息

真正难的不是堆砌技术,而是确定你的瓶颈在哪——是磁盘吞吐?CPU 序列化?还是网络延迟?先用 PerfView 抓一下 ThreadPoolIO 的热区,再决定上 MemoryMappedFile 还是老实用队列。

text=ZqhQzanResources