C#并行分块下载文件使用MemoryMappedFile写入出现损坏问题排查
问题原因
- Range范围与视图长度不匹配:HTTP
Range头的区间是闭区间,例如Range: bytes=0-99代表请求前100个字节(包含下标99)。原代码请求分块时指定的范围是[piece.Start, piece.End],返回的内容长度为piece.End - piece.Start + 1,但创建内存映射视图流时用了piece.End-piece.Start - 1,直接截断了2字节的写入空间,每个分块都会丢失末尾2字节,最终文件自然损坏。 - 异步方法同步阻塞导致死锁/数据丢失:在
Parallel.ForEachAsync的异步委托中使用.Result、.Wait()同步阻塞,极易引发线程池饥饿、请求死锁,部分分块的写入逻辑可能未执行完成就被释放,导致区块缺失。 - HttpClient使用错误:原代码中
httpPool.Get()属于无效调用(HttpClient无此方法),且HttpClient本身是线程安全类,不需要重复获取实例,错误的实例获取逻辑会导致请求失败,对应分块无数据写入。 - 资源未正确释放:内存映射文件、视图流未用
using做生命周期管理,缓冲区数据可能未完全刷入磁盘就被释放,导致文件尾部损坏。 - 缺少服务端范围请求校验:未校验目标服务器是否支持
Accept-Ranges头,若服务端不支持分块下载,所有Range请求都会返回完整文件,直接导致写入内容重叠损坏。 - 未定义并行配置:原代码中
parallelOptions变量未声明,编译无法通过,运行时默认并行度不受控,容易触发服务端限流导致部分分块下载失败。
修复方案
核心修改点
- 修正视图流长度与Range请求参数,保证请求长度与写入长度完全一致
- 移除所有同步阻塞调用,全程使用
await执行异步操作 - 直接复用线程安全的HttpClient实例,删除无效的实例获取逻辑
- 为所有非托管资源添加
using声明,自动完成资源释放与缓冲区刷写 - 新增服务端范围请求支持校验,避免不支持分块的场景下出现文件损坏
- 显式定义并行配置,控制最大并行度避免触发服务端限流
- 替换已过时的
WebRequest,改用HttpClient发送Head请求获取文件长度
修复后完整代码
using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.IO.MemoryMappedFiles; using System.Net.Http; using System.Net.Http.Headers; using System.Threading; using System.Threading.Tasks; using static System.Console; namespace ZenTester { internal class FileChunk { public long Start { get; set; } public long End { get; set; } public int Id { get; set; } public FileChunk(long startByte, long endByte) { Start = startByte; End = endByte; } } internal class RetryHandler : DelegatingHandler { private int _maxRetries = 3; public RetryHandler(HttpMessageHandler innerHandler) : base(innerHandler) { } public RetryHandler(HttpMessageHandler innerHandler, int maxRetries) : base(innerHandler) { _maxRetries = maxRetries; } protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { HttpResponseMessage response = null; for (var i = 0; i < _maxRetries; i++) { response = await base.SendAsync(request, cancellationToken); if (response.IsSuccessStatusCode) { return response; } // 失败后释放响应资源避免泄漏 response.Dispose(); } return response; } } public static class ZenTester { private static async Task DownloadFile(string url, int parts, string outFile = null!) { using var httpClient = new HttpClient(new RetryHandler(new HttpClientHandler(), 10)) { MaxResponseContentBufferSize = 1000000000 }; // 发Head请求获取文件信息 using var headRequest = new HttpRequestMessage(HttpMethod.Head, url); using var headResponse = await httpClient.SendAsync(headRequest); headResponse.EnsureSuccessStatusCode(); var responseLength = headResponse.Content.Headers.ContentLength ?? throw new InvalidOperationException("无法获取目标文件长度"); if (!headResponse.Headers.AcceptRanges.Contains("bytes")) { throw new NotSupportedException("目标服务器不支持分块下载"); } var partSize = (long)Math.Floor(responseLength / (parts + 0.0)); var pieces = new List<FileChunk>(); var uri = new Uri(url); WriteLine($"{responseLength.ToString(CultureInfo.InvariantCulture)} TOTAL SIZE"); WriteLine($"{partSize.ToString(CultureInfo.InvariantCulture)} PART SIZE\n"); string filename = outFile ?? Path.GetFileName(uri.LocalPath); // 用using管理内存映射文件生命周期 using var mmf = MemoryMappedFile.CreateFromFile(filename, FileMode.OpenOrCreate, null, responseLength); // 生成分块列表 for (long i = 0; i < responseLength; i += partSize) { pieces.Add(i + partSize < responseLength ? new FileChunk(i, i + partSize) : new FileChunk(i, responseLength)); } // 显式定义并行配置 var parallelOptions = new ParallelOptions { MaxDegreeOfParallelism = parts, CancellationToken = CancellationToken.None }; await Parallel.ForEachAsync(pieces, parallelOptions, async (piece, cancellationToken) => { using var request = new HttpRequestMessage { RequestUri = new Uri(url) }; // Range头用闭区间,end要减1 request.Headers.Range = new RangeHeaderValue(piece.Start, piece.End - 1); using var message = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); // 校验是否是分块响应 if (message.IsSuccessStatusCode && message.StatusCode == System.Net.HttpStatusCode.PartialContent) { await using var streamToRead = await message.Content.ReadAsStreamAsync(cancellationToken); // 视图长度和分块大小一致,不需要减1 await using var streams = mmf.CreateViewStream(piece.Start, piece.End - piece.Start); await streamToRead.CopyToAsync(streams, cancellationToken); } else { throw new InvalidOperationException($"分块{piece.Start}-{piece.End}下载失败,状态码:{message.StatusCode}"); } }); } public static async Task Main(string[] args) { var url = "https://wallpaperaccess.com/full/2159447.jpg"; await DownloadFile(url, 8); } } }
内容的提问来源于stack exchange,提问作者0xSingularity
相关产品推荐
相关产品推荐

