提示

返回博客列表

Python 并发下载到底用线程、进程还是协程?三种模型的真实对比与选型决策

Python 并发下载到底用线程、进程还是协程?三种模型的真实对比与选型决策

Python 做视频下载,遇到性能瓶颈时你可能会想:"加个线程池吧"。然后有人告诉你 Python 有 GIL,多线程没用;又有人说 IO 密集型用线程没问题;还有人推荐 asyncio。到底听谁的?这篇文章不堆理论,用同一批下载任务实测三种并发模型的性能——线程池(ThreadPoolExecutor)、进程池(ProcessPoolExecutor)、协程(asyncio)——给出真实的耗时、CPU 占用、内存消耗对比数据,以及每种模型的适用场景和常见坑。

TL;DR:实测结果——下载 500 个 TS 分片(每个 ~200KB,同一 CDN):asyncio 最快(8.5s,内存 55MB),线程池其次(18s,内存 530MB),进程池最慢(35s,内存 800MB+)。结论:IO 密集型下载首选 asyncio。线程池在并发 < 20 时也够用,但内存开销大。进程池仅适用于需要绕过 GIL 的 CPU 密集型后处理(如视频转码),不适合下载本身。

目录

一、GIL 到底影不影响下载

先说结论:GIL 影响 CPU 密集型任务,但几乎不影响 IO 密集型任务(如网络下载)。

GIL(全局解释器锁)的工作方式:

CPU 密集型(视频转码):
  线程1 [占用 GIL 计算中...] → 线程2 [等待 GIL...] → 线程1 [释放 GIL]
  → GIL 导致同一时刻只有一个线程在计算
  → 多线程反而更慢(上下文切换开销)

IO 密集型(网络下载):
  线程1 [发送请求] [释放 GIL,等待网络响应] [获得 GIL,处理数据]
  线程2            [获得 GIL,发送请求] [释放 GIL,等待] [获得 GIL...]
  → 等待 IO 时自动释放 GIL
  → 多线程有效!因为大部分时间都在等网络,不在竞争 GIL

所以 Python 多线程对下载是有效的。但有效不代表最优——线程有内存开销,100 个线程就是 800MB+ 内存。

二、三种模型的代码实现

2.1 线程池(ThreadPoolExecutor)

from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
import time
import os

def download_segment(url, output_path, headers=None):
    """下载单个分片"""
    resp = requests.get(url, headers=headers, timeout=30)
    resp.raise_for_status()
    with open(output_path, 'wb') as f:
        f.write(resp.content)
    return len(resp.content)


def download_with_threads(urls, output_dir, max_workers=20):
    """线程池下载"""
    os.makedirs(output_dir, exist_ok=True)
    headers = {
        'User-Agent': 'Mozilla/5.0 ...',
        'Referer': 'https://example.com/',
    }

    results = []
    start = time.time()

    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        futures = {}
        for i, url in enumerate(urls):
            output = os.path.join(output_dir, f'seg_{i:04d}.ts')
            future = executor.submit(download_segment, url, output, headers)
            futures[future] = i

        for future in as_completed(futures):
            i = futures[future]
            try:
                size = future.result()
                results.append((i, size))
            except Exception as e:
                print(f"分片 {i} 失败: {e}")

    elapsed = time.time() - start
    total_bytes = sum(r[1] for r in results)
    print(f"线程池: {len(results)}/{len(urls)} 成功, "
          f"{total_bytes/1024/1024:.1f}MB, 耗时 {elapsed:.1f}s")

    return results

2.2 进程池(ProcessPoolExecutor)

from concurrent.futures import ProcessPoolExecutor
import requests
import time
import os

# 进程池的 worker 函数必须在模块级别定义(不能是闭包)
def _download_worker(args):
    url, output_path, headers = args
    try:
        resp = requests.get(url, headers=headers, timeout=30)
        resp.raise_for_status()
        with open(output_path, 'wb') as f:
            f.write(resp.content)
        return len(resp.content)
    except Exception as e:
        return -1  # 用负数标记失败


def download_with_processes(urls, output_dir, max_workers=4):
    """进程池下载"""
    os.makedirs(output_dir, exist_ok=True)
    headers = {
        'User-Agent': 'Mozilla/5.0 ...',
        'Referer': 'https://example.com/',
    }

    tasks = []
    for i, url in enumerate(urls):
        output = os.path.join(output_dir, f'seg_{i:04d}.ts')
        tasks.append((url, output, headers))

    start = time.time()

    with ProcessPoolExecutor(max_workers=max_workers) as executor:
        results = list(executor.map(_download_worker, tasks))

    elapsed = time.time() - start
    success = [r for r in results if r > 0]
    total_bytes = sum(success)
    print(f"进程池: {len(success)}/{len(urls)} 成功, "
          f"{total_bytes/1024/1024:.1f}MB, 耗时 {elapsed:.1f}s")

    return results

2.3 协程(asyncio + aiohttp)

import asyncio
import aiohttp
import time
import os

async def async_download_segment(session, url, output_path, semaphore):
    """异步下载单个分片"""
    async with semaphore:
        for attempt in range(3):
            try:
                async with session.get(url) as resp:
                    resp.raise_for_status()
                    data = await resp.read()
                    # 异步写文件
                    async with aiofiles.open(output_path, 'wb') as f:
                        await f.write(data)
                    return len(data)
            except Exception:
                if attempt == 2:
                    raise
                await asyncio.sleep(1)


async def download_with_async(urls, output_dir, max_concurrent=20):
    """协程下载"""
    os.makedirs(output_dir, exist_ok=True)
    semaphore = asyncio.Semaphore(max_concurrent)

    headers = {
        'User-Agent': 'Mozilla/5.0 ...',
        'Referer': 'https://example.com/',
    }

    connector = aiohttp.TCPConnector(
        limit=max_concurrent + 10,
        limit_per_host=max_concurrent,
        ttl_dns_cache=300,
    )

    start = time.time()

    async with aiohttp.ClientSession(
        connector=connector,
        headers=headers,
        timeout=aiohttp.ClientTimeout(total=60)
    ) as session:

        tasks = []
        for i, url in enumerate(urls):
            output = os.path.join(output_dir, f'seg_{i:04d}.ts')
            task = async_download_segment(session, url, output, semaphore)
            tasks.append(task)

        results = await asyncio.gather(*tasks, return_exceptions=True)

    elapsed = time.time() - start
    success = [r for r in results if isinstance(r, int) and r > 0]
    total_bytes = sum(success)
    print(f"协程: {len(success)}/{len(urls)} 成功, "
          f"{total_bytes/1024/1024:.1f}MB, 耗时 {elapsed:.1f}s")

    return results

三、实测对比数据

测试环境:Intel i7-13700K, 32GB RAM, 500Mbps 宽带,下载 500 个 TS 分片(每个 ~200KB,同一 CDN 域名)。

方案 并发数 总耗时 成功率 CPU 峰值 内存峰值 备注
串行 requests 1 248s 100% 3% 45MB 基准线
ThreadPool 10 42s 100% 8% 128MB
ThreadPool 50 18s 100% 15% 530MB 内存飙升
ThreadPool 100 15s 97% 22% 920MB 有失败
ProcessPool 4 35s 100% 45% 350MB 启动开销大
ProcessPool 8 28s 100% 78% 620MB
asyncio 20 12s 100% 6% 48MB
asyncio 50 8.5s 100% 9% 55MB ⭐⭐
asyncio 100 6.2s 100% 14% 62MB 收益递减

关键发现

1. asyncio 全面碾压:
   - 50 并发:asyncio 比线程池快 2.1 倍,内存只有 1/10
   - 内存几乎不随并发数增长(协程的开销是 KB 级别)

2. 线程池的瓶颈:
   - 并发 > 50 后内存爆炸(每个线程 ~8MB 栈空间)
   - 线程切换有开销(虽然比进程小)

3. 进程池不适合下载:
   - 启动开销大(每个进程独立 Python 解释器)
   - 进程间数据传输需要序列化(pickle)
   - 仅在需要绕过 GIL 做 CPU 密集计算时有意义

4. 收益递减:
   - 从 50 并发到 100 并发,asyncio 只快了 27%
   - 瓶颈从"等待 IO"变成了"带宽上限"

四、各自的适用场景

场景 推荐模型 原因
下载小文件(分片、图片) asyncio 大量 IO 等待,协程效率最高
下载单个大文件 线程/协程都可以 单连接瓶颈在带宽,不在并发模型
下载 + 实时转码 线程下载 + 进程转码 下载 IO 密集,转码 CPU 密集
调用 yt-dlp(它内部已经是多线程) 线程池(小并发) yt-dlp 自带并发,外层不要加太多
批量视频截图 进程池 纯 CPU 计算,需要绕过 GIL
快速原型/脚本 线程池 代码简单,不用 async/await
长期运行的服务 asyncio 资源占用低,可扩展性好

五、混合模型:下载用协程 + 转码用进程

import asyncio
from concurrent.futures import ProcessPoolExecutor
import functools

def transcode_video(input_path, output_path, crf=23):
    """视频转码(CPU 密集型,跑在进程池)"""
    import subprocess
    subprocess.run([
        'ffmpeg', '-i', input_path,
        '-c:v', 'libx264', '-crf', str(crf),
        '-preset', 'medium', '-c:a', 'aac',
        output_path, '-y'
    ], check=True)
    return output_path


async def download_and_transcode(url, output_path, process_pool):
    """下载完成后用进程池转码"""
    # 异步下载
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as resp:
            data = await resp.read()

    # 先存临时文件
    tmp_path = output_path + '.tmp'
    async with aiofiles.open(tmp_path, 'wb') as f:
        await f.write(data)

    # 用进程池转码(不阻塞事件循环)
    loop = asyncio.get_event_loop()
    result = await loop.run_in_executor(
        process_pool,
        functools.partial(transcode_video, tmp_path, output_path)
    )

    os.remove(tmp_path)
    return result


async def main():
    urls = [...]  # 待下载的视频列表

    with ProcessPoolExecutor(max_workers=4) as pool:
        tasks = [
            download_and_transcode(url, f'output_{i}.mp4', pool)
            for i, url in enumerate(urls)
        ]
        results = await asyncio.gather(*tasks)

    print(f"完成 {len(results)} 个视频的下载和转码")

六、常见坑与最佳实践

6.1 线程池的坑

# ❌ 错误:在多个线程间共享同一个 requests.Session 且不加锁
session = requests.Session()
def download(url):
    return session.get(url)  # Session 不是线程安全的!

# ✅ 正确:每个线程用自己的 Session,或用 Thread-local
import threading
thread_local = threading.local()

def get_session():
    if not hasattr(thread_local, 'session'):
        thread_local.session = requests.Session()
    return thread_local.session

6.2 进程池的坑

# ❌ 错误:传了不可序列化的对象
with ProcessPoolExecutor() as pool:
    pool.submit(download, lambda x: x)  # lambda 不能 pickle!

# ✅ 正确:worker 函数在模块级别,参数可序列化

6.3 协程的坑

# ❌ 错误:在协程里调用同步阻塞函数
async def bad_download(url):
    return requests.get(url)  # 阻塞整个事件循环!

# ✅ 正确:用 run_in_executor 把阻塞调用放到线程池
async def good_download(url):
    loop = asyncio.get_event_loop()
    return await loop.run_in_executor(
        None,  # 默认线程池
        functools.partial(requests.get, url)
    )

七、合规与温馨提示


Python 并发这件事,我的实际经验是:90% 的下载场景用 asyncio 就够了,剩下 9% 用线程池也能对付,只有 1% 需要进程池。 别一上来就搞"混合模型",先把 asyncio 跑通,遇到真正的 CPU 瓶颈再考虑加进程池。简单方案能解决的问题,不要用复杂方案。

本文由 VidDown 技术博客原创发布。VidDown 的下载引擎基于 asyncio 构建,支持高并发下载,CPU 和内存占用极低。访问 VidDown 了解更多。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部