导语
当文件巨大如山河,程序如何像一位精通水利的工程师,既能引水灌溉,又不致淹没堤坝?——本篇文章用直观的比喻与大量可运行的 C# 示例,带你系统、细致、并且生动地掌握“大文件读写”这门工艺。从最基础的 FileStream 到高性能的 MemoryMappedFile、Pipelines 与 ArrayPool,包括压缩、并发读写、进度与取消控制、行级流式读取、以及常见陷阱和实战技巧。你若愿意,可以将示例直接放入 .NET 6/7/8 项目中运行。
为什么需要特殊处理“大文件”?
- 内存有限:一次把一个 10GB 文件读进内存会导致 OOM(内存溢出)。
- IO 成本高:不合理的缓冲与同步/异步策略可能导致程序变慢,甚至耗尽系统资源。
- 并发与共享:像日志或数据库导出等场景,需要并发写入或边读边写,必须处理文件锁、共享模式与原子替换。
- 可用性与恢复:长时间操作要求有进度反馈与可中断能力,或需要断点续传与校验。
本文目标
- 给出清晰的概念、策略与对比;
- 提供可运行的 C# 示例(使用现代 API,如
Memory<byte>、ArrayPool、IAsyncEnumerable、await using、Pipelines等); - 列出常见陷阱与性能调优建议。
一、流(Stream)的本质:数据是一条河
在读写文件时,FileStream 就像一条河道,数据是河水,缓冲区则像闸门与水桶。我们不能把河水一次性装进房间(即内存),而需要分批取水、处理后再放回。流式处理保持内存占用恒定,使得程序可以处理任意大小的文件。
二、基本 API 回顾:FileStream、StreamReader/Writer、BinaryReader/Writer
同步读取示例(分块读取字节):
using System;
using System.IO;
class BasicReadSync
{
public static void ReadFile(string path)
{
const int bufferSize = 81920; // .NET default for CopyTo / FileStream buffering
byte[] buffer = new byte[bufferSize];
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
int bytesRead;
long total = 0;
while ((bytesRead = fs.Read(buffer, 0, buffer.Length)) > 0)
{
// 处理 buffer[0..bytesRead)
total += bytesRead;
}
Console.WriteLine($"Read {total} bytes (sync).");
}
}
解析:
bufferSize的选择会影响系统调用次数。太小会导致频繁 IO;太大则浪费内存并可能触发更慢的路径。FileShare.Read表示允许其他进程同时读取。
同步写入示例(覆盖写入):
using System;
using System.IO;
class BasicWriteSync
{
public static void WriteFile(string path, byte[] data)
{
using var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None);
fs.Write(data, 0, data.Length);
fs.Flush(true); // true -> flush OS buffers to disk (代价高)
}
}
小结:
FileMode(Create/Append/Open)、FileAccess、FileShare、bufferSize是常调整的参数。Flush(true)能保证写入物理磁盘,但代价很大,应谨慎使用。
三、异步 IO:避免阻塞线程池(现代 C# 的优先选项)
在大量 I/O 操作中使用异步(ReadAsync/WriteAsync)可避免线程阻塞,确保在高并发服务器或 GUI 应用中响应性更好。
异步分块读取并报告进度:
using System;
using System.Buffers;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
class AsyncReadWithProgress
{
public static async Task ReadFileAsync(string path, IProgress<long>? progress = null, CancellationToken ct = default)
{
const int bufferSize = 65536; // 64KB
byte[] buffer = ArrayPool<byte>.Shared.Rent(bufferSize);
try
{
await using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read,
bufferSize: bufferSize, useAsync: true);
long totalRead = 0;
int read;
while ((read = await fs.ReadAsync(buffer.AsMemory(0, bufferSize), ct)) > 0)
{
// 处理 buffer[..read]
totalRead += read;
progress?.Report(totalRead);
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
}
要点:
useAsync: true在 Windows 上可启用更优异的异步底层机制;在 .NET Core/.NET 5+ 已默认高效。- 使用
ArrayPool可避免频繁分配大数组,降低 GC 压力。 - 使用
Memory<byte>/Span<byte>的异步重载(ReadAsync(Memory<byte> ...))可避免中间 GC。
四、文本文件与行读取:StreamReader、File.ReadLines、IAsyncEnumerable
在处理大文本文件(例如日志、CSV)时,逐行流式读取是常见场景。
流式逐行同步读取(记住:StreamReader 内部有字符缓冲):
using System;
using System.IO;
using System.Text;
class LineByLineSync
{
public static void ProcessLines(string path)
{
using var sr = new StreamReader(path, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 65536);
string? line;
long lineNo = 0;
while ((line = sr.ReadLine()) != null)
{
lineNo++;
// 处理一行
if (lineNo % 100000 == 0) Console.WriteLine($"Processed {lineNo} lines...");
}
}
}
懒惰逐行读取(更简洁):
using System;
using System.IO;
class LazyFileLines
{
public static void IterateLines(string path)
{
foreach (var line in File.ReadLines(path))
{
// ReadLines laz遲读取,不会把整个文件读入内存
}
}
}
异步逐行读取(C# 8+ IAsyncEnumerable):
using System;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Generic;
class AsyncLineReader
{
public static async IAsyncEnumerable<string> ReadLinesAsync(string path, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
{
await using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 65536, useAsync: true);
using var sr = new StreamReader(fs, Encoding.UTF8, true, 65536);
string? line;
while ((line = await sr.ReadLineAsync()) != null)
{
ct.ThrowIfCancellationRequested();
yield return line;
}
}
public static async Task ConsumeAsync(string path, CancellationToken ct = default)
{
await foreach (var line in ReadLinesAsync(path, ct))
{
// 逐行处理
}
}
}
注意:
StreamReader.ReadLineAsync在某些版本实现上仍存在内部同步部分,但通常远优于同步读取。- 对于极长行(MB 级),
ReadLine会占用相应内存;若需处理超长行,可以用基于缓冲的低级读取并手动通过分隔符扫描。
五、二进制文件与固定记录:BinaryReader、Span、MemoryMarshal
当文件由固定长度的记录组成(比如每条记录 128 byte),可以用 MemoryMapped 或直接分块读取然后解析结构体。
按固定记录读取(示例读取结构体):
using System;
using System.IO;
using System.Runtime.InteropServices;
[StructLayout(LayoutKind.Sequential, Pack = 1)]
readonly struct Record
{
public readonly int Id;
public readonly long Timestamp;
// 还有其他字段...
}
class FixedRecordReader
{
public static void ReadRecords(string path)
{
const int recordSize = 12; // 假设
byte[] buffer = new byte[recordSize];
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
while (fs.Read(buffer, 0, buffer.Length) == recordSize)
{
var record = MemoryMarshal.Read<Record>(buffer);
// 处理 record
}
}
}
注意事项:
MemoryMarshal.Read需要确保字节序和内存布局一致(可能受平台字节序影响),在跨平台或网络协议场景要小心(使用BinaryPrimitives处理 endian)。- 避免将结构体设计得过大或包含引用类型——那样
MemoryMarshal无效。
六、内存映射文件(MemoryMappedFile):当文件比内存还大时的“窗口化”
MemoryMappedFile 允许把文件映射到地址空间的一段窗口,应用可以像访问内存一样读写,适合随机访问和高性能场景。
示例:按窗口读取:
using System;
using System.IO;
using System.IO.MemoryMappedFiles;
class MemoryMappedExample
{
public static void ReadLargeFileWindowed(string path)
{
using var mmf = MemoryMappedFile.CreateFromFile(path, FileMode.Open, null, 0, MemoryMappedFileAccess.Read);
long fileSize = new FileInfo(path).Length;
const long windowSize = 1L << 30; // 1GB window
long offset = 0;
while (offset < fileSize)
{
long size = Math.Min(windowSize, fileSize - offset);
using var accessor = mmf.CreateViewAccessor(offset, size, MemoryMappedFileAccess.Read);
Span<byte> span = new byte[size];
accessor.ReadArray(0, span.ToArray(), 0, (int)size); // 简化演示;可用 unsafe 或直接访问
// 处理 span
offset += size;
}
}
}
要点:
MemoryMappedFile适合随机访问与多进程共享同一文件(可创建命名映射)。- 在 .NET 中需注意对大窗口的访问方式,避免一次性复制大量数据到托管内存,推荐使用 Accessor 的直接读取或使用 unsafe 指针。
- 在 32 位进程中地址空间受限,映射大文件会失败;推荐使用 64 位进程。
七、System.IO.Pipelines:为高并发设计的流式抽象(服务器场景的利器)
Pipelines 提供基于 buffer pool 的读写模型,适合高性能 TCP 或文件流处理,能更好地减少拷贝与 GC 压力。
示例:
using System;
using System.IO;
using System.IO.Pipelines;
using System.Text;
using System.Threading.Tasks;
class PipelinesFileReader
{
public static async Task ReadWithPipelines(string path)
{
const int bufferSize = 65536;
var pipe = new Pipe();
// Producer: 把文件读入 pipe
var produce = Task.Run(async () =>
{
await using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize, useAsync: true);
while (true)
{
var memory = pipe.Writer.GetMemory(bufferSize);
int bytesRead = await fs.ReadAsync(memory);
if (bytesRead == 0) break;
pipe.Writer.Advance(bytesRead);
var result = await pipe.Writer.FlushAsync();
if (result.IsCompleted) break;
}
await pipe.Writer.CompleteAsync();
});
// Consumer: 从 pipe 解析行(示意)
var consume = Task.Run(async () =>
{
while (true)
{
var result = await pipe.Reader.ReadAsync();
var buffer = result.Buffer;
SequencePosition? position;
while ((position = buffer.PositionOf((byte)'\n')) != null)
{
var line = buffer.Slice(0, position.Value);
ProcessLine(line); // 处理 line (ReadOnlySequence<byte>)
buffer = buffer.Slice(buffer.GetPosition(1, position.Value)); // 跳过 '\n'
}
pipe.Reader.AdvanceTo(buffer.Start, buffer.End);
if (result.IsCompleted) break;
}
await pipe.Reader.CompleteAsync();
});
await Task.WhenAll(produce, consume);
}
static void ProcessLine(ReadOnlySequence<byte> sequence)
{
// 将 sequence 转换为字符串(示例)
if (sequence.IsSingleSegment)
{
var s = Encoding.UTF8.GetString(sequence.First.Span);
Console.WriteLine(s.TrimEnd('\r'));
}
else
{
var sb = new StringBuilder();
foreach (var seg in sequence)
{
sb.Append(Encoding.UTF8.GetString(seg.Span));
}
Console.WriteLine(sb.ToString().TrimEnd('\r'));
}
}
}
优点:
- 极低的内存拷贝和高吞吐;
- 适合高并发网络/文件处理场景;
- 但学习曲线与实现复杂度比简单
FileStream高。
八、高效写入日志与追加(Append)策略
日志通常是高频追加的写操作,需要兼顾性能与可读性。常见策略:
- 批量缓存写入:在内存缓冲若干条日志后一次写磁盘(降低 syscalls),但需要处理崩溃丢失问题(可定期 Flush)。
- 内存映射 + 原子操作:对于高并发写入场景可考虑
MemoryMappedFile与 CAS,但实现复杂。 - 使用专门的库(如 Serilog、NLog)和切割策略(按大小/时间滚动)更稳妥。
示例:
using System;
using System.IO;
using System.Text;
class AppendLog
{
public static void AppendText(string path, string text)
{
byte[] bytes = Encoding.UTF8.GetBytes(text);
using var fs = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.Read);
fs.Write(bytes, 0, bytes.Length);
}
}
注意:
FileMode.Append自动移动到文件末尾,在多线程多进程中仍存在竞争,需要上层同步。- 对于高并发写入的场景,使用专门的队列 + 单写线程模式通常更简单可靠。
九、并发读写、文件锁与共享模式
在多个进程/线程访问同一文件时,FileShare 与 Lock/Unlock 成为关键。
FileShare.Read/Write/None控制其他FileStream打开时的权限。FileStream.Lock(offset, length)在同进程/跨进程中设置强制的文件区域锁(平台有关),但使用时要谨慎以免死锁。
示例:
using System;
using System.IO;
class FileLockExample
{
public static void WriteWithLock(string path, byte[] data)
{
using var fs = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
long pos = fs.Length;
fs.Lock(pos, data.Length);
try
{
fs.Seek(pos, SeekOrigin.Begin);
fs.Write(data, 0, data.Length);
}
finally
{
fs.Unlock(pos, data.Length);
}
}
}
注意:
Lock并非万能:不同平台与环境下行为不同(尤其是 NFS 等分布式文件系统)。- 对于跨进程协调,优先考虑外部协调机制(消息队列、数据库、单写进程)而不是大规模文件锁。
十、压缩读写(GZipStream 等):节省 IO 带宽但增加 CPU
当磁盘或网络是瓶颈时,压缩可以大幅降低 IO 量。
示例:读写 gzip 文件:
using System;
using System.IO;
using System.IO.Compression;
using System.Text;
using System.Threading.Tasks;
class GzipExample
{
public static async Task WriteGzipAsync(string path, string content)
{
await using var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None);
await using var gzip = new GZipStream(fs, CompressionLevel.Optimal);
byte[] data = Encoding.UTF8.GetBytes(content);
await gzip.WriteAsync(data, 0, data.Length);
}
public static async Task<string> ReadGzipAsync(string path)
{
await using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
await using var gzip = new GZipStream(fs, CompressionMode.Decompress);
using var sr = new StreamReader(gzip, Encoding.UTF8);
return await sr.ReadToEndAsync();
}
}
平衡点:
- CPU 与 IO 的权衡:如果 CPU 过载,压缩可能适得其反;
- 对随机访问压缩数据不友好(需解压整个区间)。
十一、原子写入与替换(避免文件损坏)
在写配置文件或关键数据时,建议写到临时文件再替换原文件,保证崩溃时不会留下半成品。
示例:
using System;
using System.IO;
class AtomicReplace
{
public static void AtomicWrite(string path, byte[] data)
{
string temp = Path.GetTempFileName();
try
{
File.WriteAllBytes(temp, data);
File.Replace(temp, path, null); // 如果目标不存在,会抛异常;可用 Move
}
finally
{
if (File.Exists(temp)) File.Delete(temp);
}
}
}
注意:
- 在跨卷(不同磁盘)时,
Move操作不是原子的;File.Replace在 Windows 上可用于原子替换。 - 备份与回滚策略视业务需求而定。
十二、性能诊断:什么慢,如何测?
- 磁盘类型:SSD、HDD 与网络文件系统有本质差异(延迟、随机/顺序吞吐)。
- 系统调用数量:每次
Read/Write都有 syscalls,增大缓冲能减少次数。 - GC 与内存分配:避免频繁分配大数组,使用
ArrayPool。 - 线程阻塞:在服务器中,阻塞线程会降低吞吐,用异步 IO 可以缓解。
- 测试工具:使用
dotnet-trace、perf、Windows Performance Monitor、iostat/lsof等监控 IO。
十三、常见陷阱与防御清单
- 切勿使用
ReadAllText/ReadAllBytes处理大文件(会 OOM)。 - 小
buffer频繁读写会极慢;大buffer可能占用过多内存,建议使用 64KB ±。 - 不要在 UI 线程执行长时间同步 IO,使用
async/await。 FileShare与Lock配置不当会导致无法打开文件或数据竞态。- 使用
ArrayPool时确保Return,即使发生异常也要返回(try/finally)。 StreamReader的默认缓冲较小(4KB),可手动设置为 64KB。- 在 Azure/网络存储/分布式 FS 上,文件锁与行为可能不同,应进行额外兼容测试。
十四、实战场景示例:分块上传(断点续传)读取文件
典型场景:将大文件分块上传到远端(HTTP),每块 4MB,支持断点续传与失败重试。
示例:
using System;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
class ChunkUploader
{
private readonly HttpClient _http = new HttpClient();
public async Task UploadFileInChunksAsync(string path, Uri uploadUri, int chunkSize = 4 * 1024 * 1024, CancellationToken ct = default)
{
long fileSize = new FileInfo(path).Length;
await using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 65536, useAsync: true);
long offset = 0;
int chunkIndex = 0;
byte[] buffer = new byte[chunkSize];
while (offset < fileSize)
{
int toRead = (int)Math.Min(chunkSize, fileSize - offset);
int read = await fs.ReadAsync(buffer.AsMemory(0, toRead), ct);
if (read == 0) break;
using var content = new ByteArrayContent(buffer, 0, read);
content.Headers.Add("X-Chunk-Index", chunkIndex.ToString());
content.Headers.Add("X-Chunk-Offset", offset.ToString());
// 简化:在真实场景需要实现重试、认证、校验(MD5)等
var resp = await _http.PostAsync(uploadUri, content, ct);
resp.EnsureSuccessStatusCode();
offset += read;
chunkIndex++;
}
}
}
要点:
- 在网络传输失败时要支持重试与断点续传(保存已上传块的索引/偏移到元数据)。
- 每块加上校验(如 SHA-256 或 MD5)能确保数据一致性。
十五、总结与最佳实践清单(工程师随手可用)
- 永远不要将大文件一次性读入内存(除非确认大小受控)。
- 使用流式(chunk)处理,推荐 64KB 作为良好起点(针对多数场景)。
- 在服务器或 UI 场景中使用
async/await避免阻塞; - 对频繁大数组分配使用
ArrayPool; - 对随机访问、并发访问或跨进程共享的场景考虑
MemoryMappedFile; - 对超高性能或低拷贝需求考虑
System.IO.Pipelines; - 对于关键文件写入,采用原子替换(临时文件 + Replace)以避免半成品;
- 在写日志/追加场景,优先使用批量写入或单写线程 + 队列;
- 在多平台部署时,对文件锁、文件共享与底层文件系统行为做兼容测试;
- 加入进度报告、取消令牌与合理的异常处理,提高可维护性与可恢复性。
结语
数据如此之大,技巧要更巧。大文件读写不是靠单一 API 就能解决的“神器”,而是多种工具与策略的组合——基于平台(SSD vs HDD)、基于场景(顺序扫描 vs 随机访问)、基于需求(可靠性 vs 性能)来设计。你需要的是理解 FileStream 的特性,掌握内存管理与异步 IO 技巧,并在必要时采用 MemoryMappedFile、Pipelines、ArrayPool 等现代 API 做精细优化。最重要的,是首先写出正确、可读、可靠的实现,再在压力测试下识别瓶颈并逐步优化。
本文介绍了一种使用C#进行大文件读写的方法,通过创建文件流对象并利用缓冲区来提高读写效率。该方法适用于需要处理大量数据的应用场景。
6602

被折叠的 条评论
为什么被折叠?



