1. 为什么我们需要并发编程?
当你的Python脚本需要同时处理多个任务时,比如一边下载文件一边解析数据,或者同时响应多个用户请求,单线程的局限性就暴露无遗。我十年前第一次用Python写网络爬虫时就遇到了这个问题——明明服务器带宽足够,但单线程下载几百个页面时速度慢得令人发指。
1.1 全局解释器锁(GIL)的桎梏
Python的GIL就像是一个严格的交通警察,它规定任何时候只有一个线程能够执行Python字节码。这意味着即使你有8核CPU,多线程程序在CPU密集型任务上也可能表现不佳。我在数据分析项目中实测过:用4个线程计算斐波那契数列,速度反而比单线程慢了15%,这就是GIL的"功劳"。
但别急着否定多线程,在I/O密集型场景下它依然大有用武之地。当线程在等待网络响应或磁盘读写时,GIL会被释放,其他线程就有机会执行。我的爬虫项目改用多线程后,下载效率提升了8倍。
1.2 进程与线程的本质区别
理解这个区别至关重要。线程像是同一条流水线上的工人,共享工作环境(内存空间);进程则是完全独立的工厂,有自己独立的内存。这种差异带来了完全不同的特性:
| 特性 | 线程 | 进程 |
|---|---|---|
| 创建开销 | 小(通常几MB) | 大(通常几十MB) |
| 通信方式 | 直接读写共享内存(需同步) | 必须通过IPC(管道、队列等) |
| 崩溃影响 | 可能导致整个程序崩溃 | 只影响自身 |
| Python适用场景 | I/O密集型、GUI应用 | CPU密集型、需要绕过GIL的任务 |
| 调试难度 | 较难(共享状态复杂) | 相对简单(隔离性好) |
2. 多线程编程实战指南
2.1 threading模块的正确打开方式
新手常犯的错误是直接创建大量线程。在我的电商价格监控项目中,最初同时启动200个线程导致服务器拒绝服务。后来改用线程池后,稳定性和性能都大幅提升。
python复制from concurrent.futures import ThreadPoolExecutor
import requests
def fetch_price(url):
try:
response = requests.get(url, timeout=3)
return parse_price(response.text)
except Exception as e:
print(f"Failed to fetch {url}: {str(e)}")
# 最佳实践:根据I/O延迟调整线程数
with ThreadPoolExecutor(max_workers=20) as executor:
urls = [f"https://api.example.com/products/{i}" for i in range(100)]
prices = list(executor.map(fetch_price, urls))
关键经验:线程数不是越多越好。I/O密集型任务通常设置为2-3倍CPU核心数,网络请求频繁的应用需要根据平均响应时间调整。
2.2 线程同步的陷阱与解决方案
共享数据时如果不加保护,就会遇到竞态条件。我在开发交易系统时曾因为一个未加锁的余额变量导致用户资金计算错误。以下是几种同步方式的对比:
- Lock(锁):最基本的同步原语,但容易造成死锁
python复制import threading
balance = 0
lock = threading.Lock()
def update_balance(amount):
global balance
with lock: # 自动获取和释放锁
balance += amount
- RLock(可重入锁):同一个线程可以多次获取,避免死锁
- Condition(条件变量):适合生产者-消费者模型
- Semaphore(信号量):控制并发访问数量
血泪教训:永远不要在持锁时调用外部I/O操作,这可能导致整个应用卡死。我曾因在锁内调用第三方API导致服务雪崩。
3. 多进程编程深度解析
3.1 突破GIL的多进程方案
当你的Python程序需要榨干CPU性能时,multiprocessing模块是救星。在最近的图像处理项目中,多进程将批量处理时间从45分钟缩短到7分钟(8核机器)。
python复制from multiprocessing import Pool
import cv2
def process_image(img_path):
img = cv2.imread(img_path)
# 复杂的图像处理操作
return processed_img
if __name__ == '__main__':
with Pool(processes=8) as pool:
image_paths = ['img1.jpg', 'img2.jpg', ...]
results = pool.map(process_image, image_paths)
3.2 进程间通信的几种姿势
进程不像线程那样共享内存,必须使用特殊方式通信。在我的分布式任务系统中,尝试过多种方案:
- Queue(队列):最常用的进程安全队列
python复制from multiprocessing import Queue
q = Queue()
q.put('data')
item = q.get()
- Pipe(管道):适合两个进程间的双向通信
- 共享内存:Value/Array实现基础类型共享
- Manager:支持更复杂的共享对象
性能实测:在传输1MB数据时,Queue的吞吐量约为Pipe的65%,但更稳定。小数据量(<10KB)时差异不明显。
4. 现代Python并发新选择
4.1 asyncio的协程革命
在开发高频交易API时,我发现当需要处理数千个并发连接时,多线程消耗资源太多,而asyncio只用单线程就轻松应对:
python复制import asyncio
import aiohttp
async def fetch_ticker(session, symbol):
url = f"https://api.example.com/ticker/{symbol}"
async with session.get(url) as response:
return await response.json()
async def main():
async with aiohttp.ClientSession() as session:
tasks = [fetch_ticker(session, s) for s in symbols]
return await asyncio.gather(*tasks)
tickers = asyncio.run(main())
协程的秘诀在于事件循环和await挂起机制。当遇到I/O等待时,事件循环会切换到其他就绪的协程,实现超高并发。
4.2 多进程+协程的混合模式
在我的区块链数据索引项目中,结合了多进程和协程的优势:
- 用多进程绕过GIL进行CPU密集型计算
- 每个进程内使用协程处理高并发I/O
python复制async def process_block(block_data):
# 协程处理I/O
transactions = await fetch_transactions(block_data['hash'])
# 提交到进程池进行CPU密集型计算
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(
None, # 使用默认进程池
cpu_intensive_analysis,
transactions
)
return result
5. 实战选型决策树
经过多个项目的锤炼,我总结出这样的选择策略:
- 纯CPU密集型任务:multiprocessing是唯一选择
- 高并发I/O任务:
- 连接数<1000:ThreadPoolExecutor
- 连接数>1000:asyncio
- 混合型任务:
- CPU密集型部分用进程池
- I/O部分用线程池或协程
- 需要避免GIL又需要共享状态:多进程+Manager共享对象
最后分享一个调试技巧:在Linux下可以用htop命令观察线程/进程的实际CPU占用情况。如果发现多线程程序的所有线程都在同一个CPU核心上运行,就说明GIL在作祟了。