直接用threading.Thread下载常卡住或失败,因未控并发数、未捕获异常、未等待线程结束;应改用ThreadPoolExecutor限流(如max_workers=3),每线程内建Session并try/except,安全处理文件名与路径。

为什么直接用 threading.Thread 启动多个下载线程常卡住或失败
常见现象是:程序看似运行了,但图片没下完、线程提前退出、甚至报 ConnectionResetError 或 Max retries exceeded。根本原因不是线程写错了,而是没控制并发数 + 没处理请求异常 + 没等待线程结束。
Python 的 threading 模块本身不管理任务队列或限流,全靠手动协调。盲目起 50 个 Thread 对多数网站等于主动触发反爬或服务端拒绝连接。
- 必须用
threading.Semaphore或concurrent.futures.ThreadPoolExecutor控制最大并发数(通常 5–10 较安全) - 每个线程内必须包住
try/except,捕获requests.exceptions.RequestException等网络异常 - 主线程必须调用
thread.join()或用executor.shutdown(wait=True),否则可能进程退出时线程被强制终止
用 ThreadPoolExecutor 下载图片最简可靠写法
比起裸写 Thread,concurrent.futures.ThreadPoolExecutor 自动管理线程生命周期、异常传播和结果收集,出错时不会静默吞掉异常。
以下是一个能直接跑通的最小可行示例(需先 pip install requests):
立即学习“Python免费学习笔记(深入)”;
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
import os
<p>def download_image(url, filename):
try:
resp = requests.get(url, timeout=10)
resp.raise_for_status()
with open(filename, "wb") as f:
f.write(resp.content)
return f"✅ {filename}"
except Exception as e:
return f"❌ {filename}: {e}"</p><p>urls = [
"<a href="https://www.php.cn/link/4e20035d57fb4532e8659968a1b73541">https://www.php.cn/link/4e20035d57fb4532e8659968a1b73541</a>",
"<a href="https://www.php.cn/link/9bb798005b85d8c73ac4c821a729c33e">https://www.php.cn/link/9bb798005b85d8c73ac4c821a729c33e</a>",
]</p><p>with ThreadPoolExecutor(max_workers=3) as executor:</p><h1>提交所有任务</h1><pre class="brush:php;toolbar:false;">futures = [executor.submit(download_image, url, f"img_{i}.jpg") for i, url in enumerate(urls)]
# 按完成顺序获取结果
for future in as_completed(futures):
print(future.result())
注意:max_workers=3 是关键——它限制同时最多 3 个 HTTP 请求发出,避免压垮本地 socket 或远端服务。
requests.Session 复用能明显提升多图下载速度
每次新建 requests.get() 都会重建 TCP 连接,而图片下载通常是大量小请求。复用 Session 可启用 HTTP 连接池,实测提速 2–5 倍(尤其同域名多图)。
但要注意:Session 不是线程安全的,不能在多个线程间共享同一个实例。正确做法是每个线程自己创建一个,或用线程局部存储:
- 简单场景:在
download_image函数内部新建Session(开销可接受) - 高频场景:用
threading.local()绑定每个线程专属的Session,避免重复创建
示例(线程局部 Session):
import threading
<p>thread_local = threading.local()</p><p>def get_session():
if not hasattr(thread_local, "session"):
thread_local.session = requests.Session()
return thread_local.session</p><p>def download_image(url, filename):
session = get_session()
try:
resp = session.get(url, timeout=10)
resp.raise_for_status()
with open(filename, "wb") as f:
f.write(resp.content)
return f"✅ {filename}"
except Exception as e:
return f"❌ {filename}: {e}"
文件名冲突和路径安全问题容易被忽略
从 URL 提取文件名时,直接用 url.split("/")[-1] 很危险:可能得到空字符串、含非法字符(如 ?、&)、或长度超限。更糟的是,攻击者构造恶意 URL 可导致路径遍历(如 ../../etc/passwd)。
稳妥做法:
- 用
os.path.basename(urlparse(url).path)提取路径部分再清洗 - 用正则或
re.sub(r"[^a-zA-Z0-9._-]", "_", name)过滤非法字符 - 始终用
os.path.join(download_dir, safe_filename)拼接路径,不要拼字符串 - 下载前检查
download_dir是否存在,不存在则os.makedirs(download_dir, exist_ok=True)
真正上线时,这些细节比“怎么开线程”更容易导致批量失败。


















