Skip to content

分片与竞速 ​

一个文件切成若干片,多条连接并行拉,写进同一个临时文件的指定偏移。这页讲片怎么切、源怎么选、以及为什么并发数不能看池子第一个。

什么时候值得分片 ​

分片不是免费的:每片一条请求,握手 + 首字节的开销要能被并行收益盖过。所以有一条体积门槛:

csharp
public const long Threshold = 4 * 1024 * 1024;   // 4 MiB

小于 4 MiB 一律走整文件路径(走整文件也才有断点续传,见校验与断点续传)。门槛之外还有第二个条件:这个源得支持 Range。

片大小怎么算 ​

目标是「每个 worker 手上约 2 片」——一片在跑、一片在等,worker 不空闲:

csharp
var w = Math.Clamp(workers, 1, MaxWorkersPerFile);
var target = (size + w * 2L - 1) / (w * 2L);
var chunk = AlignMiB(Math.Clamp(target, MinChunkSize, MaxChunkSize));

三个约束叠在一起:MinChunkSize = 1 MiB(片太小,握手开销吃掉并行收益)、MaxChunkSize = 8 MiB(再大不会更快,只会让尾片拖长收尾)、按 1 MiB 对齐(边界对齐 MiB 能减少 CDN 缓存穿透)。

超大文件还要受片数上限约束:

csharp
public const int MaxChunks = 64;
if ((size + chunk - 1) / chunk > MaxChunks)
    chunk = AlignMiB((size + MaxChunks - 1) / MaxChunks); // 按片数上限均分

片数封顶 64 是为了防止 4 GB 的文件切出几千片。

并发的三层预算 ​

csharp
public const int MaxWorkersPerFile = 8;   // 单文件
public const int GlobalWorkerBudget = 32; // 进程级

单文件 8 条连接是实测结论:CDN 单连接常被限到 1–3 MB/s,4 条并行能拿 3.1×。进程级 32 是给批量下载留的总闸——否则 20 个文件一起下就是 160 条连接,把网络和 CDN 都打爆。

预算不是静态分配,是能租几条算几条:租不到就等,但至少保证有一条能走。

csharp
var leased = 0;
while (leased < wanted && await Budget.WaitAsync(0, ct)) leased++;
if (leased == 0) { await Budget.WaitAsync(ct); leased = 1; }

并发数取候选源里最宽松的 ​

这是最容易写错的一处。直觉是「池里第一个源最健康,用它的并发数」——错。

csharp
internal int ConcurrencyFor(string url, IReadOnlyList<SourceKind> pool)
{
    var widest = pool
        .Select(k => ParallelGovernor.LimitFor(HostOf(_sources.Rewrite(url, k))))
        .DefaultIfEmpty(MaxWorkersPerFile)
        .Max();
    return Math.Max(1, Math.Min(MaxWorkersPerFile, widest));
}

取的是 max,不是 first。原因是池首经常是刚被 429 限过的镜像——ParallelGovernor 一旦看到 429 就把该主机的并发上限减半,冷却 30s 起。而这个文件的实际负载很可能落在官方 CDN 上。按镜像的限流值收并发,等于把整个文件压成单连接。实测对比:0.29 MB/s(按镜像限流值收并发)vs 官方多连接 1 MB/s 以上。

ParallelGovernor 的规则:起始 4 条 → 连续 8 次成功后 +1(上限 8)→ 一旦 429/503 立刻减半并指数冷却(30s / 60s / 120s…封顶 5 分钟)。状态是进程内的静态字典,重启就清零。

竞速选源:先测速,再只用最快的 ​

旧实现是每片轮询所有源——坏源和慢源也均摊 1/N 的片,一个慢片能把整个文件拖住。现在的策略是先让每个健康源各跑一片测速,之后只用最快的那一个:

csharp
// 还没测过速度的源:每个源只放一条探测请求(多 worker 同时压同一个慢源会更慢)
if (candidates.Any(k => SpeedOf(k) <= 0))
{
    foreach (var kind in candidates)
    {
        if (SpeedOf(kind) > 0) continue;
        if (_probing.TryAdd(kind, 1)) return kind;
    }
    ...
}

测速用 EMA 平滑,避免单片抖动:

csharp
sample.Ema = sample.Samples == 0 ? instant : sample.Ema * 0.6 + instant * 0.4;

某个片失败就换源重试,退避按平方增长;被限流时用冷却时间而不是退避——限流不会因为立刻重试就消失:

csharp
var backoff = TimeSpan.FromMilliseconds(400 * failed * failed);
var cooldown = ParallelGovernor.CooldownFor(host);
await Task.Delay(cooldown > backoff ? cooldown : backoff, ct);

某个源在这个文件里失败一次,就只在本文件内把它摘掉。否则 8 个 worker 会轮流撞同一个坏源。## 几条工程细节

分片必须走 HTTP/1.1。HTTP/2 会把 8 条「并行」分片多路复用到一条 TCP 连接上,流量被单连接拥塞窗口掐住——实测同样 8 分片,HTTP/2 只有单连接速度,慢速 CDN 上还会因流控停顿触发空闲超时反复重试。

空闲看门狗一条流一个。早期实现在每次 ReadAsync 前都新建 CancelAfter,满速下载时每秒几百个定时器,成了主要开销。现在 IdleTimeout 每条流一个实例,Touch() 距上次续期不足 1 秒就零开销。

字节遥测 10 Hz 聚合:窗口内字节合并成一次上报,不必每 512 KiB 就锁一次调度中心。

相关链接:下载引擎总览、镜像与源健康度

基于 MIT 许可证(有修改)开源