下载 100 个分片要等 100 秒?用 Python 异步并发把时间压到 10 秒
你用 Python 写了一个视频下载器,功能都正常——但它太慢了。一个 M3U8 视频有 500 个 TS 分片,每个分片下载 0.5 秒,串行下载就是 250 秒。用户等了 4 分钟,进度条才走到头。有没有办法让这 500 个请求同时跑?这就是 Python 异步编程(asyncio + aiohttp) 要解决的问题。本文从零讲解异步下载的原理、协程与线程的区别、并发控制策略、以及在视频下载器中的实战应用——让你的下载速度提升 5-20 倍。
TL;DR:Python 的
asyncio+aiohttp可以实现真正的并发 HTTP 下载,利用 I/O 等待时间同时发起多个请求。核心模式:asyncio.Semaphore控制并发数 →aiohttp.ClientSession复用连接池 →asyncio.gather()批量执行 → 回调函数更新进度。配合断点续传和自动重试,可以实现工业级的下载性能。
目录
- 一、为什么串行下载这么慢
- 二、协程 vs 线程:Python 并发的正确打开方式
- 三、aiohttp 入门:从同步到异步的转变
- 四、并发控制:信号量、连接池与限速
- 五、实战:异步 M3U8 下载器的完整实现
- 六、错误处理与自动重试
- 七、进度追踪与回调模式
- 八、性能对比:串行 vs 线程池 vs asyncio
- 九、高级优化:连接复用、DNS 缓存、pipeline
- 十、合规与温馨提示
一、为什么串行下载这么慢
1.1 时间都花在哪了
import time
import requests
def download_serial(segments):
for seg in segments:
resp = requests.get(seg['url']) # 等待 0.5s
save_to_disk(resp.content) # 写入 0.1s
# 100 个分片 = 100 × (0.5 + 0.1) = 60 秒
时间拆解:
单个分片下载(0.5s):
├── DNS 解析 5ms
├── TCP 握手 15ms
├── TLS 握手 30ms
├── 发送 HTTP 请求 1ms
├── 等待服务器处理 400ms ← 主要时间消耗(I/O 等待)
├── 接收数据 40ms
└── 写磁盘 10ms
总计 501ms,其中 I/O 等待占 80%
关键发现:下载过程中,CPU 大部分时间在等待网络 I/O,而不是在计算。这意味着我们可以利用等待时间同时处理其他请求。
1.2 并发的效果
串行(1 个连接):
[==请求1==] [==请求2==] [==请求3==] ...
时间轴 →→→→→→→→→→→→→→→→→→→→→→→→
并发(10 个连接):
[==请求1==]
[==请求2==]
[==请求3==] ← 10 个请求同时进行
...
[==请求10==]
时间轴 →→→→→
二、协程 vs 线程:Python 并发的正确打开方式
2.1 三种并发模型的对比
| 特性 | 线程(Threading) | 多进程(Multiprocessing) | 协程(asyncio) |
|---|---|---|---|
| 适用场景 | I/O 密集型 | CPU 密集型 | I/O 密集型 ⭐ |
| 切换开销 | 中(系统调度) | 高(进程切换) | 极低(用户态切换) |
| 内存占用 | 每线程 ~8MB | 每进程独立内存 | KB 级别 |
| GIL 影响 | 受限(I/O 时释放) | 不受限 | 不受 GIL 阻塞 |
| 编程复杂度 | 中(锁、条件变量) | 高(IPC) | 中(async/await) |
| 1000 并发 | 系统资源耗尽 | 几乎不可行 | 轻松实现 |
2.2 为什么 asyncio 最适合网络 I/O
# 线程版本——100 个线程的开销
import threading
def download_with_threads(urls):
threads = []
for url in urls:
t = threading.Thread(target=requests.get, args=(url,))
t.start()
threads.append(t)
for t in threads:
t.join()
# 100 个线程 ≈ 800MB 内存 + 大量上下文切换
# asyncio 版本——单线程处理 100 个并发
import asyncio
import aiohttp
async def download_with_async(urls):
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url) for url in urls]
return await asyncio.gather(*tasks)
# 单线程,几乎零额外内存,100 并发轻松
2.3 async/await 的心智模型
async def fetch_data(url):
print(f"开始请求 {url}") # ① 同步执行
resp = await http_get(url) # ② 挂起,让出控制权
print(f"获取到数据") # ③ 恢复后继续执行
return resp
# await = "我在这里暂停,你先去忙别的,等有结果了再回来"
核心概念:
- async def 定义的函数返回一个协程对象,调用它不会立即执行
- await 暂停当前协程,将控制权交还给事件循环
- 事件循环在多个协程之间调度,哪个的 I/O 完成了就恢复哪个
三、aiohttp 入门:从同步到异步的转变
3.1 基础对比
# 同步版本(requests)
import requests
def download_sync(url):
resp = requests.get(url, timeout=30)
return resp.content
# 异步版本(aiohttp)
import aiohttp
import asyncio
async def download_async(url):
async with aiohttp.ClientSession() as session:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
return await resp.read()
# 运行
result = asyncio.run(download_async('https://example.com'))
3.2 Session 复用——性能的关键
重要:每次请求都创建新的 ClientSession 会失去连接池复用,性能退化到接近串行:
# ❌ 错误做法——每次都新建 session
async def bad_download(urls):
for url in urls:
async with aiohttp.ClientSession() as session: # 每次都新连接
async with session.get(url) as resp:
...
# ✅ 正确做法——全局共享 session
async def good_download(urls):
async with aiohttp.ClientSession() as session: # 一次创建
tasks = [fetch(session, url) for url in urls] # 全部复用
return await asyncio.gather(*tasks)
async def fetch(session, url):
async with session.get(url) as resp:
return await resp.read()
ClientSession 内部维护了连接池,复用 TCP 连接可以避免反复握手:
不复用 Session:
[握手][请求1][断开] [握手][请求2][断开] ...
复用 Session:
[握手][请求1] [请求2] [请求3] ... [断开]
3.3 流式下载大文件
async def download_large_file(session, url, output_path):
"""流式下载大文件,边下边写磁盘"""
async with session.get(url) as resp:
with open(output_path, 'wb') as f:
async for chunk in resp.content.iter_chunked(8192):
f.write(chunk)
四、并发控制:信号量、连接池与限速
4.1 Semaphore——控制并发数量
无限制并发会导致:被目标服务器封 IP、本机网络拥塞、内存爆掉。用 asyncio.Semaphore 限流:
import asyncio
class RateLimiter:
"""并发控制器"""
def __init__(self, max_concurrent=10):
self.semaphore = asyncio.Semaphore(max_concurrent)
async def fetch_with_limit(self, session, url):
async with self.semaphore: # 超过上限则等待
async with session.get(url) as resp:
return await resp.read()
# 使用
limiter = RateLimiter(max_concurrent=20)
tasks = [limiter.fetch_with_limit(session, url) for url in urls]
results = await asyncio.gather(*tasks)
4.2 连接池配置
# 配置 TCP 连接池
connector = aiohttp.TCPConnector(
limit=50, # 总连接数上限
limit_per_host=10, # 单个主机连接数上限
ttl_dns_cache=300, # DNS 缓存时间(秒)
use_dns_cache=True, # 启用 DNS 缓存
force_close=False, # 启用 keep-alive
enable_cleanup_closed=True, # 自动清理关闭的连接
)
async with aiohttp.ClientSession(connector=connector) as session:
...
4.3 限速——避免触发反爬
import time
class ThrottledDownloader:
"""限速下载器"""
def __init__(self, max_rps=50):
self.max_rps = max_rps
self.tokens = max_rps
self.last_refill = time.monotonic()
self.lock = asyncio.Lock()
async def acquire(self):
"""获取一个令牌(令牌桶算法)"""
async with self.lock:
now = time.monotonic()
elapsed = now - self.last_refill
self.tokens = min(self.max_rps, self.tokens + elapsed * self.max_rps)
self.last_refill = now
if self.tokens >= 1:
self.tokens -= 1
return
# 等待下一个令牌
wait_time = (1 - self.tokens) / self.max_rps
await asyncio.sleep(wait_time)
async def fetch(self, session, url):
await self.acquire()
async with session.get(url) as resp:
return await resp.read()
五、实战:异步 M3U8 下载器的完整实现
import asyncio
import aiohttp
import os
from pathlib import Path
from Crypto.Cipher import AES
class AsyncM3U8Downloader:
"""高性能异步 M3U8 下载器"""
def __init__(self, max_concurrent=20, retries=3, chunk_size=8192):
self.max_concurrent = max_concurrent
self.retries = retries
self.chunk_size = chunk_size
self.semaphore = asyncio.Semaphore(max_concurrent)
# 统计信息
self.total_segments = 0
self.completed_segments = 0
self.failed_segments = 0
self.total_bytes = 0
# 进度回调
self.progress_callback = None
def on_progress(self, callback):
"""注册进度回调函数"""
self.progress_callback = callback
return callback
def _notify_progress(self):
if self.progress_callback:
self.progress_callback(
completed=self.completed_segments,
total=self.total_segments,
failed=self.failed_segments,
bytes_downloaded=self.total_bytes
)
async def download(self, segments, output_path, key=None, headers=None):
"""主下载入口"""
self.total_segments = len(segments)
self.completed_segments = 0
self.failed_segments = 0
self.total_bytes = 0
connector = aiohttp.TCPConnector(
limit=self.max_concurrent + 10,
limit_per_host=self.max_concurrent,
ttl_dns_cache=300,
)
headers = headers or {}
headers.setdefault('User-Agent',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36')
async with aiohttp.ClientSession(
connector=connector,
headers=headers,
timeout=aiohttp.ClientTimeout(total=60)
) as session:
# 如果需要解密,先获取密钥
aes_key = None
if key:
aes_key = await self._fetch_key(session, key)
# 创建带索引的任务列表(保证写入顺序)
tasks = []
for idx, seg in enumerate(segments):
task = self._download_segment(
session, seg['url'], idx, aes_key,
seg.get('iv')
)
tasks.append(task)
# 并发下载所有分片
results = await asyncio.gather(*tasks, return_exceptions=True)
# 按顺序写入文件
os.makedirs(os.path.dirname(output_path) or '.', exist_ok=True)
with open(output_path, 'wb') as f:
for idx, result in enumerate(results):
if isinstance(result, Exception):
print(f"分片 {idx} 下载失败: {result}")
self.failed_segments += 1
continue
if result:
f.write(result)
self.total_bytes += len(result)
self._notify_progress()
return {
'total': self.total_segments,
'completed': self.completed_segments,
'failed': self.failed_segments,
'bytes': self.total_bytes,
'output': output_path
}
async def _download_segment(self, session, url, idx, key, iv):
"""下载单个分片(带并发控制和重试)"""
async with self.semaphore:
for attempt in range(self.retries):
try:
async with session.get(url) as resp:
if resp.status == 404 and attempt < self.retries - 1:
await asyncio.sleep(1 * (attempt + 1))
continue
resp.raise_for_status()
data = await resp.read()
# 解密(如果需要)
if key:
cipher_iv = iv or idx.to_bytes(16, 'big')
cipher = AES.new(key, AES.MODE_CBC, cipher_iv)
data = cipher.decrypt(data)
# 去除 PKCS7 填充
pad_len = data[-1]
data = data[:-pad_len]
self.completed_segments += 1
self._notify_progress()
return data
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
if attempt == self.retries - 1:
raise
await asyncio.sleep(2 ** attempt) # 指数退避
return None
async def _fetch_key(self, session, key_info):
"""获取解密密钥"""
if isinstance(key_info, dict):
key_url = key_info.get('uri')
else:
key_url = key_info
async with session.get(key_url) as resp:
return await resp.read()
# 使用示例
async def main():
downloader = AsyncM3U8Downloader(max_concurrent=30)
# 注册进度回调
@downloader.on_progress
def show_progress(completed, total, failed, bytes_downloaded):
pct = (completed / total * 100) if total else 0
mb = bytes_downloaded / 1024 / 1024
print(f'\r下载进度: {completed}/{total} ({pct:.1f}%) - {mb:.1f} MB', end='')
segments = [
{'url': f'https://example.com/seg-{i}.ts'} for i in range(500)
]
result = await downloader.download(
segments=segments,
output_path='output.mp4',
)
print(f"\n下载完成: {result['completed']}/{result['total']} 分片, "
f"{result['bytes']/1024/1024:.1f} MB")
if __name__ == '__main__':
asyncio.run(main())
六、错误处理与自动重试
6.1 重试策略
async def fetch_with_retry(session, url, max_retries=3):
"""指数退避重试"""
for attempt in range(max_retries):
try:
async with session.get(url) as resp:
resp.raise_for_status()
return await resp.read()
except aiohttp.ClientResponseError as e:
# 4xx 错误(权限问题)不重试
if 400 <= e.status < 500:
raise
if attempt == max_retries - 1:
raise
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
if attempt == max_retries - 1:
raise
# 指数退避:1s → 2s → 4s
await asyncio.sleep(2 ** attempt)
6.2 部分失败的处理
async def download_with_fallback(segments):
"""部分分片失败时仍继续"""
results = await asyncio.gather(
*[safe_fetch(url) for url in segments],
return_exceptions=True # 关键:异常不中断整个 gather
)
success_count = sum(1 for r in results if not isinstance(r, Exception))
print(f"下载完成: {success_count}/{len(results)} 成功")
return [r for r in results if not isinstance(r, Exception)]
七、进度追踪与回调模式
import time
class ProgressTracker:
"""实时进度追踪器"""
def __init__(self, total, update_interval=0.5):
self.total = total
self.completed = 0
self.bytes_downloaded = 0
self.start_time = time.monotonic()
self.last_update = self.start_time
self.update_interval = update_interval
def update(self, bytes_chunk=0):
self.completed += 1
self.bytes_downloaded += bytes_chunk
now = time.monotonic()
if now - self.last_update < self.update_interval:
return
self.last_update = now
elapsed = now - self.start_time
pct = self.completed / self.total * 100
speed = self.bytes_downloaded / elapsed / 1024 / 1024 # MB/s
eta = (self.total - self.completed) / (self.completed / elapsed) if self.completed else 0
print(f'\r[{pct:5.1f}%] {self.completed}/{self.total} | '
f'{speed:.1f} MB/s | 剩余 {eta:.0f}s', end='')
八、性能对比:串行 vs 线程池 vs asyncio
实测下载 500 个 TS 分片(每个 ~200KB,来自同一 CDN):
| 方案 | 并发数 | 总耗时 | 平均速度 | 内存占用 |
|---|---|---|---|---|
| 串行 requests | 1 | 248s | 0.4 MB/s | 45 MB |
| ThreadPoolExecutor | 10 | 42s | 2.4 MB/s | 128 MB |
| ThreadPoolExecutor | 50 | 18s | 5.6 MB/s | 530 MB |
| asyncio + aiohttp | 10 | 38s | 2.6 MB/s | 48 MB |
| asyncio + aiohttp | 50 | 8.5s | 11.8 MB/s | 55 MB |
| asyncio + aiohttp | 100 | 6.2s | 16.1 MB/s | 62 MB |
结论:asyncio 在 50 并发时比线程池快 2 倍,内存仅占 1/10。100 并发下,500 个分片仅需 6.2 秒。
九、高级优化:连接复用、DNS 缓存、pipeline
9.1 连接预建立
async def warmup_connections(session, urls, count=5):
"""预建立连接(减少冷启动延迟)"""
sample = urls[:count]
await asyncio.gather(*[
session.head(url) for url in sample
])
9.2 批量写入优化
async def buffered_write(file_handle, data_queue, batch_size=10):
"""批量缓冲写入,减少磁盘 I/O"""
buffer = []
while True:
chunk = await data_queue.get()
if chunk is None: # 结束信号
break
buffer.append(chunk)
if len(buffer) >= batch_size:
file_handle.write(b''.join(buffer))
buffer.clear()
if buffer:
file_handle.write(b''.join(buffer))
9.3 分片预取
class PrefetchDownloader:
"""预取下载器:提前下载接下来 N 个分片"""
def __init__(self, prefetch_count=5):
self.prefetch_count = prefetch_count
self.cache = {}
async def get_segment(self, session, segments, current_idx):
"""获取分片,已预取的直接返回缓存"""
# 提交预取任务
for i in range(current_idx + 1,
min(current_idx + self.prefetch_count + 1, len(segments))):
if i not in self.cache:
self.cache[i] = asyncio.create_task(
self._fetch(session, segments[i]['url'])
)
# 获取当前分片
if current_idx in self.cache:
data = await self.cache.pop(current_idx)
else:
data = await self._fetch(session, segments[current_idx]['url'])
return data
十、合规与温馨提示
- 高并发下载可能对目标服务器造成压力,请合理设置并发数和请求间隔
- 部分平台的反爬机制会检测并发请求特征,过度并发可能导致 IP 被封
- 本文技术仅用于个人学习和合法授权的下载场景
- 更多讨论见 下载视频算侵权吗?聊聊个人备份与版权的那条线