一、线程基础概念:进程与线程的区别、线程的生命周期、Python中的GIL全局解释器锁
1.1 进程与线程:到底差在哪?
很多初学者问我,进程和线程到底有什么区别?我习惯用一个比喻来解释:进程就像一家餐厅,线程就是里面的厨师。
餐厅有自己的场地、设备、食材(内存空间、文件句柄、系统资源)。每个厨师共享这些资源,但各自负责不同的菜品。厨师之间可以随时交流,共用一把菜刀(共享数据)。
但餐厅之间是隔离的。你不可能跑到隔壁餐厅去拿他们的酱油。这就是进程的边界——每个进程拥有独立的地址空间。
核心区别一览:
| 对比维度 | 进程 | 线程 |
|---|---|---|
| 资源开销 | 大(独立内存、文件表等) | 小(共享进程资源) |
| 通信方式 | IPC(管道、消息队列、共享内存) | 直接读写共享变量 |
| 切换成本 | 高(需要切换页表、刷新TLB) | 低(只需保存寄存器) |
| 健壮性 | 一个进程挂了不影响其他进程 | 一个线程崩溃可能导致整个进程挂掉 |
我在项目中遇到过这样一个场景:一个后台服务需要同时处理1000个WebSocket连接。如果用进程,光内存开销就扛不住。用线程,每个连接只占几KB的栈空间,轻松搞定。但代价是什么?线程安全问题——后面我们会详细讲。
1.2 线程的生命周期:从出生到死亡
线程不是凭空出现的。它有自己的生命周期。我习惯把它分成五个阶段:
- 新建(New)——线程对象刚创建,还没启动。就像招了个厨师,还没让他上灶。
- 就绪(Runnable)——调用了start(),线程已经准备好,等待CPU分配时间片。厨师已经站在灶台前,等锅热起来。
- 运行(Running)——CPU开始执行线程的run()方法。厨师开始颠勺炒菜。
- 阻塞(Blocked/Waiting)——线程因为等待I/O、获取锁、调用sleep()等原因暂停。厨师发现没盐了,等着采购员送过来。
- 终止(Terminated)——run()执行完毕,或者抛出未捕获异常。厨师下班了。
避坑指南:我曾经犯过一个低级错误——调用了线程的run()而不是start()。结果run()在当前线程同步执行,根本没创建新线程。你想想看,这相当于让厨师长亲自去切菜,其他菜全耽误了。记住:start()才是启动线程的正确姿势。
1.3 Python的GIL:绕不开的"紧箍咒"
说到Python多线程,就不得不提GIL(Global Interpreter Lock,全局解释器锁)。很多Python开发者一听到这个词就头疼。说实话,我刚接触时也觉得这是个设计缺陷。
GIL是什么?简单说,CPython解释器里有一把大锁。任何时刻,只有一个线程能执行Python字节码。其他线程想执行?等着吧。
GIL的影响:
- CPU密集型任务:多线程反而可能比单线程慢(锁竞争开销)
- I/O密集型任务:多线程依然有效(等待I/O时会释放GIL)
- 数值计算、图像处理:建议用多进程或C扩展
为什么会这样?我解释一下历史原因。Python早期设计时,内存管理用的是引用计数。如果没有GIL,多个线程同时修改对象的引用计数,会导致内存泄漏甚至崩溃。说白了,GIL是用性能换安全。
我在项目中遇到过真实案例:一个爬虫程序,用多线程抓取100个网页。按理说应该快10倍,结果只快了2倍。为什么?因为解析HTML时CPU密集,GIL成了瓶颈。后来改成多进程+异步I/O,速度直接起飞。
注意:GIL只存在于CPython。如果你用Jython或IronPython,就没有这个限制。但现实是,99%的Python项目都用CPython。所以,别指望绕过GIL,学会与它共存。
1.4 一张图看懂本章知识体系
1.5 代码示例:感受线程的创建与GIL的影响
import threading
import time
def cpu_intensive_task(n):
"""CPU密集型任务:计算斐波那契数列"""
if n <= 1:
return n
return cpu_intensive_task(n-1) + cpu_intensive_task(n-2)
def io_intensive_task():
"""模拟I/O密集型任务:等待+打印"""
time.sleep(1)
print(f"线程 {threading.current_thread().name} 完成I/O操作")
# 测试CPU密集型(单线程 vs 多线程)
start = time.time()
for _ in range(4):
cpu_intensive_task(35)
print(f"单线程CPU耗时: {time.time() - start:.2f}s")
start = time.time()
threads = []
for _ in range(4):
t = threading.Thread(target=cpu_intensive_task, args=(35,))
threads.append(t)
t.start()
for t in threads:
t.join()
print(f"多线程CPU耗时: {time.time() - start:.2f}s")
# 测试I/O密集型(单线程 vs 多线程)
start = time.time()
for _ in range(4):
io_intensive_task()
print(f"单线程I/O耗时: {time.time() - start:.2f}s")
start = time.time()
threads = []
for _ in range(4):
t = threading.Thread(target=io_intensive_task)
threads.append(t)
t.start()
for t in threads:
t.join()
print(f"多线程I/O耗时: {time.time() - start:.2f}s")
运行结果预测:
- CPU密集型:单线程和多线程耗时差不多(甚至多线程更慢)
- I/O密集型:多线程比单线程快3-4倍
这就是GIL的"偏心"——它只卡CPU,不卡I/O。嗯,记住这个特性,以后选型时心里就有数了。
1.6 避坑指南:我踩过的那些坑
最后分享几个实战教训:
- 别用time.sleep(0)来让出GIL——我曾经这么干过,结果CPU占用率飙升。正确做法是用threading.Event或条件变量。
- 线程数不是越多越好——我见过有人开500个线程抓数据,结果上下文切换开销比实际工作还大。一般I/O密集型线程数=CPU核心数×2就够了。
- 守护线程要小心——设置daemon=True的线程,主线程退出时会强制终止。如果线程正在写文件,数据可能丢失。我习惯用join()等待所有线程完成。
好了,这一章就到这里。线程的基础概念是后续所有并发编程的基石。把进程和线程的区别、生命周期、GIL的影响搞明白,后面学锁、条件变量、线程池才会顺手。