组长退出后其他成员仍可留在该组,而另一子进程不属于这次信号目标。

进程组选择信号接收者,不等于进程家族树

进程组是内核维护的一组进程,通过进程组编号 PGID 标识,可用于向一个作业投递信号等操作。父子关系与组成员关系是两个维度:有亲缘关系的进程可以属于不同组,组长也不必始终存活,其他成员仍可继续属于原组。理解这些关系,才能判断一个控制动作到底覆盖哪些进程,以及某个进程退出意味着什么。 坏掉的样子 向单个进程发信号,不会自动向它的所有子进程发信号。向进程组发信号,选择的是当前组成员,也不是整棵后代树。创建新会话或新组的子进程,可能已经位于目标范围之外;反过来,多个兄弟进程可以刻意共享一个组,即使任何一个都不是另一个的父进程。进程树图不能直接替代组成员清单。 组投递也不保证所有接收者作出相同响应。一个成员可能采用默认动作,另一个安装处理函数,还有成员可能忽略或阻塞允许处理的信号。因此,组操作不会自动形成原子停止,也不能证明组内全部工作完成。每个进程的信号处理方式和应用关停协议仍然决定最终结果,需要分别观察投递、响应与退出。 组长退出不等于整个组退出。只要还有成员,PGID 就可能继续有效,即使组长已经被父进程回收。只观察组长状态的监督程序,可能在成员仍运行时报告结束。组身份应与实际生命周期管理结合,不能被当作一批启动工作永久不变的标识,也不能单凭领导进程消失就推断所有资源都已释放。 快速验证 把脚本保存为 groups.py,在具有 Python 3.11 或更新版本的一次性 Linux 环境中运行 python3 groups.py。实测使用 Linux 6.12.76 和 Python 3.11.2。process_group 选项在执行程序前安排进程组,start_new_session 请求新会话。所有收到信号的进程,都由此样本创建: import os,signal,subprocess,sys worker=r''' import os,signal,sys if sys.argv[1]=='ignore-term':signal.signal(signal.SIGTERM,signal.SIG_IGN) signal.signal(signal.SIGUSR1,lambda *_:print('USR1',flush=True)) print(os.getpid(),os.getpgrp(),os.getsid(0),flush=True) while True:signal.pause() ''' children=[] def launch(role,**settings): process=subprocess.Popen([sys.executable,'-c',worker,role],stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True,**settings) children.append(process) identity=tuple(map(int,process.stdout.readline().split())) assert len(identity)==3 and identity[0]==process.pid return process,identity try: leader,first=launch('default',process_group=0) member,second=launch('ignore-term',process_group=leader.pid) outsider,third=launch('default',start_new_session=True) group=leader.pid assert first[1]==second[1]==group and first[2]==second[2]==os.getsid(0) assert third==(outsider.pid,outsider.pid,outsider.pid) assert group!=os.getpgrp() and third[1]!=group print('two sibling children share a group; another sibling starts a separate session and group') os.killpg(group,signal.SIGTERM) assert leader.wait(timeout=5)==-signal.SIGTERM assert member.poll() is None and outsider.poll() is None assert os.getpgid(member.pid)==group print('group TERM terminates the default recipient, leaves the ignoring member alive, and excludes the other group') os.killpg(group,signal.SIGUSR1) assert member.stdout.readline().strip()=='USR1' print('the group remains addressable after its leader has exited and been reaped') os.killpg(group,signal.SIGKILL) assert member.wait(timeout=5)==-signal.SIGKILL assert outsider.poll() is None outsider.terminate();assert outsider.wait(timeout=5)==-signal.SIGTERM print('cleanup reaps all three children; signaling a group did not signal the whole family tree') finally: for process in children: if process.poll() is None:process.kill();process.wait() 第一个子进程新建进程组,PGID 等于自己的 PID。第二个子进程在控制进程所在会话中加入该组。第三个子进程创建独立会话,同时成为会话首领和进程组长。脚本读取真实 PID、PGID 和会话编号,而不是仅根据请求参数推测安排已经生效,这让实验身份与操作目标可以直接核对。 ...

九月 24, 2027 · 1 分钟 · XB Field Notes
一个小钩子,在信号发生时执行命令,而不是默默退出。

Shell 的 trap 把信号转成一次清理动作

trap 让 Shell 为某个信号或自身事件安排动作,适合删除临时文件、协调退出,但不会让中断变成原子操作,也无法保证任何终止方式都能完成清理。在 Bash 中,trap 是内建命令,EXIT 是 Shell 事件,而不是操作系统信号。可靠的设计需要明确处理哪些出口,并保留执行为什么结束的证据,不能仅凭注册了一次处理器就断言资源一定会被回收。 信号处理与退出清理分开 trap 'cleanup' INT TERM 会替换这些信号的默认动作。如果处理器只是返回,Shell 可能继续执行;安装处理器并不天然等于启动关停。一种明确的 Bash 写法是在信号处理器中主动退出,再让统一的退出处理器负责清理。正常执行到末尾、显式调用退出命令,也会经过这个出口,从而减少各业务分支之间重复的资源回收逻辑。 从函数或被点命令加载的文件返回,不等于结束整个 Shell。普通执行脚本里的顶层 return 也不是通用退出方式。此外,不能跨 Shell 假定未处理的致命信号一定会触发退出清理。应明确处理准备支持的终止信号,并使用服务真正采用的解释器测试,而不是在交互终端里试过一次便推广到所有启动环境。 trap - INT 恢复默认动作,trap '' INT 则忽略信号,两者策略不同。非交互 Shell 还可能从启动者继承被忽略的信号,而且无法重新捕获或重置,所以启动路径也是行为的一部分。需要确认实际安装的动作时,可以检查 trap -p 输出;配置中写过处理器,与当前 Shell 最终持有什么处理器,仍然需要区分。 保留最初的退出原因 进入退出处理器后,应先保存 $?,避免后面的命令覆盖它。清理末尾固定执行 exit 1,会把成功改成失败,也会抹掉原来不同的错误码。使用保存的状态退出,才能保留触发收尾的原始结果。清理本身失败时仍应输出可见诊断,并制定明确策略,尤其要说明主工作成功而清理失败时最终如何报告,不能把这两种结果混成一句“脚本结束”。 ...

一月 12, 2027 · 2 分钟 · XB Field Notes
阻塞的普通信号可以合并,终止时能否清理取决于程序实际安装的行为。

信号具有处理方式、掩码和投递规则

信号依据内核规定的投递规则,把事件通知给进程或线程。信号处理方式可以选择默认动作、安装处理函数,或者在允许时忽略;线程掩码则能阻塞符合条件的信号,让它们暂时保持待处理。当程序似乎漏掉重复通知,或者没有按预期停止时,需要分别检查处理方式、阻塞状态和待处理状态,不能把这些不同层面统称为“有没有收到信号”。 坏掉的样子 普通信号不是可靠事件计数器。同一种标准信号已经处于待处理状态时,随后多次发生可能合并。如果应用把每次 SIGUSR1 都理解为一个排队任务,就可能丢失这个计数关系。实时信号具有排队语义,但资源仍然有限,也需要明确的应用协议;不能仅因为名字里有实时,就假定它适合无限量承载业务消息。 信号名称不保证应用功能。某些守护进程约定 SIGHUP 表示重载,但它的默认动作是终止。SIGTERM 默认也会终止,只有应用实现了相应行为,它才成为优雅关停请求。发送成功不能证明配置已经重载,也不能证明在途工作已经排空。控制方必须观察目标实际响应,而不是把投递结果当作业务操作结果。 SIGKILL 与 SIGSTOP 不能捕获、忽略或阻塞。KILL 不给应用执行清理的机会,但也不应被描述为在每种内核状态下都保证瞬时释放资源。监督者仍需观察实际完成。使用符号名称也更清晰,不能假定所有平台上的信号编号都完全一致;信号数字与进程退出状态还可能由工具采用不同方式表示。 快速验证 把下面脚本保存为 signals.py,在一次性 Linux 环境中运行 python3 signals.py。实测使用 Linux 6.12.76 和 Python 3.11.2。脚本只向自身和自己创建的子进程发送信号,恢复原始掩码,并把清理标记限制在临时目录中: from pathlib import Path from tempfile import TemporaryDirectory import errno,os,signal,subprocess,sys watched={signal.SIGUSR1,signal.SIGRTMIN} old=signal.pthread_sigmask(signal.SIG_BLOCK,watched) try: for _ in range(5):os.kill(os.getpid(),signal.SIGUSR1) assert signal.SIGUSR1 in signal.sigpending() assert signal.sigwait({signal.SIGUSR1})==signal.SIGUSR1 assert signal.sigtimedwait({signal.SIGUSR1},0) is None for _ in range(3):os.kill(os.getpid(),signal.SIGRTMIN) for _ in range(3):assert signal.sigtimedwait({signal.SIGRTMIN},0).si_signo==signal.SIGRTMIN assert signal.sigtimedwait({signal.SIGRTMIN},0) is None print('five blocked standard signals coalesce into one; three blocked real-time signals remain queued') finally:signal.pthread_sigmask(signal.SIG_SETMASK,old) for sig in [signal.SIGKILL,signal.SIGSTOP]: try:signal.signal(sig,signal.SIG_IGN) except OSError as error:assert error.errno==errno.EINVAL else:raise AssertionError('uncatchable signal accepted a disposition change') old=signal.pthread_sigmask(signal.SIG_BLOCK,{signal.SIGKILL,signal.SIGSTOP}) assert not {signal.SIGKILL,signal.SIGSTOP}&signal.pthread_sigmask(signal.SIG_BLOCK,set()) signal.pthread_sigmask(signal.SIG_SETMASK,old) print('SIGKILL and SIGSTOP cannot be ignored or added to the blocked mask') worker=r''' from pathlib import Path import signal,sys if int(sys.argv[1]):signal.signal(signal.SIGTERM,lambda *_:sys.exit(0)) try: print('ready',flush=True) while True:signal.pause() finally:Path(sys.argv[2]).write_text('cleanup') ''' with TemporaryDirectory(prefix='xbcatm-signals-') as directory: for name,sig,handled,expected,cleaned in [ ('hup-default',signal.SIGHUP,False,-signal.SIGHUP,False), ('term-default',signal.SIGTERM,False,-signal.SIGTERM,False), ('term-handled',signal.SIGTERM,True,0,True), ('kill',signal.SIGKILL,True,-signal.SIGKILL,False)]: marker=Path(directory)/name process=subprocess.Popen([sys.executable,'-c',worker,str(int(handled)),str(marker)],stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True) try: assert process.stdout.readline().strip()=='ready' os.kill(process.pid,sig);output,error=process.communicate(timeout=5) assert process.returncode==expected,(name,process.returncode,error) assert marker.exists()==cleaned,name print(name,'returncode',process.returncode,'cleanup',cleaned) finally: if process.poll() is None:process.kill();process.wait() 脚本先阻塞 SIGUSR1,向自己发送五次,再确认它待处理。一次同步等待取走信号后,第二次零超时等待没有结果。先阻塞再发送,让这个对照具有确定性。它证明的是待处理普通信号的合并规则,而不是根据异步回调碰巧打印几次,去猜测信号是否丢失或程序调度是否足够快。 ...

十一月 23, 2026 · 2 分钟 · XB Field Notes