公司动态
daemon 线程说没就没?一文讲透 threading 的适用场景与线程安全
「Python 进阶之路」系列 Day17写在前面Day16 讲透了 GIL结论是CPU 密集型任务用多线程没有加速效果I/O 密集型任务才是 threading 的用武之地。今天这篇就在这个结论之上把threading模块实际怎么用讲清楚——从创建线程的两种写法到 daemon 线程消失的坑再到死锁怎么产生、怎么用锁避免最后落到更现代的ThreadPoolExecutor写法。一、是什么创建线程的两种方式与生命周期threading模块创建线程有两种方式传一个函数给target参数或者继承Thread重写run方法。importthreading# 方式一函数式defworker(name):print(f函数式线程{name}在运行)t1threading.Thread(targetworker,args(A,))t1.start()t1.join()# 阻塞主线程等待t1执行完毕# 方式二继承式classMyThread(threading.Thread):def__init__(self,name):super().__init__()self.name_namedefrun(self):print(f继承式线程{self.name_}在运行)t2MyThread(B)t2.start()t2.join()start()真正启动线程去执行join()会阻塞调用它的线程通常是主线程直到目标线程执行完毕——不调用join()主线程不会等待子线程会继续往下跑。二、为什么daemon 线程与死锁1. daemon 守护线程主线程退出时会怎样普通线程非 daemon会让主进程等它跑完哪怕主线程的代码已经执行完了daemon守护线程则相反一旦主线程结束daemon 线程会被强制终止不管它跑到哪一步了。用两个独立脚本对比验证# daemon_demo.pyimportthreading,timedefdaemon_worker():time.sleep(2)print(守护线程执行完了(不应该被看到))dthreading.Thread(targetdaemon_worker,daemonTrue)d.start()print(主线程立刻结束不等待daemon线程)# nondaemon_demo.pyimportthreading,timedefworker():time.sleep(2)print(普通线程执行完了)tthreading.Thread(targetworker)# 默认daemonFalset.start()print(主线程代码跑完了但程序不会立刻退出)实测运行结果daemon_demo.py总耗时约0.17s只打印了主线程立刻结束那一行守护线程执行完了根本没有机会打印nondaemon_demo.py总耗时约2.15s两行都打印了。这说明 Python 进程会等待所有非 daemon 线程执行完才真正退出但不会等 daemon 线程。适合设成 daemon 的场景后台监控、日志上报这类跟着主程序活、主程序死了它也该跟着死的辅助任务不适合的场景任何必须执行完比如写文件、提交事务的任务daemon 线程可能在关键操作做到一半时就被粗暴终止。2. 死锁是怎么产生的Day16 讲过多个线程共享同一份内存空间操作共享数据需要用锁保护。但用锁本身也会引入新问题死锁——两个线程各自持有一把锁同时想要获取对方手里的另一把锁谁都不肯先放手于是永远互相等待。lock_athreading.Lock()lock_bthreading.Lock()deftask1():withlock_a:print(task1 拿到 lock_a)time.sleep(0.1)gotlock_b.acquire(timeout1)# 用timeout避免真的死锁卡死print(task1 尝试拿 lock_b:,成功ifgotelse超时失败(死锁发生了))ifgot:lock_b.release()deftask2():withlock_b:print(task2 拿到 lock_b)time.sleep(0.1)gotlock_a.acquire(timeout1)print(task2 尝试拿 lock_a:,成功ifgotelse超时失败(死锁发生了))ifgot:lock_a.release()th1threading.Thread(targettask1)th2threading.Thread(targettask2)th1.start();th2.start()th1.join();th2.join()线程1持有LockA线程1等待LockB线程2持有LockB线程2等待LockA实测结果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信号量可以指定一个数量上限允许多个线程同时进入semthreading.Semaphore(2)# 最多同时2个线程能进入defaccess_resource(name):withsem:print(f{name}进入资源)time.sleep(0.3)print(f{name}离开资源)threads[threading.Thread(targetaccess_resource,args(f线程{i},))foriinrange(5)]fortinthreads:t.start()fortinthreads:t.join()实测输出显示5 个线程里始终只有 2 个能同时处于进入资源和离开资源之间的状态其余的会排队等待——这是限流、控制并发连接数比如限制同时访问某个外部 API 的线程数的典型场景。2. Event线程间的信号通知Event是最简单的线程间通信方式一个线程等待某个信号另一个线程在合适的时机发出这个信号。eventthreading.Event()defwaiter():print(waiter 开始等待信号)event.wait()# 阻塞直到event被setprint(waiter 收到信号继续执行)defsetter():time.sleep(0.5)print(setter 发出信号)event.set()wthreading.Thread(targetwaiter)sthreading.Thread(targetsetter)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 starttime.perf_counter()withThreadPoolExecutor(max_workers5)asexecutor:resultslist(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对象更简洁。四、面试追问Q1threading 创建线程的方式有哪些两种传一个函数给Thread(targetfunc)或者继承Thread类并重写run方法。两种方式都要调用start()启动线程join()阻塞等待线程执行完毕。Q2守护线程daemon和普通线程有什么区别普通线程会让主进程等它跑完才真正退出哪怕主线程的代码已经执行完守护线程在主线程结束时会被强制终止不管有没有执行完。适合放后台监控、日志上报这类可以随时被打断的辅助任务不适合放必须完整执行的关键操作写文件、提交事务等。Q3死锁产生的条件是什么怎么避免多个线程各自持有一把锁同时想获取对方手里的另一把锁谁都不放手就会死锁。避免的根本方法是让所有代码路径按同一个固定顺序获取多把锁从根源上消除循环等待实践中也常给acquire()设置超时超时后主动放弃并释放已持有的锁避免真正卡死但这只是缓解手段不是根本解决方案。Q4Semaphore 和 Lock 的区别是什么Lock同一时刻只允许一个线程进入临界区本质是信号量数量为 1 的特例Semaphore可以指定一个数量上限允许多个线程同时进入常用于限流场景比如限制同时访问某个外部资源/API 的并发线程数。Q5什么场景该用 threading什么场景不该用I/O 密集型任务网络请求、文件读写、数据库查询适合用多线程因为 I/O 等待期间会释放 GILDay16 讲过多线程能有效利用这些等待空档、缩短总耗时CPU 密集型任务大量数值计算用多线程得不到并行加速效果应该考虑用多进程绕开 GIL 的限制。下一篇预告Day18 讲多进程multiprocessing——既然 GIL 让多线程没法真正并行跑 CPU 密集型任务多进程是怎么绕开这个限制的以及多进程之间数据不共享带来的新问题该怎么解决。