1. 多线程基础回顾:进程与线程的区别、Python中的GIL、threading模块核心API
1.1 进程与线程:到底差在哪?
先问个问题:你写代码时,到底该用进程还是线程?
我个人习惯这样理解——进程是资源分配的最小单位,线程是CPU调度的最小单位。说白了,进程就像一家工厂,有自己的厂房、设备和原材料(内存空间、文件句柄)。线程就是工厂里的工人,共享厂房和设备,但各干各的活。
我在项目中遇到过这样的场景:一个爬虫程序,如果用多进程,每个进程都要加载一遍网络库和解析库,内存直接飙到2GB。换成多线程后,共享这些资源,内存只用了400MB。这就是共享内存空间带来的好处。
| 对比维度 | 进程 | 线程 |
|---|---|---|
| 内存空间 | 独立,互不干扰 | 共享同一进程的内存 |
| 创建开销 | 大(需要复制页表等) | 小(只需分配栈空间) |
| 通信方式 | IPC(管道、队列等) | 直接读写共享变量 |
| 稳定性 | 一个进程挂了不影响其他 | 一个线程崩溃可能导致整个进程挂掉 |
1.2 Python中的GIL:绕不开的坎
聊Python多线程,GIL是躲不开的话题。GIL全称Global Interpreter Lock,全局解释器锁。它保证同一时刻只有一个线程在执行Python字节码。
为什么会这样?CPython的内存管理不是线程安全的。没有GIL的话,两个线程同时操作引用计数,内存就乱套了。所以Python设计者选择了这个简单粗暴的方案。
- CPU密集型任务:多线程反而更慢(锁竞争开销)
- I/O密集型任务:多线程有效(等待I/O时会释放GIL)
我记得有一次优化一个图片处理服务。同事用了8个线程并行处理图片,结果CPU利用率只有30%。换成多进程后,8个进程直接把CPU跑满。这就是GIL的典型影响——计算密集型任务,多线程帮倒忙。
但别急着否定GIL。对于网络请求、文件读写这类I/O操作,线程在等待时会让出GIL,其他线程就能继续执行。这时候多线程的效果还是不错的。
1.3 threading模块核心API:实战必备
Python的threading模块,我几乎每个项目都用。下面这几个API,你想想看,是不是经常碰到?
1.3.1 Thread:创建线程
import threading
import time
def worker(name):
print(f"线程 {name} 开始工作")
time.sleep(2)
print(f"线程 {name} 结束")
# 创建线程
t1 = threading.Thread(target=worker, args=("A",))
t2 = threading.Thread(target=worker, args=("B",))
# 启动线程
t1.start()
t2.start()
# 等待线程结束
t1.join()
t2.join()
print("所有线程执行完毕")
嗯,这里要注意:join() 一定要调。不调的话,主线程可能直接退出,子线程还没跑完就被强制结束了。我在线上遇到过这种bug,查了半天才发现是忘了join。
1.3.2 Lock:线程同步
多个线程操作共享变量时,不加锁就会出问题。看这个经典例子:
counter = 0
lock = threading.Lock()
def increment():
global counter
for _ in range(100000):
with lock: # 自动获取和释放锁
counter += 1
threads = [threading.Thread(target=increment) for _ in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"最终结果: {counter}") # 应该是 1000000
用 with lock 是Python推荐的写法。它保证即使代码抛出异常,锁也会被释放。我曾经见过有人手动 acquire() 和 release(),结果某个分支忘了release,直接死锁。嗯,血的教训。
1.3.3 Event:线程间通信
Event就像一面旗子。一个线程设置旗子,其他线程等着旗子升起。
event = threading.Event()
def waiter():
print("等待信号...")
event.wait() # 阻塞,直到event被set
print("收到信号,开始干活")
def signaler():
time.sleep(2)
print("发送信号")
event.set() # 设置事件
threading.Thread(target=waiter).start()
threading.Thread(target=signaler).start()
我个人习惯用Event来做优雅关闭。比如一个后台服务线程,主线程想让它停下来时,就set一个event,服务线程检测到后自己清理退出。比直接kill优雅多了。
1.3.4 ThreadPoolExecutor:线程池
手动创建线程太麻烦,而且线程多了管理不过来。Python 3.2+ 提供了 concurrent.futures.ThreadPoolExecutor,用起来很顺手:
from concurrent.futures import ThreadPoolExecutor
import requests
def fetch_url(url):
resp = requests.get(url)
return len(resp.content)
urls = ["https://example.com"] * 10
with ThreadPoolExecutor(max_workers=4) as executor:
results = list(executor.map(fetch_url, urls))
print(f"总字节数: {sum(results)}")
线程池大小怎么设?我一般遵循这个原则:I/O密集型任务,线程数设为CPU核心数的2-4倍。因为线程大部分时间在等待I/O,多开几个没关系。但别超过100,否则上下文切换开销就上来了。
好了,这一章就到这里。进程线程的区别、GIL的影响、threading的核心API,这三块是后续所有内容的基础。下一章我们开始聊线程安全和高并发设计模式。