「Python 进阶之路」系列 Day17
写在前面
Day16 讲透了 GIL,结论是"CPU 密集型任务用多线程没有加速效果,I/O 密集型任务才是 threading 的用武之地"。今天这篇就在这个结论之上,把threading模块实际怎么用讲清楚——从创建线程的两种写法,到 daemon 线程消失的坑,再到死锁怎么产生、怎么用锁避免,最后落到更现代的ThreadPoolExecutor写法。
一、是什么:创建线程的两种方式与生命周期
threading模块创建线程有两种方式:传一个函数给target参数,或者继承Thread重写run方法。
importthreading# 方式一:函数式defworker(name):print(f"函数式线程{name}在运行")t1=threading.Thread(target=worker,args=("A",))t1.start()t1.join()# 阻塞主线程,等待t1执行完毕# 方式二:继承式classMyThread(threading.Thread):def__init__(self,name):super().__init__()self.name_=namedefrun(self):print(f"继承式线程{self.name_}在运行")t2=MyThread("B")t2.start()t2.join()start()真正启动线程去执行;join()会阻塞调用它的线程(通常是主线程),直到目标线程执行完毕——不调用join(),主线程不会等待子线程,会继续往下跑。
二、为什么:daemon 线程与死锁
1. daemon 守护线程:主线程退出时会怎样
普通线程(非 daemon)会让主进程"等它跑完",哪怕主线程的代码已经执行完了;daemon(守护)线程则相反:一旦主线程结束,daemon 线程会被强制终止,不管它跑到哪一步了。
用两个独立脚本对比验证:
# daemon_demo.pyimportthreading,timedefdaemon_worker():time.sleep(2)print("守护线程执行完了(不应该被看到)")d=threading.Thread(target=daemon_worker,daemon=True)d.start()print("主线程立刻结束,不等待daemon线程")# nondaemon_demo.pyimportthreading,timedefworker():time.sleep(2)print("普通线程执行完了")t=threading.Thread(target=worker)# 默认daemon=Falset.start()print("主线程代码跑完了,但程序不会立刻退出")实测运行结果:daemon_demo.py总耗时约0.17s,只打印了"主线程立刻结束"那一行,"守护线程执行完了"根本没有机会打印;nondaemon_demo.py总耗时约2.15s,两行都打印了。这说明 Python 进程会等待所有非 daemon 线程执行完才真正退出,但不会等 daemon 线程。适合设成 daemon 的场景:后台监控、日志上报这类"跟着主程序活、主程序死了它也该跟着死"的辅助任务;不适合的场景:任何必须执行完(比如写文件、提交事务)的任务,daemon 线程可能在关键操作做到一半时就被粗暴终止。
2. 死锁是怎么产生的
Day16 讲过,多个线程共享同一份内存空间,操作共享数据需要用锁保护。但用锁本身也会引入新问题:死锁——两个线程各自持有一把锁,同时想要获取对方手里的另一把锁,谁都不肯先放手,于是永远互相等待。
lock_a=threading.Lock()lock_b=threading.Lock()deftask1():withlock_a:print("task1 拿到 lock_a")time.sleep(0.1)got=lock_b.acquire(timeout=1)# 用timeout避免真的死锁卡死print("task1 尝试拿 lock_b:","成功"ifgotelse"超时失败(死锁发生了)")ifgot:lock_b.release()deftask2():withlock_b:print("task2 拿到 lock_b")time.sleep(0.1)got=lock_a.acquire(timeout=1)print("task2 尝试拿 lock_a:","成功"ifgotelse"超时失败(死锁发生了)")ifgot: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(信号量)可以指定一个数量上限,允许多个线程同时进入:
sem=threading.Semaphore(2)# 最多同时2个线程能进入defaccess_resource(name):withsem:print(f"{name}进入资源")time.sleep(0.3)print(f"{name}离开资源")threads=[threading.Thread(target=access_resource,args=(f"线程{i}",))foriinrange(5)]fortinthreads:t.start()fortinthreads:t.join()实测输出显示:5 个线程里始终只有 2 个能同时处于"进入资源"和"离开资源"之间的状态,其余的会排队等待——这是限流、控制并发连接数(比如限制同时访问某个外部 API 的线程数)的典型场景。
2. Event:线程间的信号通知
Event是最简单的线程间通信方式:一个线程等待某个信号,另一个线程在合适的时机发出这个信号。
event=threading.Event()defwaiter():print("waiter 开始等待信号")event.wait()# 阻塞,直到event被setprint("waiter 收到信号,继续执行")defsetter():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提供了更方便的线程池接口:
fromconcurrent.futuresimportThreadPoolExecutorimporttimedeffake_io(n):time.sleep(0.3)returnn*n start=time.perf_counter()withThreadPoolExecutor(max_workers=5)asexecutor:results=list(executor.map(fake_io,range(5)))print(results,time.perf_counter()-start)# [0, 1, 4, 9, 16] 耗时约0.30s5 个各耗时 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 密集型任务,多进程是怎么绕开这个限制的,以及多进程之间数据不共享带来的新问题该怎么解决。