等待线程结束:别着急吃饭,等菜做好
开篇除恐
当你第一次看到 threading.Thread.join(),可能会觉得:“为什么我非得在这里停下来等?线程不是应该自己跑完就自己走吗?”其实,等待线程结束并不是一种被动的“浪费时间”,而是确保共享资源不会在被使用时突然被销毁的必要手段。把它想象成厨房里的规矩:你不能在厨师还没把菜端出来就收走案板和刀子,否则菜会掉地上、刀会飞出来伤人。同样,主线程在子线程还在使用某些内存、文件句柄或其他资源之前,必须等它把活儿干完、把手上的东西放下,才能安全地继续或退出程序。
白话化
- Join(等待):主线程在此处挂起,直到目标线程的任务函数返回(即线程自然结束)才继续向下执行。期间主线程不消耗 CPU(实际上被调度器挂起),而是让出时间片给其他线程。
- Detach(分离):在 Python 中没有直接的 detach,但可以将线程设置为守护线程(daemon=True),主线程不再负责等它结束;守护线程在所有非守护线程结束时会被强制终止。使用前必须确保线程不会访问已经被主线程释放的数据(比如栈上变量、即将被删除的对象)。
- 超时等待(可选):可以使用
threading.Event或threading.Condition配合wait(timeout)实现带超时的等待,允许主线程在等待一定时间后仍可继续执行,用于需要响应性的场景。
简而言之,join 是“щим你把活儿干完我再走”的同步点;守护线程是“我不等你了,你自己负责收尾”的后台模式。
直觉先行
想象你叫了外卖,送餐员说:“我把饭送到你楼下,你下来拿。”你有两种选择: 1. 在楼下等(join):你站在门口,直到送餐员把饭递到你手里才转身走开。这样可以确保你拿到饭,不会因为自己提前离开而让饭被偷或掉地上。 2. 不等,直接回屋继续干活(守护线程):你告诉送餐员“把饭放在门口的架子上就行”,然后自己回去忙别的。只要架子可靠、不会被风走走或被别人拿错,你也能安全地拿到饭;但如果架子不稳或你忘了去拿,饭可能就会丢失。
在程序里,共享资源就是那只架子或者你手中的饭碗;如果你在子线程还没把数据写完就提前回收它的内存(比如主线程返回或删除了一个对象),子线程继续写就会导致未定义行为(段错误、数据 corruption)。于是,除非你完全确定子线程不会再触碰那些资源,否则最安全的做法就是在需要那些资源之前 join 一下(或确保线程为非守护线程)。
例子贴身
例子 1(☼ 热身):使用 join 防止主线程提前退出
任务:验证如果不 join 主线程会不会导致子线程输出丢失。
怎么想到的:我们创建一个子线程,它会循环打印 10 次带时间戳的消息,每次间隔 100 ms。主线程在创建后立即返回(不调用 join)或者调用 join,观察输出差异。为了演示主线程提前退出导致子线程被终止,我们把子线程设置为守护线程(daemon=True),这样主线程结束时守护线程会被杀死。
示例代码(Python)
import threading
import time
def printer(thread_id):
for i in range(10):
time.sleep(0.1) # 100 ms
print(f"Thread {thread_id}: tick {i}")
def main():
# 创建守护线程,主线程不 join 时会被提前终止
t = threading.Thread(target=printer, args=(1,), daemon=True)
t.start()
# 主线程在这里返回(不调用 join),程序会因为只有守护线程在运行而立即结束
# 如果改为 t.join(),则会等待子线程完成
# 注意:实际演示中我们故意不 join,以展示输出可能不完整
# 为了让主线程有足够时间看到至少一点输出,这里我们稍作停顿(但不等待子线程完成)
time.sleep(0.2) # 让主线程稍等一下,看看能否打印几行
# 主线程结束,守护线程被强制终止
if __name__ == "__main__":
main()
运行结果(不加 join,设置守护线程)
可能只看到几行输出,例如:
Thread 1: tick 0
Thread 1: tick 1
(如果改为 t.join() 并去掉 daemon 设置,则会完整打印 10 行。)
要点:
- 在 Python 中,主线程退出时会等待所有非守护线程结束;守护线程会被强制终止。因此,若想模拟 C++ 中不 join 导致子线程被杀的情形,可以将子线程设置为守护线程。
- 正确的做法是:在需要确保子线程完成工作时,使用 t.join() 主线程等待;若不需要等待且子线程不会访问即将失效的资源,可设置 daemon=True 让其在主线程结束时自动终止。
例子 2(☼☼ 正经):带超时的等待(模拟点餐最长等待时间)
任务:主线程发起一个子线程去做一些可能耗时不定的工作(比如从网络获取数据),但最多只愿意等待 2 秒;超时后继续执行后备方案。
怎么想到的:我们使用 threading.Event 来实现带超时的等待。子线程工作完成后设置事件并唤醒;主线程在事件上等待最多 2 秒,若在此时收到通知则认为工作完成,否则执行后备逻辑。
示例代码(Python)
import threading
import time
def worker(event):
# 模拟不定时工作
time.sleep(1.5) # 假设有时快
event.set() # 工作完成,通知主线程
def main():
event = threading.Event()
t = threading.Thread(target=worker, args=(event,))
t.start()
# 主线程最多等待 2 秒
if event.wait(timeout=2.0):
print("工作已完成,继续后处理")
else:
print("等待超时,执行后备方案")
# 这里可以决定是否仍要让子线程继续,或尝试让它自行结束
# 无论哪种情况,最后确保子线程结束
t.join()
print("主线程结束")
if __name__ == "__main__":
main()
运行结果(示例)(工作 1.5 s < 超时 2.0 s)
工作已完成,继续后处理
主线程结束
如果把工作时间改为 3.0 s,则会输出等待超时的分支。
要点:
- 带超时的等价等价让程序保持响应性,避免因某个线程卡死而无限阻塞。
- 在实际使用中,如果超时后仍需要子线程的结果,必须自己设计某种机制(比如让子线程把结果写入共享内存或队列,主线程在超时后检查是否已写好)。
- threading.Event 是实现简单同步的常用工具;更复杂的场景可使用 threading.Condition 或 queue.Queue。
收尾
这一章要带走的东西
- join 是主线程等待子线程自然结束的最基本同步方式,能够确保子线程在返回前不被意外终止,进而保护共享资源。
- 在 Python 中,守护线程(daemon=True)可用于后台自管理的执行单元;使用前必须保证线程不会访问即将被主线程释放的数据。
- 真实场景中常需要带超时的等待(Event/Condition)或其它同步原子(如 Queue、Semaphore)来避免无限阻塞并实现更灵活的协作。
- 掌握了等待线程的技巧,你就能安全地在主线程和子线程之间交接数据和控制权;接下来我们将探讨线程之间最常见的通信方式——共享内存以及如何避免由此引发的竞态条件。
就这样。 下一章我们进入“共享厨房”:多个线程怎么围着同一块内存切菜、炒菜,却不把厨房弄成一团糟?