手搓Claude Code-第十三章 background_tasks
写在前面
上一章我们完成了整个任务分发系统,但你有没有发现,无论agent分化了多少个任务,其实都是由一个agent串行执行的。
一个典型的agent循环:
bash
用户提问 → LLM 决策 → 工具调用 → 等待结果 → LLM 继续决策 → ...
只有上个任务做完了,下个任务才可以开始,就算是互不相干的,可以并行执行的任务也是这样。并且有的任务耗时长,有的任务耗时短,这就我们需要实现异步操作了。本章涉及许多操作系统的知识,依旧注明原项目如下:
https://github.com/shareAI-lab/learn-claude-code/blob/main/s11_background_tasks/README.zh.md
本章,我们的任务是:
- 实现异步操作,并对照原理分析。
- 跑任务,一步步滤清
一、实现异步操作(结合s12对比来学习比较清晰,但注意两者并非联动)
这一章的内容比较细,每一处小改动不难,叠加在一起给整个任务实现异步就显得复杂了。不过没关系,我会尽力把每个设计细节讲清楚。
我们应该清楚,coding agent 是为了完成不同的 coding 任务存在的,一个任务在 s12 后,会拆分成多个不同的小任务,而小任务执行的时间有长有短 ,所以 s12 是给不同的 subtasks(小任务)引入了依赖关系 (但不是异步操作)。每个小任务可能是由一个或多个需要执行的 bash 命令组成的,所以在不同的 bash 命令执行间才需要异步。
异步的粒度是单个 bash 命令,一个 subtask 的所有命令都执行完毕后,这个 subtask 才算真正的完成。
bash
Task (大任务)
└── SubTask 1 (依赖关系由 s12 管理)
├── Bash Command A (快速) → 同步执行
├── Bash Command B (耗时) → 🆕 后台执行 (s13 引入)
└── Bash Command C (快速) → 等待 B 完成后同步执行
└── SubTask 2 (等待 SubTask 1 完成)
└── ...
最关键的是,如果一个任务跑完了,可能这个任务其中一个被放到后台的命令还没有结束,也就是命令执行可能比主循环活得更久 ,所以我们应该合理地管理进程的生命周期(何时创建,何时杀死)。
首先,我们直接来实现 bash 之间的异步。可重入锁解释见附件一 ,我们创建一个元素为 popen 对象的集合,通过可重入锁来维护这个集合,从而保证对进程生命周期的管理。
这里有必要解释下信号 ,下图摘自我在学习计算机组成原理时的笔记。简单的说,信号就是给进程一个命令,告诉进程现在应该执行的操作,是一种进程与操作系统内核以及其他进程的通信方式 。

再简单解释下信号编号 和返回码 的联系:信号编号是操作系统发给进程的"命令",告诉进程该干嘛了。返回码是进程退出时留给操作系统的一个数字,告诉操作系统"我是怎么走的"。操作系统发信号,进程收到了,可能死,也可能不死。如果进程因为信号而死了,它退出时需要给操作系统留个数字,这个数字就按照 "128 + 信号编号" 的规矩来写,也就是退出码。
_handle_termination_signal(signum, _frama) 这个函数是在模拟这个过程(其实通过 api 也是可以完成的),传入信号编号,然后计算出退出码,用 raise 扔出一个异常。
atexit 是 python 的内置模块,专门用来处理注册程序退出时要执行的函数 。它是栈式调用,通俗点说就是在整个程序结束时,规定要将注册的函数执行。
python
import atexit
def cleanup():
print("🧹 正在清理垃圾...")
def goodbye():
print("👋 再见,世界!")
# 注册两个函数
atexit.register(cleanup)
atexit.register(goodbye)
# 程序结束时,会先执行 goodbye(),再执行 cleanup()
# 后注册的先执行(栈式调用)
python
# 全局进程集合: 一个Python集合(set),里面存的都是subprocess.Popen对象。
_shell_processes: set[subprocess.Popen] = set()
# 多线程锁:一个可重入锁(Reentrant Lock),来自Python的threading模块。
_shell_process_lock = threading.RLock()
def _stop_process_group(process: subprocess.Popen):
"""传入进程popen对象,企图杀死它"""
# 依次遍历这两个结束信号,
for sig in (signal.SIGTERM, signal.SIGKILL):
try:
# 杀死整个进程组(发起kill系统调用的信号),第一个是进程组id,第二个是操作信号
os.killpg(process.pid, sig)
except (ProcessLookupError, OSError): # 如果进程组不存在(说明进程已经没了),或者其他os错误,就不用管了
return
time.sleep(0.05) # 给os操作一个缓冲时间
def _stop_all_shell_processes():
"""清理所有后台进程"""
# 先上锁,当with退出时会自动释放
with _shell_process_lock:
processes = list(_shell_processes)
# 拿到上锁后的所有进程,然后逐个杀死
for process in processes:
_stop_process_group(process)
def _handle_termination_signal(signum, _frama):
"""传入信号编号,将退出码传给内核"""
_stop_all_shell_processes()
raise SystemExit(128 + signum) # 这是 Unix/Linux 的惯例:被信号杀死的进程,退出码 = 128 + 信号编号
# 下面这两部分就是在处理程序异常退出的情况
# 程序退出时清理(程序正常退出的时候自动调用)
atexit.register(_stop_all_shell_processes)
# sigterm信号处理(程序收到 SIGTERM 信号时,调用 _handle_termination_signal)
signal.signal(signal.SIGTERM, _handle_termination_signal)
def _run_bash_process(command: str) -> tuple[str, int | None]:
"""传入一个字符串命令并执行,等待它完成(最长 120 秒),返回输出结果。无论成功还是失败,最后都会清理进程。"""
process = None
try:
process = subprocess.Popen(
command, # 要执行的命令,比如 "ls -la"
shell=True, # 通过 shell 执行(可以用管道、通配符等)
cwd=WORKDIR, # 在指定目录下执行
stdout=subprocess.PIPE, # 捕获标准输出
stderr=subprocess.PIPE, # 捕获标准错误
text=True, # 返回字符串而不是字节
start_new_session=True, # 创建新的进程组(方便一起杀子进程)
)
# 上锁,把这个process增添的新的进程里去
with _shell_process_lock:
_shell_processes.add(process)
# 收集进程的标准输出和标准输入,最多等待120秒
stdout, stderr = process.communicate(timeout=120)
# 合并,作为字符串返回,还有返回码
output = (stdout + stderr).strip()
return (output[:50000] if output else "(no output)"), process.returncode
# 超时报错
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)", None
# 系统错误
except OSError as error:
return f"Error: {type(error).__name__}: {error}", None
# 最后清理进程
finally:
if process is not None:
_stop_process_group(process)
try:
process.wait(timeout=0.2)
except subprocess.TimeoutExpired: # 如果等了0.2秒还没结束,直接忽略它。(这样设计意思是,"就等你0.2秒,到时间我就忽略你,不管你是执行完还是卡死了(防止一直阻塞finally)")
pass
# 上锁,并从集合中移除
with _shell_process_lock:
_shell_processes.discard(process)
def _format_bash_result(output: str, exit_code: int | None) -> str:
"""把 bash 命令的执行结果格式化成统一的字符串格式。"""
if exit_code in (0, None): # 为什么None也算成功?查看_run_bash_process()中的设计,0代表成功,none表示我们实现就猜到了output,所以也算成功。不然前面的设计就没意义了
return output
# 否则就是失败了,返回原始的码和输出
return f"Error: command exited with status {exit_code}\n{output}"
现在完成的这部分在管理整个进程的生命周期 ,即创建、及时清理避免僵尸进程 等。不过到现在你可能一头雾水不要紧,因为还没有和其他模块耦合起来。接下来,我们来实现一个后台管理器。
python
class BackgroundManager:
"""
一个后台任务管理器,它让 Agent 可以将耗时的 Bash 命令放到后台线程执行,
而主循环可以继续运行,稍后再通过 collect() 方法收集完成的任务结果。
"""
def __init__(self):
self.tasks: dict[str, dict] = {} # 存储任务元数据 [task_id, {"tool_use_id": block.id,"command": command,"status": "running",}]
self.results: dict[str, str] = {} # 存储任务结果
self._ready: list[str] = [] # 就绪列表:记录已完成任务的 ID
self._counter = 0 # 任务计数器:
self._lock = threading.Lock() # 线程锁:保证并发安全
def start(self, block) -> str:
"""创建一个后台任务,记录任务,启动任务,捕捉异常,后返回任务id"""
# 不是bash不能启动
if block.name != "bash":
raise ValueError("Only Bash commands can run in the background")
command = block.input.get("command")
# 若这个命令不是字符串或者根本不存在,说明命令是无效的
if not isinstance(command, str) or not command.strip():
raise ValueError("Bash command cannot be empty")
# 加锁,生成任务 ID 并保留原数据
with self._lock:
# 计数器加一
self._counter += 1
task_id = f"bg_{self._counter:04d}" # 为什么要将计数器的值作为任务id?简单且唯一
self.tasks[task_id] = {
"tool_use_id": block.id,
"command": command,
"status": "running",
}
# 创建线程。用锁保护计数器自增和字典写入,任务状态初始为 "running"
thread = threading.Thread(
target=self._run, # 线程启动后的执行函数
args=(task_id, command), # 传给run方法的参数
daemon=True, # 设置为守护线程,当主程序退出时自动终止
)
try:
thread.start() # 启动线程,后台开始执行这个run方法
except Exception: # 如果执行失败的话
with self._lock:
self.tasks.pop(task_id, None) # 从 tasks 字典中删除刚才创建的任务记录(如果存在)。
raise
print(f" [background] started {task_id}: {command[:60]}")
return task_id
def _run(self, task_id: str, command: str):
"""执行一个bash命令,处理该过程中发生的异常,并修改实体属性"""
try:
# 拿到命令执行结果,并格式化结果
output, exit_code = _run_bash_process(command)
result = _format_bash_result(output, exit_code)
status = "completed" if exit_code == 0 else "failed"
except Exception as error:
result = f"Error: {type(error).__name__}: {error}"
status = "failed"
# 上锁,保证下面这些修改类属性的操作是原子性的
with self._lock:
# 拿到任务实体(其实是一个字典)
task = self.tasks.get(task_id)
if task is None:
return
task["status"] = status
self.results[task_id] = result
self._ready.append(task_id)
def collect(self) -> list[str]:
"""收集已完成的后台任务,从对应的实体属性中删除,加载到内存中,并返回"""
# 上锁
with self._lock:
ready = []
for task_id in self._ready:
# 删除实例属性,若存在,则返回任务对象;若不存在,则返回None
task = self.tasks.pop(task_id, None)
result = self.results.pop(task_id, "")
if task is not None:
ready.append((task_id, task, result))
# 清空_ready列表,因为里面的内容以及被ready取走了。
self._ready.clear()
notifications = []
for task_id, task, result in ready:
notifications.append(
f"<task_notification>\n"
f" <task_id>{task_id}</task_id>\n"
f" <status>{task['status']}</status>\n"
f" <command>{task['command']}</command>\n"
f" <summary>{result[:500]}</summary>\n"
f"</task_notification>"
)
print(f" [background] collected {task_id}: {task['status']}")
return notifications
# 创建一个实例
BACKGROUND = BackgroundManager()
background_tasks = BACKGROUND.tasks
background_results = BACKGROUND.results
现在任务管理器基本成型了,我们还需要写一些接口 ,用来连接后台任务管理器和主循环的一些操作。注意,这些不作为工具函数封装到 HANDLES 中去。
python
def should_run_background(tool_name: str, tool_input: dict) -> bool:
"""判断一个工具是否应该被放入后台执行"""
return (
tool_name == "bash"
and tool_input.get("run_in_background") is True
)
def start_background_task(block) -> str:
"""封装start"""
return BACKGROUND.start(block)
def collect_background_results() -> list[str]:
"""封装collect"""
return BACKGROUND.collect()
# 将完成的后台任务结果注入到对话当中
def inject_background_results(messages: list) -> int:
"""传入messages,将完成的后台任务信息传入到当前的messages中,并返回后台信息数目"""
notifications = collect_background_results()
# 如果不存在,那么返回0
if not notifications:
return 0
# 将每个collect的已完成的后台任务转换为 messageBlock 风格的消息内容块
blocks = [{"type": "text", "text": item} for item in notifications]
if messages and messages[-1].get("role") == "user": # messgaes和其最后一条消息角色是user
content = messages[-1].get("content", "") # 从最后一条消息中获取 "content" 字段。
if isinstance(content, list): # 如果最后一条消息是列表
content.extend(blocks) # 直接就可以追加
else: # 不是列表的话,需要换一种方式添加,否则格式会不对
messages[-1]["content"] = [
{"type": "text", "text": str(content)},
*blocks,
]
else: # 否则
messages.append({"role": "user", "content": blocks})
return len(notifications)
思考一个问题:我们应该在何时 去触发把任务放后台执行的操作?作者将其放在了 execute_tool() 中。为什么这样设计?因为 execute_tool() 是所有工具调用的唯一入口 。你看,原来这里是直接调用 handler 的。现在加了后台任务功能,作者把这段逻辑挪到了 call_tool() 里,在它前面加了一个**"是否放后台"的判断。这样一来,一个任务就可以被分为 同步或异步**执行。
python
def execute_tool(block) -> str:
"""输入block,输出钩子的结果"""
blocked = trigger_hooks("PreToolUse", block)
if blocked:
return str(blocked)
if should_run_background(block.name, block.input):
try:
task_id = start_background_task(block)
output = (
f"[Background task {task_id} started] "
"The result will be collected on a later turn."
)
except Exception as error:
output = f"Error: {error}"
else:
output = call_tool(block)
# handler = TOOL_HANDLERS.get(block.name)
# try:
# output = handler(**block.input) if handler else f"Unknown: {block.name}"
# except Exception as error:
# output = f"Error: {error}"
trigger_hooks("PostToolUse", block, output)
return str(output)
来看看 loop 中的内容,和从前没有什么区别,只不过是在一开始就将 messages 传入后台,然后进行一系列的操作。
python
def agent_loop(messages: list):
while True:
inject_background_results(messages) # 注入后台
response = client.messages.create(
model=MODEL,
system=SYSTEM,
messages=messages,
tools=TOOLS,
max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
force = trigger_hooks("Stop", messages)
if force:
messages.append({"role": "user", "content": force})
continue
return
results = []
for block in response.content:
if block.type != "tool_use":
continue
output = execute_tool(block)
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
总结一下整个流程吧。
以一个例子展示:我说"帮我装一下项目依赖,然后运行测试"。agent 思考后可能就决定,1️⃣ 先执行 npm install(耗时,放到后台),2️⃣ 先去干别的(比如阅读项目文档),3️⃣ 等 npm install 完成后再回来运行测试。
OK,那第一步 agent 会发起工具调用,调用 bash,参数带上 "run_in_background": true,可能就像下面这样。
python
{
"name": "bash",
"input": {
"command": "npm install",
"run_in_background": true
}
}
第二步,execute_tool() 做路由决策,should_run_background() 检查条件:tool_name = "bash" 且 run_in_background = True,就会 start 一个进程,其中守护进程 会在后台执行 _run 方法,任务就被放在了后台默默执行,等执行结束自动修改对应的实例属性。
第三步,agent 进入下一次 loop 后会调用 collect_background(),这个方法会从管理器当中取结果,然后清空属性,取到的结果会加载到 notifications 列表当中并注入 messages 中。agent 会看到结果,然后继续工作。
这是正常流程,可以总结为下:
bash
主线程:启动后台任务 → 继续干活 → 任务完成 → collect() 取走结果
后台线程:执行命令 → 完成 → 结果入队
但是如果后台任务比主程序还长呢?或者说,最开始的 bash 异步是如何实现的?比如下面这种情况:
bash
主线程:启动后台任务 → 继续干活 → 用户按了 Ctrl+C 或程序崩溃
后台线程:npm install 正在跑(可能还要跑 2 分钟)
出现异常后,系统内核会发出 SIGINT 信号,atexit 注册的函数被触发,_stop_all_shell_processes() 调用后会杀死所有在 _shell_processes 中的进程,也就是删去所有的 popen 对象,并清除对应的所有子进程。如果依然觉得困惑的话见附件二的大白话解释。
总的来说,当发生异常导致程序终止的时候,会主动把所有还活着的子进程全部找到并杀死。
二、测试一下
可以看到 pip list 和 sleep(3) 都被放在了后台执行,等到第二轮时 LLM 给出了 pip list 执行失败的日志------pip 命令找不到,后台的任务管理器将失败的信息也完整返回了。当 LLM 看到两轮对话完整的 messages,最终给出了总结和 pip 失败的建议。
bash
s13 >> Run pip list in the background and find all Python files in this directory
[HOOK] UserPromptSubmit: working in /Users/bx/Documents/coding/learn_cladudecode
[HOOK] bash(['pip list', True])
[background] started bg_0001: pip list
[HOOK] glob(['**/*.py'])
[HOOK] Stop: session used 2 tool calls
Here are the results:
---
### 🔄 `pip list` (Running in Background)
The command has been started in the background (task `bg_0001`). Its output will be available once it completes.
---
### 📁 Python Files Found (21 files)
| # | File Path |
|---|-----------|
| 1 | `s13_background_tasks/code.py` |
| 2 | `s13_background_tasks/text.py` |
| 3 | `s05_todo_write/hello.py` |
| 4 | `s05_todo_write/code.py` |
| 5 | `s05_todo_write/test.py` |
| 6 | `s01_agentLoop/code.py` |
| 7 | `s01_agentLoop/test.py` |
| 8 | `s10_system_prompt/code.py` |
| 9 | `s09_memory/code.py` |
| 10 | `s09_memory/test.py` |
| 11 | `s07_skill_loading/code.py` |
| 12 | `s07_skill_loading/test.py` |
| 13 | `s06_subagent/string_tools.py` |
| 14 | `s06_subagent/code.py` |
| 15 | `s06_subagent/text.py` |
| 16 | `s02_tooluse/code.py` |
| 17 | `s02_tooluse/test.py` |
| 18 | `s04_hooks/code.py` |
| 19 | `s08_context_compact/code.py` |
| 20 | `s12_task_system/code.py` |
| 21 | `s12_task_system/text.py` |
| 22 | `s03_permission/code.py` |
| 23 | `s03_permission/test.py` |
| 24 | `s11_error_recovery/code.py` |
There are **21 Python files** spread across 12 subdirectories in this project. Let me know if you'd like to inspect any of them or check on the `pip list` results!
s13 >> Run a short sleep in the background, then list all Markdown files
[HOOK] UserPromptSubmit: working in /Users/bx/Documents/coding/learn_cladudecode
[background] collected bg_0001: failed
[HOOK] bash(['sleep 3', True])
[background] started bg_0002: sleep 3
[HOOK] glob(['**/*.md'])
[HOOK] Stop: session used 4 tool calls
Here are the results:
---
### 🔄 `sleep 3` (Running in Background)
The short sleep has been started in the background (task `bg_0002`). It will complete after 3 seconds.
---
### 📝 Markdown Files Found (3 files)
| # | File Path |
|---|-----------|
| 1 | `s04_hooks/README.md` |
| 2 | `s08_context_compact/README.md` |
| 3 | `s12_task_system/README.md` |
---
### ⚠️ Note on the earlier `pip list` failure
The background task `bg_0001` (`pip list`) failed with:
> `/bin/sh: pip: command not found`
This means `pip` isn't available in the default shell path. You could try one of these alternatives:
- **`pip3 list`** --- if Python 3's pip is installed under a different name
- **`python3 -m pip list`** --- invoking pip as a Python module
- **`python -m pip list`** --- if `python` is on the path
Would you like me to retry with one of these?
s13 >> q
总结
整个异步操作的注入可以归结为下述:
bash
┌────────────────────────────────────────────────────────────────────┐
│ Agent 主循环 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ LLM 推理 │ → │ 工具调用 │ → │inject_background_results│ │
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
│ │ ▲ │
│ ▼ │ │
│ should_run_background? │ │
│ ┌───────┴───────┐ │ │
│ ▼ ▼ │ │
│ [同步执行] [后台启动] │ │
│ │ │ │ │
│ │ ┌─────────┴──────────┐ │ │
│ │ │ BackgroundManager │ │ │
│ │ │ ┌──────────────┐ │ │ │
│ │ │ │ tasks │ │ │ │
│ │ │ │ results │ │ │ │
│ │ │ │ _ready │ │ │ │
│ │ │ └──────────────┘ │ │ │
│ │ └─────────┬──────────┘ │ │
│ │ │ │ │
│ │ [守护线程] │ │
│ │ _run() │ │
│ │ 执行 Bash 命令 │ │
│ │ │ │ │
│ └───────────────┴──────────────┘ │
│ │
└────────────────────────────────────────────────────────────────────┘
附件一:可重入锁
🔐 可重入锁(Reentrant Lock)详解
什么是可重入锁?
可重入锁是线程同步 中的一个重要概念,它允许同一个线程多次获取同一把锁 而不会造成死锁。在 Python 中,它对应 threading.RLock,而普通锁是 threading.Lock。
普通锁与可重入锁的区别
普通锁 :当使用普通锁时,如果同一个线程在持有锁的情况下再次尝试获取这把锁,线程会永久等待 ,因为锁已经被自己持有了。这就是经典的死锁场景。
想象一个场景:你拿着一把钥匙进入一个房间,然后你想再进入这个房间。如果你需要同一把钥匙,但你已经拿在手里了,你会把自己锁在外面。这就是普通锁的行为。
可重入锁 :可重入锁解决了这个问题。它会记录两件事:当前持有锁的是哪个线程 ,以及这个线程获取锁的次数 。当同一个线程再次尝试获取时,它不会阻塞自己,而是简单地增加一个计数器。每次释放锁时,计数器减一,直到归零时锁才真正被释放。
在实际代码中的应用
在后台任务管理系统中,进程集合使用了可重入锁。这样设计的主要原因是存在嵌套调用的可能。
比如,程序正常执行时可能已经持有了锁,但突然收到终止信号,信号处理函数需要清理所有进程,又需要获取同一把锁。如果使用普通锁,就会死锁,程序无法正常退出。而使用可重入锁,同一个线程可以安全地再次获取锁,完成清理工作后正常退出。
可重入锁的性能考虑
可重入锁比普通锁稍慢 ,因为它需要额外维护持有线程的标识和重入计数。但换来的好处是更安全、更灵活,特别适合那些可能存在嵌套锁获取的场景。
使用建议
一般开发中,如果没有特殊的嵌套需求,优先使用普通锁 ,性能更好且语义更清晰。但当代码中存在递归调用 、多层函数调用都需要同一把锁,或者在信号处理等特殊场景下,可重入锁就是更安全的选择。
总之,可重入锁是为了解决"同一个线程多次获取同一把锁"的问题而设计的。在复杂的系统中,特别是在需要处理各种异常退出场景 时,可重入锁提供了一种防御性的保护机制 ,用微小的性能代价换取了系统的健壮性和安全性。这也是为什么进程管理这类关键基础设施会选择使用可重入锁的原因。
附件二:信号与进程的联系
第一层:操作系统是怎么"通知"程序的?
你按下 Ctrl+C 的时候,其实不是"让程序停止",而是 "给程序发了一个消息" 。这个"消息",在操作系统里就叫做 信号(Signal) 。Ctrl+C 发出的信号名字叫 SIGINT (编号 2),意思是"请中断(Interrupt)"。这个信号不是发给某个变量的,是直接发给整个正在运行的进程 (你的 Python 程序)的。内核(操作系统核心)只管发,发完就不管了,至于程序收到后是死是活,由程序自己决定。
第二层:Python 程序收到信号后,默认会干什么?
默认情况下(如果你什么都不写),Python 程序收到 SIGINT 会直接**"暴毙",什么都不管,立刻终止。这会导致所有未完成的子进程变成 孤儿**。但是,Python 允许你 "截获" 这个信号,并且 "替换成你自己的处理方式" 。这就是 signal.signal() 这个函数做的事。
第三层:signal.signal() 的作用
它相当于在程序中写了一段话:"如果以后收到 SIGTERM 信号,不要默认死掉,去执行我指定的函数。"但是注意:你在上一段代码里,只注册了 SIGTERM (编号 15),没有注册 SIGINT(编号 2)。
第四层:那为什么按 Ctrl+C 也会触发 _stop_all_shell_processes()?
因为你按 Ctrl+C 发出的是 SIGINT,而 SIGINT 没有被单独注册信号处理函数,所以 Python 会执行默认行为:抛出 KeyboardInterrupt 异常,然后程序开始**"有序退出"**。而"有序退出"的过程中,Python 会检查有没有通过 atexit 注册过"临终遗言",如果有,就会在程序彻底结束前把这些遗言执行完。