管理器持续持有监听套接字,兼容的工作进程可以按需要启动与结束。

Socket 激活把监听器交给兼容的服务

Socket 激活让 systemd 在应用进程启动之前先打开监听端点。端点上的活动随后可以触发兼容服务,并把管理器已经持有的套接字描述符传给它。这里真正分开的,是监听器所有权与应用生命周期。端点已经开放,还不证明应用完成初始化、接受了请求或生成了正确响应;传输层能连接与业务层能工作,需要分别验证。 坏掉的样子 应用必须理解激活协议。如果守护进程无条件再次绑定同一个端点,就可能因为管理器已经占用而失败。激活机制把绑定责任移到 socket 单元,并不会消除地址冲突、接口缺失、文件系统权限错误,或其他导致首次绑定失败的条件。因此不能把“端口先开好”写成所有启动竞态已经自动消失,真正的监听建立仍然可能失败。 使用 Accept=no 时,服务接收监听套接字并自行接受连接;Accept=yes 则为合适的流套接字支持按连接启动服务实例的模式。它们对应不同的应用契约,不能互换。任何一种模式都不意味着任意守护进程会自动判断空闲、完成工作后退出,或者只在安全时重新启动。是否退出,以及退出时哪些请求已经完成,仍然由应用行为决定。 排队连接也有边界。客户端可以在应用初始化完成之前建立传输状态,随后因为等不到响应而超时。Socket 激活不会让数据库依赖自动就绪,也不会提供无限请求队列。真实工作负载仍需要合适的服务依赖、就绪逻辑与客户端超时;先拥有监听器只是交付链路的一部分,不等于依赖链已经完整兑现。 快速验证 在 PID 1 为 systemd 的一次性 Linux 系统中,以管理员身份运行下面的 Python 脚本。记录环境为 Debian 12、systemd 252.39。脚本在临时目录中使用 Unix 流套接字,创建唯一命名的 socket 与 service,不暴露公共 TCP 端口。工作程序有意只处理一条以换行结尾的消息,然后退出: from pathlib import Path from tempfile import TemporaryDirectory import json,socket,subprocess,time,uuid name='xbcatm-socket-'+uuid.uuid4().hex[:8];service=name+'.service';unit=name+'.socket' paths={n:Path('/run/systemd/system')/n for n in (service,unit)} assert all(not p.exists() for p in paths.values()) def run(*args): result=subprocess.run(args,capture_output=True,text=True,timeout=10) assert result.returncode==0,(args,result.stderr) return result.stdout.strip() def value(key):return run('systemctl','show',service,'-p',key,'--value') def wait_inactive(): deadline=time.monotonic()+5 while value('ActiveState')!='inactive': assert time.monotonic()<deadline,'service did not exit';time.sleep(0.02) with TemporaryDirectory(prefix=name+'-',dir='/run') as directory: root=Path(directory);endpoint=root/'listener.sock';worker=root/'worker.py' worker.write_text(r'''from pathlib import Path import errno,json,os,socket root=Path(__file__).parent assert int(os.environ['LISTEN_PID'])==os.getpid() assert int(os.environ['LISTEN_FDS'])==1 with socket.socket(fileno=3) as listener: assert listener.getsockopt(socket.SOL_SOCKET,socket.SO_ACCEPTCONN)==1 with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as duplicate: try:duplicate.bind(str(root/'listener.sock')) except OSError as error:assert error.errno==errno.EADDRINUSE else:raise AssertionError('unexpected duplicate bind success') connection,_=listener.accept() with connection,connection.makefile('rb') as incoming: message=incoming.readline(256);assert message.endswith(b'\n') connection.sendall(b'ack '+message) (root/(str(os.getpid())+'.json')).write_text(json.dumps({'pid':os.getpid(),'fd':3,'message':message.decode().strip()})) ''') paths[unit].write_text(f'[Socket]\nListenStream={endpoint}\nAccept=no\nSocketMode=0600\nRemoveOnStop=yes\n') paths[service].write_text(f'[Service]\nType=exec\nExecStart=/usr/bin/python3 {worker}\n') try: run('systemd-analyze','verify',*[str(p) for p in paths.values()]);run('systemctl','daemon-reload') run('systemctl','start',unit) assert endpoint.is_socket() and value('ActiveState')=='inactive' and value('MainPID')=='0' print('socket listening while service has no process') for message in ('first','second'): with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as client: client.settimeout(5);client.connect(str(endpoint));client.sendall((message+'\n').encode()) with client.makefile('rb') as response:assert response.readline()==('ack '+message+'\n').encode() wait_inactive();assert endpoint.is_socket() and value('Result')=='success' records=[json.loads(p.read_text()) for p in root.glob('*.json')] assert len(records)==2 and len({row['pid'] for row in records})==2 assert {row['message'] for row in records}=={'first','second'} assert all(row['fd']==3 for row in records) print('two requests: two deliberate one-request workers; each uses inherited listening fd 3') print('rebinding the occupied socket fails; manager retains listener between workers') run('systemctl','stop',unit);assert not endpoint.exists() print('stopping socket removes its endpoint with RemoveOnStop=yes') finally: run('systemctl','stop',unit,service) for name,path in paths.items(): if run('systemctl','show',name,'-p','ActiveState','--value')=='failed':run('systemctl','reset-failed',name) path.unlink() run('systemctl','daemon-reload') 只启动 socket 单元后,端点已经存在,对应服务却仍为非活动,主进程编号为零。第一个客户端连接随后触发工作程序执行,客户端发送有完整边界的消息,并检查应用回复,不会仅凭 connect 成功就认定服务可用。流式连接并不自动划分应用消息,所以样本明确使用换行作为这一小型协议的结束标记。 ...

十一月 8, 2027 · 2 分钟 · XB Field Notes
fork 创建保留部分共享引用的子进程,exec 在保留该 PID 时替换程序。

fork 创建进程,exec 替换其中的程序

fork 后接 exec,是 Unix 常见的进程启动模型之一。fork 创建子进程,成功的 execve 则替换调用进程中的程序映像。它们解决不同问题,也具有不同继承规则。在一个程序为另一个命令安排输入、输出、环境和生命周期管理时,必须分清这两个动作,否则很容易把创建新身份与替换运行内容混为一谈。 坏掉的样子 fork 不会让之后所有状态都相互独立。普通私有内存通常通过写时复制逐渐分离,但共享映射仍然共享。继承描述符也继续引用父进程对应的同一份打开文件描述,因此任一侧读取普通文件,都可能改变另一侧观察到的偏移。复制描述符表,不等于为每个文件重新独立执行 open;进程不同,也不能证明资源状态完全不同。 exec 同样不会简单保留全部启动设置。带 close-on-exec 的描述符在成功执行时关闭,其他符合条件的描述符可以继续存在。execve 接收显式环境数组,不是自动复制调用者当前环境。被捕获信号的处理方式会重置,旧内存映射也会消失,即使进程身份和若干其他属性仍然保留。需要逐项理解哪些状态跨越了替换边界。 多线程进程执行 fork 时尤其需要注意。子进程只包含发起调用的线程,却可能继承由其他、已经不在子进程中的线程持有的锁。在 exec 之前任意调用库函数,就可能死锁,因此这段间隔里的可用操作受到约束。方便理解的单线程演示,不能直接变成在线多线程服务器 fork 后随意处理业务状态的方案。 快速验证 把脚本保存为 forkexec.py,在一次性 Linux 环境中运行 python3 forkexec.py。实测使用 Linux 6.12.76 和 Python 3.11.2。样本刻意保持单线程,包含一个私有 Python 值、一份显式共享匿名映射、临时数据文件,以及传递少量结果的管道: from pathlib import Path from tempfile import TemporaryDirectory import errno,json,mmap,os,sys program=r''' import errno,json,os,sys original_pid,kept,dropped,report=map(int,sys.argv[1:]) assert os.getpid()==original_pid try:os.fstat(dropped) except OSError as error:assert error.errno==errno.EBADF else:raise AssertionError('close-on-exec descriptor survived') assert os.read(kept,2)==b'ef' assert os.environ['XB_FORK_VALUE']=='child-value' os.write(report,json.dumps({'pid_unchanged':True,'kept_descriptor':'ef','close_on_exec':'closed','explicit_environment':'child-value'}).encode()) ''' with TemporaryDirectory(prefix='xbcatm-fork-') as directory: data=Path(directory)/'data';data.write_bytes(b'abcdef') descriptor=os.open(data,os.O_RDONLY);dropped=os.dup(descriptor) read_end,write_end=os.pipe();os.set_inheritable(descriptor,True);os.set_inheritable(write_end,True) assert not os.get_inheritable(dropped) private=['parent'];shared=mmap.mmap(-1,8,flags=mmap.MAP_SHARED);shared[:]=b'parent!!' assert os.read(descriptor,2)==b'ab' sys.stdout.flush();pid=os.fork() if pid==0: try: os.close(read_end);private[0]='child';shared[:]=b'child!!!' assert os.read(descriptor,2)==b'cd' try:os.execve(str(Path(directory)/'missing-program'),['missing-program'],{}) except FileNotFoundError:pass else:raise AssertionError('missing exec unexpectedly succeeded') os.execve(sys.executable,[sys.executable,'-c',program,str(os.getpid()),str(descriptor),str(dropped),str(write_end)],{'XB_FORK_VALUE':'child-value'}) except BaseException as error: os.write(write_end,('ERROR '+repr(error)).encode());os._exit(1) os.close(write_end) try: waited,status=os.waitpid(pid,0);report=os.read(read_end,4096) assert waited==pid and os.WIFEXITED(status) and os.WEXITSTATUS(status)==0,report result=json.loads(report);assert result['pid_unchanged'] assert private==['parent'] and shared[:]==b'child!!!' assert os.lseek(descriptor,0,os.SEEK_CUR)==6 print('fork: private Python state diverges, MAP_SHARED changes and open-description offsets remain shared') print('missing exec returns to the child; successful exec keeps its PID, honors close-on-exec, and receives an explicit environment') print('parent reaped a successful exit with waitpid and retained its original private value') finally: shared.close() for fd in [descriptor,dropped,read_end]:os.close(fd) fork 之前,父进程从 abcdef 读取 ab。子进程修改自己的 Python 列表和共享映射,再通过继承描述符读取 cd。子进程结束后,父进程列表仍为原值,共享映射却包含子进程写入的字节。这两个结果需要共同解释,才能准确描述内存继承:只展示普通变量不同,无法证明不存在共享映射。 ...

十月 12, 2027 · 2 分钟 · XB Field Notes
创建权限由请求模式结合进程掩码或继承的默认 ACL 规则确定。

umask 过滤新建权限,默认 ACL 会改变规则

进程的 umask 在普通模式位创建规则下,为新文件和目录移除权限位。应用先提供请求模式,掩码再限制这份请求。它既不是完整访问策略,也不会重写所有既有文件。如果父目录具有默认访问控制列表,创建计算方式还会改变,因此只检查进程掩码,可能无法准确预测最后得到的权限。排查时需要同时看到应用请求和目录环境。 坏掉的样子 没有默认 ACL 时,普通权限结果是请求模式与 umask 按位取反后的值进行按位与。例如请求 0666、掩码 0022,得到 0644。这个计算不是算术减法:相同掩码下请求 0600,仍然得到 0600。应用没有请求执行权限时,宽松掩码也不会主动添加执行位;掩码负责移除,而不是凭空授予应用未请求的普通权限。 限制严格的 umask 不会修复已经存在的全员可写文件。打开并截断该文件,会改变内容,却保留既有模式。后续 chmod 也可以不受当前掩码限制地扩大权限。因此,把 umask 当作权限永远不能超过的上界,会遗漏既有文件与之后权限修改两类情况。必须区分文件首次创建,以及此后围绕同一 inode 发生的操作。 父目录具有默认 ACL 时,Linux 从它派生新对象的访问 ACL,再按应用请求模式限制权限,不再按普通方式应用进程 umask。因此,即使创建者掩码是 0077,共享目录里仍可能生成组可访问文件。遇到与预期不同的模式时,应检查继承的 ACL,而不能只看到严格掩码就断言内核没有遵守权限设置。 快速验证 把脚本保存为 mask.py,在安装 Python 3 和 setfacl 的 Linux 环境中运行 python3 mask.py。临时目录所在文件系统必须支持默认 ACL。实测使用 Python 3.11.2、acl 2.3.1,以及通过 TMPDIR 选择的独立 ext4 卷。最初 tmpfs 环境拒绝设置默认 ACL,因此完整实验随后在 ext4 上重新执行: ...

十月 9, 2027 · 2 分钟 · XB Field Notes
一个生产者向消费者持续传数据的连接。

管道让进程彼此流式连接

shell 管道把一个命令的标准输出连接到下一个命令的标准输入。各段命令可以并发运行,生产者不必全部完成后消费者才开始处理。这种连接支持增量处理,却不保证任一程序真的采用增量算法。用户态缓冲、提前结束以及各阶段的退出状态,都会影响整项操作是否达到预期。理解管道时,需要同时观察数据流、文件描述符生命周期和进程结果,不能只看最后一行输出。 有限容量的字节流 管道传输的是字节,不是自带边界的记录。读者不能假设每次读取恰好得到一行,也不能假设一次读取对应生产者的一次写入。需要消息边界的程序必须自己定义和解析。标准错误默认独立于管道连接,因此,上游诊断可能显示在终端,却不会进入下游过滤器。是否合并诊断和数据应当明确决定,避免错误文本污染本来供机器读取的内容。 内核缓冲区容量有限。对于阻塞操作,管道满时写者等待;管道空但仍有写端打开时,读者等待。这种反压限制生产者在这个管道上领先消费者的距离。容量取决于操作系统和配置,并不是普遍固定为六十四千字节。程序自身仍可能维护不断增长的数据结构,因此,使用管道不等于整个处理链的内存已经有界,必须另外检查各段算法。 用户态缓冲还可能引入额外等待。某个程序向终端逐行刷新,改成管道输出后却可能先积累较大的块。此时消费者看起来没有动静,并不代表内核丢了数据;数据可能尚未离开生产者的用户态缓冲。应先检查程序提供的流式处理和刷新控制,再考虑调整内核限制。完整排序等转换本身也需要先看到更多输入,添加随意的睡眠无法改变这种依赖。 坏掉的样子 只有所有指向写端的文件描述符都关闭后,读者才会看到文件结束。如果某个子进程继承了不再使用的写端,即使看起来生产者已经退出,消费者也可能一直等不到结束。反方向上,所有读端关闭后继续写入会产生 SIGPIPE;忽略这个信号时,写操作会以 EPIPE 错误失败。因此,消费者主动提前结束,也可能让原本正常的生产者以非零状态退出。 Bash 默认返回最后一段命令的状态。上游失败、下游 cat 成功读完部分数据时,整体可能仍然成功。开启 pipefail 后,整体返回从左到右最靠右的非零状态,并不是时间上最先失败的进程,也不是最左侧失败命令。PIPESTATUS 数组短暂保留每段结果,应立即保存,因为后面一次赋值或打印就可能替换这份观察记录。 Bash 管道中的阶段通常运行在子 shell 环境。在管道右侧循环里给变量赋值,通常不会修改调用者的变量。lastpipe 选项在关闭作业控制时可以改变最后一段的执行位置,因此依赖状态保留的代码必须明确 shell 配置。需要保留循环状态时,也可以让循环直接通过重定向读取输入,避免把它放进管道带来的作用域变化。 前台 Bash 管道会等待各阶段结束,并不是消费者读到第一行就立即返回。如果生产者捕获断管错误后继续执行其他工作,整体完成时间仍会被拖延。因此,管道应被理解为多个具有独立结果、通过描述符连接的进程,不能假设它们总会像一个不可分割操作一样共同成功或共同停止。 快速验证 下面实验需要 Bash、cat、yes 和 head。它先用主动返回七的生产者比较默认状态和 pipefail,然后构造两段都失败的管道,最后检查提前关闭的消费者。仅在收集预期非零结果时关闭 errexit,取得结果后立即重新开启,再执行断言。 bash --noprofile --norc <<'BASH' set -e set +o pipefail set +e (printf 'partial\n'; exit 7) | cat >/dev/null plain=("$?" "${PIPESTATUS[@]}") set -e [[ ${plain[*]} == '0 7 0' ]] set -o pipefail set +e (printf 'partial\n'; exit 7) | cat >/dev/null strict=("$?" "${PIPESTATUS[@]}") set -e [[ ${strict[*]} == '7 7 0' ]] set +e (exit 7) | (exit 4) multiple=("$?" "${PIPESTATUS[@]}") set -e [[ ${multiple[*]} == '4 7 4' ]] set +e yes | head -n 1 >/dev/null early=("$?" "${PIPESTATUS[@]}") set -e [[ ${early[0]} -ne 0 && ${early[1]} -ne 0 && ${early[2]} -eq 0 ]] printf 'default: %s\npipefail: %s\nrightmost: %s\nearly close: %s\n' \ "${plain[*]}" "${strict[*]}" "${multiple[*]}" "${early[*]}" printf 'PASS: aggregate and individual stage outcomes\n' BASH 每个打印数组的第一项是管道整体状态,后面按源码顺序列出各段状态。前三组预期分别为零七零、七七零、四七四。最后一组中,head 成功,而 yes 返回非零;断言没有硬编码具体信号编号,避免把平台表示方式混入行为要求。生产环境应区分这种主动提前停止与必需输出丢失,不能看到 pipefail 非零就无条件重试全部操作。吞吐量、缓冲延迟和内存峰值仍需独立测量,这些退出状态断言并没有验证性能。 ...

九月 28, 2027 · 1 分钟 · XB Field Notes
父进程把已导出配置的快照传给另一个程序。

环境变量属于各自的进程

环境是程序启动时收到的一组名称与字符串值。子进程通常接收父进程选定的环境,修改自己的副本不会回写父进程或兄弟进程。这是一条有用的配置边界,却不是命令没有副作用的保证:程序仍可能根据配置修改共享文件、数据库或其他资源。理解变量如何传递,与判断程序实际会做什么,是两个需要分别核对的问题。 导出决定另一个程序能收到什么 Shell 变量与环境条目有关联,但并不是同一概念。DEMO_SETTING=local 创建或更新 Shell 参数,export DEMO_SETTING 才把它标记为启动外部命令时应包含的环境项。父 Shell 能打印变量,不代表新启动的 Bash 就能看到未导出的值。把赋值写进某个配置文件,也不会让每个 Shell 自动读取它;还必须确认文件是否在对应启动路径上被加载。 Bash 从环境继承的变量已经带有导出属性,之后重新赋值会保留该属性。因此子进程修改值后,再启动孙进程时可以直接传递新值,不要求每一代重复写一次导出命令。unset 删除变量,Bash 的 export -n 则取消导出属性但保留当前 Shell 中的值。真正需要确认的是直接启动者传入了什么,而不是机械地搜索祖先脚本里出现了多少次导出语句。 子 Shell 不等于重新执行一个 Shell 程序 圆括号命令组和命令替换使用从当前 Shell 复制出来的子 Shell 环境,所以能够看到从未导出的普通 Shell 变量。它们的修改仍不会回到外层。这与重新执行一个 Bash 程序不同:后者收到的是导出的环境条目,不是父 Shell 整张参数表。若把两种情况都简单称为“子进程只认识已导出变量”,就会错误解释圆括号内的实际行为。 使用点命令加载文件时,文件中的命令在当前 Shell 执行,赋值因而能直接改变调用者。这并没有打破进程边界,因为根本不是另一个进程把修改传回来。被加载的文件是可执行 Shell 代码,不是被动的数据文件;只有当你确实打算在当前上下文执行那些内容时,才应加载它。文件扩展名叫配置,并不会改变这一点。 ...

九月 25, 2027 · 1 分钟 · XB Field Notes
组长退出后其他成员仍可留在该组,而另一子进程不属于这次信号目标。

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

进程组是内核维护的一组进程,通过进程组编号 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 内建的 ulimit 改变的是该 shell 及其随后启动进程的资源限制。对于文件描述符,RLIMIT_NOFILE 包含当前生效的软值与硬性上界。当终端显示很宽松的数字,独立受管服务却仍然无法打开文件或接受连接时,关键往往在于这些值从哪里继承。只知道某个命令可以调大数字,并不足以证明改到了真正执行工作负载的进程。 坏掉的样子 软值通常约束新的描述符分配。普通进程可以在现有硬上限内提高软值,也可以降低硬值;提高硬值则需要相应权限,而且仍然受内核约束。因此,启动时继承的硬值太低,可能使应用无法设置自己打算使用的软值。不能只检查应用配置写了多少,还要核对它是否真正有能力应用那个数字,以及失败有没有被正确处理。 子进程修改自身限制,不会改变启动它的父进程。同样,在新终端里执行命令,也不会追溯配置已经由 systemd 启动的守护进程。服务实际父进程和启动配置决定其初始值;shell 配置文件只有在真实启动路径读取并应用时才有效。把交互终端的状态当成机器上所有进程的共同状态,会让排障从一开始就观察错对象。 Bash 的选项选择也需要注意。读取 ulimit -n 通常显示软值,但赋值时如果没有选择 -S 或 -H,会同时修改软硬两项。只想暂时降低软限制的操作者,可能顺带降低硬上限,随后无法再以普通权限恢复。应依据具体 shell 的文档理解语义,不能把几条长得相似的命令当成完全等价。 快速验证 在装有 Bash 和 Python 的一次性 Linux 容器中,以管理员身份运行下面的脚本。它通过一个普通数字身份执行实验,管理员控制进程的限制保持不变。实测环境为 Linux 6.12.76、Bash 5.2.15、Python 3.11.2,没有修改任何既有账号配置或服务单元: import resource,subprocess before=resource.getrlimit(resource.RLIMIT_NOFILE) assert before[0]>=64 and before[1]>=64 script=r''' set -eu ulimit -S -n 32 ulimit -H -n 64 python3 - <<'CHILD' import resource assert resource.getrlimit(resource.RLIMIT_NOFILE)==(32,64) resource.setrlimit(resource.RLIMIT_NOFILE,(48,64)) assert resource.getrlimit(resource.RLIMIT_NOFILE)==(48,64) print('first child inherits 32/64 and raises its own soft limit to 48') CHILD python3 - <<'CHILD' import resource assert resource.getrlimit(resource.RLIMIT_NOFILE)==(32,64) print('second child still inherits shell values 32/64') CHILD if ulimit -S -n 65 2>/dev/null; then exit 11; fi if ulimit -H -n 128 2>/dev/null; then exit 12; fi ( ulimit -n 16 python3 - <<'CHILD' import resource assert resource.getrlimit(resource.RLIMIT_NOFILE)==(16,16) print('Bash assignment without -S/-H changed both limits in this subshell') CHILD ) python3 - <<'CHILD' import resource assert resource.getrlimit(resource.RLIMIT_NOFILE)==(32,64) print('parent shell limits survived child changes and rejected invalid increases') CHILD ''' # Run the experiment under an unprivileged numeric identity in this disposable container. result=subprocess.run(['bash','--noprofile','--norc','-c',script],user=61001,group=61001,extra_groups=[], env={'PATH':'/usr/bin:/bin','LC_ALL':'C'},capture_output=True,text=True,timeout=10) assert result.returncode==0,(result.returncode,result.stderr) assert resource.getrlimit(resource.RLIMIT_NOFILE)==before print(result.stdout,end='') print('administrator controller limits remain unchanged') 测试 shell 先明确设置软值三十二、硬值六十四。它启动的第一个 Python 子进程继承这组值,然后在硬值保持六十四的情况下,成功把自己的软值提高到四十八。这展示的是现有上界内允许的调整,不需要子进程拥有管理员权限,也不等于它可以绕过硬限制任意扩大资源范围。 ...

七月 19, 2027 · 2 分钟 · XB Field Notes
不同描述符编号可以共享一份打开描述,独立打开则保留各自偏移。

文件描述符标识进程持有的打开引用

文件描述符是一个进程内部用于引用已打开资源的小整数。进程的描述符表把这个数字映射到内核维护的打开文件描述;对于普通文件,打开描述包含当前偏移和文件状态标志。另一个进程可以把相同整数用于完全不同的资源,因此数字本身不是全局文件身份。理解描述符表、打开描述与底层文件之间的关系,才能正确判断复制、关闭和继承的效果。 坏掉的样子 dup 分配另一个描述符,让它引用同一份打开文件描述;它不是简单复制整数,也不是重新按路径打开文件。因此,两份描述符读取普通文件时共享偏移。相反,再次打开相同路径,通常会得到具有独立偏移的新打开描述。设备与 inode 身份相同,不代表打开状态也相同,排查读取位置异常时必须区分这两个层面。 状态标志和描述符标志也属于不同层次。通过 F_SETFL 改变共享打开描述的状态,复制出来的引用都会看到;close-on-exec 标志则分别属于每个描述符。把这些层次混在一起,可能改变另一个消费者的行为,也可能让描述符意外进入后续执行的程序。选择复制引用之前,需要知道自己想独立控制的是寿命、偏移,还是继承行为。 关闭描述符会释放表项,整数可能立即被再次使用。只保留旧数字的代码,之后可能操作新资源,而不是稳定得到“无效描述符”错误。当描述符在回调、线程或库之间传递时,需要明确所有权和寿命,不能仅检查一个整数非负,就认定它仍然代表原来那个对象。数字看起来没有变化,引用关系却可能已经完全不同。 快速验证 在一次性 Linux 环境中运行下面的 Python 脚本。它创建两份无害临时文件,并关闭自己打开的所有描述符。实测环境为 Linux 6.12.76、Python 3.11.2。实验没有后台线程,因此可以观察分配与复用,不会有另一个线程同时抢占刚释放的编号: from pathlib import Path from tempfile import TemporaryDirectory import fcntl,os with TemporaryDirectory(prefix='xbcatm-fds-') as directory: root=Path(directory);original=root/'original';other=root/'other' original.write_bytes(b'abcdef');other.write_bytes(b'replacement') handles=set() try: first=os.open(original,os.O_RDONLY);handles.add(first) duplicate=os.dup(first);handles.add(duplicate) independent=os.open(original,os.O_RDONLY);handles.add(independent) assert first!=duplicate assert os.read(first,2)==b'ab' and os.read(duplicate,2)==b'cd' assert os.read(independent,2)==b'ab' assert os.lseek(first,0,os.SEEK_CUR)==4 print('dup uses a different descriptor but shares offset; a separate open has its own offset') flags=fcntl.fcntl(duplicate,fcntl.F_GETFL) fcntl.fcntl(duplicate,fcntl.F_SETFL,flags|os.O_NONBLOCK) assert fcntl.fcntl(first,fcntl.F_GETFL)&os.O_NONBLOCK assert not fcntl.fcntl(independent,fcntl.F_GETFL)&os.O_NONBLOCK os.set_inheritable(first,False);os.set_inheritable(duplicate,True) assert not os.get_inheritable(first) and os.get_inheritable(duplicate) print('file status flags shared by dup; close-on-exec flags remain descriptor-specific') os.close(first);handles.remove(first) reused=os.open(other,os.O_RDONLY);handles.add(reused) assert reused==first and os.read(first,32)==b'replacement' assert os.read(duplicate,2)==b'ef' print('closed descriptor number is reused for another file; surviving duplicate still reads original') original.unlink();os.lseek(duplicate,0,os.SEEK_SET) assert os.fstat(duplicate).st_nlink==0 and os.read(duplicate,32)==b'abcdef' print('unlink removes the pathname while an open descriptor retains access') finally: for handle in handles:os.close(handle) 第一次打开读取 ab,复制描述符接着读取 cd,两者此时都看到偏移四。对同一个文件独立执行 open,则仍从自己的起始位置读到 ab。这些精确字节证明哪份状态被共享;仅仅看到两个入口都能读取相同内容,并不足以区分复制引用和独立打开,因为它们本来就可以指向同一个底层文件。 ...

二月 21, 2027 · 1 分钟 · XB Field Notes