「Python 进阶之路」系列 Day17
写在前面
Day16 讲透了 GIL,结论是"CPU 密集型任务用多线程没有加速效果,I/O 密集型任务才是 threading 的用武之地"。今天这篇就在这个结论之上,把 threading 模块实际怎么用讲清楚------从创建线程的两种写法,到 daemon 线程消失的坑,再到死锁怎么产生、怎么用锁避免,最后落到更现代的 ThreadPoolExecutor 写法。
一、是什么:创建线程的两种方式与生命周期
threading 模块创建线程有两种方式:传一个函数给 target 参数,或者继承 Thread 重写 run 方法。
python
import threading
# 方式一:函数式
def worker(name):
print(f"函数式线程 {name} 在运行")
t1 = threading.Thread(target=worker, args=("A",))
t1.start()
t1.join() # 阻塞主线程,等待t1执行完毕
# 方式二:继承式
class MyThread(threading.Thread):
def __init__(self, name):
super().__init__()
self.name_ = name
def run(self):
print(f"继承式线程 {self.name_} 在运行")
t2 = MyThread("B")
t2.start()
t2.join()
start() 真正启动线程去执行;join() 会阻塞调用它的线程(通常是主线程),直到目标线程执行完毕------不调用 join(),主线程不会等待子线程,会继续往下跑。
二、为什么:daemon 线程与死锁
1. daemon 守护线程:主线程退出时会怎样
普通线程(非 daemon)会让主进程"等它跑完",哪怕主线程的代码已经执行完了;daemon(守护)线程则相反:一旦主线程结束,daemon 线程会被强制终止,不管它跑到哪一步了。
用两个独立脚本对比验证:
python
# daemon_demo.py
import threading, time
def daemon_worker():
time.sleep(2)
print("守护线程执行完了(不应该被看到)")
d = threading.Thread(target=daemon_worker, daemon=True)
d.start()
print("主线程立刻结束,不等待daemon线程")
python
# nondaemon_demo.py
import threading, time
def worker():
time.sleep(2)
print("普通线程执行完了")
t = threading.Thread(target=worker) # 默认daemon=False
t.start()
print("主线程代码跑完了,但程序不会立刻退出")
实测运行结果:daemon_demo.py 总耗时约 0.17s,只打印了"主线程立刻结束"那一行,"守护线程执行完了"根本没有机会打印;nondaemon_demo.py 总耗时约 2.15s,两行都打印了。这说明 Python 进程会等待所有非 daemon 线程执行完才真正退出,但不会等 daemon 线程。适合设成 daemon 的场景:后台监控、日志上报这类"跟着主程序活、主程序死了它也该跟着死"的辅助任务;不适合的场景:任何必须执行完(比如写文件、提交事务)的任务,daemon 线程可能在关键操作做到一半时就被粗暴终止。
2. 死锁是怎么产生的
Day16 讲过,多个线程共享同一份内存空间,操作共享数据需要用锁保护。但用锁本身也会引入新问题:死锁------两个线程各自持有一把锁,同时想要获取对方手里的另一把锁,谁都不肯先放手,于是永远互相等待。
python
lock_a = threading.Lock()
lock_b = threading.Lock()
def task1():
with lock_a:
print("task1 拿到 lock_a")
time.sleep(0.1)
got = lock_b.acquire(timeout=1) # 用timeout避免真的死锁卡死
print("task1 尝试拿 lock_b:", "成功" if got else "超时失败(死锁发生了)")
if got:
lock_b.release()
def task2():
with lock_b:
print("task2 拿到 lock_b")
time.sleep(0.1)
got = lock_a.acquire(timeout=1)
print("task2 尝试拿 lock_a:", "成功" if got else "超时失败(死锁发生了)")
if got:
lock_a.release()
th1 = threading.Thread(target=task1)
th2 = threading.Thread(target=task2)
th1.start(); th2.start()
th1.join(); th2.join()
实测结果:task1 尝试拿 lock_b 超时失败------因为 task2 一直握着它;task2 反而成功拿到了 lock_a------因为 task1 的 acquire 超时失败后,紧接着退出了 with lock_a: 代码块,把 lock_a 释放了,task2 才趁机拿到手。这正是用 timeout 化解死锁的原理:只要有一方肯"放弃等待并释放自己手里的锁",这个循环等待的僵局就被打破了。如果两边都不设置超时(用普通的 acquire() 死等),这个例子会真的卡死,程序永远无法继续。
避免死锁最根本的办法:让所有代码路径都按照同一个固定顺序 去获取多把锁(比如永远先拿 lock_a 再拿 lock_b),从根源上消除"互相等待对方"的可能性。
三、怎么用:更多同步原语与现代写法
1. Semaphore:限制同时访问的线程数量
Lock 只允许一个线程同时进入临界区,Semaphore(信号量)可以指定一个数量上限,允许多个线程同时进入:
python
sem = threading.Semaphore(2) # 最多同时2个线程能进入
def access_resource(name):
with sem:
print(f"{name} 进入资源")
time.sleep(0.3)
print(f"{name} 离开资源")
threads = [threading.Thread(target=access_resource, args=(f"线程{i}",)) for i in range(5)]
for t in threads: t.start()
for t in threads: t.join()
实测输出显示:5 个线程里始终只有 2 个能同时处于"进入资源"和"离开资源"之间的状态,其余的会排队等待------这是限流、控制并发连接数(比如限制同时访问某个外部 API 的线程数)的典型场景。
2. Event:线程间的信号通知
Event 是最简单的线程间通信方式:一个线程等待某个信号,另一个线程在合适的时机发出这个信号。
python
event = threading.Event()
def waiter():
print("waiter 开始等待信号")
event.wait() # 阻塞,直到event被set
print("waiter 收到信号,继续执行")
def setter():
time.sleep(0.5)
print("setter 发出信号")
event.set()
w = threading.Thread(target=waiter)
s = threading.Thread(target=setter)
w.start(); s.start()
w.join(); s.join()
waiter 会一直卡在 event.wait(),直到 setter 调用 event.set() 才会继续往下走------适合"必须等某个前置条件达成才能继续"的场景。
3. ThreadPoolExecutor:更现代的线程池写法
手动创建、管理一堆 Thread 对象比较繁琐,concurrent.futures.ThreadPoolExecutor 提供了更方便的线程池接口:
python
from concurrent.futures import ThreadPoolExecutor
import time
def fake_io(n):
time.sleep(0.3)
return n * n
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=5) as executor:
results = list(executor.map(fake_io, range(5)))
print(results, time.perf_counter() - start)
# [0, 1, 4, 9, 16] 耗时约0.30s
5 个各耗时 0.3s 的"I/O 任务"并发执行,总耗时约等于单个任务的耗时(0.3s),而不是 5 个任务顺序执行的 1.5s------这正是 Day16 结论的直接应用:I/O 密集型任务用线程池能有效缩短总耗时,ThreadPoolExecutor 用 with 语句自动管理线程池的创建和关闭(呼应 Day06 的上下文管理器),比手动维护一堆 Thread 对象更简洁。
四、面试追问
Q1:threading 创建线程的方式有哪些?
两种:传一个函数给 Thread(target=func);或者继承 Thread 类并重写 run 方法。两种方式都要调用 start() 启动线程,join() 阻塞等待线程执行完毕。
Q2:守护线程(daemon)和普通线程有什么区别?
普通线程会让主进程等它跑完才真正退出,哪怕主线程的代码已经执行完;守护线程在主线程结束时会被强制终止,不管有没有执行完。适合放后台监控、日志上报这类可以随时被打断的辅助任务,不适合放必须完整执行的关键操作(写文件、提交事务等)。
Q3:死锁产生的条件是什么,怎么避免?
多个线程各自持有一把锁,同时想获取对方手里的另一把锁,谁都不放手就会死锁。避免的根本方法是让所有代码路径按同一个固定顺序获取多把锁,从根源上消除循环等待;实践中也常给 acquire() 设置超时,超时后主动放弃并释放已持有的锁,避免真正卡死,但这只是缓解手段,不是根本解决方案。
Q4:Semaphore 和 Lock 的区别是什么?
Lock 同一时刻只允许一个线程进入临界区,本质是信号量数量为 1 的特例;Semaphore 可以指定一个数量上限,允许多个线程同时进入,常用于限流场景,比如限制同时访问某个外部资源/API 的并发线程数。
Q5:什么场景该用 threading,什么场景不该用?
I/O 密集型任务(网络请求、文件读写、数据库查询)适合用多线程,因为 I/O 等待期间会释放 GIL(Day16 讲过),多线程能有效利用这些等待空档、缩短总耗时;CPU 密集型任务(大量数值计算)用多线程得不到并行加速效果,应该考虑用多进程绕开 GIL 的限制。
下一篇预告
Day18 讲多进程 multiprocessing------既然 GIL 让多线程没法真正并行跑 CPU 密集型任务,多进程是怎么绕开这个限制的,以及多进程之间数据不共享带来的新问题该怎么解决。