导读:本期聚焦于苹果创作的《python如何使用多线程下载文件?多线程并发下载教程详解》,敬请观看详情。下载大文件时单线程速度总是上不去?网络带宽明明充足,下载进度条却慢得让人着急。这个问题在Python中其实很好解决,核心思路是把文件按字节范围切分成多个分段,每个线程负责下载其中一段,最后再合并成完整文件。本文将详细讲解Python多线程下载文件的完整实现过程,包括HTTP Range请求头的工作原理、如何通过请求头获取文件总大小、使用ThreadPoolExecutor管理线程池、每个线程如何指定下载区间,以及下载完成后如何校验文件完整性。文中提供完整可运行的代码示例,并分析多线程下载的适用场景与注意事项,帮助你真正掌握并发下载这项实用技能。

用Python写下载脚本的人,多半遇到过这样一个问题:用requests库下载一个大文件,速度只有几百KB每秒,而网络带宽明明有好几十兆。造成这种情况的原因,往往是服务器对单个连接限速,或者单线程下载无法充分利用带宽。解决办法很直接——把文件切分成若干段,开多个线程同时下载,最后拼接起来。这篇文章就从原理到代码,完整讲一遍Python多线程下载文件的实现过程。

python如何使用多线程下载文件?多线程并发下载教程详解

多线程下载的核心原理:HTTP Range请求

要理解多线程下载,先要弄明白HTTP协议中的一个重要机制——Range请求头。正常情况下,客户端向服务器发起GET请求,服务器会返回文件的完整内容,从第0字节一直到最后。但如果请求头里带上Range字段,情况就不同了。

比如在请求头中写上Range: bytes=0-1023,服务器就只会返回文件的第0到1023字节这一小段。利用这个特性,我们可以把一个大文件虚拟地切成若干块,每个线程负责下载其中一块,各自写入文件的不同位置,最终拼成一个完整的文件。支持Range请求的服务器会返回状态码206 Partial Content,如果不支持,则会返回200和完整内容。

还有一个关键点:在开始下载之前,必须先知道文件的总大小。这可以通过发送一个HEAD请求,或者发送一个Range: bytes=0-0的请求来实现,服务器响应头中的Content-Range字段会包含文件总长度,形如bytes 0-0/10485760,斜杠后面的数字就是总大小。拿到总大小后,才能合理地划分每个线程的下载区间。

实现思路与准备工作

整个多线程下载的流程可以拆成四步:第一步,通过HEAD请求获取文件总大小;第二步,根据线程数量把文件总大小均分成若干段,计算每段的起止字节;第三步,创建一个与目标文件大小相同的空文件,每个线程用seek()定位到自己负责的偏移位置再写入;第四步,等待所有线程结束后校验文件完整性。

这里有一个容易被忽略的细节:多个线程同时写同一个文件对象是危险的,不同线程各自的写入指针会互相干扰。正确的做法有两种,一种是每个线程单独打开文件写入(操作系统允许多个进程或线程以二进制方式打开同一文件),另一种是预先创建好固定大小的文件,每个线程打开后直接定位写入。本文的示例采用后一种方式,更加稳妥。

准备工作方面,只需要安装requests库,它是Python中最常用的HTTP客户端,对流式下载和自定义请求头支持都很完善:

pip install requests

完整代码实现

下面是完整的多线程下载代码,使用了concurrent.futures模块中的ThreadPoolExecutor来管理线程。相比手动创建threading.Thread,线程池的写法更简洁,也能自动处理异常收集和结果等待:

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

def get_file_size(url, headers=None):
    """通过HEAD请求获取文件总大小"""
    resp = requests.head(url, headers=headers, allow_redirects=True)
    size = int(resp.headers.get('Content-Length', 0))
    if size == 0:
        # 某些服务器不支持HEAD,改用GET流式请求读取
        resp = requests.get(url, headers=headers, stream=True)
        size = int(resp.headers.get('Content-Length', 0))
        resp.close()
    return size

def download_chunk(url, start, end, file_path, headers=None):
    """下载文件的指定字节区间并写入对应位置"""
    chunk_headers = {'Range': f'bytes={start}-{end}'}
    if headers:
        chunk_headers.update(headers)
    resp = requests.get(url, headers=chunk_headers, stream=True, timeout=30)
    resp.raise_for_status()
    with open(file_path, 'r+b') as f:
        f.seek(start)  # 定位到该线程负责的起始位置
        for data in resp.iter_content(chunk_size=1024 * 256):
            f.write(data)
    return end - start + 1

def multi_thread_download(url, file_path, num_threads=8):
    total_size = get_file_size(url)
    if total_size == 0:
        raise ValueError('无法获取文件大小,服务器可能不支持分块下载')

    # 预创建一个指定大小的空文件
    with open(file_path, 'wb') as f:
        f.truncate(total_size)

    # 计算每个线程负责的字节区间
    chunk_size = total_size // num_threads
    tasks = []
    for i in range(num_threads):
        start = i * chunk_size
        # 最后一个线程负责到文件末尾,处理除不尽的情况
        end = total_size - 1 if i == num_threads - 1 else start + chunk_size - 1
        tasks.append((start, end))

    downloaded = 0
    with ThreadPoolExecutor(max_workers=num_threads) as pool:
        futures = {
            pool.submit(download_chunk, url, s, e, file_path): (s, e)
            for s, e in tasks
        }
        for future in as_completed(futures):
            downloaded += future.result()
            percent = downloaded / total_size * 100
            print(f'下载进度:{percent:.1f}%')

    # 校验文件大小
    actual = os.path.getsize(file_path)
    print(f'下载完成,文件大小 {actual} 字节,预期 {total_size} 字节')
    assert actual == total_size, '文件大小校验失败,可能需要重新下载'

if __name__ == '__main__':
    url = 'https://bbccb.com/files/bigfile.zip'
    multi_thread_download(url, 'bigfile.zip', num_threads=8)

代码中有几处值得展开说明。首先是f.truncate(total_size),这一步会在磁盘上先创建一个占位文件,好处是文件大小一开始就确定了,即使中途某个线程失败,也能明显看出文件不完整。其次是iter_content(chunk_size=1024*256),它让requests以256KB为单位流式读取数据,避免一次性把几十MB的分段全部加载进内存。

最后一段的区间计算也容易出错:end = start + chunk_size - 1是因为Range区间是包含端点的,如果不减1,各线程之间会出现一个字节的重叠或空缺。而最后一个线程直接取total_size - 1,则是为了处理总大小除以线程数除不尽的情况,把余量全部交给最后一个线程。

进阶优化:断点续传与失败重试

实际下载中,网络波动是常态,如果下载到90%时线程挂掉,从头再来代价太大。可以在每个分段下载完成后记录进度,失败时只重新下载未完成的分段。一个简单的做法是为每个分段维护一个临时文件,命名格式如bigfile.zip.part0bigfile.zip.part1,全部完成后合并删除:

import time

def download_chunk_with_retry(url, start, end, file_path, index, headers=None, max_retries=3):
    """带重试机制的分段下载,下载到独立临时文件"""
    part_file = f'{file_path}.part{index}'
    for attempt in range(max_retries):
        try:
            # 如果临时文件已存在,从断点位置继续
            downloaded_bytes = 0
            if os.path.exists(part_file):
                downloaded_bytes = os.path.getsize(part_file)
                if downloaded_bytes >= end - start + 1:
                    return downloaded_bytes  # 该分段已完成

            real_start = start + downloaded_bytes
            chunk_headers = {'Range': f'bytes={real_start}-{end}'}
            resp = requests.get(url, headers=chunk_headers, stream=True, timeout=30)
            resp.raise_for_status()
            mode = 'ab' if downloaded_bytes > 0 else 'wb'
            with open(part_file, mode) as f:
                for data in resp.iter_content(chunk_size=1024 * 256):
                    f.write(data)
            return end - real_start + 1 + downloaded_bytes
        except Exception as e:
            print(f'分段{index}第{attempt + 1}次下载失败:{e},准备重试')
            time.sleep(2 ** attempt)  # 指数退避
    raise RuntimeError(f'分段{index}重试{max_retries}次后仍然失败')

def merge_parts(file_path, num_threads):
    """合并所有分段临时文件"""
    with open(file_path, 'wb') as out:
        for i in range(num_threads):
            part_file = f'{file_path}.part{i}'
            with open(part_file, 'rb') as p:
                while True:
                    data = p.read(1024 * 1024)
                    if not data:
                        break
                    out.write(data)
            os.remove(part_file)  # 合并后删除临时文件
    print('所有分段合并完成')

重试逻辑里用了指数退避策略,也就是失败后先等2秒,再失败等4秒、8秒,避免短时间内反复冲击服务器。断点续传的原理则是检查临时文件已下载的字节数,下次请求时把Range的起点往后移,从断点位置继续追加写入。

多线程下载的注意事项与适用场景

多线程下载并非万能,有几点限制必须清楚。第一,服务器必须支持Range请求,部分小文件服务器、CDN动态内容或者需要鉴权的接口可能不支持,此时多线程和单线程没有区别,甚至可能因为并发请求触发服务器的反爬限制。第二,线程数量不是越多越好,一般8到16个线程就足够打满家庭带宽,开到几十个线程只会增加系统调度开销和服务器压力。第三,Python受GIL限制,网络IO密集型任务中线程是够用的,因为等待网络响应时会释放GIL,但如果后续要做解压、校验等CPU密集操作,线程并不能加速。

关于适用场景,多线程下载最适合这些情况:下载源是支持Range的静态文件服务器、单连接被限速、网络延迟较高导致单连接吞吐低。如果下载速度瓶颈在本地磁盘写入或者服务器出口带宽,多线程基本没有提升,这种情况下应该先排查真正的瓶颈在哪。

最后提醒一点,批量并发下载时请控制好请求频率,加上合理的User-Agent,尊重目标站点的robots协议和服务条款。技术本身没有问题,但滥用并发下载可能给对方服务器造成压力,甚至带来法律风险。把并发数控制在一个合理范围内,既保证了自己的下载效率,也是一种基本的网络礼仪。

python多线程下载多线程文件下载python并发下载修改时间:2026-09-13 13:56:41

免责声明:​ 已尽一切努力确保本网站所含信息的准确性。网站内容多为原创整理与精心编撰,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们处理。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。