1. 为什么需要并发编程?
现代计算机普遍配备多核CPU,但默认情况下Python程序只会使用其中一个核心。这就好比餐厅有8个厨师(CPU核心),但老板只让1个人干活,其他7个都在摸鱼。当我们需要处理I/O密集型任务(如网络请求)或计算密集型任务(如数据分析)时,这种单线程模式会造成严重的资源浪费。
我在处理一个爬虫项目时就深有体会:单线程爬取1000个网页需要2小时,而改用多线程后仅需15分钟。但并发编程不是银弹,错误的使用会导致:
- 数据竞争(Data Race)
- 死锁(Deadlock)
- GIL性能陷阱
- 调试难度指数级上升
2. 多线程:I/O密集型的首选方案
2.1 线程的本质与GIL机制
Python线程是操作系统原生线程的封装,但受到全局解释器锁(GIL)的限制。可以把GIL想象成公司唯一的一把会议室钥匙:
- 任何时候只有一个线程能拿到钥匙执行字节码
- 遇到I/O操作时会主动释放钥匙
- CPU密集型操作会一直霸占钥匙
这种机制导致纯计算任务使用多线程反而更慢。但在网络爬虫、文件处理等I/O等待占95%以上的场景中,多线程能大幅提升吞吐量。
2.2 实战:线程池最佳实践
直接创建裸线程是新手常见错误,正确做法是使用concurrent.futures线程池:
python复制from concurrent.futures import ThreadPoolExecutor
import requests
def fetch(url):
return requests.get(url).status_code
urls = ['https://example.com' for _ in range(100)]
# 推荐写法
with ThreadPoolExecutor(max_workers=10) as executor:
results = list(executor.map(fetch, urls))
关键参数经验值:
- I/O密集型:worker数量 = min(32, os.cpu_count() + 4)
- 数据库操作:不超过连接池最大连接数
- 超时设置:总超时+单任务超时双重保险
2.3 线程安全防护手册
共享数据时务必加锁,但要注意锁粒度:
python复制from threading import Lock
class Counter:
def __init__(self):
self.value = 0
self.lock = Lock()
def increment(self):
with self.lock: # 自动获取和释放
self.value += 1
常见坑点:
- 死锁:按固定顺序获取多个锁
- 饥饿:避免长时间持有锁
- 性能:用RLock替代Lock减少阻塞
3. 多进程:突破GIL的核武器
3.1 进程的工作原理
每个Python进程都有独立的GIL,适合计算密集型任务。进程间内存隔离,通信需要特殊机制:
- 管道(Pipe)
- 队列(Queue)
- 共享内存(Value/Array)
- 信号量(Semaphore)
我在图像处理项目中测试过:用4进程处理1000张图片比单进程快3.8倍。
3.2 进程池性能调优
python复制from multiprocessing import Pool
def process_image(img_path):
# 模拟耗时计算
return img_path.upper()
if __name__ == '__main__':
with Pool(processes=4) as pool:
results = pool.map(process_image, ['a.jpg', 'b.png'])
重要经验:
- 进程数 ≤ CPU核心数
- 避免在Windows平台频繁创建进程
- 大数据传输用Manager().dict()共享
- 用initializer加载全局资源
3.3 进程通信的四种武器
- 队列(最常用):
python复制from multiprocessing import Queue
q = Queue()
q.put('data')
print(q.get())
- 管道(性能更高):
python复制parent_conn, child_conn = Pipe()
child_conn.send('hello')
print(parent_conn.recv())
- 共享内存(小心竞态):
python复制from multiprocessing import Value, Array
counter = Value('i', 0)
arr = Array('d', [1.0, 2.0])
- 信号量(控制并发):
python复制from multiprocessing import Semaphore
sem = Semaphore(3)
sem.acquire()
# 临界区代码
sem.release()
4. 选型决策树与性能对比
4.1 选择线程还是进程?
根据任务类型选择:
| 特征 | 多线程 | 多进程 |
|---|---|---|
| CPU使用率 | 单核(受GIL限制) | 多核并行 |
| 内存占用 | 共享内存,开销小 | 每个进程独立,开销大 |
| 启动速度 | 快(微秒级) | 慢(毫秒级) |
| 适用场景 | I/O密集型、GUI应用 | 计算密集型、科学计算 |
| 调试难度 | 中等(共享状态) | 较高(IPC复杂) |
4.2 混合使用案例
在既有计算又有I/O的场景,可以组合使用:
python复制import concurrent.futures
import math
def compute(n):
return math.factorial(n)
def io_bound(url):
return requests.get(url).text
with concurrent.futures.ThreadPoolExecutor() as io_executor:
io_results = io_executor.map(io_bound, urls)
with concurrent.futures.ProcessPoolExecutor() as cpu_executor:
cpu_results = cpu_executor.map(compute, numbers)
5. 高级技巧与性能陷阱
5.1 协程与异步IO
对于超高并发I/O(如万级连接),asyncio比线程更高效:
python复制import aiohttp
import asyncio
async def fetch(session, url):
async with session.get(url) as response:
return await response.text()
async def main():
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url) for url in urls]
return await asyncio.gather(*tasks)
5.2 避免GIL陷阱的C扩展
用Cython编写计算密集型部分:
cython复制# 编译为扩展模块
cdef long heavy_computation(int n):
cdef long result = 1
cdef int i
for i in range(1, n+1):
result *= i
return result
5.3 调试并发程序的技巧
- 使用
faulthandler定位死锁:
python复制import faulthandler
faulthandler.enable()
- 用
logging线程安全输出:
python复制import logging
logging.basicConfig(format='%(threadName)s: %(message)s')
- 可视化分析工具:
py-spy生成火焰图vprof性能分析器threading模块的_after_fork()钩子
6. 真实项目经验分享
在开发分布式爬虫时,我采用三级并发架构:
- 主进程:管理任务队列(Redis)
- 工作进程:每个进程维护独立线程池
- 工作线程:执行实际抓取任务
关键配置参数:
python复制# 进程数 = CPU核心数
PROCESSES = os.cpu_count()
# 每个进程的线程数 = (目标QPS / 单个请求耗时) / PROCESSES
THREADS_PER_PROCESS = 8
# 连接池大小 = THREADS_PER_PROCESS * 1.5
CONNECTION_POOL_SIZE = 12
遇到的典型问题:
- 数据库连接泄漏 → 给连接池添加超时
- 内存暴涨 → 改用生成器分批处理
- 僵尸进程 → 添加信号处理器