交互式作业控制让 shell 管理共享控制终端的多个进程组。终端同一时刻只有一个前台组,其他作业可以在后台运行,也可以保持停止状态。前台归属、是否运行和 shell 的作业表,是三种不同状态。后台作业不等于已经暂停,停止作业也不等于已经终止;判断当前交互为什么没有进展,需要先分清这些状态。

坏掉的样子

启用终端信号处理时,输入已配置的中断或暂停字符,会由终端驱动向前台组发送信号,不是只发送给最后打印文字的那个进程。控制字符重新映射、终端模式变化以及应用信号处理函数,都可能改变结果。因此只说按某个键,并不能保证在每个程序和每套终端配置中得到相同反应,还要核对实际处理条件。

普通后台作业尝试读取自己的控制终端时,会被 SIGTTIN 停止。把它放到后台,并不会给它单独分配一份键盘输入流。输出又有不同规则:后台写入通常允许,还会受 TOSTOP 等终端设置影响。因此后台执行不意味着彻底脱离终端,输入、输出和前台信号各有自己的约束,需要分别观察。

%1 这类作业标识属于某个 shell 的作业表,不是全局进程编号。换一个终端或进入非交互 shell 后,不能假定相同号码仍然管理相同任务。管道和后台命令如何分组,也取决于真实 shell 调用是否启用了作业控制。交互终端中看见的行为,不能不加检查地套到所有脚本启动路径上。

快速验证

把脚本保存为 jobcontrol.py,在装有 Bash 和 Python 的 Linux 环境中运行 python3 jobcontrol.py。本轮使用 Linux 6.18.44、Bash 5.2.37 和 Python 3.12.14 原样重跑了两种语言的样本。脚本创建自己的伪终端与交互 Bash,不会把控制字符发给你用于启动它的终端:

from pathlib import Path
import os,pty,re,select,signal,termios,time

pid,master=pty.fork()
if pid==0:
    attributes=termios.tcgetattr(0);attributes[3]&=~termios.ECHO;attributes[3]|=termios.ISIG|termios.ICANON
    attributes[6][termios.VINTR]=b'\x03';attributes[6][termios.VSUSP]=b'\x1a';attributes[6][termios.VEOF]=b'\x04'
    termios.tcsetattr(0,termios.TCSANOW,attributes)
    os.execve('/bin/bash',['bash','--noprofile','--norc','-i'],{'PATH':'/usr/bin:/bin','TERM':'dumb','LC_ALL':'C','PS1':'XB-PROMPT> ','HISTFILE':'/dev/null'})
known_groups={pid};pending=b''
def until_prompt():
    global pending
    deadline=time.monotonic()+5
    while b'XB-PROMPT> ' not in pending:
        remaining=deadline-time.monotonic();assert remaining>0,pending
        ready,_,_=select.select([master],[],[],remaining);assert ready,pending
        chunk=os.read(master,65536);assert chunk,'PTY closed before prompt';pending+=chunk
    output,pending=pending.split(b'XB-PROMPT> ',1)
    return output.decode(errors='replace')
def command(text):os.write(master,text.encode()+b'\n');return until_prompt()
def wait_for(predicate):
    deadline=time.monotonic()+5
    while not predicate():
        assert time.monotonic()<deadline,'terminal state did not change';time.sleep(0.01)
def state(process):
    try:return (Path('/proc')/str(process)/'stat').read_text().rsplit(') ',1)[1].split()[0]
    except FileNotFoundError:return None
try:
    until_prompt();assert os.tcgetpgrp(master)==pid
    os.write(master,b'sleep 60 | cat\n')
    wait_for(lambda:os.tcgetpgrp(master)!=pid)
    group=os.tcgetpgrp(master);known_groups.add(group)
    def pipeline_started():
        children=[]
        for process in Path('/proc').iterdir():
            if not process.name.isdecimal():continue
            try:
                fields=(process/'stat').read_text().rsplit(') ',1)[1].split()
                if int(fields[1])==pid:
                    children.append(((process/'comm').read_text().strip(),int(fields[2])))
            except (FileNotFoundError,ProcessLookupError):continue
        return sorted(children)==[('cat',group),('sleep',group)]
    wait_for(pipeline_started)
    os.write(master,b'\x1a');output=until_prompt()
    assert 'Stopped' in output and os.tcgetpgrp(master)==pid,output
    assert 'Stopped' in command('jobs -l')
    print('Ctrl-Z stops the foreground pipeline and returns the terminal to the interactive shell')
    command('bg %1');assert os.tcgetpgrp(master)==pid
    os.write(master,b'fg %1\n');wait_for(lambda:os.tcgetpgrp(master)==group)
    os.write(master,b'\x03');until_prompt()
    assert 'RESULT:130' in command("printf 'RESULT:%s\\n' \"$?\"")
    print('bg resumes without foreground ownership; fg restores ownership; Ctrl-C gives this pipeline status 130')
    output=command("cat & printf 'BG:%s\\n' \"$!\"")
    background=int(re.search(r'BG:(\d+)',output)[1]);known_groups.add(background)
    wait_for(lambda:state(background)=='T')
    assert os.tcgetpgrp(master)==pid
    assert 'Stopped (tty input)' in command('jobs -l')
    print('a background reader is stopped by terminal input rules, rather than receiving the shell keyboard stream')
    os.write(master,b'fg %+\n');wait_for(lambda:os.tcgetpgrp(master)==background)
    os.write(master,b'hello\n\x04');output=until_prompt();assert 'hello' in output,output
    assert 'RESULT:0' in command("printf 'RESULT:%s\\n' \"$?\"")
    print('foregrounding that reader lets it read; EOF ends it normally; all jobs are reaped')
    assert not command('jobs -p').strip()
    os.write(master,b'exit\n');waited,status=os.waitpid(pid,0);assert waited==pid and os.WIFEXITED(status)
    pid=None
finally:
    if pid is not None:
        for group in known_groups:
            try:os.killpg(group,signal.SIGKILL)
            except ProcessLookupError:pass
        os.waitpid(pid,0)
    os.close(master)

样本启用终端信号处理和规范输入模式,明确配置中断、暂停与 EOF 字符,并关闭输入回显。Bash 启动时不加载用户配置文件,使用可识别提示符。这些条件使实验不依赖用户别名、提示符命令或自定义快捷键;观察到的行为来自样本明确建立的终端状态,而不是碰巧继承的交互环境。

Bash 首先在前台运行 sleep 与 cat 管道,控制进程观察到终端前台 PGID 从 Bash 变成管道。本轮有一次在只确认前台 PGID 变化后发送暂停字符,最终等待超时;没有追踪到内核层面的确切原因。因此现在先从 Linux 的进程信息中确认本实验的 sleep 与 cat 都已启动且位于预期进程组,再发送暂停字符。这个检查只用于私有、受控且尚未退出的两个子进程,不是通用的进程发现方法。发送暂停字符后,作业停止,输出 Stopped,终端前台回到 Bash。jobs -l 确认 shell 仍然保留该作业记录。停止并没有销毁任务,因此后续仍可恢复,而不是必须重新启动一份新的工作。

执行 bg %1 后,管道恢复,Bash 仍保留终端前台;fg %1 再把前台归属交回管道。发送中断字符后,采用默认行为的这条管道结束,Bash 报告状态一百三十。这是此次命令的实测结果,不代表每个应用都必须响应 INT 退出,也不代表所有运行时都使用相同数字表达信号终止。

脚本接着启动后台 cat,记录其 PID,并等待进程进入停止状态。Bash 报告 Stopped (tty input),说明它遇到终端输入限制,没有完成,也没有读走 shell 的下一条命令。后台读取期间,shell 始终是终端前台组。这样可以把等待交互资格,与程序崩溃、正常结束或主动睡眠分开解释。

将该读取者调到前台后,它能收到 hello;在规范输入的空行位置发送 EOF 字符,使它正常结束。Bash 随后报告零状态,作业表为空。控制进程退出 Bash 并回收它;断言失败时,清理也只针对实验创建的隔离进程组。整个流程验证停止、恢复、输入和退出,而不是只检查命令是否被 shell 接受。

明确工作寿命和输入来源

停止作业不需要交互输入就能继续时,可以使用 bg;再次需要终端时使用 fg。把输入重定向到合适文件或管道,可以让后台工作不依赖键盘读取,但不会自动让它脱离会话,也不会定义登录结束时的行为。因此“在后台运行”只回答当前调度与交互关系的一部分,不能替代完整生命周期设计。

需要跨交互会话持续运行,并明确重启、日志和资源策略的工作,应交给服务管理器。shell 作业表适合管理当前交互任务,却不是持久服务状态。把任务移出作业表,或者调整挂断信号处理,也只解决更窄的问题,不能直接等同于已经建立可靠的后台服务监督。

当作业看似冻结,应先检查它是停止、等待数据,还是仍在计算,再决定是否发送终止信号。需要把作业标识、PID、PGID 与终端前台组对应起来,才能避免把正常的终端输入停止误判为崩溃。正确识别状态,有助于恢复原本需要的交互,同时保留仍然有效的工作进度。

来源

  • Bash job-control basics,前台进程组、终端输入、暂停与作业状态,已核验 2026-10-15。
  • Bash job-control builtins,jobs、bg、fg 与 shell 局部作业标识,已核验 2026-10-15。
  • termios(3),终端控制字符与信号生成,已核验 2026-10-15。
  • tcgetpgrp(3),读取与选择终端前台进程组,已核验 2026-10-15。
  • proc_pid_stat(5),用于受控子进程就绪检查的父进程与进程组标识,已核验 2026-10-15。
  • proc_pid_comm(5),仅用于识别本实验受控子进程的命令名称,已核验 2026-10-15。