Python学习之——多线程断点续传下载器

实现约束:仅使用 Python 标准库(urllib.requestthreadingconcurrent.futureshashlib 等),不依赖第三方 HTTP、进度条或持久化库。


目录

  1. 源码
  2. 目标、非目标与设计原则
  3. 总体架构与模块职责
  4. 公开 Python API
  5. 命令行接口
  6. Manifest 格式、校验和任务摘要
  7. 临时文件、元数据与原子发布
  8. HTTP 探测、代理与镜像协议
  9. 下载策略选择与单文件流程
  10. 顺序、流式与断点续传细节
  11. 并行 Range 分块下载
  12. 进度、取消、线程安全与限速
  13. Manifest 批量调度、优先级与状态机
  14. TaskStore 持久化与恢复语义
  15. 错误处理矩阵与退出码
  16. 测试覆盖
  17. 已知限制与生产使用建议

0. 源码

https://gitee.com/selfsongs/python_downloader

1. 目标、非目标与设计原则

1.1 目标

下载器面向可由 HTTP/HTTPS 直接访问的文件,提供以下已实现能力:

  • 单文件下载,URL 位置参数可按顺序提供多个候选镜像。
  • 已知文件长度且服务端正确支持单区间 Range 时,按固定大小切片、并行下载。
  • 已有 .part 临时文件时,顺序和未知长度路径可从已写入长度继续请求。
  • 并行路径将已完成区间保存到 .part.meta,进程中断后只提交未记录的固定区间。
  • 下载完成后可做 SHA-256、MD5 或两者的流式校验;校验成功才发布正式目标文件。
  • 同一文件系统内通过 os.replace() 原子发布,避免损坏内容覆盖已有正式文件。
  • 每个任务可设置独立代理、超时、Range 重试、连接数、分块大小和任务总速率。
  • manifest 批量模式提供稳定优先级排序、多个独立任务并发、任务级重试、状态 JSON、--resume--pause 与全局带宽预算。
  • 对下载过程提供进度回调、阶段回调和本进程内取消原语。

1.2 非目标

下列能力当前没有实现,调用者不应据此设计依赖:

  • 不支持 FTP、SFTP、BT、对象存储 SDK 或非 HTTP/HTTPS 协议。
  • 不支持自定义请求头、Cookie、Bearer Token、客户端证书、自定义 CA、认证挑战处理或会话刷新。
  • 不通过 ETag、Last-ModifiedIf-Range 绑定远端版本;远端内容在断点期间变更时,主要依赖最终摘要校验发现问题。
  • 不支持多区间 Range 请求;每个请求只发一个 bytes=start-end 区间。
  • 不在同一下载中动态扩缩分块、动态调整连接数或做自适应镜像负载均衡。
  • 不提供跨进程下载锁、守护进程、服务端控制 API、交互式暂停控制或任务依赖图。
  • --pause 不会向另一个已经运行的下载进程发送取消信号,不能跨进程中断传输。
  • 不承诺损坏的 .part 或手工伪造的 .part.meta 能被内容级校验后自动修复。

1.3 关键设计原则

  1. 临时写入优先:网络响应永远先写入 <destination>.part,校验成功后才替换目标文件。
  2. 探测与传输分层:镜像选择仅在探测阶段发生。选定响应最终 URL 后,后续请求复用该 URL。
  3. 有限、可恢复的元数据:并行恢复元数据只记录完成的字节区间;manifest 状态只保存无敏感明文的任务摘要。
  4. 显式共享而非隐式全局:任务限速器按下载创建;全局限速器仅由 manifest 入口创建并显式注入每个 manager。
  5. 并发边界清晰:一个 DownloadManager 负责一个顺序生命周期的下载;Range 子任务可并发,但其共享进度由锁保护。
  6. 失败保留现场:网络失败、校验失败和取消一般不删除 .part / .part.meta,便于后续恢复或人工排查。

2. 总体架构与模块职责

2.1 架构图

调用方或终端用户

downloader.__main__

downloader 公共 Python API

Manifest 解析与调度

单文件请求构造

TaskStore

共享全局 RateLimiter

DownloadManager

HTTP Range 探测

流式 顺序 或并行 Range 传输

目标.part

目标.part.meta

摘要校验

os.replace 原子发布

manifest 状态 JSON

2.2 文件级职责

文件核心职责不负责的事项
downloader/__init__.py重导出公共类和异常,定义包的 Python 使用入口。不解析 CLI,不做传输。
downloader/core.py请求/结果/进度数据模型、HTTP 打开与探测、四类传输路径、校验、取消、限速、Range 分块并发。不解析 manifest,不维护批量任务状态。
downloader/task_store.py读取、准备、更新并原子写入 manifest 的任务状态 JSON。不执行下载,不校验状态转换是否合法。
downloader/__main__.py参数解析、单文件 CLI、manifest 严格校验、稳定优先级调度、任务级重试、状态更新和终端输出。不实现底层 socket/HTTP 传输。
downloader/test_downloader.py自建本地 Range HTTP 服务,覆盖主要集成路径和 CLI 行为。不替代大文件、代理、断网等生产压测。

2.3 核心对象关系

  • DownloadRequest 是不可变请求配置,包含 URL、目标、摘要、Range/重试、代理和任务级限速参数。
  • DownloadManager 是一次下载的执行器。它拥有取消事件、进度状态锁、任务限速器和可选的全局限速器。
  • RateLimiter 是可在线程间共享的漏桶预约器。任务自己的实例由 DownloadManager.download() 新建;manifest 的共享实例由 CLI 创建并注入。
  • ManifestTask 将解析后的 DownloadRequest 与任务 ID、优先级、任务级重试数、manifest 原始下标和安全摘要绑定。
  • TaskStore 只负责任务状态的持久化。它不知道网络,也不验证状态机的前后转移是否合法。

3. 公开 Python API

包根 downloader 当前导出以下符号:

from downloader import (
    DownloadCancelled,
    DownloadError,
    DownloadManager,
    DownloadProgress,
    DownloadRequest,
    DownloadResult,
    RateLimiter,
)

3.1 DownloadRequest

@dataclass(frozen=True)
class DownloadRequest:
    urls: tuple[str, ...]
    destination: Path | str
    sha256: str | None = None
    md5: str | None = None
    chunk_size: int = 1024 * 1024
    connections: int = 4
    retries: int = 3
    timeout: float = 30.0
    rate_limit: int | None = None
    proxy: str | None = None
字段含义当前构造期校验
urls按优先级排序的镜像元组。不可为空。
destination正式目标文件路径。Path 在执行期处理。
sha256可选 SHA-256 十六进制摘要。不做格式长度预校验。
md5可选 MD5 十六进制摘要。不做格式长度预校验。
chunk_size并行模式每个固定 Range 的大小,默认 1 MiB。必须大于 0。
connections并行 Range 最大工作线程数。必须大于 0。
retries每个 Range 的额外重试次数。必须非负。
timeout每次 opener.open() 的超时参数。当前未在 dataclass 中限制正值。
rate_limit本任务所有读取流共享的字节/秒预算。None 或假值表示不限速。
proxy代理 URL。未做协议/可达性验证。

DownloadRequest.__post_init__() 只拒绝:空 URL、非正的 chunk_size / connections、负数 retries。例如摘要字符串格式和 rate_limit 正值不是这里的强制校验;CLI manifest 对部分字段另有类型校验。

3.2 DownloadProgress

@dataclass(frozen=True)
class DownloadProgress:
    destination: Path
    downloaded: int
    total: int | None
    active_parts: int
    complete: bool
    cancelled: bool

这是回调收到的不可变快照:

  • total is None 表示探测阶段未得到完整长度。
  • downloaded 是当前 DownloadManager 生命周期内通过 _copy() 累加的字节数;恢复时它不是磁盘已有 .part 大小加本次数据量。
  • active_parts 当前调用路径传入值为 1;即使并行 Range 实际有多个活跃工作线程,也不会在回调中报告真实并发数量。
  • 成功结束时,complete=Truecancelled=Falseactive_parts=0。已知长度时完成快照的 downloaded 使用 total
  • 检测到取消时,先发 cancelled=True 快照,再抛出 DownloadCancelled

3.3 DownloadResult

@dataclass(frozen=True)
class DownloadResult:
    destination: Path
    bytes_downloaded: int
    source_url: str
    sha256: str | None
    md5: str | None

返回值仅在下载、可选摘要校验和原子发布全部成功后产生。已知长度路径的 bytes_downloaded 为探测到的总长度;未知长度流式路径为发布后正式文件的大小。source_url 是探测响应的 response.geturl(),因此重定向后是最终 URL。

3.4 DownloadManager

DownloadManager(
    progress_callback: Callable[[DownloadProgress], None] | None = None,
    global_rate_limiter: RateLimiter | None = None,
    phase_callback: Callable[[str], None] | None = None,
)
方法行为
download(request)下载一个请求,必要时进行探测、传输、校验和发布;失败时抛出 DownloadError 或其子类。
download_all(requests, workers=2)用线程池提交多个 download(),按输入顺序调用 future.result() 返回结果。任何一个 future 的异常会被重新抛出;线程池退出仍等待已提交任务。
cancel()设置本 manager 的取消事件。实际退出取决于下一处取消检查或阻塞读返回。

phase_callback 仅由已知长度主路径调用,顺序为 PROBINGDOWNLOADINGVERIFYING。未知长度流式路径在 DOWNLOADING 后自行校验和发布,不额外回调 VERIFYING,这是当前代码的实际差异。

并发使用约束:同一个 DownloadManager 不应被同时用于多个 download() 调用。它的 _cancel_event、进度字段和任务限速器都是实例状态,会被每次 download() 重置或替换。多任务并发时应像 manifest CLI 一样为每个任务创建独立 manager;若需聚合带宽,再注入同一个 RateLimiter

3.5 异常和限速器

  • DownloadError(RuntimeError):下载不可完成时的统一错误类型。
  • DownloadCancelled(DownloadError):取消专用类型,使调用方可与普通失败分开处理。
  • RateLimiter(bytes_per_second):线程安全共享漏桶。consume(count) 为字节数预约时隙;None0 或其他假值速率时立即返回。

最小 API 示例:

from pathlib import Path
from downloader import DownloadManager, DownloadRequest

request = DownloadRequest(
    urls=("https://download.example.invalid/client.zip",),
    destination=Path(r"D:\downloads\client.zip"),
    sha256="<expected-sha256>",
    connections=4,
    chunk_size=1024 * 1024,
)
result = DownloadManager().download(request)
print(result.destination, result.bytes_downloaded)

4. 命令行接口

入口为:

python -m downloader [URL ...] -o OUTPUT [single-file options]
python -m downloader --manifest MANIFEST [manifest options]

4.1 单文件模式

python -m downloader ^
  -o "D:\downloads\package.zip" ^
  --sha256 "<sha256>" ^
  --connections 4 ^
  --chunk-size 1048576 ^
  --retries 3 ^
  --timeout 30 ^
  --rate-limit 2097152 ^
  --proxy "http://proxy.example:8080" ^
  "https://primary.example/package.zip" ^
  "https://mirror.example/package.zip"
参数单文件模式含义
URL ...至少一个 URL;后续 URL 只在探测失败时作为镜像回退候选。
-o, --output必填,正式目标路径。
--sha256, --md5可选最终摘要。两个同时指定时依次执行两种校验。
-c, --connectionsRange 并行最大连接数,默认 4
--chunk-sizeRange 固定分块大小,默认 1048576
--retries单个 Range 的额外重试数,默认 3
--timeouturllib 打开请求时的超时,默认 30
--rate-limit当前任务全部数据流的总字节/秒预算。
--proxy同时用于 HTTP 和 HTTPS 的代理值。

单文件模式不创建全局限速器,因此 --rate-limit 只表达该任务的限速。位置 URL 或 --output 缺失时 CLI 返回 1

4.2 Manifest 模式

python -m downloader --manifest "D:\downloads\manifest.json" --workers 2
python -m downloader --manifest "D:\downloads\manifest.json" --workers 2 --global-rate-limit 4194304
python -m downloader --manifest "D:\downloads\manifest.json" --resume
python -m downloader --manifest "D:\downloads\manifest.json" --pause patch-data
python -m downloader --manifest "D:\downloads\manifest.json" --state-file "D:\state\patch.json"
参数含义
--manifest PATHJSON 数组形式的独立下载任务清单。与 URL 位置参数和 --output 互斥。
--workers N同时提交的 manifest 任务最大数,默认 2,必须大于 0。每个任务内部仍可有自己的 Range 线程。
--global-rate-limit BPS全部 manifest 任务及其所有分块共享的字节/秒预算,必须大于 0。只允许 manifest 模式。
--state-file PATH状态 JSON 路径。未提供时为 <manifest_stem>.state.json,位于 manifest 同目录。
--resume仅跳过摘要仍匹配且状态为 COMPLETED 的任务。
--pause TASK_ID预先将目标任务写为 PAUSED 后退出,不启动任何网络请求。

--global-rate-limit--state-file--resume--pause 只能与 --manifest 合用;在单文件模式使用任一项会打印 CLI 失败信息并返回 1

4.3 CLI 输出和退出码

  • 进度行写入标准错误,格式为 [filename] downloaded / total。多任务输出通过一个终端打印锁串行化,避免字符交织。
  • 单文件成功时标准输出打印来源、字节数和目标路径。
  • manifest 成功任务标准输出打印 COMPLETED;失败和取消消息写标准错误。
  • manifest 中某个任务最终失败不会阻止其他已提交任务继续,最终只要存在失败则返回 1
  • 捕获到主线程 KeyboardInterrupt 时打印 Cancelled. 并返回 130。这不是对每个 worker manager 的协作式 cancel() 广播。

5. Manifest 格式、校验和任务摘要

5.1 完整 schema

manifest 顶层必须是 JSON 数组;每个元素必须是对象。当前实现采用白名单校验,未知字段直接报错。

[
  {
    "id": "base-package",
    "priority": 100,
    "task_retries": 1,
    "urls": [
      "https://primary.example/package.zip",
      "https://mirror.example/package.zip"
    ],
    "destination": "D:\\downloads\\package.zip",
    "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
    "md5": null,
    "chunk_size": 1048576,
    "connections": 4,
    "retries": 3,
    "timeout": 30.0,
    "rate_limit": 2097152,
    "proxy": "http://proxy.example:8080"
  }
]

5.2 字段表

字段是否必填类型与默认值语义解析/校验规则
id非空字符串或 null;默认 task-<index>持久化任务 ID 和 --pause 目标。所有最终 ID 必须唯一。null 会触发默认 ID。
priority整数,默认 0值越大越早提交到线程池。不接受布尔值。相等时保留 manifest 顺序。
task_retries非负整数,默认 0整个下载任务失败后的额外尝试次数。不替代 retries
urls非空字符串数组以顺序表示探测候选镜像。每项必须为非空字符串。
destination非空字符串正式目标路径。不在解析时创建文件。
sha256非空字符串或 null完成后的 SHA-256 期望值。不做十六进制格式预校验。
md5非空字符串或 null完成后的 MD5 期望值。不做十六进制格式预校验。
chunk_size整数,默认 1048576Range 固定区间大小。最终由 DownloadRequest 要求大于 0。
connections整数,默认 4每个任务的 Range 工作线程数。最终由 DownloadRequest 要求大于 0。
retries整数,默认 3每一个 Range 的额外重试次数。最终由 DownloadRequest 要求非负。
timeout数字,默认 30.0每次打开请求的超时。允许整数或浮点数,不接受布尔值。
rate_limit整数或缺省此任务所有流的总预算。manifest 仅检查整数类型,未强制正数。
proxy非空字符串或 nullHTTP/HTTPS 代理。状态文件不会保存其明文。

允许字段集合严格为:idprioritytask_retriesurlsdestinationsha256md5chunk_sizeconnectionsretriestimeoutrate_limitproxy

5.3 两层重试的区别

层次配置字段作用对象实际次数退避
Range 层retries一个固定 bytes=start-end 请求最多 retries + 1失败后 min(2 ** attempt, 5) 秒。
任务层task_retries一个完整 DownloadManager.download() 生命周期最多 task_retries + 1失败后 min(2 ** attempt, 5) 秒,并持久化 RETRY_WAIT

顺序路径和未知长度流式路径当前没有围绕整个请求的内部 retries 循环;retries 的实现位置是 _download_range()。在 manifest 模式下,顺序/流式失败可由 task_retries 重试整个任务;单文件模式则直接失败。

5.4 安全摘要

为避免状态 JSON 保存 URL 和代理明文,CLI 为每个 manifest 条目构造以下摘要:

{
  "url_digest": "SHA-256(用换行连接的 urls UTF-8 文本)",
  "destination": "D:\\downloads\\package.zip",
  "sha256": "<manifest sha256 or null>",
  "md5": null,
  "chunk_size": 1048576,
  "connections": 4
}

该摘要是 TaskStore 是否复用旧状态的精确比较对象。值得注意的当前行为:retriestimeoutrate_limitproxytask_retriespriority 不属于 summary;其中 priority 会在匹配旧摘要时覆盖更新,其他未包含字段的变化不会触发状态重置。


6. 临时文件、元数据与原子发布

6.1 文件角色

对目标文件 D:\downloads\package.zip

文件创建时机内容/用途成功后处理
package.zip全部传输和摘要校验成功后。用户可消费的正式文件。os.replace(part, destination) 原子替换。
package.zip.part下载开始写入时。未验证或未完成的文件内容;可作为顺序/流式恢复断点。被原子移动为正式文件。
package.zip.part.meta仅并行 Range 模式每完成一个区间后。ASCII 文本,每行一个 start-end 已完成区间。正式发布后 unlink(missing_ok=True)

6.2 生命周期

开始下载

网络失败或取消时保留现场

并行 Range 写完成区间

继续其他区间

全部数据可用

校验成功且 os.replace

校验失败时保留现场

NoTemp

PartWriting

MetaWriting

Verifying

Published

图中 MetaWriting 是并行路径才存在的辅助步骤;顺序和流式路径不创建也不消费 .part.meta

6.3 原子发布保证

已知长度路径在 _verify(part_path, request) 成功后执行:

os.replace(part_path, destination)
meta_path.unlink(missing_ok=True)

未知长度流式路径同样先校验 .part,再执行 os.replace(),但不会主动处理旧 .part.metaos.replace() 在同一文件系统中提供原子替换语义,因此:

  • 目标路径之前已有正式文件时,校验失败不会覆盖它。
  • 成功发布后,消费者不会看到“正式文件名下的半个新文件”。
  • 临时文件必须与目标位于同一目录,因此通常天然满足同一文件系统条件。

6.4 失败与保留

  • Range 网络失败、顺序/流式网络失败、摘要错误、DownloadCancelled 都不会由核心代码删除 .part
  • 并行路径失败时,先前已经记录的 .part.meta 区间保留;下次可跳过这些已记录区间。
  • 摘要失败意味着 .part 的内容不可信,但实现仍保留它;下一次顺序路径可能从其文件长度继续,下一次并行路径可能按照已有 meta 跳过区间。因此生产运维发现摘要失败时,宜先审查或清理对应临时文件和元数据,再重新下载。
  • 并行预分配会将 .part 截断到完整文件长度。文件长度本身不等于内容完成;必须结合 .part.meta 和最终摘要理解其状态。

7. HTTP 探测、代理与镜像协议

7.1 探测协议

每次 download() 先调用 _probe(request)。它按 request.urls 顺序发送:

GET /path HTTP/1.1
Range: bytes=0-0

探测响应处理规则:

  1. 响应头优先读取 Content-Range,否则读取 Content-Length
  2. 若两者均不存在,返回 total=None;Range 支持标志仍以 response.status == 206 判断。
  3. 若头值包含 /,取最后一个 / 后的数字作为总长度,例如 bytes 0-0/10485760 得到 10485760
  4. 否则把头值直接解析为整数。
  5. response.status == 206 判断当前服务器是否支持 Range。
  6. response.geturl() 保存最终 URL,后续传输请求均使用它。

HTTPErrorURLErrorOSError、长度转换 ValueError 会被视为该镜像探测失败,错误文本累计。所有候选均失败时抛出:

all mirrors failed during probe: <url>: <error>; <url>: <error>

7.2 HTTP、代理、镜像时序图

Fallback mirror Primary mirror ProxyHandler DownloadManager Fallback mirror Primary mirror ProxyHandler DownloadManager alt [Primary probe succeeds] [Primary probe fails] GET primary with Range bytes=0-0 Forward probe request 206 or other response with headers Response and final URL Subsequent GET requests use selected final URL Transfer requests Data responses Data responses HTTPError URLError OSError or invalid length Probe failure GET fallback with Range bytes=0-0 Forward probe request Probe response Response and final URL

7.3 代理行为

_opener(proxy) 的实际实现为:

build_opener(ProxyHandler({"http": proxy, "https": proxy})) if proxy else build_opener()

因此:

  • 未传 proxy 时,使用默认 build_opener()
  • 传入 proxy 时,同一个字符串同时映射到 httphttps
  • 每次 _open() 都会创建新的 opener;实现没有连接池或 opener 缓存。
  • 探测和所有传输请求均使用相同任务的 proxytimeout
  • manifest 状态摘要不保存代理;错误字符串理论上可能携带底层异常文本,TaskStore 只承诺不主动写 URL/凭据字段,不能把任意异常文本视为脱敏系统。

7.4 镜像边界:只在探测阶段回退

镜像列表不是逐 Range 的容灾列表。准确行为如下:

  • _probe() 对第一个探测失败的 URL 尝试下一个候选。
  • 一旦探测成功,传输阶段只使用该响应的 response.geturl()
  • 并行分块中某个 Range 失败后,会对同一个已选 URL重试;不会尝试 urls 中的其他镜像。
  • 顺序和流式传输失败也不会回到 _probe() 自动切换镜像。

因此,镜像用于“选择一个可探测源”,而不是“下载中的透明故障迁移”。


8. 下载策略选择与单文件流程

8.1 四种实际策略

探测返回 (total, source_url, ranges_supported) 后,选择逻辑如下:

策略条件请求方式恢复依据
未知长度流式total is None初次普通 GET;已有 .part 时发送 Range: bytes=<offset>-.part 当前长度。
已知长度顺序首次下载已知长度,但不满足并行条件,且 .part 不存在或长度为 0。普通 GET,不带 Range。无。
已知长度顺序续传已知长度,但不满足并行条件,且 .part 长度介于 1total - 1Range: bytes=<offset>-<total-1>.part 当前长度。
并行固定 Rangeranges_supported 为真、connections > 1total > chunk_size多个 Range: bytes=<start>-<end> 单区间 GET。.part.meta 的完成区间。

额外的已知长度短路:顺序路径中 .part 存在且大小恰好等于 total 时,不发传输请求,直接进入最终校验和发布。这不是独立策略,但在断点恢复时很重要。

8.2 策略决策流程图

开始 download

依次探测镜像 Range bytes=0-0

是否得到总长度

未知长度流式路径

Range 支持且 connections 大于 1 且 total 大于 chunk_size

并行固定 Range 路径

已知长度顺序路径

校验并发布

校验并发布

8.3 单文件下载流程图

构造 DownloadRequest

重置 manager 状态

创建目标父目录

计算 .part 与 .part.meta 路径

PROBING 回调

镜像 Range 0-0 探测

DOWNLOADING 回调

策略选择

流式或顺序写 .part

并行写 .part 和 .part.meta

检查取消

VERIFYING 回调

SHA-256 和 MD5 流式校验

os.replace .part 到目标

删除 .part.meta

完成进度回调和 DownloadResult

对未知长度流式路径,代码在进入路径前已发出 DOWNLOADING 回调,但后续的校验与发布封装在 _download_stream() 中,主路径不会额外发出 VERIFYING 回调,也不会清理 .part.meta


9. 顺序、流式与断点续传细节

9.1 已知长度顺序路径

_download_sequential() 的逐步流程:

  1. .part 存在,读取其 stat().st_size;否则偏移为 0。
  2. 计算 offset = min(existing_size, total)。超过总长度的临时文件会被当作 offset == total
  3. offset == total,立即返回主流程,不做 HTTP 传输;主流程随后校验并发布。
  4. offset > 0,请求头为 Range: bytes=<offset>-<total-1>;否则不带 Range 请求头。
  5. 当偏移非 0 时,服务器必须返回 206,否则抛出 DownloadError("server does not support resuming this download")
  6. 以追加模式 ab 打开 .part(无偏移时以 wb 覆盖),调用 _copy() 写入。
  7. 读取完成后由主流程检查取消、校验并发布。

已知长度顺序恢复依赖 .part文件大小,不记录已验证内容边界,也不检查服务端 Content-Range 是否与请求偏移完全一致。

9.2 未知长度流式路径

当探测没有 Content-Range / Content-Length 时,不能安全预分配或切成固定区间,使用 _download_stream()

  1. 读取 .part 现有大小作为 offset
  2. offset > 0 时发送 Range: bytes=<offset>-;否则发普通请求。
  3. 若续传请求的响应状态不是 206,报“server does not support resuming this download”。
  4. abwb.part,以 64 KiB 循环复制至 EOF。
  5. 调用 _check_cancelled(),执行可选摘要校验,os.replace() 发布。
  6. 以发布后目标文件大小构造 DownloadResult

此路径无法验证预期响应长度,因为没有可靠总长度;服务端提前 EOF 可能只有在最终 SHA-256/MD5 被提供时才被检测。

9.3 复制循环的共同语义

顺序、流式和每个 Range 都经由 _copy()

  1. 循环前与每次读取前检查取消事件。
  2. 每次 response.read(64 * 1024),即单次读取最多 64 KiB。
  3. 读到空字节即结束。
  4. 在写入前,先消费任务级 RateLimiter,再消费可选全局 RateLimiter
  5. 写入输出文件,累加本次局部 count,并更新全局进度快照。
  6. Range 路径传入 expected,若本次读取字节数与区间长度不相等则抛出 DownloadError

因此 64 KiB 是限速预约和进度上报的最大粒度;它不是 Range 区间大小。


10. 并行 Range 分块下载

10.1 分片构造

只有满足以下三个条件才进入并行模式:

ranges_supported is True
and request.connections > 1
and total > request.chunk_size

区间通过以下概念生成:

(start, min(start + chunk_size, total) - 1)
for start in range(0, total, chunk_size)

例如总长度 10chunk_size=4 时区间为:0-34-78-9。所有区间均为闭区间。

10.2 分块、写入、元数据与恢复流程

生成固定 Range 列表

读取并过滤 .part.meta

以 append 模式打开 .part 并 truncate 到 total

排除已完成区间得到 pending

按 connections 创建 Range 线程池

每个 worker 请求单个 bytes start-end

以 r+b 打开 .part 并 seek 到 start

复制并严格检查 expected 字节数

future 完成

主线程加入 completed 集合

覆盖写 .part.meta

所有固定区间已完成

返回主流程进行摘要校验和发布

详细步骤:

  1. 读取 .part.meta。每个合法行按 start-end 解析为整数元组。
  2. 仅保留满足 0 <= start <= end < totalstart % chunk_size == 0 的行;格式错误、越界、无效整数行静默忽略。
  3. ab 打开 .part,执行 truncate(total) 预分配/扩展文件,使每个 worker 可按偏移写同一文件。
  4. 计算 pending = ranges - completed。已记录区间不会重新请求。
  5. 创建 ThreadPoolExecutor(max_workers=request.connections),为每个待下载区间提交 _download_range()
  6. 主线程通过 as_completed() 等待 future;每个成功 future 返回其 (start, end)
  7. 主线程将成功区间加入集合,并用 sorted(completed) 覆盖写 ASCII .part.meta,每行 start-end
  8. 所有 future 正常结束后,若完成集合数量不等于完整区间数量,抛出 parallel download did not complete all ranges
  9. 返回外层执行最终取消检查、摘要校验、原子发布和 meta 删除。

10.3 同一文件并发写入

每个 Range worker:

  • 对选定 URL 发 Range: bytes=<start>-<end>
  • 强制要求 HTTP 状态为 206;否则报 server stopped honoring Range requests
  • 单独以 r+b 打开同一 .part 文件,而不是共享 Python 文件对象。
  • seek(start) 后由 _copy() 连续写入本区间。
  • 传入 expected=end-start+1,EOF 时必须精确匹配,否则报不完整响应。

由于固定区间不重叠,且文件已预分配,各线程写入不同偏移区域。元数据更新集中在主线程的 as_completed() 循环中,所以多个 worker 不会并发覆盖 .part.meta

10.4 Range 内重试

每个分块最多尝试 request.retries + 1 次:

  • 每次尝试开始前检查取消。
  • 可捕获 HTTPErrorURLErrorOSErrorDownloadError
  • 若尚可重试,睡眠 min(2 ** attempt, 5) 秒。例如首次失败等待 1 秒,第二次等待 2 秒,之后最高 5 秒。
  • 某次成功只返回区间;之前同区间的失败尝试没有单独元数据记录。
  • 最终失败抛出 range <start>-<end> failed: <last_error>,使 future 抛错,进而使并行主循环失败。

Range 重试不会改用其他镜像,也不会把失败区间拆小。

10.5 .part.meta 的精确格式与边界

示例:

0-1048575
1048576-2097151
2097152-2621439

当前恢复过滤是“格式和起点边界检查”,不是完整的内容证明:

  • 会检查起点是否为 chunk_size 的倍数。
  • 会检查区间整体是否在 [0, total-1] 内。
  • 不会校验 end 恰好等于该固定分片预期终点。
  • 不会.part 的对应字节重新计算分片摘要。
  • .part.meta 本身不是原子写入,写入中断可留下损坏文本;下次会忽略无法解析的行,但不能保证所有部分写入情形都能恢复为正确集合。

因此 .part.meta 是恢复优化元数据,不是防篡改的完成证明。最终 SHA-256/MD5 可在提供时对整体内容给出强校验;没有摘要时,系统不额外验证完整文件内容。

10.6 并行恢复注意事项

并行模式恢复时会无条件 truncate(total)。这会把较短 .part 扩展成完整长度的稀疏或零填充文件,但只有 .part.meta 中已记录的区间才被视为完成。反过来,若 meta 记录了实际上不存在或损坏的区间,当前实现会跳过下载它。生产环境应把 .part.part.meta 作为配套文件管理,必要时一起删除后重新下载。


11. 进度、取消、线程安全与限速

11.1 进度数据流图

HTTP response read 64 KiB

任务 RateLimiter

可选全局 RateLimiter

写入 .part

_report

状态锁更新快照

锁外调用 progress_callback

CLI stderr 输出

11.2 进度线程安全

DownloadManager_state_lock 保护:_downloaded_total_active_parts_destination

  • Range worker 可同时调用 _report(),每次累加和快照构造在锁内完成。
  • 用户 progress_callback 在锁外执行,避免回调阻塞状态锁,也避免回调重入时死锁。
  • CLI 自身再用 progress_lock 保护 print(),避免多个 manifest 任务的进度行混杂。
  • 回调异常不会在核心层被捕获;调用方提供的回调若抛异常,会中断所在下载路径。

恢复时进度计数从 0 开始,并不会读取 .part 既有大小,因此进度数值是“本次调用新传输字节”的累加;成功快照对已知长度则强制显示完整 total

11.3 取消语义

cancel() 仅设置一个 threading.Event_check_cancelled() 出现于:

  • _copy() 的每一次读循环开始前;
  • 每个 Range 尝试开始前;
  • 主路径传输完成后、校验前;

检测到事件后:

  1. 若存在进度回调,先从锁内读取当前快照并回调 cancelled=True
  2. 抛出 DownloadCancelled("download cancelled")
  3. 当前代码不主动删除 .part.part.meta

限制:标准库阻塞 response.read() 不能被事件立即打断,最坏等待时间受网络和 timeout 影响。并行模式下,一个 Range worker 取消后,线程池上下文退出仍按 ThreadPoolExecutor 语义等待已提交 worker 收束。

11.4 任务级与全局双层限速

每个 _copy() 数据块按以下顺序处理:

读取至多 64 KiB
  -> task_rate_limiter.consume(n)
  -> global_rate_limiter.consume(n)(若有)
  -> 写文件

一个 64 KiB 以内的数据块

任务共享 RateLimiter

是否 manifest 全局限速

所有任务共享的 RateLimiter

写入文件

下一数据块

RateLimiter 算法

它维护 _next(下一个可预约的单调时刻)和锁:

  1. now = time.monotonic()
  2. start = max(now, _next)
  3. 在锁内写 _next = start + count / rate
  4. 释放锁后计算 delay = start - now
  5. delay > 0,在锁外 sleep(delay)

这是预约式漏桶/时间排程模型:锁只保护时隙分配,睡眠不阻塞其他下载线程预约。所有 Range 分块共享任务限速器;manifest 下所有任务的 manager 又共享同一个全局限速器。

约束结论
  • --rate-limit 是一个任务整体的预算,不是“每条连接各自的预算”。
  • --global-rate-limit 只在 manifest CLI 模式创建和使用;单文件模式没有对应全局预算。
  • 同时设置任务和全局限速时,每个数据块都消费两者,因此总体吞吐受更严格的约束;不能把两个数相加。
  • 限速在 HTTP read() 后、文件 write() 前执行,网络层读取缓冲可能已发生;它约束的是应用层写入节奏,而非严格的 socket 接收瞬时速率。

12. Manifest 批量调度、优先级与状态机

12.1 批量并发模型

manifest 模式有两层并发:

  1. 任务层线程池:CLI 创建 ThreadPoolExecutor(max_workers=--workers),为每个可运行任务提交一个 run_task()
  2. 任务内 Range 线程池:某个任务满足并行条件时,DownloadManager 再创建最大为该任务 connections 的线程池。

因此理论峰值活动传输线程会随 workers 和每任务 connections 叠加;workers 仅限制独立 manifest 任务数,不限制这些任务内部的 Range worker 总数。

12.2 优先级稳定排序

调度步骤:

  1. manifest 原始数组顺序赋予 index
  2. 对非跳过任务先写 QUEUED,加入 runnable
  3. 执行 runnable.sort(key=lambda task: -task.priority)
  4. Python 排序稳定,因此相同 priority 的条目保留 manifest 原始顺序。
  5. 按排序后的列表顺序调用 executor.submit()

“更早提交”不等价于“严格先完成”:线程池的可用 worker、操作系统调度和网络耗时会影响实际开始/结束时间。高优先级任务优先占据初始可用 worker;低优先级任务只有在此前提交任务释放 worker 后才开始执行。

12.3 批量状态调度图

读取并严格校验 manifest

TaskStore prepare

是否 --pause

目标任务写 PAUSED

成功退出且无网络请求

--resume 且匹配 COMPLETED

打印 COMPLETED skipped

写 QUEUED

按 priority 降序稳定排序

任务线程池提交 run_task

PROBING

DOWNLOADING

VERIFYING

写 COMPLETED

失败处理

仍有 task_retries

写 RETRY_WAIT 并退避

写 FAILED

12.4 状态定义与转移

TaskStore 接受的状态集合为:

状态含义当前写入来源实际可见后继
PENDING新条目,或配置摘要不匹配后重置。TaskStore.prepare()QUEUEDPAUSED
QUEUED已决定要在当前运行中调度。CLI runnable 构建阶段。PROBING,或重新运行时可再写 QUEUED
PROBINGmanager 开始探测镜像。phase_callbackDOWNLOADINGRETRY_WAITFAILEDCANCELLED
DOWNLOADING探测成功后开始传输。phase_callbackVERIFYINGRETRY_WAITFAILEDCANCELLED
VERIFYING已知长度路径正在校验临时文件。phase_callbackCOMPLETEDRETRY_WAITFAILEDCANCELLED
RETRY_WAIT任务级失败后等待下一次完整尝试。run_task()PROBINGFAILED
COMPLETEDdownload() 返回成功且 CLI 已写状态。run_task() future 成功后。普通运行写回 QUEUED--resume 时跳过。
FAILED任务级重试耗尽。run_task()之后普通运行可写 QUEUED
CANCELLEDmanager 抛出 DownloadCancelledrun_task()之后普通运行可写 QUEUED
PAUSED--pause <id> 预先落盘。CLI pause 分支。之后普通运行可写 QUEUED

PENDING

QUEUED

PAUSED

PROBING

DOWNLOADING

VERIFYING

COMPLETED

RETRY_WAIT

FAILED

CANCELLED

上图是 CLI 的运行模型,而不是 TaskStore 内建的转移验证器。TaskStore.update() 只验证目标状态属于固定集合,不检查前置状态,因此库级调用者可写出状态机以外的跳转;文档中的合法转移描述的是当前入口代码的实际路径。

12.5 --resume 的精确行为

启动时每个任务都会先调用 store.prepare(task_id, summary, priority)

  • 不存在旧条目、旧条目不是字典、或 summary 不完全相等:创建/替换为 PENDINGattempts=0error=null
  • 摘要相等:复用旧状态,并更新其 priority 字段。

随后:

  • 使用 --resume 时,摘要匹配且 status == "COMPLETED" 的任务被跳过,并打印 COMPLETED (skipped)
  • FAILEDCANCELLEDPAUSEDPENDINGQUEUED、中途状态都会再次写 QUEUED 并调度。
  • 不使用 --resume 时,连已完成任务也会写 QUEUED 并重新下载;这正是普通运行的默认行为。

--resume 只跳过任务调度,不重新验证已存在正式文件的摘要,也不检查正式文件是否仍存在。

12.6 --pause 的精确行为

--pause TASK_ID 的处理在所有 prepare() 完成后、任何 runnable 任务构造前:

  1. 若 ID 不在当前 manifest 解析出的条目中,打印错误并返回 1
  2. 否则 store.update(TASK_ID, "PAUSED")
  3. 打印 [TASK_ID] PAUSED 并返回 0

它是预先落状态命令:不发探测请求、不创建任务线程池、不检查目标任务是否在另一个进程中运行,也不向其他进程或 manager 发取消事件。因此它不能实现跨进程的即时暂停。


13. TaskStore 持久化与恢复语义

13.1 状态文件路径和结构

默认状态文件规则:

<manifest 同目录>/<manifest_stem>.state.json

例如:

Manifest默认状态文件
D:\downloads\manifest.jsonD:\downloads\manifest.state.json
D:\downloads\patch.list.jsonD:\downloads\patch.list.state.json

典型内容:

{
  "version": 1,
  "tasks": {
    "base-package": {
      "attempts": 1,
      "error": null,
      "priority": 100,
      "status": "COMPLETED",
      "summary": {
        "chunk_size": 1048576,
        "connections": 4,
        "destination": "D:\\downloads\\package.zip",
        "md5": null,
        "sha256": "<expected-sha256>",
        "url_digest": "<sha256-of-url-list>"
      }
    }
  }
}

json.dumps(..., ensure_ascii=False, indent=2, sort_keys=True) 生成格式化 JSON。根对象版本当前固定写 1,读取时不依据版本做迁移或拒绝。

13.2 安全与摘要重置

状态文件设计目标是避免主动持久化以下明文:

  • urls 完整列表;改存 url_digest
  • proxy;完全不存入 summary。

每次 prepare() 比较旧 entry["summary"] 与当前 summary:只要不相等,就替换整个条目为:

{
  "summary": "当前摘要对象",
  "priority": "当前优先级",
  "status": "PENDING",
  "attempts": 0,
  "error": null
}

这使先前的 COMPLETED 不能被配置不匹配的任务错误复用。反之,摘要相等时旧 statusattemptserror 都保留,只有 priority 在内存中更新。

13.3 原子保存与线程安全

TaskStore 持有自己的 threading.Lock

  1. 更新一个 entry 时持锁。
  2. 创建状态文件父目录。
  3. <state-file-name>.tmp
  4. 执行 temporary.replace(self.path) 原子替换状态 JSON。

并行 manifest 任务可以同时调用 store.update();锁保证一个更新不会覆盖另一个刚写入的内存状态。若状态文件不存在、不可读或 JSON 损坏,_load() 安全退化为空任务字典,而不是终止下载。

当前实现没有 fsync、跨进程锁或临时文件恢复逻辑;两个独立进程同时操作同一状态文件仍可能互相竞争。

13.4 错误字段语义

update(task_id, status, error=None, attempts=None) 只有传入非 Noneerror 时才覆写旧错误字段。成功写 COMPLETED 时调用未传 error,所以如果某任务历史上曾失败后重试成功,旧错误文本可能继续保留在完成条目中。attempts 是本次任务级尝试序号,从 1 开始,不是所有 Range 请求的累计次数。


14. 错误处理矩阵与退出码

场景发生层行为临时文件/状态影响CLI 结果
manifest JSON 不可读或无效_load_manifestDownloadError不开始下载。1
manifest 顶层不是数组、未知字段、缺字段、类型非法、重复 ID_load_manifest以条目下标构造错误。不开始下载。1
单文件缺 URL 或 outputCLI 参数校验打印失败信息。无。1
manifest 与 URL/output 混用CLI 参数校验拒绝。无。1
manifest 专用选项用于单文件CLI 参数校验拒绝。无。1
workers <= 0 或全局速率非正CLI 参数校验拒绝。无。1
某镜像探测失败_probe记录原因并尝试下一个 URL。无传输文件变更。仅全部失败才失败。
全部镜像探测失败_probe抛聚合 DownloadError.part 通常尚未写入。单文件 1;manifest 可任务级重试。
续传响应不是 206顺序/流式路径DownloadError已有 .part 保留。同上。
Range 响应不是 206单 Range作为分块失败,可按 retries 重试同一 URL。.part.meta 不记录该区间。重试耗尽后任务失败。
Range 响应提前 EOF_copy(expected=...)抛不完整响应错误,可按 retries 重试。该区间不写完成 meta。重试耗尽后任务失败。
HTTP/URL/OS 错误Range worker 或探测探测阶段换镜像;Range 阶段重试同源。保留恢复现场。视重试结果。
SHA-256/MD5 不匹配_verifyDownloadError,不发布目标。.part 保留;并行 meta 也保留。单文件 1;manifest 可任务级重试。
调用 manager.cancel()_check_cancelled回调取消快照,抛 DownloadCancelled保留 .part / meta。manifest 写 CANCELLED;单文件为失败路径。
manifest 任务级失败且仍可重试run_taskRETRY_WAIT,指数退避,再完整重试。保留下载现场。最终成功则 0
manifest 任务级重试耗尽run_taskFAILED,不阻止其他任务。保留现场和错误文本。只要有任一失败,最终 1
主线程 Ctrl+Cmain捕获 KeyboardInterrupt不保证广播取消所有 worker。130

错误层次需特别区分:镜像切换只包围探测;Range 重试只包围 _download_range();任务级重试只由 manifest run_task() 提供。单文件路径没有任务级 retry 包装。


15. 测试覆盖

运行测试:

python -m unittest downloader.test_downloader -v

测试使用 ThreadingHTTPServer 和自定义 RangeHandler,本地模拟单区间 Range 响应、206Content-RangeContent-Length 与有限响应体。覆盖项目如下:

测试已验证行为
test_parallel_download_and_sha256约 2 MiB 本地载荷触发多个 128 KiB Range;结果内容一致;SHA-256 成功;Range 请求数量大于 2。
test_resume_from_partial_file_and_md5预写 .part 后,以单连接顺序续传;MD5 校验通过;服务端收到多个 Range 请求。
test_invalid_checksum_preserves_partial_fileSHA-256 错误时抛出 DownloadError;正式文件不存在;.part 保留。
test_manifest_cli_downloads_independent_filesmanifest 可并发下载两个不同目标文件。
test_manifest_priority_state_resume_pause_and_shared_limiter稳定优先级排序结果;两个 manager 可注入同一全局 limiter;manifest 默认状态文件;完成状态;--resume--pause
test_manifest_cli_rejects_invalid_entriesmanifest 未知字段、缺 destination、错误 urls 类型,以及单文件误用 manifest 专用选项时返回失败码。

15.1 当前测试未覆盖或覆盖不充分的领域

以下是代码存在但现有测试集未形成完整自动验证矩阵的重点:

  • 真实 HTTP/HTTPS 代理连通性、代理认证和代理异常文本脱敏。
  • 多镜像探测首源失败、次源成功;以及下载中源故障不切换镜像的边界。
  • 未知长度流式响应、没有长度且中途断流的行为。
  • Range 重试、任务级 task_retries 的重试次数与退避状态序列。
  • 取消时阻塞读取、并行 worker 收束和取消进度回调。
  • .part.meta 损坏行、截断写、伪造区间、临时文件与 meta 不一致。
  • 两个进程同时使用相同目标或状态文件。
  • 限速数值准确性、任务和全局双限速下的吞吐上界。
  • 长路径、权限错误、磁盘满、跨平台文件替换差异和超大文件。

16. 已知限制与生产使用建议

16.1 已知限制汇总

  1. 镜像只在探测阶段回退:分块/顺序下载一旦选定源,失败不会迁移到其他镜像。
  2. 无远端版本绑定:无 ETag、Last-ModifiedIf-Range。远端更新可能与本地临时数据拼接,最终只能主要依靠可选摘要发现。
  3. 摘要可选而非强制:没有 SHA-256/MD5 时,下载器不对完整内容做密码学验证;未知长度流式也无法依据长度验证截断。
  4. 恢复元数据不是事务日志.part.meta 不是原子写入,也不验证每个区间的终点或内容;手工修改或异常中断可能导致错误跳过。
  5. 顺序恢复仅看大小.part 内容损坏但长度正确时,顺序路径可能直接校验/发布;没有摘要时无法发现。
  6. 取消非即时:阻塞 socket 读无法被 Event 强制中断,响应时间取决于网络和超时。
  7. --pause 不是运行中控制:仅预写 PAUSED,不能跨进程暂停或取消当前传输。
  8. 状态文件不具备跨进程协调:无文件锁、无 fsync,多个进程共享状态文件或输出路径不安全。
  9. 线程规模可能放大workers 与每任务 connections 叠加;大 manifest 配合大连接数可能造成连接、线程和磁盘 IO 压力。
  10. 限速是应用层预约:读取后的限速不能严格约束网络栈中已经缓冲的数据;rate_limit=0 在核心 RateLimiter 中会视为不限速,而非禁止下载。
  11. 状态阶段细节不完全对称:未知长度流式路径不会发 VERIFYING phase callback;成功后旧 error 字段可能保留。

16.2 生产使用建议

  • 始终提供 SHA-256:优先 SHA-256;MD5 可以兼容旧发布物,但不应作为安全完整性的唯一依据。
  • 让镜像内容严格一致:所有镜像应提供同一不可变版本文件,支持 Range: bytes=0-0,并对单区间请求稳定返回 206 和准确的 Content-Range
  • 合理设置分片:大文件、稳定服务器和较高带宽可使用多个连接;小文件或高延迟/低配代理环境可降低 connections,避免并行开销超过收益。
  • workersconnections 一起规划:例如 workers=4、每任务 connections=8 可能同时运行大量网络线程。应按服务端连接限制、客户端 CPU、磁盘随机写能力和代理容量配置。
  • 同时设置两类限速时按更严预算设计:任务级控制单文件公平性,全局级控制整个 manifest 总出口;全局限制仅在 manifest 模式生效。
  • 将状态和临时文件保存在可靠本地磁盘:避免网络共享盘、随时清理的临时目录或不同进程共享同一个 destination。确保目标目录有足够空间容纳并行预分配的完整 .part
  • 摘要失败时清理恢复现场再重试:尤其并行模式下,为避免复用错误 meta,建议成对删除 .part.part.meta,再重新运行。
  • --resume 做外部文件存在性检查:它只依据状态摘要和 COMPLETED 跳过,并不重新校验正式文件。自动化部署前可额外验证目标文件存在且摘要正确。
  • 为生产环境补齐测试与观测:至少增加真实代理、多镜像故障、断网重连、磁盘满、取消、损坏 meta、限速和跨平台路径测试;同时在调用方记录任务 ID、选定源、失败分类和校验结果。
  • 需要更强恢复语义时扩展协议而非猜测现状:可考虑将远端版本标识、原子元数据写、每片摘要、文件锁、跨进程控制和可中断 HTTP 客户端作为明确的新需求实现;它们不是当前下载器的隐含能力。

附录:实现行为速查

主题当前真实行为
默认状态文件<manifest_stem>.state.json
全局限速仅 manifest CLI;所有任务和 Range 分块共享一个实例。
双层限速任务和全局都会消费,吞吐受更严约束。
镜像回退仅探测阶段;传输和分块中不迁移镜像。
--resume跳过摘要匹配且状态为 COMPLETED 的任务。
普通 manifest 运行会重新调度 COMPLETED 在内的所有任务。
--pause仅预写 PAUSED 后退出,不能跨进程中断。
并行恢复.part.meta 已记录的固定区间为准。
顺序恢复.part 当前长度为准。
发布条件可选摘要校验通过后才 os.replace()
状态文件敏感信息不主动保存 URL/proxy 明文;URL 保存 SHA-256 摘要。
取消进程内 Event 协作取消;阻塞读取不能保证立即停止。

更多推荐