第一章:多线程基础回顾
各位同学好,我是你们的老朋友。今天咱们聊聊多线程的基础知识。说实话,很多人在并发编程上栽跟头,都是因为基础没打牢。我见过太多人一上来就写线程池、搞锁,结果死锁了都不知道怎么死的。
这一章,我会带着大家把三个核心问题搞清楚:进程和线程到底差在哪?Python 那个被人吐槽的 GIL 锁到底是什么鬼?以及 threading 模块怎么用才顺手。
1.1 进程与线程:别再傻傻分不清
先问个问题:你打开一个浏览器,再打开一个记事本,这是进程还是线程?
答案是进程。每个程序启动,操作系统都会给它分配一块独立的内存空间,这就是一个进程。进程之间是隔离的,你浏览器崩了,不会影响我写代码——嗯,这点很重要。
那线程呢?线程是进程里的执行单元。一个进程可以包含多个线程,它们共享进程的内存空间。说白了,线程就是轻量级的执行流。
核心区别一句话总结:
- 进程:资源分配的最小单位,独立内存,切换开销大
- 线程:CPU调度的最小单位,共享内存,切换开销小
我在项目中遇到过一个问题:用多进程处理大量IO任务,结果内存直接爆了。为什么?因为每个进程都复制了一份完整的内存空间。后来换成线程,内存占用降了80%。
这里有个表格,方便大家对比:
| 对比维度 | 进程 | 线程 |
|---|---|---|
| 内存隔离 | 完全隔离 | 共享 |
| 创建开销 | 大 | 小 |
| 通信方式 | IPC(管道、队列等) | 直接读写共享变量 |
| 适用场景 | CPU密集型 | IO密集型 |
| Python中的GIL影响 | 无影响 | 有影响 |
1.2 Python GIL 锁:到底锁了个啥?
说到Python多线程,GIL是绕不开的话题。很多人一提到GIL就摇头,觉得它是Python的缺陷。我个人觉得,这其实是个设计取舍问题。
GIL的全称是 Global Interpreter Lock,全局解释器锁。它的作用很简单:保证同一时刻只有一个线程在执行Python字节码。
为什么会这样?因为Python的内存管理不是线程安全的。如果不加GIL,多个线程同时操作引用计数,内存分分钟就崩了。你想想看,CPython解释器在1990年代设计的时候,多核CPU还没普及呢。
避坑指南:
我曾经以为GIL会让多线程完全没用,直到我做了个网络爬虫。爬虫大部分时间在等网络响应,这时候线程会释放GIL,其他线程就能跑。所以对于IO密集型任务,多线程依然有效。
但如果你做的是CPU密集型计算,比如图像处理、数值计算,多线程就尴尬了。多个线程抢一个GIL,反而因为上下文切换变慢。这时候,多进程才是正解。
我画了一张图,帮你理解GIL的工作机制:
1.3 threading 模块核心API:手把手教你用
好了,理论说完了,咱们上代码。Python的threading模块是标准库,开箱即用。
1.3.1 创建线程的两种方式
第一种,直接传函数:
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("所有线程执行完毕")
第二种,继承Thread类。我个人习惯用第一种,代码更简洁。但如果你需要在线程里维护状态,继承方式更合适。
class MyThread(threading.Thread):
def __init__(self, name):
super().__init__()
self.name = name
def run(self):
print(f"线程 {self.name} 开始工作")
time.sleep(2)
print(f"线程 {self.name} 结束工作")
t = MyThread("C")
t.start()
t.join()
1.3.2 常用API一览
| API | 说明 | 使用频率 |
|---|---|---|
| Thread(target, args) | 创建线程对象 | ⭐⭐⭐⭐⭐ |
| start() | 启动线程 | ⭐⭐⭐⭐⭐ |
| join(timeout) | 等待线程结束,可设超时 | ⭐⭐⭐⭐ |
| daemon | 设为守护线程,主线程退出时自动结束 | ⭐⭐⭐ |
| name | 线程名称,方便调试 | ⭐⭐⭐ |
| ident | 线程唯一标识符 | ⭐⭐ |
| is_alive() | 检查线程是否还在运行 | ⭐⭐⭐ |
| active_count() | 当前存活的线程数 | ⭐⭐ |
| enumerate() | 返回所有存活线程列表 | ⭐⭐ |
小技巧:
调试多线程程序时,我习惯给每个线程起个有意义的名字,比如"爬虫线程-1"、"写入线程"。这样看日志的时候,一眼就能知道哪个线程出了问题。
1.3.3 守护线程的坑
守护线程是个好东西,但用不好会出问题。我举个例子:
import threading
import time
def background_task():
while True:
print("后台任务运行中...")
time.sleep(1)
t = threading.Thread(target=background_task)
t.daemon = True # 设为守护线程
t.start()
time.sleep(3)
print("主线程结束")
# 主线程结束,守护线程自动退出
我曾经踩过的坑:
有一次写日志收集程序,把写日志的线程设成了守护线程。结果主线程一退出,日志还没写完就被强制终止了。后来我改成非守护线程,加上join()确保日志写完再退出。
记住一个原则:守护线程适合做不重要的后台任务,比如心跳检测、缓存清理。重要的数据写入、状态保存,千万别用守护线程。
1.3.4 线程安全与锁
多个线程共享变量时,会出现竞态条件。看这个经典例子:
import threading
counter = 0
def increment():
global counter
for _ in range(100000):
counter += 1
threads = []
for _ in range(10):
t = threading.Thread(target=increment)
threads.append(t)
t.start()
for t in threads:
t.join()
print(f"期望值: 1000000, 实际值: {counter}")
# 实际值往往小于1000000
为什么会这样?因为 counter += 1 不是原子操作。它分三步:读取、加1、写回。线程切换可能发生在任意一步。
解决办法是用锁:
lock = threading.Lock()
def safe_increment():
global counter
for _ in range(100000):
with lock: # 获取锁
counter += 1
# 离开with块自动释放锁
用 with lock 是Python推荐的写法,比手动 acquire() 和 release() 安全得多。万一中间抛异常,锁也能自动释放。
关于锁,记住三点:
- 锁的范围要尽量小,只保护关键代码
- 避免嵌套锁,容易死锁
- 能用
with就别手动操作
好了,第一章的内容就到这里。多线程的基础概念、GIL的本质、threading模块的核心API,咱们都过了一遍。这些是后面做参数调优和压力测试的基石,一定要理解透彻。
可可精品资料库