← 返回题目列表

Python 怎么处理信号?服务如何优雅关闭?

中等 第 19 / 27 题 更新于 2026/08/01
信号SIGTERM优雅关闭容器

简化版

信号是操作系统通知进程「发生了某件事」的机制,最常用的四个:SIGINT(Ctrl-C,Python 默认把它转成 KeyboardInterrupt)、SIGTERMkill 默认发的,也是 Docker/K8s 停止容器时发的,可以捕获)、SIGKILLkill -9无法捕获、无法忽略,进程立即被杀)、SIGHUP(终端断开/配置重载约定)。Python 里用 signal.signal(signal.SIGTERM, handler) 注册处理函数,但必须记住三条限制:① 只能在主线程注册和接收信号② 处理函数不是「立即」执行的——C 层收到信号只是设置一个标志位,解释器要等当前字节码执行完才调用你的 handler,所以一个阻塞在 C 库里的调用(比如没有超时的 sock.recv)可能让信号迟迟得不到处理③ handler 里能做的事很有限(它会打断任意位置的代码),标准做法是只设置一个标志或 threading.Event,真正的清理放到主循环里做优雅关闭的标准五步:收到信号 → 停止接收新任务等待在途任务完成(带超时) → 释放资源(关连接、flush 日志、注销服务发现) → 按约定的退出码退出。容器场景的头号坑:Dockerfile 用 shell 形式的 CMD python app.py 会让 shell 成为 PID 1、信号传不到 Python 进程——必须用 exec 形式 CMD ["python", "app.py"]。核心记忆:SIGTERM 可捕获、SIGKILL 不可;handler 只设标志;容器里用 exec 形式的 CMD。

详细版

常见信号

信号编号触发场景能否捕获Python 默认行为
SIGINT2Ctrl-CKeyboardInterrupt
SIGTERM15killdocker stopK8s 停 Pod直接终止(无异常)
SIGKILL9kill -9、宽限期超时后不可捕获立即杀死
SIGHUP1终端断开、约定用于重载配置终止
SIGQUIT3Ctrl-\终止 + core dump
SIGCHLD17子进程结束忽略(要 wait 回收)
SIGALRM14signal.alarm() 定时终止
SIGPIPE13写入已关闭的管道Python 忽略,改抛 BrokenPipeError
import signal, threading, time, sys

# ① 基本注册:handler 只做"设标志"这一件事
shutdown = threading.Event()

def on_signal(signum, frame):
    print(f"收到信号 {signal.Signals(signum).name}", file=sys.stderr)
    shutdown.set()                      # ★只设标志,不做清理★

signal.signal(signal.SIGTERM, on_signal)
signal.signal(signal.SIGINT, on_signal)

# ② 主循环里检查标志(真正的工作和清理都在这里)
while not shutdown.is_set():
    do_work()
    shutdown.wait(timeout=1)            # ★用 Event.wait 代替 sleep:能被立刻唤醒★
cleanup()                               # 收到信号后统一清理

# ③ ★signal.signal 的三条限制★
# - 只能在★主线程★调用(子线程里调用抛 ValueError)
# - handler 在★字节码之间★执行(阻塞在 C 调用里时不会被立刻打断)
# - handler 里不要做耗时/加锁的事(可能在持有同一把锁时被打断 → ★死锁★)

# ④ 查看/恢复原处理器
old = signal.getsignal(signal.SIGTERM)
signal.signal(signal.SIGTERM, signal.SIG_DFL)   # 恢复默认
signal.signal(signal.SIGHUP, signal.SIG_IGN)    # 忽略

# ⑤ ★让阻塞的系统调用能被信号唤醒★
signal.set_wakeup_fd(w_fd)              # 信号发生时往 fd 写一个字节 → select/epoll 能醒
# 更常用:给所有阻塞调用设超时
sock.settimeout(1.0)                    # ★没有超时的阻塞调用是优雅关闭的头号敌人★

# ⑥ 超时控制(Unix,★仅主线程★)
def timeout_handler(signum, frame):
    raise TimeoutError("超时")
signal.signal(signal.SIGALRM, timeout_handler)
signal.alarm(5)                         # 5 秒后发 SIGALRM
try:
    slow_operation()
finally:
    signal.alarm(0)                     # ★取消★

# ⑦ 优雅关闭线程池
from concurrent.futures import ThreadPoolExecutor
pool = ThreadPoolExecutor(max_workers=8)
# ...
pool.shutdown(wait=True, cancel_futures=True)   # ★3.9+:取消还没开始的任务★

# ⑧ 兜底:atexit(★不保证被调用★)
import atexit
atexit.register(cleanup)   # 正常退出/sys.exit 会执行;★SIGKILL、os._exit、段错误不会★

⚠️ 三条必须记住的机制:① 信号处理函数不是「立即」执行的。C 层的信号处理器只是设置一个标志位并写入 wakeup fd,真正的 Python handler 要等解释器执行到下一条字节码的边界才被调用——这意味着如果主线程正阻塞在一个没有超时的 C 层调用里(sock.recv()lock.acquire()time.sleep(3600)subprocess.wait()),信号可能要等很久才被处理(time.sleep 和标准库的锁在 Unix 上会被 EINTR 唤醒,但第三方 C 库不一定)。所以「所有阻塞调用都设超时」是优雅关闭的前提。② 只有主线程能注册和接收信号:在子线程里调用 signal.signal() 会抛 ValueError,信号也永远只投递给主线程执行 handler——所以「主线程收到信号后,通过 Event 通知各工作线程退出」是唯一可行的架构。③ SIGKILLkill -9)和 SIGSTOP 无法被捕获、忽略或阻塞——它们由内核直接处理,进程没有任何机会做清理。这就是为什么 K8s 的流程是「先发 SIGTERM、等 terminationGracePeriodSeconds(默认 30 秒)、还不退就 SIGKILL」,你的清理逻辑必须在宽限期内跑完

完整版教学

一、信号机制与 Python 的实现

信号是什么:★操作系统层面的异步通知★
  内核/其他进程 → 目标进程 → 打断它正在做的事 → 执行注册的处理函数

  发送方式:
    kill -TERM <pid>        命令行
    os.kill(pid, signal.SIGTERM)
    Ctrl-C                  终端向★前台进程组★发 SIGINT
    docker stop             向 PID 1 发 SIGTERM(然后等 10s 再 SIGKILL)
    kubectl delete pod      向容器发 SIGTERM(等 terminationGracePeriodSeconds)
    systemctl stop          按 unit 配置发信号

★ Python 的两级处理(理解这个才能解释所有"信号没生效"的问题):
  ① C 层的信号处理器(真正被内核调用的那个)
     做的事极少:★设置一个标志位★ + 往 wakeup fd 写一个字节
     (因为信号处理器里能安全调用的函数极其有限——async-signal-safe)
  ② 解释器主循环
     每执行完一条字节码,检查"有没有待处理的信号"
     → 有就调用你用 signal.signal() 注册的 ★Python 函数★

  ┌────────────────────────────────────────────────────────────┐
  │  内核发信号 → C handler 设标志 → …继续执行当前字节码…        │
  │  → 字节码边界检查标志 → ★调用 Python handler★               │
  └────────────────────────────────────────────────────────────┘

  ★ 由此推出三个必然结果:
    ① 信号处理有★延迟★(要等到字节码边界)
    ② 阻塞在 C 调用里时★可能长时间不响应★
       time.sleep / 标准库的锁:Unix 上会被 EINTR 唤醒(PEP 475 后自动重试)
       第三方 C 扩展的长计算(numpy 大矩阵、正则回溯):★完全打不断★
    ③ handler 可能在★任意两条字节码之间★被插入执行
       → 它可能打断一个"看似原子"的操作 → ★handler 里不能做复杂的事★

三条硬性限制:
  ① ★只有主线程能注册信号★:子线程调用 signal.signal() → ValueError
  ② ★只有主线程执行 handler★:即使信号在子线程运行时到达
  ③ ★SIGKILL(9) 和 SIGSTOP(19) 无法捕获/忽略/阻塞★(内核直接处理)

Windows 的差异(★跨平台代码要注意★):
  只支持 SIGINT / SIGTERM / SIGBREAK 等少数几个
  没有 SIGHUP、SIGUSR1/2、SIGALRM、SIGCHLD
  ★signal.alarm() 在 Windows 上不存在★
  → 跨平台的超时控制要用 threading.Timer 或 concurrent.futures 的 timeout

理解信号处理必须理解 Python 的两级机制:内核调用的 C 层处理器只做两件事——设置标志位、往 wakeup fd 写一个字节(因为信号处理器里能安全调用的函数极少);而你用 signal.signal() 注册的 Python 函数,要等解释器执行到下一条字节码的边界才被调用。这个设计直接推出三个必然结果:信号处理有延迟阻塞在 C 调用里时可能长时间不响应time.sleep 和标准库的锁在 Unix 上会被 EINTR 唤醒,但 numpy 的大矩阵运算、灾难性正则回溯这类纯 C 计算完全打不断);handler 可能在任意两条字节码之间插入执行,所以里面不能做复杂的事。三条硬性限制要背下来:只有主线程能注册信号、只有主线程执行 handler、SIGKILLSIGSTOP 无法捕获。跨平台方面,Windows 只支持少数几个信号,没有 SIGHUP/SIGUSR1/SIGALRM/SIGCHLD

二、handler 里能做什么、不能做什么

★ 黄金法则:handler 里只做"设置标志"这一件事★

  ✓ 推荐写法:
    shutdown = threading.Event()
    def on_signal(signum, frame):
        shutdown.set()                # ★就这一行★
    signal.signal(signal.SIGTERM, on_signal)
    # 真正的清理在主循环里做

  ✗ 危险写法:
    def on_signal(signum, frame):
        with some_lock:               # ★可能死锁★
            flush_buffers()           # 主线程可能正好持有这把锁
        logging.info("退出中")         # ★logging 内部有锁,同理★
        db.close()                    # 可能阻塞很久
        sys.exit(0)                   # ★在任意位置抛 SystemExit,可能破坏一致性★

为什么 handler 里加锁会死锁(★经典问题★):
  主线程:with lock: 正在写文件(持有锁)
    ↓ 信号到达,handler 在★同一个线程★里被插入执行
  handler:with lock: ...           ← ★等待一把自己已经持有的锁 → 永久阻塞★
  (Lock 不可重入;即使换 RLock 也只是掩盖了逻辑错误)

  ★ 同类风险:logging、print(有锁)、任何用了锁的库
  ★ 现实中很多"服务收到 SIGTERM 后卡死不退"就是这个原因

handler 里相对安全的操作:
  ✓ 给全局变量赋值(简单赋值是原子的)
  ✓ threading.Event().set()(★设计上是信号安全的★)
  ✓ os.write(fd, b"x")(写自管道)
  ✓ 记录信号编号
  ✗ 加锁、IO、logging、分配大量内存、抛异常(除非你清楚后果)

★ 在 handler 里抛异常的语义:
  def h(signum, frame): raise KeyboardInterrupt
  → 异常会从★主线程当前正在执行的那一行★抛出
  → 这正是 Ctrl-C 的默认行为(Python 默认给 SIGINT 装了这样一个 handler)
  → 风险:异常可能在 try/finally 的 finally 块中间、或在 with 的 __exit__ 里抛出
    → ★资源清理本身被打断 → 泄漏★
  → 所以健壮的服务通常★接管 SIGINT★,改成"设标志"而不是抛异常

信号安全的经典模式:★自管道技巧(self-pipe trick)★
  r, w = os.pipe()
  os.set_blocking(w, False)
  signal.set_wakeup_fd(w)            # ★信号到达时自动往 w 写一个字节★
  # 主循环用 select 同时监听业务 fd 和 r
  ready, _, _ = select.select([sock, r], [], [], timeout)
  if r in ready:
      os.read(r, 1024)               # 清空
      handle_shutdown()              # ★在主循环的安全位置处理★
  → 让"信号"变成"一个可以被 select/epoll 感知的事件"
  → asyncio 的 loop.add_signal_handler() 内部就是这个思路

关于 handler 的黄金法则只有一句:里面只做「设置标志」这一件事。原因是 handler 会在主线程的任意两条字节码之间被插入执行,于是产生一个经典死锁:主线程正 with lock: 写文件(持有锁),信号到达、handler 在同一个线程里被插入,它又去 with lock:——等待一把自己已经持有的锁,永久阻塞。同类风险包括 loggingprint(都有内部锁)和任何用锁的库——现实中大量「服务收到 SIGTERM 后卡死不退」就是这么来的。在 handler 里抛异常也很危险:异常会从主线程当前正在执行的那一行抛出,可能落在 finally 块中间或 with__exit__ 里,导致资源清理本身被打断(这正是 Ctrl-C 默认行为的风险,所以健壮的服务通常会接管 SIGINT、改成设标志)。真正专业的做法是自管道技巧(self-pipe trick):用 signal.set_wakeup_fd() 让信号到达时自动往一个 pipe 写字节,主循环用 select 同时监听业务 fd 和这个 pipe——把「信号」变成「一个可被事件循环感知的普通事件」asyncioadd_signal_handler() 内部就是这个思路。

三、优雅关闭的标准五步

★ 完整流程(顺序不能乱):
  ① 收到 SIGTERM/SIGINT → 设置 shutdown 标志
  ② ★停止接收新任务★:从负载均衡/服务发现摘除、关闭监听 socket、
     停止消费队列、健康检查返回不健康
  ③ ★等待在途任务完成(带超时)★:正在处理的请求做完
  ④ ★释放资源★:关数据库连接池、flush 日志和指标、提交 offset、释放分布式锁
  ⑤ 退出:sys.exit(0)(正常)或 sys.exit(128 + signum)(★Unix 惯例★)

  ★ ②和③的顺序最关键:
    先摘流量再等在途 → 新请求不会再来,在途请求能正常完成
    顺序反了 → 一边等一边还在进新请求 → ★永远等不完★

一个完整的服务骨架:
  import signal, threading, sys, logging

  class Service:
      def __init__(self):
          self._stop = threading.Event()
          self._workers = []

      def start(self):
          signal.signal(signal.SIGTERM, self._on_signal)
          signal.signal(signal.SIGINT, self._on_signal)
          for i in range(4):
              t = threading.Thread(target=self._worker, name=f"w{i}")
              t.start()                       # ★不要设 daemon=True★(见下)
              self._workers.append(t)
          self._stop.wait()                   # ★主线程在这里等信号★
          self._shutdown()

      def _on_signal(self, signum, frame):
          self._stop.set()                    # ★只设标志★

      def _worker(self):
          while not self._stop.is_set():
              item = self._queue.get(timeout=1)   # ★带超时,才能定期检查标志★
              if item is None: break
              self._handle(item)              # ★一个任务处理完再检查,不中途放弃★

      def _shutdown(self, grace=25):
          logging.info("开始优雅关闭")
          deregister_from_lb()                # ② 停止进新流量
          deadline = time.monotonic() + grace
          for t in self._workers:             # ③ 等在途任务(★总超时★)
              t.join(timeout=max(0, deadline - time.monotonic()))
          alive = [t.name for t in self._workers if t.is_alive()]
          if alive:
              logging.warning("超时未结束的线程: %s", alive)
          close_db_pool(); flush_metrics(); logging.shutdown()   # ④ 释放资源
          sys.exit(0)

★ 三个容易做错的细节:
  ① ★超时要用"总预算"而不是"每个线程 N 秒"★
     for t in workers: t.join(timeout=5)   ← 10 个线程最坏要等 50 秒 → 超过宽限期被 SIGKILL
     ✓ 用 deadline = now + grace,每次 join 传剩余时间
  ② ★工作线程的阻塞调用必须有超时★
     q.get() 无超时 → 永远醒不来 → join 超时 → 白等
     ✓ q.get(timeout=1) 或用哨兵值(往队列放 None)唤醒
  ③ ★宽限期要比编排层小★
     K8s 默认 terminationGracePeriodSeconds=30
     → 你的 grace 应设 25 秒左右,留时间给进程真正退出
     → 否则你还在清理就被 SIGKILL 了

退出码惯例:
  0        正常
  1        通用错误
  128+N    ★被信号 N 终止★(SIGTERM=15 → 143,SIGINT=2 → 130)
  → 优雅关闭后 exit(0) 表示"我处理完了";exit(143) 表示"我是被 TERM 结束的"
  → K8s 里 exit(0) 才不会被记为异常退出(影响重启策略和告警)

优雅关闭的标准流程是五步,其中第②步和第③步的顺序最关键必须先「停止接收新任务」(从负载均衡摘除、关闭监听、停止消费队列),再「等待在途任务完成」——顺序反了就是一边等一边还在进新请求,永远等不完。三个容易做错的细节:① 超时要用「总预算」而不是「每个线程 N 秒」(10 个线程各 join 5 秒,最坏要等 50 秒,早就超过宽限期被 SIGKILL 了),正确做法是算一个 deadline 然后每次传剩余时间;② 工作线程里的阻塞调用必须有超时q.get() 不带超时就永远醒不来,join 再久也是白等,要么带超时要么用哨兵值唤醒);③ 你的宽限期要比编排层的小(K8s 默认 terminationGracePeriodSeconds=30,你就设 25 秒,留时间给进程真正退出)。退出码也有惯例:128+N 表示被信号 N 终止(SIGTERM → 143、SIGINT → 130),而优雅关闭完成后应该 exit(0)——K8s 里只有 0 才不会被记为异常退出。

四、多线程、线程池与子进程的关闭

★ daemon 线程的陷阱(最常见的错误做法):
  t = threading.Thread(target=work, daemon=True)
  → 主线程退出时,daemon 线程★被强制杀死、不执行 finally、不做任何清理★
  → 后果:写了一半的文件、没提交的事务、没释放的分布式锁
  ✓ 正确做法:非 daemon 线程 + Event 协作退出 + join(timeout)
  ✓ daemon=True 只适合"纯粹的后台轮询、随时可以死"的线程(如心跳打点)

★ 线程池的关闭:
  pool.shutdown(wait=True)                      等所有已提交任务完成
  pool.shutdown(wait=True, cancel_futures=True) ★3.9+:取消队列里还没开始的任务★
  pool.shutdown(wait=False)                     不等(★慎用★,任务可能被中途丢下)
  ★ 注意:shutdown ★不会中断正在执行的任务★(Python 无法强制中断线程)
    → 任务函数内部必须自己检查 stop 标志

  with ThreadPoolExecutor() as ex:   # ★with 退出时等价于 shutdown(wait=True)★
      ...

★ 无法取消的任务怎么办:
  Python ★没有强制终止线程的机制★(PyThreadState_SetAsyncExc 不可靠且危险)
  → 长任务必须★自己分块并检查标志★:
    def long_task(stop: threading.Event):
        for chunk in chunks:
            if stop.is_set():
                save_progress(); return       # ★可恢复地退出★
            process(chunk)

★ 子进程的信号传播(★经常被忽略★):
  你的进程收到 SIGTERM ≠ 你启动的子进程也收到
  - Ctrl-C:终端向★整个前台进程组★发 SIGINT → 子进程也会收到
  - kill <pid>:★只发给这一个进程★ → 子进程收不到
  ✓ 要一起结束:
    ① 自己转发:proc.terminate() / proc.send_signal(sig)
    ② 用进程组:subprocess.Popen(..., start_new_session=True) 建新会话,
       结束时 os.killpg(os.getpgid(p.pid), signal.SIGTERM)
    ③ Linux 专有:prctl(PR_SET_PDEATHSIG) 让父死时内核自动杀子

  标准的子进程终止序列:
    proc.terminate()               # SIGTERM,给它机会清理
    try:
        proc.wait(timeout=10)
    except subprocess.TimeoutExpired:
        proc.kill()                # ★SIGKILL 兜底★
        proc.wait()

★ multiprocessing 的关闭:
  Process.terminate()   → 发 SIGTERM(★不执行 finally、不清理 Queue★)
  Process.kill()        → SIGKILL(3.7+)
  ✓ 优雅方式:用 multiprocessing.Event 通知子进程自己退出,再 join(timeout)
  ★ 坑:terminate 一个正在往 Queue 写数据的进程 → ★队列可能损坏、其他进程卡死★

多线程环境下的关闭有几个高频错误。daemon=True 是最常见的错误做法——主线程退出时 daemon 线程被强制杀死、不执行 finally、不做任何清理,留下写了一半的文件和没提交的事务;正确做法是非 daemon 线程 + Event 协作退出 + join(timeout)daemon=True 只适合「随时可以死」的心跳打点类线程。线程池的 shutdown() 不会中断正在执行的任务(Python 根本没有强制终止线程的机制),cancel_futures=True(3.9+)只能取消队列里还没开始的任务——所以长任务必须自己分块并检查停止标志子进程的信号传播也经常被忽略:Ctrl-C 会发给整个前台进程组(子进程也收到),而 kill <pid> 只发给一个进程,要一起结束就得自己 proc.terminate() 转发、或用 start_new_session=True 建进程组后 os.killpg()。终止子进程的标准序列是 terminate()wait(timeout) → 超时则 kill()

五、容器与 PID 1:信号传不到的坑

★ 头号坑:Dockerfile 的 CMD 写法
  ✗ CMD python app.py                    ← ★shell 形式★
     实际执行:/bin/sh -c "python app.py"
     → ★PID 1 是 sh,Python 是它的子进程★
     → docker stop 发 SIGTERM 给 PID 1(sh)
     → ★sh 不转发信号给子进程★ → Python 完全收不到 → 10 秒后被 SIGKILL 强杀
     → 现象:"我明明写了 SIGTERM handler,为什么没被调用?"

  ✓ CMD ["python", "app.py"]             ← ★exec 形式(JSON 数组)★
     → Python 直接成为 PID 1,能收到信号
  ✓ 或 ENTRYPOINT ["python", "app.py"]
  ✓ 需要 shell 特性时:CMD ["sh", "-c", "exec python app.py"]  ← ★exec 替换进程★

★ 第二个坑:PID 1 的特殊语义
  PID 1 是"init 进程",内核对它有特殊规则:
  ① ★没有默认信号处理★:普通进程收到 SIGTERM 默认终止,
     而 PID 1 ★只有显式注册了 handler 才会响应★
     → 一个没注册 SIGTERM handler 的 Python 进程作为 PID 1 时,
       docker stop 会★等满超时后 SIGKILL★
  ② PID 1 要负责★回收孤儿进程★(僵尸进程问题)
     → 你的应用如果 fork 子进程且不 wait,僵尸会一直堆积
  ✓ 解决:用 tini(docker run --init)或 dumb-init 作为 PID 1
     Dockerfile: ENTRYPOINT ["/usr/bin/tini", "--"]  CMD ["python", "app.py"]
     → tini 负责转发信号 + 回收僵尸

★ Kubernetes 的完整停止流程(要能说清楚):
  ① Pod 被标记为 Terminating,★从 Service 的 Endpoints 摘除★(异步!)
  ② 执行 preStop hook(如果配了)
  ③ 向容器 PID 1 发 ★SIGTERM★
  ④ 等待 ★terminationGracePeriodSeconds(默认 30s)★
  ⑤ 还没退出 → ★SIGKILL★

  ★ 一个经典的"优雅关闭了还是有 500"问题:
    第①步的"摘除 Endpoints"和第③步的"发 SIGTERM"是★并行发生★的,
    kube-proxy/Ingress 更新规则需要时间(几百毫秒到数秒)
    → 进程已经开始关闭,但流量还在打进来 → 502/504
    ✓ 解决:preStop hook 里 sleep 5~10 秒(★先让流量摘干净再开始关闭★),
      或应用收到 SIGTERM 后先"继续服务一段时间"再停止接新连接

★ 其他环境:
  systemd:  KillSignal=SIGTERM,TimeoutStopSec 控制宽限期
  supervisor:stopsignal=TERM,stopwaitsecs
  gunicorn: master 收到 TERM 后优雅关闭 worker(graceful_timeout)
  uvicorn:  支持 SIGTERM 优雅关闭,配合 --timeout-graceful-shutdown

容器场景有两个必知的坑。第一是 Dockerfile 的 CMD 写法shell 形式的 CMD python app.py 实际执行 /bin/sh -c "python app.py",PID 1 是 sh,而 sh 不会转发信号给子进程——于是 docker stop 发的 SIGTERM 你的 Python 根本收不到,10 秒后直接被 SIGKILL。必须用 exec 形式 CMD ["python", "app.py"](需要 shell 特性时写 CMD ["sh", "-c", "exec python app.py"]exec 会用 Python 替换掉 sh 进程)。第二是 PID 1 的特殊语义:内核对 PID 1 不提供默认信号处理——没注册 handler 的进程作为 PID 1 时,SIGTERM 会被直接忽略、只能等超时后被强杀;PID 1 还要负责回收孤儿进程(否则僵尸堆积),所以推荐用 tinidocker run --init)做 PID 1。K8s 场景还有个经典问题:「摘除 Endpoints」和「发 SIGTERM」是并行发生的,而 kube-proxy 更新规则需要时间,导致进程已开始关闭但流量还在进来(502/504)——标准解法是 preStop hook 里 sleep 5~10 秒,先让流量摘干净再开始关闭。

六、asyncio 与实践清单

asyncio 里的信号处理(★不要用 signal.signal★):
  ✗ signal.signal(signal.SIGTERM, handler)
     → handler 在字节码边界执行,可能★在事件循环内部的任意位置★被插入
  ✓ loop.add_signal_handler(signal.SIGTERM, callback)     ★Unix 专有★
     → 内部用 self-pipe,回调在★事件循环的安全位置★被调度执行

  async def main():
      loop = asyncio.get_running_loop()
      stop = asyncio.Event()
      for sig in (signal.SIGTERM, signal.SIGINT):
          loop.add_signal_handler(sig, stop.set)     # ★安全★
      server = await asyncio.start_server(...)
      await stop.wait()
      server.close()                    # ★停止接受新连接★
      await server.wait_closed()
      await asyncio.wait_for(drain_inflight(), timeout=25)   # 等在途

  ★ Windows 上 add_signal_handler 不可用 → 退回 signal.signal + call_soon_threadsafe

★ 最终实践清单:
  □ 注册 SIGTERM 和 SIGINT 的 handler,handler ★只设 Event★
  □ 主循环用 Event.wait(timeout) 而不是 time.sleep(能被立刻唤醒)
  □ ★所有阻塞调用都设超时★(socket、queue.get、锁、HTTP 请求)
  □ 关闭顺序:★先停止接新任务,再等在途任务★
  □ 用"总时间预算"控制等待,不是每个线程各等 N 秒
  □ 宽限期 ★小于★ 编排层的(K8s 30s → 你设 25s)
  □ 工作线程不要 daemon=True(除非真的可以随时死)
  □ 长任务分块 + 检查停止标志(Python 无法强制中断线程)
  □ 子进程:terminate → wait(timeout) → kill 兜底
  □ Dockerfile 用 ★exec 形式的 CMD★;考虑 tini 做 PID 1
  □ K8s:配 preStop sleep 让流量先摘干净
  □ 退出码:正常 0;被信号终止用 128+N
  □ atexit 只作兜底(★SIGKILL/os._exit 时不执行★)
  □ ★演练★:真的 kill -TERM 一次,看日志确认每一步都跑到了

asyncio不要用 signal.signal()——handler 会在字节码边界执行,可能落在事件循环内部的任意位置;正确做法是 loop.add_signal_handler(sig, callback)(Unix 专有),它内部用 self-pipe,回调在事件循环的安全位置被调度执行。最后那份实践清单值得逐条对照,其中最容易被忽略的三条是:所有阻塞调用都要设超时(否则线程永远醒不来,join 再久也没用)、宽限期必须小于编排层的配置、以及真的演练一次 kill -TERM 看日志确认每一步都执行到了——很多「优雅关闭」代码写完从没被真正触发过,上线才发现 handler 压根没被调用。

记忆钩子:「四个必知信号:★SIGINT(2, Ctrl-C, Python 默认转成 KeyboardInterrupt)、SIGTERM(15, kill/docker stop/K8s 默认发的, 可捕获)、SIGKILL(9, 无法捕获忽略阻塞)、SIGHUP(1, 约定用于重载配置)★。Python 的信号处理是★两级★的:内核调用的 C handler 只设标志位 + 写 wakeup fd,你注册的 Python 函数要等★字节码边界★才执行——所以①信号处理有延迟②★阻塞在没有超时的 C 调用里可能长时间不响应★(numpy 大矩阵、灾难性正则完全打不断)③handler 可能在任意两条字节码之间插入。三条硬限制:★只有主线程能注册和接收信号★(子线程调 signal.signal 抛 ValueError)、SIGKILL/SIGSTOP 不可捕获、Windows 只支持少数几个(没有 SIGHUP/SIGALRM/SIGCHLD)。★handler 的黄金法则:只做『设置 Event 标志』这一件事★——在里面加锁会死锁(主线程持有锁时 handler 在同一线程被插入、又去拿同一把锁),logging/print 内部有锁同理,这是『服务收到 SIGTERM 后卡死』的常见原因;专业做法是 ★self-pipe 技巧★(set_wakeup_fd + select),asyncio 的 add_signal_handler 内部就是它。★优雅关闭五步且顺序不能乱:设标志 → 先停止接新任务(摘 LB/关监听)→ 再等在途任务(用『总时间预算』而不是每线程各等 N 秒)→ 释放资源 → exit(0 或 128+signum)★。三个必备前提:所有阻塞调用带超时、工作线程★别用 daemon=True★(会被强杀、不执行 finally)、长任务自己分块检查标志(★Python 无法强制中断线程★,shutdown(cancel_futures=True) 只能取消没开始的)。★容器头号坑:CMD 写成 shell 形式会让 sh 当 PID 1 而 sh 不转发信号★,必须用 exec 形式 CMD [“python”,“app.py”];PID 1 还★没有默认信号处理★(不注册 handler 就只能等超时被 SIGKILL)且要回收僵尸 → 用 tini。K8s 里摘 Endpoints 和发 SIGTERM 是★并行★的,要配 preStop sleep 5~10s 才不会有 502。」

七、常见误区与追问

  • 误区:注册了 SIGTERM handler,进程就一定能优雅退出。 有三种情况会让它落空。① 主线程阻塞在没有超时的 C 层调用里——Python 的 handler 只在字节码边界执行,如果主线程卡在 sock.recv()(无超时)、第三方 C 库的长计算(numpy 大矩阵、灾难性正则回溯)里,信号可能很久都得不到处理(time.sleep 和标准库的锁在 Unix 上会被 EINTR 唤醒并自动重试,但这不覆盖第三方扩展)。② 容器里 PID 1 不是你的 Python 进程——CMD python app.py(shell 形式)会让 /bin/sh 成为 PID 1,而 sh 不转发信号,你的 handler 根本不会被调用。③ 清理逻辑超过了宽限期——K8s 默认 30 秒后直接 SIGKILL,清理跑到一半就被强杀。所以「注册 handler」只是第一步,阻塞调用带超时、exec 形式的 CMD、宽限期内跑完三者缺一不可。
  • 误区:kill -9 也能被捕获,只要注册了 SIGKILL 的 handler。 SIGKILL(9)和 SIGSTOP(19)无法被捕获、忽略或阻塞——这是内核层面的硬性规定,目的是保证系统始终有办法终止任何进程。signal.signal(signal.SIGKILL, handler) 会直接抛 OSError。所以收到 SIGKILL 时进程没有任何机会做清理:缓冲区里的日志丢失、事务不会回滚、临时文件不会删除、分布式锁不会释放(只能等 TTL 过期)。这正是「宽限期」机制存在的意义——编排层总是先发 SIGTERM 给你机会,超时才用 SIGKILL。工程上的应对是:把清理做得快且幂等,并且对「进程可能随时消失」这件事有兜底设计(锁带 TTL、写操作幂等、用 WAL 或事务保证一致性)。
  • 误区:在信号处理函数里做清理工作(关连接、flush 日志、写文件)最直接。 这是死锁和数据损坏的高发写法。handler 是在主线程的任意两条字节码之间被插入执行的:如果主线程此刻正持有某把锁(比如 logging 的内部锁、你自己的 Lock),handler 里再去获取同一把锁就会永久阻塞Lock 不可重入)——「服务收到 SIGTERM 后完全卡死」的典型原因就是 handler 里调了 logging.info()。此外 handler 还可能打断一个正在进行的多步操作,导致状态不一致。正确做法是 handler 里只 event.set()Event.set() 被设计为信号安全),把真正的清理放到主循环收到标志后执行;更严谨的方案是 self-pipe 技巧signal.set_wakeup_fd() + select),让信号变成主循环能感知的普通事件。
  • 误区:把工作线程设成 daemon=True,主线程退出时它们就会自动清理。 恰恰相反——daemon 线程在主线程退出时被强制杀死,不执行 finally、不运行 with__exit__、不做任何清理。后果是写了一半的文件、没提交的数据库事务、没释放的分布式锁、没 flush 的日志。daemon=True 的真实语义是「这个线程不值得等待,进程要退出就直接扔掉它」,只适合心跳打点、指标上报这类随时可以死的后台线程。需要清理的工作线程必须是非 daemon 的,配合「Event 通知 + join(timeout)」协作退出。同理,atexit 注册的函数在 daemon 线程被杀时也帮不上忙,而且它在 SIGKILLos._exit()、段错误时完全不会执行——只能当兜底,不能当依赖。
  • 误区:executor.shutdown(cancel_futures=True) 能取消所有任务,包括正在执行的。 只能取消「还在队列里、尚未开始执行」的任务Python 没有强制中断线程的机制PyThreadState_SetAsyncExc 不可靠且危险,可能在任意位置抛异常破坏状态),所以已经开始跑的任务会一直跑到自然结束——shutdown(wait=True) 会老老实实等着它。这意味着一个正在执行的长任务(比如 10 分钟的批处理)会让优雅关闭直接超时被 SIGKILL唯一的解法是让任务自己配合:把长任务分成小块,每块之间检查一个 threading.Event,收到停止信号就保存进度并可恢复地退出。这也是「设计并发任务时要考虑可中断性」的原因——事后再想加取消能力是加不上的。
  • 追问:为什么容器里 CMD python app.py 收不到 SIGTERM? 因为 shell 形式的 CMD(不带 JSON 数组)实际执行的是 /bin/sh -c "python app.py"sh 成为容器的 PID 1,Python 是它的子进程。docker stop / K8s 只把 SIGTERM 发给 PID 1,而 sh 默认不会把信号转发给子进程(它只是在 wait 而已),于是 Python 完全收不到信号,直到宽限期结束被 SIGKILL 强杀——现象就是「明明写了 handler 却从没被调用,而且每次停容器都要等满 10 秒」。解决办法是用 exec 形式CMD ["python", "app.py"],让 Python 直接成为 PID 1;确实需要 shell 特性(变量展开、管道)时写 CMD ["sh", "-c", "exec python app.py"]——exec 会用 Python 进程替换掉 sh,PID 1 依然是 Python。另外还要知道 PID 1 没有默认信号处理(内核特殊对待):即使信号送达,没注册 handler 的进程也不会被 SIGTERM 终止,所以用 tini/dumb-initdocker run --init)做 PID 1 是更稳妥的方案,它还能顺便回收僵尸进程。
  • 追问:K8s 里已经实现了优雅关闭,为什么滚动更新时还是会出现 502? 因为 「把 Pod 从 Service 的 Endpoints 摘除」和「向容器发 SIGTERM」是并行发生的,而前者的生效是异步且有延迟的:kube-apiserver 更新 Endpoints → kube-proxy/Ingress Controller/云负载均衡各自 watch 到变更并更新转发规则,这个链路通常要几百毫秒到数秒。而你的应用收到 SIGTERM 后可能几十毫秒就关闭了监听 socket——这段时间差里,仍有流量按旧规则被转发到已经不再接受连接的 Pod 上,表现为 502/504。标准解法有两个(通常一起用):① 配置 preStop hook 执行 sleep 5~10——让 Pod 在收到 SIGTERM 前先「什么都不做地等一会儿」,给流量摘除留出时间;② 应用收到 SIGTERM 后不要立刻关监听,而是先把健康检查改为失败、继续正常服务若干秒,再开始拒绝新连接。此外还要确保 terminationGracePeriodSeconds 大于「preStop sleep + 应用清理时间」的总和。
  • 追问:asyncio 程序里应该怎么处理信号? 不要用 signal.signal()——它注册的 handler 会在字节码边界被插入执行,可能落在事件循环内部的任意位置(正在处理回调队列、正在 epoll 返回的中间),此时调用 loop.stop() 或操作 asyncio 对象都不安全,还可能因为 handler 里抛异常而破坏事件循环状态。正确做法是 loop.add_signal_handler(signal.SIGTERM, callback)(Unix 专有):它内部使用 self-pipe 技巧——C 层信号处理器往管道写一个字节,事件循环像处理普通 IO 事件一样感知到它,然后在回调的安全位置调度执行你的 callback。典型模式是 loop.add_signal_handler(sig, stop_event.set),主协程 await stop_event.wait(),醒来后依次做「server.close() + await server.wait_closed()(停止接受新连接)→ await asyncio.wait_for(等在途任务, timeout=25) → 关连接池 → 返回」。Windows 上 add_signal_handler 不可用,只能退回 signal.signal() 并在 handler 里用 loop.call_soon_threadsafe(stop_event.set) 把处理调度回事件循环。

八、加强记忆

四个必知信号SIGINT(2,Ctrl-C,Python 默认转成 KeyboardInterrupt)、SIGTERM(15,kill/docker stop/K8s 默认发送,可捕获)、SIGKILL(9,无法捕获、忽略或阻塞)、SIGHUP(1,约定用于重载配置)。Python 的信号处理是两级的:内核调用的 C 层处理器只设置标志位并写 wakeup fd,你注册的 Python 函数要等解释器执行到字节码边界才被调用——由此推出三点:信号处理有延迟、阻塞在没有超时的 C 调用里可能长时间不响应(numpy 大矩阵、灾难性正则完全打不断)、handler 可能在任意两条字节码之间插入。三条硬限制:只有主线程能注册和接收信号(子线程调用 signal.signal()ValueError)、SIGKILL/SIGSTOP 不可捕获、Windows 只支持少数几个(没有 SIGHUP/SIGALRM/SIGCHLD)。handler 的黄金法则:只做「设置 Event 标志」这一件事——在里面加锁会死锁(主线程持锁时 handler 在同一线程被插入、又去拿同一把锁),logging/print 内部有锁同理,这是「服务收到 SIGTERM 后卡死」的常见原因;专业做法是 self-pipe 技巧set_wakeup_fd + select),asyncioadd_signal_handler 内部就是它(asyncio 里绝不要用 signal.signal)。优雅关闭五步且顺序不能乱:设标志 → 先停止接收新任务(摘 LB、关监听、停止消费)→ 再等待在途任务(用「总时间预算」而不是每个线程各等 N 秒)→ 释放资源 → exit(0)128+signum。三个必备前提:所有阻塞调用带超时、工作线程别用 daemon=True(会被强杀、不执行 finally)、长任务自己分块检查标志(Python 无法强制中断线程shutdown(cancel_futures=True) 只能取消尚未开始的)。容器的头号坑:CMD 写成 shell 形式会让 sh 当 PID 1 而 sh 不转发信号,必须用 exec 形式 CMD ["python", "app.py"]PID 1 还没有默认信号处理(不注册 handler 就只能等超时被 SIGKILL)且要负责回收僵尸进程,所以推荐 tini。K8s 里摘除 Endpoints 和发送 SIGTERM 是并行的,必须配 preStop sleep 5~10 秒才不会出现 502。