HTTP范围请求的核心原理
HTTP范围请求是HTTP/1.1协议定义的标准功能,允许客户端向服务器请求资源的部分内容,而不是完整的资源。要实现这个功能,客户端只需要在HTTP请求头中添加Range字段,格式为Range: bytes=start-end,其中start是请求块的起始字节位置,end是结束字节位置。如果服务器支持范围请求,会返回状态码206 Partial Content,并且在响应头中包含Content-Range字段,说明当前返回的内容在整个资源中的位置,比如Content-Range: bytes 0-1023/10000表示返回的是前1024个字节,整个资源总共有10000个字节。
如果服务器不支持范围请求,会返回200 OK状态码,并且返回完整的资源内容,这种情况下就无法实现分块下载。因此在发起分块请求之前,我们需要先发送一个HEAD请求,获取资源的Accept-Ranges响应头,如果该值包含bytes,说明服务器支持字节范围请求,同时还可以从Content-Length响应头拿到整个资源的总字节数,这是后续划分分块区间的重要依据。
分块下载的基本逻辑就是根据总资源大小,把整个资源划分成若干个大小相近的块,每个块分配一个独立的线程去下载,每个线程的请求头中携带对应的Range字段,下载完成后把各个块的内容按顺序拼接到一起,就得到了完整的资源文件。这种方式可以充分利用带宽,多个线程同时传输不同部分的内容,避免单线程下载时因为网络波动或者服务器限速导致的效率低下问题。
C#实现多线程分块下载的完整步骤
首先我们需要先获取目标资源的基本信息,包括是否支持范围请求、总大小、文件名称等。这一步通过发送HEAD请求实现,使用HttpWebRequest或者HttpClient都可以,这里以HttpClient为例,因为它的异步模型更符合现代C#的开发习惯,而且处理异步任务更方便。发送HEAD请求时不需要获取响应体,只需要读取响应头即可,注意要设置请求方法为HEAD,避免不必要的资源传输。
获取到总大小和确认支持范围请求之后,就可以进行分块划分了。分块大小可以根据总大小和预期的线程数量计算,比如总大小是100MB,预期用4个线程下载,那么每个块的大小就是25MB,最后一个块如果大小不足也可以正常处理。划分的时候要注意每个块的起始和结束字节位置不能重叠,也不能超出总大小的范围,比如第一个块是0-26214400(25MB对应的字节数,25*1024*1024),第二个块就是26214401-52428800,以此类推。
接下来每个分块对应一个下载任务,我们把这些任务放到一个任务列表中,然后等待所有任务完成。每个下载任务中需要构造带有Range头的请求,下载对应的字节内容,然后把内容保存到临时文件中,临时文件的命名可以包含块的序号,方便后续拼接。这里要注意线程安全的问题,多个线程同时写不同的临时文件不存在竞争,但如果是写同一个文件的不同位置,就需要加锁或者使用文件流的指定位移写入,避免内容错乱。
下面是一段获取资源信息的示例代码:
using System;
using System.Net.Http;
using System.Threading.Tasks;
public class RangeDownloadHelper
{
private static readonly HttpClient _httpClient = new HttpClient();
/// <summary>
/// 获取目标资源的基本信息
/// </summary>
/// <param name="url">资源URL</param>
/// <returns>是否支持范围请求、总大小、文件名</returns>
public async Task<(bool supportRange, long totalSize, string fileName)> GetResourceInfoAsync(string url)
{
var request = new HttpRequestMessage(HttpMethod.Head, url);
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
// 检查是否支持范围请求
bool supportRange = response.Headers.AcceptRanges.Contains("bytes");
// 获取总大小
long totalSize = response.Content.Headers.ContentLength ?? 0;
// 获取文件名,从响应头的Content-Disposition或者URL中提取
string fileName = GetFileNameFromResponse(response, url);
return (supportRange, totalSize, fileName);
}
private string GetFileNameFromResponse(HttpResponseMessage response, string url)
{
if (response.Content.Headers.ContentDisposition != null)
{
var disposition = response.Content.Headers.ContentDisposition;
if (!string.IsNullOrEmpty(disposition.FileName))
{
return disposition.FileName.Trim('"');
}
}
// 从URL中提取文件名
Uri uri = new Uri(url);
return System.IO.Path.GetFileName(uri.LocalPath);
}
}
分块下载与拼接的完整实现
划分好分块区间之后,就可以启动多线程下载每个块的内容了。每个下载任务需要使用HttpClient发送GET请求,并且在请求头中添加Range字段,指定当前任务要下载的字节范围。下载得到的内容是字节数组,我们可以把每个块的内容先保存到对应的临时文件中,比如块序号是0的临时文件命名为temp_0.dat,块序号1的命名为temp_1.dat,所有块下载完成之后,再按照序号顺序把这些临时文件的内容读取出来,写入到最终的目标文件中,写完之后删除所有临时文件即可。
这里需要注意几个细节:第一,请求的范围如果结束位置等于总大小减1,要正确处理,比如总大小是100,最后一个块的范围是95-99,而不是95-100,否则会请求到不存在的字节。第二,下载过程中可能会出现网络异常,比如超时、连接中断等,需要给每个下载任务添加重试机制,比如最多重试3次,每次重试之间可以等待一段时间,避免频繁请求。第三,线程数量不是越多越好,一般根据网络带宽设置,比如4到8个线程比较合适,过多的线程会导致线程上下文切换开销增大,反而降低整体效率。
拼接文件的时候,要按照块的序号从小到大依次读取临时文件的内容,写入到最终文件的开头位置,因为每个块的内容是连续的,所以直接追加写入即可。如果是在下载的同时就写入最终文件的指定位置,也可以使用FileStream的Seek方法定位到对应的字节位置,然后写入内容,这样就不需要临时文件,但要注意多个线程同时操作同一个FileStream的时候需要加锁,避免同时写入导致的位置错乱。
下面是分块下载和拼接的完整示例代码:
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
public class MultiThreadRangeDownloader
{
private readonly HttpClient _httpClient;
private readonly int _threadCount;
private readonly string _tempDir;
public MultiThreadRangeDownloader(int threadCount = 4)
{
_httpClient = new HttpClient();
_threadCount = threadCount;
// 临时目录,用来存放分块下载的临时文件
_tempDir = Path.Combine(Path.GetTempPath(), "RangeDownloadTemp");
if (!Directory.Exists(_tempDir))
{
Directory.CreateDirectory(_tempDir);
}
}
/// <summary>
/// 开始下载
/// </summary>
/// <param name="url">资源URL</param>
/// <param name="savePath">最终保存路径</param>
public async Task DownloadAsync(string url, string savePath)
{
// 1. 获取资源信息
var (supportRange, totalSize, fileName) = await GetResourceInfoAsync(url);
if (!supportRange || totalSize == 0)
{
// 不支持范围请求,走单线程下载逻辑
await DownloadSingleThreadAsync(url, savePath);
return;
}
// 2. 划分分块区间
var ranges = SplitRange(totalSize, _threadCount);
var tasks = new List<Task<string>>();
// 3. 启动多线程下载每个块
for (int i = 0; i < ranges.Count; i++)
{
var range = ranges[i];
int blockIndex = i;
tasks.Add(DownloadBlockAsync(url, range.start, range.end, blockIndex));
}
// 等待所有块下载完成
var tempFiles = await Task.WhenAll(tasks);
// 4. 拼接所有块到最终文件
await MergeFilesAsync(tempFiles, savePath);
// 5. 清理临时文件
foreach (var tempFile in tempFiles)
{
if (File.Exists(tempFile))
{
File.Delete(tempFile);
}
}
}
private async Task<(bool supportRange, long totalSize, string fileName)> GetResourceInfoAsync(string url)
{
var request = new HttpRequestMessage(HttpMethod.Head, url);
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
bool supportRange = response.Headers.AcceptRanges.Contains("bytes");
long totalSize = response.Content.Headers.ContentLength ?? 0;
string fileName = GetFileNameFromResponse(response, url);
return (supportRange, totalSize, fileName);
}
private string GetFileNameFromResponse(HttpResponseMessage response, string url)
{
if (response.Content.Headers.ContentDisposition != null)
{
var fileName = response.Content.Headers.ContentDisposition.FileName;
if (!string.IsNullOrEmpty(fileName))
{
return fileName.Trim('"');
}
}
Uri uri = new Uri(url);
return Path.GetFileName(uri.LocalPath);
}
private List<(long start, long end)> SplitRange(long totalSize, int threadCount)
{
var ranges = new List<(long start, long end)>();
long blockSize = totalSize / threadCount;
for (int i = 0; i < threadCount; i++)
{
long start = i * blockSize;
long end = (i == threadCount - 1) ? totalSize - 1 : (start + blockSize - 1);
ranges.Add((start, end));
}
return ranges;
}
private async Task<string> DownloadBlockAsync(string url, long start, long end, int blockIndex)
{
int retryCount = 3;
string tempFilePath = Path.Combine(_tempDir, $"block_{blockIndex}.tmp");
for (int i = 0; i < retryCount; i++)
{
try
{
var request = new HttpRequestMessage(HttpMethod.Get, url);
// 添加Range请求头
request.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(start, end);
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
// 读取内容并写入临时文件
using (var stream = await response.Content.ReadAsStreamAsync())
using (var fileStream = new FileStream(tempFilePath, FileMode.Create, FileAccess.Write))
{
await stream.CopyToAsync(fileStream);
}
return tempFilePath;
}
catch (Exception ex)
{
if (i == retryCount - 1)
{
throw new Exception($"下载块{blockIndex}失败,区间[{start}-{end}],错误:{ex.Message}");
}
await Task.Delay(1000 * (i + 1)); // 重试等待时间递增
}
}
return tempFilePath;
}
private async Task MergeFilesAsync(string[] tempFiles, string savePath)
{
// 按照块序号排序,确保拼接顺序正确
var sortedTempFiles = tempFiles
.Select(f => new { FilePath = f, Index = int.Parse(Path.GetFileNameWithoutExtension(f).Split('_')[1]) })
.OrderBy(x => x.Index)
.Select(x => x.FilePath)
.ToArray();
using (var finalStream = new FileStream(savePath, FileMode.Create, FileAccess.Write))
{
foreach (var tempFile in sortedTempFiles)
{
using (var tempStream = new FileStream(tempFile, FileMode.Open, FileAccess.Read))
{
await tempStream.CopyToAsync(finalStream);
}
}
}
}
private async Task DownloadSingleThreadAsync(string url, string savePath)
{
var response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
using (var stream = await response.Content.ReadAsStreamAsync())
using (var fileStream = new FileStream(savePath, FileMode.Create, FileAccess.Write))
{
await stream.CopyToAsync(fileStream);
}
}
}
优化与注意事项
首先是断点续传的实现,我们可以把每个分块的下载进度保存下来,比如记录每个块的起始位置、结束位置、已经下载的字节数,如果下载中断,下次启动的时候可以读取进度文件,只下载未完成的部分。实现的时候可以在每个块下载的过程中,定期把已下载的字节数写入进度文件,下载完成之后标记该块为完成状态,重新开始下载的时候跳过已经完成的块即可。
其次是异常处理和超时控制,每个分块的下载都需要设置合理的超时时间,比如30秒,避免某个线程一直阻塞。如果某个块下载失败,除了重试之外,还可以考虑动态调整这个块的大小,比如分成更小的块重新下载,提高成功率。另外,在下载大文件的时候,要注意内存的使用,不要一次性把整个块的内容都加载到内存中,而是使用流的方式边读边写,避免内存溢出。
还有线程数量的动态调整,我们可以在开始下载的时候先测试几个不同线程数量的下载速度,选择速度最快的线程数量作为后续的下载线程数。另外,如果服务器对每个客户端的并发连接数有限制,比如最多允许同时发起5个请求,那么我们的线程数量就不能超过这个限制,否则会导致部分请求被服务器拒绝,返回429 Too Many Requests状态码,这时候需要降低线程数量,或者添加请求间隔。
最后是文件完整性的校验,所有块拼接完成之后,可以计算最终文件的哈希值,比如MD5或者SHA1,和服务器提供的哈希值对比,确保下载的文件没有损坏。如果哈希值不匹配,说明某个块下载错误,可以重新下载对应的块,再重新拼接,保证最终文件的完整性。
C# 多线程下载HTTP Range请求分块下载修改时间:2026-08-29 01:36:46