为什么需要多线程?
当你需要向同一个服务器发送大量请求时,如果使用单线程(一个接一个地发送),程序大部分时间都会在等待服务器的响应(网络 I/O 等待),这会导致程序的整体效率非常低,因为 CPU 在等待时是空闲的。

多线程 的核心思想是:当一个线程在等待 I/O(如网络请求)时,程序可以切换到另一个已经准备好执行的线程(比如已经收到响应并准备处理数据的线程),这样,CPU 的利用率就大大提高了,从而显著缩短了总体的执行时间。
核心概念
在开始编码前,我们需要理解几个关键概念:
-
requests.Session(): 这是requests库的一个高级对象,当你需要对同一个域名进行多次请求时,使用Session对象是最佳实践。- 连接复用:
Session对象会保持一个底层的 TCP 连接,后续的请求可以复用这个连接,避免了每次请求都进行三次握手和四次挥手,极大地提升了性能。 - Cookie 持久化: 如果请求之间需要保持登录状态(Cookie),
Session对象会自动处理。
- 连接复用:
-
threading.Thread: Python 内置的线程模块,用于创建和管理线程。
(图片来源网络,侵删) -
线程安全与全局解释器锁:
- GIL (Global Interpreter Lock): 在 CPython 解释器中,同一时刻只有一个线程能执行 Python 字节码,这意味着 Python 的多线程并不能实现真正的并行计算(用多线程加速 CPU 密集型任务)。
- I/O 密集型任务: 对于网络请求这类 I/O 密集型任务,GIL 的影响很小,当一个线程因为等待 I/O 而释放 GIL 时,其他线程就可以运行,多线程非常适合用来加速网络请求。
-
线程池 (
concurrent.futures.ThreadPoolExecutor):- 手动创建和管理大量线程(如几百个)会非常复杂且消耗资源。
- 线程池是一种更高级、更高效的管理线程的方式,它会预先创建一组线程,并将任务提交给线程池,线程池会自动分配任务给空闲的线程,并在任务完成后回收线程,避免了频繁创建和销毁线程的开销。
- 这是目前最推荐的方式,代码更简洁,性能也更好。
实践:三种实现方式
下面我们通过一个具体的例子来演示如何实现多线程请求,假设我们要向 http://httpbin.org/get 发送 50 个请求,并打印响应状态码。
场景准备:创建一个假的 API 服务器来模拟延迟
为了更好地观察多线程的效果,我们使用 httpbin.org 提供的 /delay/{n} 端点,它会故意延迟 n 秒再返回响应。

# 这是一个目标函数,我们将对它进行并发调用
import requests
import time
# 使用 Session 是一个好习惯
session = requests.Session()
def fetch_url(url):
"""
发送单个请求的函数
"""
try:
print(f"线程 {threading.current_thread().name}: 开始请求 {url}")
# 模拟一个网络延迟,1 秒
# response = session.get(url)
# 为了演示效果,我们用一个更慢的延迟
response = session.get(f"http://httpbin.org/delay/1", timeout=5)
print(f"线程 {threading.current_thread().name}: {url} 请求成功,状态码: {response.status_code}")
return response.status_code
except requests.exceptions.RequestException as e:
print(f"线程 {threading.current_thread().name}: 请求 {url} 失败: {e}")
return None
# --- 主程序 ---
if __name__ == "__main__":
urls_to_fetch = [f"http://httpbin.org/get" for _ in range(50)]
# --- 方法 1: 简单的多线程 (不推荐用于生产) ---
print("--- 方法 1: 简单的多线程 ---")
start_time = time.time()
threads = []
for url in urls_to_fetch:
# 为每个 URL 创建一个线程
thread = threading.Thread(target=fetch_url, args=(url,))
threads.append(thread)
thread.start() # 启动线程
# 等待所有线程完成
for thread in threads:
thread.join()
end_time = time.time()
print(f"方法 1 总耗时: {end_time - start_time:.2f} 秒\n")
# --- 方法 2: 使用线程池 (推荐) ---
print("--- 方法 2: 使用线程池 ---")
start_time = time.time()
# max_workers 是最大线程数,通常设置为 CPU 核心数的几倍
# 对于 I/O 密集型任务,可以设置得大一些,如 20, 50
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
# executor.map 会将函数和参数列表一一对应,并返回一个生成器,可以获取结果
# results = list(executor.map(fetch_url, urls_to_fetch))
# executor.submit 更灵活,可以提交单个任务,返回一个 Future 对象
futures = [executor.submit(fetch_url, url) for url in urls_to_fetch]
# 可以在这里获取结果,或者等待所有任务完成
for future in concurrent.futures.as_completed(futures):
try:
result = future.result()
# print(f"任务完成,结果: {result}")
except Exception as e:
print(f"任务执行出错: {e}")
end_time = time.time()
print(f"方法 2 总耗时: {end_time - start_time:.2f} 秒\n")
# --- 方法 3: 对比 - 单线程 ---
print("--- 方法 3: 对比 - 单线程 ---")
start_time = time.time()
for url in urls_to_fetch:
fetch_url(url)
end_time = time.time()
print(f"方法 3 总耗时: {end_time - start_time:.2f} 秒")
运行结果分析 (在你的机器上可能略有不同):
- 方法 1 (简单多线程): 耗时大约在 1.x 秒左右,因为 50 个请求几乎同时开始,每个请求延迟 1 秒,所以它们几乎是同时完成的。
- 方法 2 (线程池): 耗时也大约在 1.x 秒左右,性能与方法 1 相当,但代码更优雅、资源管理更高效。
- 方法 3 (单线程): 耗时大约在 50 秒左右,因为 50 个请求是串行执行的,每个请求花费 1 秒,总共就是 50 秒。
这个对比清晰地展示了多线程在 I/O 密集型任务上的巨大优势。
重要注意事项
-
设置合理的
max_workers:- 线程数不是越多越好,创建和销毁线程本身有开销,过多的线程会导致 CPU 在线程切换上花费大量时间,反而降低性能。
- 一个经验法则是:对于 I/O 密集型任务,
max_workers可以设置为CPU核心数 * 5左右,你可以通过实验找到最适合你应用场景的数值。
-
处理异常:
- 网络请求非常不稳定,可能会因为超时、连接错误、DNS 解析失败等各种原因而失败。
- 务必使用
try...except块来捕获requests可能抛出的异常(如requests.exceptions.RequestException),确保一个请求的失败不会导致整个程序崩溃。
-
设置超时 (
timeout):- 必须为每个请求设置
timeout参数,requests.get(url, timeout=5),这可以防止你的线程因为一个响应极其缓慢或无响应的请求而被无限期地卡住。
- 必须为每个请求设置
-
服务器压力:
- 请务必尊重目标服务器! 不要对同一个服务器发起过于高频率的并发请求,这可能会被视为恶意攻击(DDoS),导致你的 IP 被封禁。
- 在进行大规模爬取或数据采集时,请遵守
robots.txt协议,并适当增加请求之间的延迟(在fetch_url函数中加入time.sleep(random.uniform(0.5, 1.5)))。
-
避免共享可变状态:
- 线程间共享变量(如全局列表)时,如果多个线程同时修改这个变量,可能会导致数据错乱(“竞态条件”),如果必须共享,应使用线程锁(
threading.Lock)来保护临界区。 - 在网络请求场景下,通常每个线程处理自己的请求并将结果返回或存入线程安全的队列(
queue.Queue),这是最安全、最简单的设计。
- 线程间共享变量(如全局列表)时,如果多个线程同时修改这个变量,可能会导致数据错乱(“竞态条件”),如果必须共享,应使用线程锁(
| 方法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 简单多线程 | 概念简单,易于理解 | 手动管理线程复杂,资源消耗大,代码冗长 | 学习目的,或少量并发 |
线程池 (ThreadPoolExecutor) |
代码简洁,资源管理高效,功能强大(如获取 Future) | 需要理解 concurrent.futures 模块 |
强烈推荐,几乎所有需要多线程 requests 的生产环境场景 |
| 单线程 | 实现最简单,逻辑清晰 | 性能极低,无法处理高并发 | 调试、学习 requests 本身,或并发量极低的任务 |
对于绝大多数应用场景,使用 concurrent.futures.ThreadPoolExecutor 是最佳实践,它既发挥了多线程的性能优势,又提供了良好的代码结构和资源管理能力。
