码农高天
atexit依托CPython生命周期在正常或异常退出时执行清理,但因注销采用相等性匹配易误删同值对象,且遭遇系统级os._exit调用会彻底失效。
AI-assisted summary of 【python】B站没人讲过的atexit,你知道是做什么的么?所有细节都知道么? — original source. Check the original for full context.
Source published:
Key ideas
- atexit 虽是 Python 极精简的标准库,但其底层通过 C 语言实现,不仅支持函数注册传参,还因返回原函数对象而能直接作为装饰器使用。 (0:00)
- atexit.unregister 采用相等性比较(==)而非身份比较(is)来匹配目标,且调用一次便会批量注销该函数的所有注册副本。 (2:37)
- 模块内存在 _ncallbacks、_clear 和 _run_exitfuncs 等未公开内部接口,可用于统计注册量、强制清空或在进程退出前提前同步触发回调。 (5:00)
- atexit 仅在解释器受控的生命周期(包括未捕获异常退出)内保证执行,若遭遇 os._exit 等绕过 Python 运行时的底层系统调用则会彻底失效。 (6:45)
Detailed notes
## Background 本视频由码农高天于 2023 年 10 月 22 日发布在 YouTube 平台。视频简介指出,本期主要探讨 Python 内置库中看似最简单的 atexit 模块,并深挖其中许多开发者未曾留意、值得讨论的底层细节。
## TL;DR 视频系统剖析了 Python 退出清理模块 atexit 的工作机制与隐藏特性。内容涵盖基础的 register 与 unregister 接口用法,并从 C 源码层面解释了为何 register 可以直接当作装饰器使用。高天重点演示了 unregister 是基于相等性(==)而非身份(is)进行匹配且会批量移除所有相同副本的反直觉细节,同时曝光了 _clear 等未记录在官方文档的私有接口。最后,视频界定了正常退出、未捕获异常与 os._exit 强退时的执行边界,并留下了 multiprocessing 子进程退出行为的悬念供观众思考。
## Atmosphere & style 整期内容兼具硬核技术拆解与极客式的风趣幽默,呈现出轻松严谨的编程探究氛围。高天在讲解中不断打趣自身“硬核 UP 主”的人设,借助生动简练的代码实验、源码翻阅以及构造自定义 __eq__ 实例一步步拨开机制迷雾。视频尾声更通过连环反转的悬念戛然而止,在让人会心一笑的同时充分激发了开发者的求知欲。
## Bulletin - [00:00](https://www.youtube.com/watch?v=bcr0lp8dDiU&t=0s) atexit 虽是 Python 极精简的标准库,但其底层通过 C 语言实现,不仅支持函数注册传参,还因返回原函数对象而能直接作为装饰器使用。 - [02:37](https://www.youtube.com/watch?v=bcr0lp8dDiU&t=157s) atexit.unregister 采用相等性比较(==)而非身份比较(is)来匹配目标,且调用一次便会批量注销该函数的所有注册副本。 - [05:00](https://www.youtube.com/watch?v=bcr0lp8dDiU&t=300s) 模块内存在 _ncallbacks、_clear 和 _run_exitfuncs 等未公开内部接口,可用于统计注册量、强制清空或在进程退出前提前同步触发回调。 - [06:45](https://www.youtube.com/watch?v=bcr0lp8dDiU&t=405s) atexit 仅在解释器受控的生命周期(包括未捕获异常退出)内保证执行,若遭遇 os._exit 等绕过 Python 运行时的底层系统调用则会彻底失效。
## Key takeaways - [00:00](https://www.youtube.com/watch?v=bcr0lp8dDiU&t=0s) atexit 模块核心公开接口仅有 register 和 unregister 两个函数,用于在 Python 进程退出生命周期中执行清理操作。 - [00:00](https://www.youtube.com/watch?v=bcr0lp8dDiU&t=0s) atexit.register 支持为回调函数传递任意位置参数与关键字参数,并将其存储在 C 语言底层的回调列表中。 - [00:00](https://www.youtube.com/watch?v=bcr0lp8dDiU&t=0s) CPython 源码中 register 函数在保存回调后会原样返回传入的函数指针,使其天然兼容 @atexit.register 装饰器语法。 - [02:37](https://www.youtube.com/watch?v=bcr0lp8dDiU&t=157s) 若一个函数被多次 register,只需调用一次 unregister 即可将其所有注册副本彻底注销,退出时不会触发任何一次。 - [02:37](https://www.youtube.com/watch?v=bcr0lp8dDiU&t=157s) 当传入未注册过的对象时,atexit.unregister 会保持静默无操作,避免抛出不必要的异常。 - [02:37](https://www.youtube.com/watch?v=bcr0lp8dDiU&t=157s) unregister 底层匹配已注册回调依据的是相等性比较(==)而非内存身份比较(is),自定义类的 __eq__ 实现会直接影响注销行为。 - [05:00](https://www.youtube.com/watch?v=bcr0lp8dDiU&t=300s) atexit 暴露了三个未在官方文档记录的私有接口:_ncallbacks、_clear 和 _run_exitfuncs,在部分调试分析工具(如 VizTracer)中有所应用。 - [05:00](https://www.youtube.com/watch?v=bcr0lp8dDiU&t=300s) 调用私有方法 _run_exitfuncs 会立即同步消耗并执行全部已注册的回调函数,并在执行完毕后自动清空注册列表。 - [06:45](https://www.youtube.com/watch?v=bcr0lp8dDiU&t=405s) 即使程序中途抛出未捕获的 Exception 导致异常退出,只要仍处于解释器受控生命周期内,atexit 回调依然能够稳定触发。 - [06:45](https://www.youtube.com/watch?v=bcr0lp8dDiU&t=405s) 通过 os._exit 强行终止进程会直接触发操作系统的系统调用瞬间杀死进程,导致 Python 解释器无法执行常规生命周期回收,atexit 完全失效。 - [06:45](https://www.youtube.com/watch?v=bcr0lp8dDiU&t=405s) 在 multiprocessing.Process 子进程中进行 atexit.register 注册时,其生命周期的退出与触发表现存在特殊的边界机制。
## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=bcr0lp8dDiU&t=0s) atexit 的核心用法与底层注册机制
Python 内置的 `atexit` 模块几乎是所有标准库中最精简的一个,核心公开接口只有两个函数:`register` 和 `unregister`。它的功能顾名思义,就是在 Python 程序结束、进程准备退出时自动运行一些清理或回调代码。码农高天在开篇感叹道:“这个 module 很神奇的地方,在于我发现 B 站竟然没有一个视频是讲他的,不知道是因为这个 module 太简单了呀,还是太不常见了啊。”
```mermaid flowchart TD accTitle: atexit 注册与调用流程 accDescr: 展示函数通过 register 存储到 C 语言结构、通过 unregister 移除,并在进程退出或手动调用时执行的完整链路。
subgraph RegisterFlow ["注册阶段"] A["定义回调函数 / Callable"] -->|"atexit.register(func, *args)"| B["C 语言底层的回调列表"] A -.->|"当作装饰器 @atexit.register"| B end
subgraph TriggerFlow ["触发执行"] B -->|"正常退出或抛出未捕获异常"| C["进程退出时触发回调"] B -->|"调用 atexit._run_exitfuncs()"| D["立即运行并清空回调"] B -->|"atexit.unregister(func)"| E["按 == 匹配批量移除"] end ```
最基础的用法是定义一个普通的函数 `f`(例如内部执行 `print('exiting')`),随后调用 `atexit.register(f)`。当 Python 进程退出时,该函数便会自动触发。如果回调函数需要传参,也可以直接把参数依次追加在 `register` 后面,例如 `atexit.register(f, 'exiting')`。除此之外,`atexit.register` 还支持直接当作装饰器(decorator)使用,在定义 `f` 的上方写上 `@atexit.register` 即可达到完全相同的注册效果。
之所以能当作装饰器使用,本质在于 `register` 内部不仅执行注册,还会把传入的函数对象原样返回。翻看 CPython 底层源码可以发现,`atexit` 整个模块是用 C 语言编写的,其 `register` 函数在保存传入的 callback 函数与其调用参数后,在第 181 行将该函数指针原封不动地返回,从而完美兼容装饰器的语法糖。
- **基础注册**:`atexit.register(func, *args, **kwargs)`,注册需要在程序退出时执行的钩子。 - **装饰器语法**:`@atexit.register` 会就地注册函数并返回原函数对象。 - **注销接口**:`atexit.unregister(func)` 能够取消已注册的回调,注销后程序退出时将不再打印或执行任何关联代码。
---
[02:37](https://www.youtube.com/watch?v=bcr0lp8dDiU&t=157s) unregister 的深层行为:批量移除与相等性比较
对于一个看似简单的注销操作,官方文档与源码中藏着很多极易被忽略的细节。码农高天打趣称很多人可能会觉得简单的 API “一点都不符合我这个硬核 UP 主的身份”,随后深入拆解了 `unregister` 背后的两项核心行为:重复注册的处理与对象匹配机制。
当同一个函数被 `register` 注册了多次之后,只需调用一次 `atexit.unregister(f)`,底层就会直接移除所有与该函数匹配的注册条目,退出时一次都不会被调用。查阅 Python 官方文档可以明确看到三条规则: 1. 若要注销的函数此前未曾注册过,`unregister` 默默不做任何事,防止无谓的报错。 2. 若函数被注册了多次,该函数的**所有**副本都会被彻底移除。 3. 在比较待注销对象与已注册对象是否一致时,底层使用的是相等性比较(`==`)而非身份比较(`is`)。
为了演示 `==` 与 `is` 的本质区别,码农高天编写了一个自定义类 `MyFunc`。该类实现了 `__call__` 使其成为 callable 对象,同时实现了 `__eq__` 方法:只要传入的另一个对象同样是 `MyFunc` 的实例,二者即判定相等。 首先实例化 `f0 = MyFunc()` 并注册 `atexit.register(f0)`;随后实例化一个全新的对象 `f1 = MyFunc()`。显而易见,`f0` 与 `f1` 在内存中是两个独立的实例,`f0 is f1` 为 `False`,但因为重写了 `__eq__`,`f0 == f1` 判定为 `True`。此时执行 `atexit.unregister(f1)`,程序退出时 `f0` 居然被成功移除,控制台没有任何输出。对此,码农高天评价道:“这就是 Python 官方文档上说的用等于等于来比较,而不是 is 所造成的结果。那这件事当然就是你用对的是 feature,用错的是 bug 对吧。”
---
[05:00](https://www.youtube.com/watch?v=bcr0lp8dDiU&t=300s) 隐藏的无文档内部接口
除了公开文档中记载的 `register` 与 `unregister`,`atexit` 模块还暴露了几个未在官方文档中记录(Undocumented)的下划线内部接口。虽然未公开的私有接口通常提倡“能不用就不用”,但在部分复杂场景或性能分析工具中不可或缺,例如著名的代码追踪工具 VizTracer 在清理退出事件时、以及 Python 官方部分内置库的内部实现中都调用了这些方法。
这些暴露的隐藏接口共有三个: - `_ncallbacks()`:返回当前底层已注册的回调函数总数量。 - `_clear()`:强制清空所有已注册的回调列表。 - `_run_exitfuncs()`:立即按顺序同步执行所有已注册的回调,并且在执行完毕后自动将列表清空。
在演示中,代码首先注册了一个打印 `exiting` 的函数,随后调用 `atexit._ncallbacks()` 输出返回值 `1`;接着调用 `atexit._clear()` 后,程序正常退出时果然不再打印任何内容。另一个对比实验是在退出前显式调用 `atexit._run_exitfuncs()`,其后紧接着一行普通的代码 `print('before exit')`。常规流程下本应是主流程先打出 `before exit`,退出生命周期时才由 hook 打印 `exiting`;但此时输出顺序颠倒,控制台先打出了 `exiting`,随后才打出 `before exit`,证明 `_run_exitfuncs()` 会立刻消耗并执行掉所有的注册函数。
---
[06:45](https://www.youtube.com/watch?v=bcr0lp8dDiU&t=405s) atexit 的执行保证边界与未解谜题
很多开发者高度依赖 `atexit` 进行关键资源回收或日志落盘,那么它究竟是否百分之百保证会被执行?码农高天直接给出了定性结论:“当你的 Python 程序 life cycle 完成了的时候它永远会运行。”无论是正常执行到代码末尾,还是中途未捕获异常抛出了未处理的 exception 导致进程终止,这都在 Python 解释器的掌控范围之内,Python 退出时必然会逐一调用 `atexit` 注册的函数。
然而,一旦脱离了 Python 运行时的受控生命周期,`atexit` 就会彻底失效。最典型的代表便是 `os._exit(0)`。在注册了函数之后如果直接调用 `os._exit(0)`,退出时屏幕上空无一物。这是因为 `os._exit` 是直接陷入操作系统的系统调用(system call),它会瞬间杀死当前进程,导致 Python 根本没有机会按照自己的节奏做常规的退出回收。
在视频尾声,码农高天留下了一个极具思考价值的“连环反转”悬念。他给出了如下一段多进程代码:在一个独立函数中通过 `atexit.register` 注册了一个打印 `exiting` 的动作,然后利用标准库 `multiprocessing.Process` 创建并启动这个子进程。他向观众提问:此时这段代码运行的结果是什么?背后真正的原因又是什么?随后便以幽默的方式戛然而止:“好那如果你以为这期视频到这里就结束的话,那就又错了……那这期视频就到这里,希望你们都猜对了这期视频结束的时间,如果喜欢的话记得点一下关注,别忘了一键三连哦。哎,还有个问题没说,下期视频再说吧。”
- **生命周期覆盖**:正常执行结束、未捕获 Exception,`atexit` 都会稳定触发。 - **失效场景**:调用底层的 `os._exit()` 等系统级强制终止时,绕过了解释器流程,`atexit` 完全失效。 - **课后思考题**:在 `multiprocessing.Process` 子进程内部进行 `atexit.register` 注册,其退出行为与回调触发是否受影响。
## Mentioned 码农高天, Python, CPython, atexit, B站, VizTracer, C语言, multiprocessing
## Video description 这期视频聊一下atexit这个库。这可能是python的内置库里最简单的一个,但是里面也有很多可以讨论的细节。我相信几乎所有人都能从这个视频里收获点自己之前不知道的知识~
[Source](https://www.youtube.com/watch?v=bcr0lp8dDiU)