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