这次我们来看一套完整的 Python 并发编程教程。对于任何想要提升程序性能、处理 I/O 密集型任务或构建高响应应用的开发者来说,并发编程都是绕不开的核心技能。这套教程从零基础出发,覆盖了多线程、多进程、线程同步、进程通信以及 ThreadLocal 等关键概念,目标是让你不仅能理解原理,更能动手写出稳定高效的并发代码。
很多教程要么只讲理论,要么例子脱离实际。这套教程的重点是“实战”,直接告诉你什么场景下该用什么并发模型,如何避免常见的坑,以及如何观察和优化并发程序的性能。无论你是想优化爬虫速度、加速数据处理,还是构建一个能同时服务多个用户的 Web 应用后端,这里的内容都能提供直接的指导。
本文将带你快速梳理 Python 并发编程的核心知识体系,并通过一系列可运行的代码示例,演示从基础概念到实战应用的全过程。我们会重点关注线程与进程的本质区别、GIL(全局解释器锁)的影响、各种同步原语的使用场景,以及如何安全地在进程间传递数据。读完本文,你将能清晰地判断何时使用多线程、何时使用多进程,并掌握构建健壮并发程序的关键技术。
1. 核心能力速览
在深入细节之前,我们先通过一个表格快速了解 Python 并发编程的核心组件及其适用场景,这能帮助你快速建立整体认知。
| 能力项 | 说明 | 典型应用场景 |
|---|---|---|
多线程 (threading) | 利用线程实现并发。线程共享同一进程的内存空间,创建和切换开销小。受 Python GIL 限制,CPU 密集型任务无法真正并行。 | I/O 密集型任务,如网络请求、文件读写、数据库查询、Web 服务器处理请求。 |
多进程 (multiprocessing) | 利用进程实现并行。每个进程有独立的内存空间和 Python 解释器,可绕过 GIL,实现多核 CPU 的并行计算。进程间通信(IPC)开销较大。 | CPU 密集型任务,如科学计算、图像处理、视频编码、大规模数据转换。 |
| 线程同步 | 协调多个线程对共享资源的访问,防止数据竞争和不一致。 | 保护共享变量、数据结构、文件句柄等,确保线程安全。 |
| 进程通信 (IPC) | 在不同进程间交换数据。由于内存独立,需要特定的通信机制。 | 多进程任务中需要传递中间结果或状态信息。 |
ThreadLocal | 为每个线程提供独立的变量副本,避免在函数调用间显式传递线程相关数据。 | Web 框架中存储当前请求上下文、数据库连接会话等。 |
并发工具 (concurrent.futures) | 高级抽象接口,简化线程池和进程池的使用。 | 快速提交一批任务并异步获取结果,代码更简洁。 |
异步 I/O (asyncio) | 单线程内基于事件循环的协程并发模型,高效处理大量 I/O 操作。 | 高性能网络应用、微服务、实时通信。 |
2. 适用场景与使用边界
学习并发编程,首先要明白“为什么用”和“什么时候用”。盲目使用并发不仅不能提升性能,反而可能引入复杂的 Bug 和性能下降。
适合使用并发编程的场景:
- I/O 密集型任务:程序大部分时间在等待外部响应,如从网络下载文件、查询数据库、调用远程 API。使用多线程或
asyncio可以在等待一个任务时执行其他任务,极大提升吞吐量。 - CPU 密集型任务:程序需要进行大量计算,如图像渲染、数据加密解密、复杂数学运算。使用多进程可以充分利用多核 CPU,实现真正的并行计算。
- 需要高响应的用户界面:在 GUI 应用中,使用后台线程执行耗时操作,可以防止界面“卡死”,保持对用户操作的响应。
- 批量任务处理:需要处理大量独立的数据项,如批量转换图片格式、清洗日志文件。使用线程池或进程池可以显著缩短总处理时间。
Python 并发编程的边界与限制:
- 全局解释器锁 (GIL):这是 CPython 解释器的特性。它确保同一时刻只有一个线程执行 Python 字节码。这意味着纯 Python 代码的多线程无法利用多核进行并行计算。GIL 主要影响 CPU 密集型任务,对 I/O 密集型任务影响较小,因为线程在等待 I/O 时会释放 GIL。
- 复杂度与调试难度:并发程序引入了不确定性,执行顺序可能每次都不一样,导致 Bug 难以复现和调试。死锁、竞态条件等问题需要精心设计才能避免。
- 资源开销:线程和进程的创建、销毁、切换都需要消耗系统资源(内存、CPU 时间)。创建过多并发单元会导致系统负载过重,性能反而下降。
- 数据共享与通信:多线程间共享数据需要同步,否则数据会错乱。多进程间内存不共享,通信需要通过队列、管道等机制,有额外开销。
核心原则:如果任务主要是 I/O 等待,优先考虑多线程或asyncio;如果是纯计算,必须使用多进程。在决定引入并发前,先评估是否有性能瓶颈,并做好测试。
3. 环境准备与前置条件
Python 并发编程的核心模块是标准库的一部分,因此环境准备相对简单。但为了获得更好的开发体验和进行性能测试,我们建议准备以下环境。
基础环境要求:
- 操作系统:Windows、macOS 或 Linux 均可。部分进程间通信的底层机制在不同系统上可能有细微差异,但
multiprocessing模块已做了良好封装。 - Python 版本:推荐使用Python 3.7 及以上版本。本文示例基于 Python 3.8+ 编写,确保能使用
concurrent.futures等现代模块的全部功能。你可以通过命令行检查版本:python --version # 或 python3 --version - 代码编辑器或 IDE:推荐使用 VSCode、PyCharm 等,它们对代码调试、特别是多线程调试有较好的支持。
可选工具与库:
- 性能分析工具:了解
cProfile和time模块,用于分析程序热点和测量执行时间。 - 系统监控:学习使用操作系统的任务管理器、
top、htop或ps命令,观察程序运行时的 CPU 和内存占用情况,特别是多进程时的资源消耗。
一个重要的心理准备:并发程序的输出顺序可能是不确定的,这是正常现象。调试时,可以使用日志模块logging并带上线程/进程名,这比直接print更清晰。
import logging import threading import time logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(threadName)s - %(message)s') def worker(): logging.info('Starting work') time.sleep(1) logging.info('Finished work') if __name__ == '__main__': threads = [] for i in range(3): t = threading.Thread(target=worker, name=f'Worker-{i}') threads.append(t) t.start() for t in threads: t.join()运行上述代码,观察日志输出中线程名的变化和时间的交错,这是理解并发执行的第一步。
4. 从基础到实践:多线程编程
多线程是并发编程中最常用的模型之一。Python 通过threading模块提供线程支持。
4.1 创建与启动线程
有两种主要方式创建线程:实例化Thread类,或继承Thread类并重写run方法。
方法一:传递函数
import threading import time def download_file(url): print(f"[{threading.current_thread().name}] 开始下载 {url}") time.sleep(2) # 模拟网络延迟 print(f"[{threading.current_thread().name}] 下载完成 {url}") if __name__ == '__main__': print(f"[{threading.current_thread().name}] 主线程开始") urls = ['https://example.com/1.zip', 'https://example.com/2.mp4', 'https://example.com/3.pdf'] threads = [] for url in urls: # 创建线程,target 指定要执行的函数,args 指定函数参数(元组形式) t = threading.Thread(target=download_file, args=(url,), name=f'Downloader-{urls.index(url)}') threads.append(t) t.start() # 启动线程,线程开始执行 target 函数 # 等待所有线程执行完毕 for t in threads: t.join() print(f"[{threading.current_thread().name}] 所有下载任务完成")关键点:
threading.current_thread().name获取当前线程名。t.start()启动线程,它是异步的,调用后立即返回。t.join()阻塞主线程,直到该线程执行完毕。如果不join,主线程可能提前结束,导致子线程被强制终止。
方法二:继承 Thread 类
import threading import time class DownloadThread(threading.Thread): def __init__(self, url): super().__init__() # 必须调用父类初始化 self.url = url def run(self): # 重写 run 方法,线程启动后执行此方法 print(f"[{self.name}] 开始下载 {self.url}") time.sleep(2) print(f"[{self.name}] 下载完成 {self.url}") if __name__ == '__main__': urls = ['https://example.com/a.jpg', 'https://example.com/b.png'] threads = [DownloadThread(url) for url in urls] for t in threads: t.start() for t in threads: t.join()这种方式更面向对象,适合将线程相关的数据和逻辑封装在一起。
4.2 线程同步:保护共享资源
当多个线程同时修改同一个变量或数据结构时,就会发生竞态条件,导致结果不可预测。我们需要使用“锁”来同步线程。
import threading import time # 一个共享的计数器 counter = 0 # 创建一个锁对象 lock = threading.Lock() def increment_counter(iterations): global counter for _ in range(iterations): # 不安全的操作 # temp = counter # time.sleep(0.0001) # 模拟一个极短的切换点 # temp += 1 # counter = temp # 使用锁保护临界区 with lock: # 自动获取和释放锁 temp = counter time.sleep(0.0001) # 即使在这里切换线程,锁也能保护数据 temp += 1 counter = temp # 锁在 with 块结束时自动释放 def unsafe_increment(iterations): global counter for _ in range(iterations): temp = counter time.sleep(0.0001) temp += 1 counter = temp if __name__ == '__main__': threads = [] num_threads = 10 iterations_per_thread = 100 print("=== 测试不安全的自增 ===") counter = 0 for i in range(num_threads): t = threading.Thread(target=unsafe_increment, args=(iterations_per_thread,)) threads.append(t) t.start() for t in threads: t.join() print(f"预期结果: {num_threads * iterations_per_thread}") print(f"实际结果: {counter}") # 结果几乎肯定小于预期 print("\n=== 测试使用锁的自增 ===") counter = 0 threads.clear() for i in range(num_threads): t = threading.Thread(target=increment_counter, args=(iterations_per_thread,)) threads.append(t) t.start() for t in threads: t.join() print(f"预期结果: {num_threads * iterations_per_thread}") print(f"实际结果: {counter}") # 结果正确运行结果分析:第一次无锁的测试,最终counter的值会远小于 1000,因为多个线程同时读取和写入,发生了数据覆盖。第二次使用锁,确保了同一时刻只有一个线程执行counter的“读-改-写”操作,结果正确。
其他同步原语:
threading.RLock: 可重入锁,同一个线程可以多次获取,防止死锁在嵌套锁中。threading.Semaphore: 信号量,用于控制同时访问资源的线程数量。threading.Event: 事件,用于线程间简单的通知机制。threading.Condition: 条件变量,用于复杂的线程间协调,如生产者-消费者模型。
4.3 线程局部数据:ThreadLocal
ThreadLocal数据是线程私有的,其他线程无法访问。它解决了参数在函数调用链中层层传递的麻烦,常用于存储请求上下文、数据库会话等。
import threading import random # 创建一个 ThreadLocal 实例 local_data = threading.local() def show_value(): try: value = local_data.value print(f"[{threading.current_thread().name}] value = {value}") except AttributeError: print(f"[{threading.current_thread().name}] No value set") def worker(): # 为当前线程设置一个随机值 local_data.value = random.randint(1, 100) show_value() if __name__ == '__main__': show_value() # 主线程没有设置 value,会抛出 AttributeError # 为主线程也设置一个值 local_data.value = "Main Thread Value" show_value() print("\n--- 启动子线程 ---") threads = [] for i in range(3): t = threading.Thread(target=worker, name=f'Worker-{i}') threads.append(t) t.start() for t in threads: t.join() print("\n--- 再次在主线程中查看 ---") show_value() # 主线程的值依然是 “Main Thread Value”,不受子线程影响每个线程操作local_data的属性,都像是在操作自己独有的对象,互不干扰。这在 Web 框架(如 Flask、Django)中广泛使用,用来存储当前请求的全局信息。
5. 突破 GIL:多进程编程
对于 CPU 密集型任务,多线程由于 GIL 的存在无法提速,这时就需要使用多进程。multiprocessing模块提供了与threading类似的接口,但创建的是进程。
5.1 创建进程
创建进程的方式与线程非常相似。
import multiprocessing import time import os def cpu_bound_task(number): """一个模拟的CPU密集型任务:计算平方和""" print(f"[进程 {os.getpid()}] 开始计算 {number} 的平方和") result = sum(i * i for i in range(number)) print(f"[进程 {os.getpid()}] 计算完成,结果: {result}") return result if __name__ == '__main__': # 多进程编程必须要有这行! print(f"[主进程 {os.getpid()}] 开始") start_time = time.time() numbers = [5000000, 5000000, 5000000, 5000000] # 四个大数 # 方法1:顺序执行 # results = [cpu_bound_task(num) for num in numbers] # 方法2:使用多进程 processes = [] results = [] for num in numbers: p = multiprocessing.Process(target=cpu_bound_task, args=(num,)) processes.append(p) p.start() for p in processes: p.join() # 等待进程结束 elapsed_time = time.time() - start_time print(f"[主进程 {os.getpid()}] 所有任务完成,耗时: {elapsed_time:.2f} 秒")关键点:
if __name__ == '__main__'::这是多进程编程的强制要求。在 Windows 和 macOS 上,Python 会通过 spawn 或 fork 方式创建子进程,子进程会导入主模块。如果没有这个保护,子进程会无限递归地创建新进程,导致错误。os.getpid():获取当前进程的 ID,可以看到任务是在不同进程中执行的。- 性能对比:你可以注释掉多进程的部分,取消注释顺序执行的部分,对比两者的运行时间。在多核 CPU 上,多进程版本的时间应该接近顺序执行时间的 1/4(假设有4个核心)。
5.2 进程间通信 (IPC)
进程拥有独立的内存空间,不能像线程那样直接共享变量。multiprocessing模块提供了多种 IPC 机制,如Queue、Pipe、Value、Array以及共享内存等。
使用Queue进行通信
Queue是进程安全的,非常适合生产者-消费者模式。
import multiprocessing import time import random def producer(queue, items): """生产者进程:向队列中放入数据""" for item in items: print(f"[生产者 {multiprocessing.current_process().name}] 生产了: {item}") queue.put(item) time.sleep(random.uniform(0.1, 0.5)) # 模拟生产耗时 # 放入结束信号 queue.put(None) print(f"[生产者 {multiprocessing.current_process().name}] 生产完毕") def consumer(queue): """消费者进程:从队列中取出数据并处理""" while True: item = queue.get() if item is None: # 收到结束信号 print(f"[消费者 {multiprocessing.current_process().name}] 收到结束信号") queue.put(None) # 将信号传递给其他消费者(如果有) break print(f"[消费者 {multiprocessing.current_process().name}] 消费了: {item}") time.sleep(random.uniform(0.2, 0.8)) # 模拟消费耗时 if __name__ == '__main__': # 创建一个跨进程的队列 task_queue = multiprocessing.Queue(maxsize=5) # 设置队列最大容量 # 准备数据 data_to_produce = [f'Task-{i}' for i in range(10)] # 创建进程 prod_process = multiprocessing.Process(target=producer, args=(task_queue, data_to_produce), name='Producer-1') cons_process = multiprocessing.Process(target=consumer, args=(task_queue,), name='Consumer-1') # 启动进程 cons_process.start() time.sleep(1) # 让消费者先启动并等待 prod_process.start() # 等待进程结束 prod_process.join() cons_process.join() print("主进程结束")Queue内部实现了锁和信号量,保证了多进程环境下数据的安全存取。maxsize参数可以控制队列容量,当队列满时,put操作会阻塞;当队列空时,get操作会阻塞。
使用Pipe进行双向通信
Pipe返回一对连接对象,默认是全双工的(两端都可收发)。
import multiprocessing def worker(conn): """子进程函数""" # 接收来自父进程的消息 received = conn.recv() print(f"[子进程] 收到: {received}") # 发送回复给父进程 conn.send(f"子进程回复: {received.upper()}") conn.close() # 关闭连接 if __name__ == '__main__': # 创建管道,返回两个连接对象 parent_conn, child_conn = multiprocessing.Pipe() p = multiprocessing.Process(target=worker, args=(child_conn,)) p.start() # 父进程发送消息 parent_conn.send("Hello from parent process") # 父进程接收回复 reply = parent_conn.recv() print(f"[父进程] 收到回复: {reply}") p.join()Pipe适用于两个进程间点对点的通信,比Queue更轻量,但管理多个连接时不如Queue方便。
6. 高级抽象:使用线程池与进程池
手动管理大量线程或进程的创建和销毁是繁琐且容易出错的。concurrent.futures模块提供了ThreadPoolExecutor和ProcessPoolExecutor这两个高级接口,它们管理着一个工作线程或进程的池子,我们只需提交任务即可。
6.1 使用 ThreadPoolExecutor
import concurrent.futures import urllib.request import time URLS = [ 'https://www.python.org/', 'https://www.github.com/', 'https://www.stackoverflow.com/', 'https://www.google.com/', 'https://www.bing.com/', ] def fetch_url(url): """获取URL的内容大小(模拟I/O密集型任务)""" start = time.time() try: with urllib.request.urlopen(url, timeout=5) as conn: data = conn.read() size = len(data) except Exception as e: return url, f"ERROR: {e}", time.time() - start return url, f"{size} bytes", time.time() - start def sequential_download(): """顺序执行""" print("=== 顺序执行 ===") start_time = time.time() for url in URLS: result = fetch_url(url) print(f"{result[0]}: {result[1]} (耗时: {result[2]:.2f}s)") print(f"总耗时: {time.time() - start_time:.2f} 秒\n") def concurrent_download(): """使用线程池并发执行""" print("=== 使用线程池并发执行 ===") start_time = time.time() # 使用 with 语句管理执行器,确保池子被正确关闭 with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor: # 使用 submit 提交单个任务,返回 Future 对象 # future_to_url = {executor.submit(fetch_url, url): url for url in URLS} # 使用 map 提交一批任务,更简洁 results = executor.map(fetch_url, URLS) # 获取结果 for url, size, elapsed in results: print(f"{url}: {size} (耗时: {elapsed:.2f}s)") print(f"总耗时: {time.time() - start_time:.2f} 秒") if __name__ == '__main__': sequential_download() time.sleep(2) # 稍作停顿 concurrent_download()关键点:
max_workers指定了线程池中最大线程数。对于 I/O 密集型任务,可以设置得比 CPU 核心数多一些。executor.map(func, iterable)是最常用的方式,它按顺序提交任务,并返回一个按提交顺序生成结果的迭代器。executor.submit(func, *args, **kwargs)提交单个任务,返回一个Future对象,可以通过future.result()获取结果(会阻塞直到任务完成)。- 使用
with语句可以确保在所有任务完成后,线程池被正确关闭。
运行这个例子,你会看到并发下载的总耗时远小于顺序下载各任务耗时之和,因为线程在等待网络响应时可以切换去执行其他任务。
6.2 使用 ProcessPoolExecutor
只需将ThreadPoolExecutor替换为ProcessPoolExecutor,代码结构几乎不变,但底层变成了多进程,适用于 CPU 密集型任务。
import concurrent.futures import math import time PRIMES = [ 112272535095293, 112582705942171, 112272535095293, 115280095190773, 115797848077099, 1099726899285419, 112272535095293, # 重复一些数字以增加计算量 112582705942171, ] def is_prime(n): """判断一个数是否为质数(CPU密集型)""" if n < 2: return False if n == 2: return True if n % 2 == 0: return False sqrt_n = int(math.floor(math.sqrt(n))) for i in range(3, sqrt_n + 1, 2): if n % i == 0: return False return True def sequential_check(): print("=== 顺序执行质数判断 ===") start = time.time() for number in PRIMES: prime = is_prime(number) print(f"{number} is prime: {prime}") print(f"顺序执行耗时: {time.time() - start:.2f} 秒\n") def concurrent_check(): print("=== 使用进程池并发判断 ===") start = time.time() # 注意:max_workers 通常设置为 CPU 核心数或略少 with concurrent.futures.ProcessPoolExecutor(max_workers=4) as executor: # 使用 map,将函数应用到可迭代对象的每个元素上 results = executor.map(is_prime, PRIMES) for number, result in zip(PRIMES, results): print(f"{number} is prime: {result}") print(f"并发执行耗时: {time.time() - start:.2f} 秒") if __name__ == '__main__': sequential_check() time.sleep(1) concurrent_check()重要区别:
ProcessPoolExecutor在 Windows 和 macOS 上使用spawn启动方式,因此主模块代码必须放在if __name__ == '__main__':之下。max_workers通常设置为机器的 CPU 核心数量。设置过多会因为进程切换开销导致性能下降。- 传递的参数和返回的结果必须是可序列化的(picklable),因为数据需要在进程间传递。
7. 资源占用与性能观察
编写并发程序时,必须关注其资源消耗和性能表现。
观察工具:
- 系统自带工具:
- Windows:任务管理器(查看 CPU、内存、磁盘、网络)。
- Linux/macOS:
top、htop、ps aux命令。
- Python 内置模块:
time/timeit:测量代码执行时间。cProfile/profile:分析函数调用耗时。memory_profiler(第三方库):分析内存使用情况。
性能测试示例:对比线程与进程
import time import threading import multiprocessing import concurrent.futures def cpu_bound_calc(n): """模拟CPU密集型计算""" count = 0 for i in range(n): count += i * i return count def io_bound_task(t): """模拟I/O密集型任务(等待)""" time.sleep(t) return t def test_threads_cpu(workers, n): """多线程处理CPU密集型任务""" with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor: start = time.time() list(executor.map(cpu_bound_calc, [n]*workers)) return time.time() - start def test_processes_cpu(workers, n): """多进程处理CPU密集型任务""" with concurrent.futures.ProcessPoolExecutor(max_workers=workers) as executor: start = time.time() list(executor.map(cpu_bound_calc, [n]*workers)) return time.time() - start def test_threads_io(workers, t): """多线程处理I/O密集型任务""" with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor: start = time.time() list(executor.map(io_bound_task, [t]*workers)) return time.time() - start def test_processes_io(workers, t): """多进程处理I/O密集型任务""" with concurrent.futures.ProcessPoolExecutor(max_workers=workers) as executor: start = time.time() list(executor.map(io_bound_task, [t]*workers)) return time.time() - start if __name__ == '__main__': cpu_workers = 4 cpu_n = 5_000_000 io_workers = 10 io_t = 0.5 print("=== CPU密集型任务测试 (计算密集型) ===") t_time = test_threads_cpu(cpu_workers, cpu_n) p_time = test_processes_cpu(cpu_workers, cpu_n) print(f"多线程 ({cpu_workers} workers) 耗时: {t_time:.2f} 秒") print(f"多进程 ({cpu_workers} workers) 耗时: {p_time:.2f} 秒") print(f"进程比线程快: {t_time/p_time:.2f} 倍\n") print("=== I/O密集型任务测试 (睡眠模拟) ===") t_time = test_threads_io(io_workers, io_t) p_time = test_processes_io(io_workers, io_t) print(f"多线程 ({io_workers} workers) 耗时: {t_time:.2f} 秒") print(f"多进程 ({io_workers} workers) 耗时: {p_time:.2f} 秒") # 对于纯I/O,两者时间应接近,但线程开销更小运行这个测试,你可以直观地看到:
- CPU 密集型:多进程耗时远小于多线程(理想情况下接近
线程耗时/CPU核心数),因为多进程绕过了 GIL。 - I/O 密集型:多线程和多进程的耗时可能接近,因为时间主要花在等待上。但多线程的创建和切换开销通常小于多进程。
最佳实践:
- 监控资源:运行程序时,打开系统监控工具,观察 CPU 使用率是否达到预期(多进程应使多核饱和),内存是否平稳。
- 避免过度并发:线程/进程数不是越多越好。I/O 密集型可以多一些(如几十个),CPU 密集型最好等于或略小于 CPU 核心数。
- 使用池化:始终优先使用
ThreadPoolExecutor或ProcessPoolExecutor,而不是手动创建大量线程/进程。
8. 常见问题与排查方法
并发编程中会遇到各种棘手的问题,下表列出了一些典型问题及其解决方法。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 程序卡住,无输出也不结束 | 1. 死锁(多个线程/进程互相等待对方释放锁)。 2. 队列操作阻塞( Queue.get()空队列或Queue.put()满队列且无超时)。3. I/O 操作无限等待(如网络请求无超时)。 | 1. 使用threading.enumerate()或multiprocessing.active_children()查看活动线程/进程。2. 添加日志,输出锁的获取和释放状态。 3. 检查队列大小和生产者/消费者逻辑。 4. 为所有网络/文件操作设置超时参数。 | 1. 设计锁的获取顺序,使用RLock或with语句管理锁。2. 使用 Queue.get(timeout=...)和Queue.put(timeout=...)。3. 使用 concurrent.futures的as_completed或wait设置超时。 |
| 数据不一致或结果错误 | 竞态条件:多个线程同时读写共享变量未加锁。 | 1. 检查所有对共享资源(全局变量、文件、数据库连接)的访问。 2. 使用线程安全的数据结构,如 queue.Queue、collections.deque(需配合锁)。 | 1. 使用threading.Lock或RLock保护临界区。2. 将共享数据访问封装到一个线程中,通过队列与其他线程通信。 |
| 多进程程序在 Windows 上报错或行为异常 | Windows 使用spawn方式创建进程,子进程会重新导入主模块。 | 检查是否将所有启动代码(特别是创建新进程的代码)放在了if __name__ == '__main__':块内。 | 强制要求:多进程代码的入口点必须是if __name__ == '__main__':。 |
| 创建大量线程/进程后程序崩溃 | 1. 达到系统资源限制(如打开文件数、内存)。 2. 每个线程/进程开销过大。 | 1. 观察系统资源使用情况。 2. 使用 ulimit -a(Linux) 查看限制。3. 使用池化技术限制并发数量。 | 1. 使用线程池/进程池 (concurrent.futures)。2. 减少每个任务的内存占用。 3. 考虑使用异步 I/O ( asyncio) 处理大量连接。 |
| 程序性能提升不明显甚至下降 | 1. 任务并非瓶颈(阿姆达尔定律)。 2. 并发开销(创建、切换、通信)抵消了收益。 3. GIL 限制了多线程的 CPU 并行。 | 1. 使用性能分析工具 (cProfile) 找到热点函数。2. 测试不同并发数下的性能。 3. 区分任务是 I/O 密集型还是 CPU 密集型。 | 1. 只对瓶颈部分进行并发优化。 2. I/O 密集型用多线程或 asyncio,CPU 密集型用多进程。3. 调整线程池/进程池的大小。 |
ThreadLocal数据丢失或混乱 | 1. 在线程池中复用线程,ThreadLocal数据未清理。2. 错误地访问了其他线程的数据(实际上做不到,可能是逻辑错误)。 | 1. 确保在任务开始前初始化ThreadLocal数据。2. 在任务结束后清理敏感数据(如数据库连接)。 | 1. 使用线程池时,在任务函数内部初始化ThreadLocal数据,而不是在外部。2. 使用 try...finally确保资源被清理。 |
9. 最佳实践与使用建议
掌握了基础之后,遵循以下最佳实践能让你的并发程序更健壮、更高效。
- 优先使用高层抽象:除非有特殊需求,否则优先使用
concurrent.futures.ThreadPoolExecutor和ProcessPoolExecutor,而不是手动管理threading.Thread或multiprocessing.Process。池化机制能自动管理生命周期,避免资源泄漏。 - 明确任务类型:在编写并发代码前,先分析任务是I/O 密集型还是CPU 密集型。这是选择多线程还是多进程的根本依据。
- 合理设置并发数:
- CPU 密集型:
max_workers设置为 CPU 核心数(os.cpu_count())或略少。 - I/O 密集型:可以设置得更高,具体数值需要通过压测确定,通常可以是核心数的几倍到几十倍,但也要考虑目标系统的连接数限制。
- CPU 密集型:
- 善用
with语句:使用with来管理执行器、锁、连接等资源,可以确保它们被正确关闭和释放,即使发生异常。 - 避免共享状态:多线程编程的万恶之源是共享可变状态。尽可能设计无状态的函数,通过参数传递数据,通过返回值获取结果。如果必须共享,务必使用锁或其他同步机制。
- 使用队列进行通信:在多进程编程中,
multiprocessing.Queue是进程间通信最安全、最常用的方式。它比共享内存(Value,Array)更不容易出错。 - 为任务设置超时:特别是网络请求、文件读写等 I/O 操作,必须设置超时,防止某个失败的任务拖垮整个程序。可以使用
concurrent.futures.as_completed的timeout参数。with ThreadPoolExecutor() as executor: future_to_url = {executor.submit(load_url, url, 60): url for url in URLS} for future in concurrent.futures.as_completed(future_to_url, timeout=10): url = future_to_url[future] try: data = future.result(timeout=5) # 为单个任务结果获取也设置超时 except concurrent.futures.TimeoutError: print(f"{url} request timed out") except Exception as exc: print(f"{url} generated an exception: {exc}") - 做好日志和错误处理:并发程序中的异常不会自动崩溃主程序,可能被静默吞掉。务必在任务函数内部做好
try...except日志记录,或者通过future.exception()检查任务是否出错。 - 考虑
asyncio作为替代:如果你处理的是大量网络 I/O(如 HTTP 请求、数据库连接),asyncio协程模型比多线程更轻量、更高效。它使用单线程,通过事件循环和await来切换任务,避免了线程切换的开销和 GIL 的影响。
Python 的并发编程工具箱非常丰富,从底层的threading/multiprocessing到高层的concurrent.futures,再到现代的asyncio。理解每种工具的原理和适用场景,是写出高效、稳定并发程序的关键。建议从简单的线程池/进程池任务开始实践,逐步深入到复杂的同步和通信场景,并在实际项目中不断积累经验。