作业控制在进程组之间交接终端前台
交互式作业控制让 shell 管理共享控制终端的多个进程组。终端同一时刻只有一个前台组,其他作业可以在后台运行,也可以保持停止状态。前台归属、是否运行和 shell 的作业表,是三种不同状态。后台作业不等于已经暂停,停止作业也不等于已经终止;判断当前交互为什么没有进展,需要先分清这些状态。 坏掉的样子 启用终端信号处理时,输入已配置的中断或暂停字符,会由终端驱动向前台组发送信号,不是只发送给最后打印文字的那个进程。控制字符重新映射、终端模式变化以及应用信号处理函数,都可能改变结果。因此只说按某个键,并不能保证在每个程序和每套终端配置中得到相同反应,还要核对实际处理条件。 普通后台作业尝试读取自己的控制终端时,会被 SIGTTIN 停止。把它放到后台,并不会给它单独分配一份键盘输入流。输出又有不同规则:后台写入通常允许,还会受 TOSTOP 等终端设置影响。因此后台执行不意味着彻底脱离终端,输入、输出和前台信号各有自己的约束,需要分别观察。 %1 这类作业标识属于某个 shell 的作业表,不是全局进程编号。换一个终端或进入非交互 shell 后,不能假定相同号码仍然管理相同任务。管道和后台命令如何分组,也取决于真实 shell 调用是否启用了作业控制。交互终端中看见的行为,不能不加检查地套到所有脚本启动路径上。 快速验证 把脚本保存为 jobcontrol.py,在装有 Bash 和 Python 的 Linux 环境中运行 python3 jobcontrol.py。实测使用 Linux 6.12.76、Bash 5.2.15 和 Python 3.11.2。脚本创建自己的伪终端与交互 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) 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 启动时不加载用户配置文件,使用可识别提示符。这些条件使实验不依赖用户别名、提示符命令或自定义快捷键;观察到的行为来自样本明确建立的终端状态,而不是碰巧继承的交互环境。 ...