Python学习之——多线程断点续传下载器
Python学习之——多线程断点续传下载器
实现约束:仅使用 Python 标准库(
urllib.request、threading、concurrent.futures、hashlib等),不依赖第三方 HTTP、进度条或持久化库。
目录
- 源码
- 目标、非目标与设计原则
- 总体架构与模块职责
- 公开 Python API
- 命令行接口
- Manifest 格式、校验和任务摘要
- 临时文件、元数据与原子发布
- HTTP 探测、代理与镜像协议
- 下载策略选择与单文件流程
- 顺序、流式与断点续传细节
- 并行 Range 分块下载
- 进度、取消、线程安全与限速
- Manifest 批量调度、优先级与状态机
- TaskStore 持久化与恢复语义
- 错误处理矩阵与退出码
- 测试覆盖
- 已知限制与生产使用建议
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-Modified或If-Range绑定远端版本;远端内容在断点期间变更时,主要依赖最终摘要校验发现问题。 - 不支持多区间
Range请求;每个请求只发一个bytes=start-end区间。 - 不在同一下载中动态扩缩分块、动态调整连接数或做自适应镜像负载均衡。
- 不提供跨进程下载锁、守护进程、服务端控制 API、交互式暂停控制或任务依赖图。
--pause不会向另一个已经运行的下载进程发送取消信号,不能跨进程中断传输。- 不承诺损坏的
.part或手工伪造的.part.meta能被内容级校验后自动修复。
1.3 关键设计原则
- 临时写入优先:网络响应永远先写入
<destination>.part,校验成功后才替换目标文件。 - 探测与传输分层:镜像选择仅在探测阶段发生。选定响应最终 URL 后,后续请求复用该 URL。
- 有限、可恢复的元数据:并行恢复元数据只记录完成的字节区间;manifest 状态只保存无敏感明文的任务摘要。
- 显式共享而非隐式全局:任务限速器按下载创建;全局限速器仅由 manifest 入口创建并显式注入每个 manager。
- 并发边界清晰:一个
DownloadManager负责一个顺序生命周期的下载;Range 子任务可并发,但其共享进度由锁保护。 - 失败保留现场:网络失败、校验失败和取消一般不删除
.part/.part.meta,便于后续恢复或人工排查。
2. 总体架构与模块职责
2.1 架构图
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=True、cancelled=False,active_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 仅由已知长度主路径调用,顺序为 PROBING、DOWNLOADING、VERIFYING。未知长度流式路径在 DOWNLOADING 后自行校验和发布,不额外回调 VERIFYING,这是当前代码的实际差异。
并发使用约束:同一个 DownloadManager 不应被同时用于多个 download() 调用。它的 _cancel_event、进度字段和任务限速器都是实例状态,会被每次 download() 重置或替换。多任务并发时应像 manifest CLI 一样为每个任务创建独立 manager;若需聚合带宽,再注入同一个 RateLimiter。
3.5 异常和限速器
DownloadError(RuntimeError):下载不可完成时的统一错误类型。DownloadCancelled(DownloadError):取消专用类型,使调用方可与普通失败分开处理。RateLimiter(bytes_per_second):线程安全共享漏桶。consume(count)为字节数预约时隙;None、0或其他假值速率时立即返回。
最小 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, --connections | Range 并行最大连接数,默认 4。 |
--chunk-size | Range 固定分块大小,默认 1048576。 |
--retries | 单个 Range 的额外重试数,默认 3。 |
--timeout | urllib 打开请求时的超时,默认 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 PATH | JSON 数组形式的独立下载任务清单。与 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 | 否 | 整数,默认 1048576 | Range 固定区间大小。 | 最终由 DownloadRequest 要求大于 0。 |
connections | 否 | 整数,默认 4 | 每个任务的 Range 工作线程数。 | 最终由 DownloadRequest 要求大于 0。 |
retries | 否 | 整数,默认 3 | 每一个 Range 的额外重试次数。 | 最终由 DownloadRequest 要求非负。 |
timeout | 否 | 数字,默认 30.0 | 每次打开请求的超时。 | 允许整数或浮点数,不接受布尔值。 |
rate_limit | 否 | 整数或缺省 | 此任务所有流的总预算。 | manifest 仅检查整数类型,未强制正数。 |
proxy | 否 | 非空字符串或 null | HTTP/HTTPS 代理。 | 状态文件不会保存其明文。 |
允许字段集合严格为:id、priority、task_retries、urls、destination、sha256、md5、chunk_size、connections、retries、timeout、rate_limit、proxy。
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 是否复用旧状态的精确比较对象。值得注意的当前行为:retries、timeout、rate_limit、proxy、task_retries 和 priority 不属于 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 生命周期
图中 MetaWriting 是并行路径才存在的辅助步骤;顺序和流式路径不创建也不消费 .part.meta。
6.3 原子发布保证
已知长度路径在 _verify(part_path, request) 成功后执行:
os.replace(part_path, destination)
meta_path.unlink(missing_ok=True)
未知长度流式路径同样先校验 .part,再执行 os.replace(),但不会主动处理旧 .part.meta。os.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
探测响应处理规则:
- 响应头优先读取
Content-Range,否则读取Content-Length。 - 若两者均不存在,返回
total=None;Range 支持标志仍以response.status == 206判断。 - 若头值包含
/,取最后一个/后的数字作为总长度,例如bytes 0-0/10485760得到10485760。 - 否则把头值直接解析为整数。
- 用
response.status == 206判断当前服务器是否支持 Range。 - 用
response.geturl()保存最终 URL,后续传输请求均使用它。
HTTPError、URLError、OSError、长度转换 ValueError 会被视为该镜像探测失败,错误文本累计。所有候选均失败时抛出:
all mirrors failed during probe: <url>: <error>; <url>: <error>
7.2 HTTP、代理、镜像时序图
7.3 代理行为
_opener(proxy) 的实际实现为:
build_opener(ProxyHandler({"http": proxy, "https": proxy})) if proxy else build_opener()
因此:
- 未传
proxy时,使用默认build_opener()。 - 传入
proxy时,同一个字符串同时映射到http和https。 - 每次
_open()都会创建新的 opener;实现没有连接池或 opener 缓存。 - 探测和所有传输请求均使用相同任务的
proxy和timeout。 - 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 长度介于 1 和 total - 1。 | Range: bytes=<offset>-<total-1>。 | .part 当前长度。 |
| 并行固定 Range | ranges_supported 为真、connections > 1、total > chunk_size。 | 多个 Range: bytes=<start>-<end> 单区间 GET。 | .part.meta 的完成区间。 |
额外的已知长度短路:顺序路径中 .part 存在且大小恰好等于 total 时,不发传输请求,直接进入最终校验和发布。这不是独立策略,但在断点恢复时很重要。
8.2 策略决策流程图
8.3 单文件下载流程图
对未知长度流式路径,代码在进入路径前已发出 DOWNLOADING 回调,但后续的校验与发布封装在 _download_stream() 中,主路径不会额外发出 VERIFYING 回调,也不会清理 .part.meta。
9. 顺序、流式与断点续传细节
9.1 已知长度顺序路径
_download_sequential() 的逐步流程:
- 若
.part存在,读取其stat().st_size;否则偏移为 0。 - 计算
offset = min(existing_size, total)。超过总长度的临时文件会被当作offset == total。 - 若
offset == total,立即返回主流程,不做 HTTP 传输;主流程随后校验并发布。 - 若
offset > 0,请求头为Range: bytes=<offset>-<total-1>;否则不带 Range 请求头。 - 当偏移非 0 时,服务器必须返回
206,否则抛出DownloadError("server does not support resuming this download")。 - 以追加模式
ab打开.part(无偏移时以wb覆盖),调用_copy()写入。 - 读取完成后由主流程检查取消、校验并发布。
已知长度顺序恢复依赖 .part 的文件大小,不记录已验证内容边界,也不检查服务端 Content-Range 是否与请求偏移完全一致。
9.2 未知长度流式路径
当探测没有 Content-Range / Content-Length 时,不能安全预分配或切成固定区间,使用 _download_stream():
- 读取
.part现有大小作为offset。 offset > 0时发送Range: bytes=<offset>-;否则发普通请求。- 若续传请求的响应状态不是
206,报“server does not support resuming this download”。 - 以
ab或wb写.part,以 64 KiB 循环复制至 EOF。 - 调用
_check_cancelled(),执行可选摘要校验,os.replace()发布。 - 以发布后目标文件大小构造
DownloadResult。
此路径无法验证预期响应长度,因为没有可靠总长度;服务端提前 EOF 可能只有在最终 SHA-256/MD5 被提供时才被检测。
9.3 复制循环的共同语义
顺序、流式和每个 Range 都经由 _copy():
- 循环前与每次读取前检查取消事件。
- 每次
response.read(64 * 1024),即单次读取最多 64 KiB。 - 读到空字节即结束。
- 在写入前,先消费任务级
RateLimiter,再消费可选全局RateLimiter。 - 写入输出文件,累加本次局部
count,并更新全局进度快照。 - 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)
例如总长度 10、chunk_size=4 时区间为:0-3、4-7、8-9。所有区间均为闭区间。
10.2 分块、写入、元数据与恢复流程
详细步骤:
- 读取
.part.meta。每个合法行按start-end解析为整数元组。 - 仅保留满足
0 <= start <= end < total且start % chunk_size == 0的行;格式错误、越界、无效整数行静默忽略。 - 以
ab打开.part,执行truncate(total)预分配/扩展文件,使每个 worker 可按偏移写同一文件。 - 计算
pending = ranges - completed。已记录区间不会重新请求。 - 创建
ThreadPoolExecutor(max_workers=request.connections),为每个待下载区间提交_download_range()。 - 主线程通过
as_completed()等待 future;每个成功 future 返回其(start, end)。 - 主线程将成功区间加入集合,并用
sorted(completed)覆盖写 ASCII.part.meta,每行start-end。 - 所有 future 正常结束后,若完成集合数量不等于完整区间数量,抛出
parallel download did not complete all ranges。 - 返回外层执行最终取消检查、摘要校验、原子发布和 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 次:
- 每次尝试开始前检查取消。
- 可捕获
HTTPError、URLError、OSError和DownloadError。 - 若尚可重试,睡眠
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 进度数据流图
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 尝试开始前;
- 主路径传输完成后、校验前;
检测到事件后:
- 若存在进度回调,先从锁内读取当前快照并回调
cancelled=True。 - 抛出
DownloadCancelled("download cancelled")。 - 当前代码不主动删除
.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)(若有)
-> 写文件
RateLimiter 算法
它维护 _next(下一个可预约的单调时刻)和锁:
now = time.monotonic()。start = max(now, _next)。- 在锁内写
_next = start + count / rate。 - 释放锁后计算
delay = start - now。 - 若
delay > 0,在锁外sleep(delay)。
这是预约式漏桶/时间排程模型:锁只保护时隙分配,睡眠不阻塞其他下载线程预约。所有 Range 分块共享任务限速器;manifest 下所有任务的 manager 又共享同一个全局限速器。
约束结论
--rate-limit是一个任务整体的预算,不是“每条连接各自的预算”。--global-rate-limit只在 manifest CLI 模式创建和使用;单文件模式没有对应全局预算。- 同时设置任务和全局限速时,每个数据块都消费两者,因此总体吞吐受更严格的约束;不能把两个数相加。
- 限速在 HTTP
read()后、文件write()前执行,网络层读取缓冲可能已发生;它约束的是应用层写入节奏,而非严格的 socket 接收瞬时速率。
12. Manifest 批量调度、优先级与状态机
12.1 批量并发模型
manifest 模式有两层并发:
- 任务层线程池:CLI 创建
ThreadPoolExecutor(max_workers=--workers),为每个可运行任务提交一个run_task()。 - 任务内 Range 线程池:某个任务满足并行条件时,
DownloadManager再创建最大为该任务connections的线程池。
因此理论峰值活动传输线程会随 workers 和每任务 connections 叠加;workers 仅限制独立 manifest 任务数,不限制这些任务内部的 Range worker 总数。
12.2 优先级稳定排序
调度步骤:
- manifest 原始数组顺序赋予
index。 - 对非跳过任务先写
QUEUED,加入runnable。 - 执行
runnable.sort(key=lambda task: -task.priority)。 - Python 排序稳定,因此相同
priority的条目保留 manifest 原始顺序。 - 按排序后的列表顺序调用
executor.submit()。
“更早提交”不等价于“严格先完成”:线程池的可用 worker、操作系统调度和网络耗时会影响实际开始/结束时间。高优先级任务优先占据初始可用 worker;低优先级任务只有在此前提交任务释放 worker 后才开始执行。
12.3 批量状态调度图
12.4 状态定义与转移
TaskStore 接受的状态集合为:
| 状态 | 含义 | 当前写入来源 | 实际可见后继 |
|---|---|---|---|
PENDING | 新条目,或配置摘要不匹配后重置。 | TaskStore.prepare() | QUEUED、PAUSED。 |
QUEUED | 已决定要在当前运行中调度。 | CLI runnable 构建阶段。 | PROBING,或重新运行时可再写 QUEUED。 |
PROBING | manager 开始探测镜像。 | phase_callback。 | DOWNLOADING、RETRY_WAIT、FAILED、CANCELLED。 |
DOWNLOADING | 探测成功后开始传输。 | phase_callback。 | VERIFYING、RETRY_WAIT、FAILED、CANCELLED。 |
VERIFYING | 已知长度路径正在校验临时文件。 | phase_callback。 | COMPLETED、RETRY_WAIT、FAILED、CANCELLED。 |
RETRY_WAIT | 任务级失败后等待下一次完整尝试。 | run_task()。 | PROBING 或 FAILED。 |
COMPLETED | download() 返回成功且 CLI 已写状态。 | run_task() future 成功后。 | 普通运行写回 QUEUED;--resume 时跳过。 |
FAILED | 任务级重试耗尽。 | run_task()。 | 之后普通运行可写 QUEUED。 |
CANCELLED | manager 抛出 DownloadCancelled。 | run_task()。 | 之后普通运行可写 QUEUED。 |
PAUSED | --pause <id> 预先落盘。 | CLI pause 分支。 | 之后普通运行可写 QUEUED。 |
上图是 CLI 的运行模型,而不是 TaskStore 内建的转移验证器。TaskStore.update() 只验证目标状态属于固定集合,不检查前置状态,因此库级调用者可写出状态机以外的跳转;文档中的合法转移描述的是当前入口代码的实际路径。
12.5 --resume 的精确行为
启动时每个任务都会先调用 store.prepare(task_id, summary, priority):
- 不存在旧条目、旧条目不是字典、或
summary不完全相等:创建/替换为PENDING、attempts=0、error=null。 - 摘要相等:复用旧状态,并更新其
priority字段。
随后:
- 使用
--resume时,仅摘要匹配且status == "COMPLETED"的任务被跳过,并打印COMPLETED (skipped)。 FAILED、CANCELLED、PAUSED、PENDING、QUEUED、中途状态都会再次写QUEUED并调度。- 不使用
--resume时,连已完成任务也会写QUEUED并重新下载;这正是普通运行的默认行为。
--resume 只跳过任务调度,不重新验证已存在正式文件的摘要,也不检查正式文件是否仍存在。
12.6 --pause 的精确行为
--pause TASK_ID 的处理在所有 prepare() 完成后、任何 runnable 任务构造前:
- 若 ID 不在当前 manifest 解析出的条目中,打印错误并返回
1。 - 否则
store.update(TASK_ID, "PAUSED")。 - 打印
[TASK_ID] PAUSED并返回0。
它是预先落状态命令:不发探测请求、不创建任务线程池、不检查目标任务是否在另一个进程中运行,也不向其他进程或 manager 发取消事件。因此它不能实现跨进程的即时暂停。
13. TaskStore 持久化与恢复语义
13.1 状态文件路径和结构
默认状态文件规则:
<manifest 同目录>/<manifest_stem>.state.json
例如:
| Manifest | 默认状态文件 |
|---|---|
D:\downloads\manifest.json | D:\downloads\manifest.state.json |
D:\downloads\patch.list.json | D:\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 不能被配置不匹配的任务错误复用。反之,摘要相等时旧 status、attempts、error 都保留,只有 priority 在内存中更新。
13.3 原子保存与线程安全
TaskStore 持有自己的 threading.Lock:
- 更新一个 entry 时持锁。
- 创建状态文件父目录。
- 写
<state-file-name>.tmp。 - 执行
temporary.replace(self.path)原子替换状态 JSON。
并行 manifest 任务可以同时调用 store.update();锁保证一个更新不会覆盖另一个刚写入的内存状态。若状态文件不存在、不可读或 JSON 损坏,_load() 安全退化为空任务字典,而不是终止下载。
当前实现没有 fsync、跨进程锁或临时文件恢复逻辑;两个独立进程同时操作同一状态文件仍可能互相竞争。
13.4 错误字段语义
update(task_id, status, error=None, attempts=None) 只有传入非 None 的 error 时才覆写旧错误字段。成功写 COMPLETED 时调用未传 error,所以如果某任务历史上曾失败后重试成功,旧错误文本可能继续保留在完成条目中。attempts 是本次任务级尝试序号,从 1 开始,不是所有 Range 请求的累计次数。
14. 错误处理矩阵与退出码
| 场景 | 发生层 | 行为 | 临时文件/状态影响 | CLI 结果 |
|---|---|---|---|---|
| manifest JSON 不可读或无效 | _load_manifest | 抛 DownloadError。 | 不开始下载。 | 1。 |
| manifest 顶层不是数组、未知字段、缺字段、类型非法、重复 ID | _load_manifest | 以条目下标构造错误。 | 不开始下载。 | 1。 |
| 单文件缺 URL 或 output | CLI 参数校验 | 打印失败信息。 | 无。 | 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 不匹配 | _verify | 抛 DownloadError,不发布目标。 | .part 保留;并行 meta 也保留。 | 单文件 1;manifest 可任务级重试。 |
调用 manager.cancel() | _check_cancelled | 回调取消快照,抛 DownloadCancelled。 | 保留 .part / meta。 | manifest 写 CANCELLED;单文件为失败路径。 |
| manifest 任务级失败且仍可重试 | run_task | 写 RETRY_WAIT,指数退避,再完整重试。 | 保留下载现场。 | 最终成功则 0。 |
| manifest 任务级重试耗尽 | run_task | 写 FAILED,不阻止其他任务。 | 保留现场和错误文本。 | 只要有任一失败,最终 1。 |
| 主线程 Ctrl+C | main | 捕获 KeyboardInterrupt。 | 不保证广播取消所有 worker。 | 130。 |
错误层次需特别区分:镜像切换只包围探测;Range 重试只包围 _download_range();任务级重试只由 manifest run_task() 提供。单文件路径没有任务级 retry 包装。
15. 测试覆盖
运行测试:
python -m unittest downloader.test_downloader -v
测试使用 ThreadingHTTPServer 和自定义 RangeHandler,本地模拟单区间 Range 响应、206、Content-Range、Content-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_file | SHA-256 错误时抛出 DownloadError;正式文件不存在;.part 保留。 |
test_manifest_cli_downloads_independent_files | manifest 可并发下载两个不同目标文件。 |
test_manifest_priority_state_resume_pause_and_shared_limiter | 稳定优先级排序结果;两个 manager 可注入同一全局 limiter;manifest 默认状态文件;完成状态;--resume;--pause。 |
test_manifest_cli_rejects_invalid_entries | manifest 未知字段、缺 destination、错误 urls 类型,以及单文件误用 manifest 专用选项时返回失败码。 |
15.1 当前测试未覆盖或覆盖不充分的领域
以下是代码存在但现有测试集未形成完整自动验证矩阵的重点:
- 真实 HTTP/HTTPS 代理连通性、代理认证和代理异常文本脱敏。
- 多镜像探测首源失败、次源成功;以及下载中源故障不切换镜像的边界。
- 未知长度流式响应、没有长度且中途断流的行为。
- Range 重试、任务级
task_retries的重试次数与退避状态序列。 - 取消时阻塞读取、并行 worker 收束和取消进度回调。
.part.meta损坏行、截断写、伪造区间、临时文件与 meta 不一致。- 两个进程同时使用相同目标或状态文件。
- 限速数值准确性、任务和全局双限速下的吞吐上界。
- 长路径、权限错误、磁盘满、跨平台文件替换差异和超大文件。
16. 已知限制与生产使用建议
16.1 已知限制汇总
- 镜像只在探测阶段回退:分块/顺序下载一旦选定源,失败不会迁移到其他镜像。
- 无远端版本绑定:无 ETag、
Last-Modified、If-Range。远端更新可能与本地临时数据拼接,最终只能主要依靠可选摘要发现。 - 摘要可选而非强制:没有 SHA-256/MD5 时,下载器不对完整内容做密码学验证;未知长度流式也无法依据长度验证截断。
- 恢复元数据不是事务日志:
.part.meta不是原子写入,也不验证每个区间的终点或内容;手工修改或异常中断可能导致错误跳过。 - 顺序恢复仅看大小:
.part内容损坏但长度正确时,顺序路径可能直接校验/发布;没有摘要时无法发现。 - 取消非即时:阻塞 socket 读无法被 Event 强制中断,响应时间取决于网络和超时。
--pause不是运行中控制:仅预写PAUSED,不能跨进程暂停或取消当前传输。- 状态文件不具备跨进程协调:无文件锁、无 fsync,多个进程共享状态文件或输出路径不安全。
- 线程规模可能放大:
workers与每任务connections叠加;大 manifest 配合大连接数可能造成连接、线程和磁盘 IO 压力。 - 限速是应用层预约:读取后的限速不能严格约束网络栈中已经缓冲的数据;
rate_limit=0在核心RateLimiter中会视为不限速,而非禁止下载。 - 状态阶段细节不完全对称:未知长度流式路径不会发
VERIFYINGphase callback;成功后旧error字段可能保留。
16.2 生产使用建议
- 始终提供 SHA-256:优先 SHA-256;MD5 可以兼容旧发布物,但不应作为安全完整性的唯一依据。
- 让镜像内容严格一致:所有镜像应提供同一不可变版本文件,支持
Range: bytes=0-0,并对单区间请求稳定返回206和准确的Content-Range。 - 合理设置分片:大文件、稳定服务器和较高带宽可使用多个连接;小文件或高延迟/低配代理环境可降低
connections,避免并行开销超过收益。 - 把
workers与connections一起规划:例如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 协作取消;阻塞读取不能保证立即停止。 |
更多推荐

所有评论(0)