一种假装物理内存约束不存在、让分配保持廉价的策略。

Linux 的 overcommit 是内核的一场豪赌

调整 vm.overcommit_memory,把从内核盲目乐观里让出的那部分判断交回你自己的预算,最终决定谁在 OOM 时被牺牲。 为什么 malloc 很少失败 vm.overcommit_memory 默认为 0,处在启发式模式:它几乎对每一个 mmap 和 malloc 都说好,只要系统还大概承担得起;只有当内存真正吃紧时才拒绝。因为虚拟内存的承诺本身几乎零成本,可容纳的页又是点到即给,所以绝大多数分配都会成功,普通进程以近乎免费的方式活得好好的。这份慷慨的代价是内核可以超卖远多于物理内存。它赌你不会真正写满每一页;一旦太多字节真的落到内存里,总有人要用一次 OOM kill 来买单。 那个开关 vm.overcommit_memory 让你选择由谁来拿预算。默认 0 是启发式超卖:内核只拒绝那些明显不可能兑现的请求,其余几乎照单全收,赌的是多数进程永远不会触碰自己申请的每一个字节。设为 1 表示总是允许超卖:内核几乎不再设防,适合那些映射巨大但大多保持稀疏、长期不触碰的程序,代价是所有失败都被推迟到回收路径或杀手裁决那一刻。设为 2 则是严格记账:凡是超出 CommitLimit 的申请都会在调用当场以内存不足失败,这个上限大致等于交换空间加上物理内存乘以 overcommit_ratio 的比例,当 overcommit_kbytes 非零时以后者为准。两种极端都不免费:总是超卖会把压力藏到整机停顿才爆发,严格记账则会让那些申请慷慨但实际触碰很少的程序直接启动失败,应当按你想控制的风险来选。 启发式对严格上限 当账最终要平的那天到来,真正的问题是谁来买单。启发式模式下,内核会继续超卖,并在内存压力下随机挑一个牺牲品;严格模式下,边界事先画好,失败在 malloc 这一步就浮出水面,由运维而不是调度器决定哪个进程去吞这枚 OOM kill。容器与虚拟机通常会把它调成受控值:一台代表许多 guest 一起超卖的主机,在需求同时冲高时可能把自己的底盘一起压垮。笔记本可以大方一点;而一台绝对不能悄悄丢掉某个工作进程的产线上,就应当把选择说得明明白白。 坏掉的样子 在默认启发式模式下,最让人困惑的故障是:一次巨大的 malloc 或 mmap 明明返回成功,进程却在稍后被杀掉。申请瞬间还没有触碰任何页面,看起来一切正常;等进程真正写入这些页面、内存开始吃紧,内核杀手就会挑选一个牺牲品,而它往往不是当初超额申请的那个,而是旁边体积最大的进程。日志里只剩一句内存不足杀进程,却找不到任何一次分配失败,排查很容易追错二进制文件,真正的超卖源头反而藏在身后。 ...

十二月 18, 2027 · 2 分钟 · XB Field Notes
清理规则删除符合条件的目录项,同时保留近期文件和排除的数据。

systemd-tmpfiles 执行创建与年龄策略

systemd-tmpfiles 用配置文件描述文件系统对象与维护规则。一条规则可以按指定模式创建目录,年龄规则则可以让旧条目符合清理条件,但只有命令实际执行对应操作,效果才会发生。这是一种有用的生命周期策略,不是连续监视器,也不是硬性存储配额。目录已经声明、清理命令曾经运行和磁盘空间始终有界,是三种不能互相替代的事实。 坏掉的样子 年龄不只是“创建文件多久了”,也不只是“多久没有修改内容”。文档中的默认规则考虑多个时间戳,包括访问与修改时间、文件状态变化时间,以及可获得的创建时间。某个参与判断的时间戳较新,就可能保留一个看起来 mtime 很老的条目。因此,为了测试而改写时间戳,也可能以意外方式改变结果;不能只观察其中一个字段就断定工具应该删除文件。 --create 和 --clean 执行不同操作。创建配置中的目录,不证明里面的旧内容已经清除。下面使用的 d 规则保留目录本身,同时让符合年龄条件的内容参与清理;其他类型有不同删除语义。不能因为都叫“清理”,就随意换成递归移除规则,否则可能把按条件删除变成完全不同的破坏性操作。 年龄清理中的 x 排除规则可以保留匹配目录及其内容,X 则只排除目录本身,没有相同的递归保护。它们也不会覆盖显式 r 或 R 删除规则。判断一次维护会删什么,必须同时阅读最终生效配置与执行操作,而不是只找到某条看起来写着“忽略”的记录,就认定其他规则都不可能影响该路径。 快速验证 在装有 systemd-tmpfiles 的一次性 Linux 环境中,以管理员身份运行下面的 Python 脚本。实测环境为 Debian 12、systemd 252.39。脚本传入一份明确配置和临时 --root,所有创建对象与删除目标都属于本次实验,不会使用当前系统平时执行的 tmpfiles 配置: from pathlib import Path from tempfile import TemporaryDirectory import os,stat,subprocess,time with TemporaryDirectory(prefix='xbcatm-tmpfiles-') as directory: root=Path(directory);config=root/'policy.conf' definition='d /work 0750 - - 1s -\nx /work/keep - - - - -\n' config.write_text(definition) def apply(action): result=subprocess.run(['systemd-tmpfiles','--root='+str(root),action,str(config)],capture_output=True,text=True,timeout=10) assert result.returncode==0,result.stderr apply('--create');work=root/'work' assert stat.S_IMODE(work.stat().st_mode)==0o750 old=work/'old';old.write_text('fixture');past=time.time()-60;os.utime(old,(past,past)) apply('--clean');assert old.exists() print('old atime and mtime alone do not satisfy the default age rule') config.write_text(definition.replace('1s','m:1s'));apply('--clean') assert not old.exists() print('explicit mtime-only age removes the same recently created file') config.write_text(definition);old.write_text('aged fixture') keep=work/'keep';keep.mkdir();(keep/'preserved').write_text('excluded fixture') outside=root/'outside';outside.write_text('outside cleanup tree') link=work/'link';link.symlink_to(outside) with old.open('rb') as retained: time.sleep(1.2) fresh=work/'fresh';fresh.write_text('recent fixture') apply('--clean') assert not old.exists() and not link.is_symlink() assert retained.read()==b'aged fixture' print('unlinked file remains readable through its already open descriptor') assert fresh.read_text()=='recent fixture' assert (keep/'preserved').read_text()=='excluded fixture' assert outside.read_text()=='outside cleanup tree' assert work.is_dir() print('aged entries removed; recent file and recursively excluded subtree remain') print('symlink removed without following it; target outside the cleaned tree survives') print('all creation and cleanup occurred in a temporary alternate root') 创建操作在临时根目录内生成模式为 0750 的 /work。随后创建一个文件,把访问与修改时间故意调到过去,默认清理仍然保留它,因为其他相关时间戳较新。仅仅回拨 mtime 和 atime,并不足以让这个文件满足完整默认年龄规则。这一对照可以解释为什么只用 touch 修改时间的测试,有时得不到想象中的删除结果。 ...

十二月 10, 2027 · 2 分钟 · XB Field Notes
一次激活按顺序执行命令,并记录完成或失败。

systemd oneshot 完成的是一次激活

Type=oneshot 服务表达一段有限工作,启动命令成功结束后,本次启动才算完成。因此,它适合初始化步骤,以及依赖方需要等待任务结束的定时工作。oneshot 描述的是一次激活的进程模型,不会建立永久记录来阻止任务在重启、再次激活或机器重新开机后重跑。名字中的“一次”,不能直接推导为业务意义上的“永远只做一次”。 坏掉的样子 成功的 oneshot 通常在命令完成后变为非活动状态,这是任务已经结束的正常表现,不是守护进程崩溃的证据。设置 RemainAfterExit=yes 后,管理器会在命令退出后仍把单元记为活动。因此,active (exited) 可以完全没有主进程;如果监控假定任何活动服务都必须有一个存活 PID,就会错误判断这种单元类型。 oneshot 允许多条 ExecStart,并按顺序执行。采用普通成功规则时,某条命令失败会阻止后续启动命令运行,并让本次激活失败。但前面的命令可能已经修改文件或数据库,systemd 不会回滚这些副作用。重新尝试整个激活,就可能重复之前已经成功的部分,不能把管理器的失败状态误当作业务事务已经全部撤销。 RemainAfterExit 保存的是管理器状态,不是持久的恰好一次执行保证。再次启动已经活动的单元不会重跑,重启却会。定时器指向一个仍然活动的 oneshot 时,也不会把每个到达的时间点都变成另一次执行。只有它确实代表一个持续成立的状态时,才适合保留活动标记;需要反复触发的定时工作,应在各次执行之间回到非活动状态。 快速验证 在由 systemd 管理的临时 Linux 环境中,以管理员身份运行下列 Python 脚本。它创建一个运行时单元和临时步骤程序。实测环境为 Debian 12、systemd 252.39,执行前确认示例单元文件名尚未使用。每个步骤先记录自身名字,第二步随后故意失败: from pathlib import Path from tempfile import TemporaryDirectory import subprocess unit='xbcatm-oneshot-lab.service';path=Path('/run/systemd/system')/unit assert not path.exists() def ctl(*args,success=True): result=subprocess.run(['systemctl',*args],text=True,capture_output=True,timeout=10) assert (result.returncode==0)==success,(args,result.stderr) return result.stdout.strip() def value(key):return ctl('show',unit,'-p',key,'--value') with TemporaryDirectory(prefix='xbcatm-oneshot-',dir='/run') as directory: root=Path(directory);worker=root/'step.py';calls=root/'calls' worker.write_text("""from pathlib import Path import sys root=Path(__file__).parent with (root/'calls').open('a') as output: output.write(sys.argv[1]+'\\n') if sys.argv[1]=='second' and not (root/'healthy').exists(): sys.exit(7) """) definition=f'''[Service] Type=oneshot RemainAfterExit=yes TimeoutStartSec=3s ExecStart=/usr/bin/python3 {worker} first ExecStart=/usr/bin/python3 {worker} second ExecStart=/usr/bin/python3 {worker} third ''' path.write_text(definition) try: subprocess.run(['systemd-analyze','verify',str(path)],check=True) ctl('daemon-reload');ctl('start',unit,success=False) assert calls.read_text().splitlines()==['first','second'] assert value('Result')=='exit-code' and value('ExecMainStatus')=='7' print('second command fails; third command does not run') (root/'healthy').touch();ctl('reset-failed',unit);ctl('start',unit) assert calls.read_text().splitlines()==['first','second','first','second','third'] assert (value('ActiveState'),value('SubState'),value('MainPID'))==('active','exited','0') before=calls.read_text();ctl('start',unit);assert calls.read_text()==before print('active/exited with PID 0; repeated start does not rerun') ctl('restart',unit) assert calls.read_text()==before+'first\nsecond\nthird\n' print('restart executes all commands again') ctl('stop',unit);path.write_text(definition.replace('RemainAfterExit=yes','RemainAfterExit=no')) ctl('daemon-reload');before=calls.read_text() for _ in range(2): ctl('start',unit) assert value('ActiveState')=='inactive' and value('Result')=='success' assert calls.read_text()==before+'first\nsecond\nthird\n'*2 print('without RemainAfterExit: two starts, two successful executions') finally: ctl('stop',unit) if value('ActiveState')=='failed':ctl('reset-failed',unit) path.unlink();ctl('daemon-reload') 第一次激活只留下 first 和 second 两项记录,启动命令返回失败,退出状态为七;第三条命令没有运行。此前的记录仍然存在,让我们直接看见部分副作用,而不是假设激活失败意味着什么都没做过。这对于会修改真实数据的任务尤其关键:管理器状态并不能描述全部业务影响。 ...

十一月 28, 2027 · 1 分钟 · XB Field Notes
只有一端处于管理启用状态,对端仍关闭时链路并不具备载波。

veth 对在两个端点之间搬运帧,路由决定这些帧承载的路径

veth 对由两个互联的虚拟以太网设备组成。从一端发送的帧到达另一端,再由接收网络栈或网桥处理。两端可以放在不同 Linux 网络命名空间,因此常用于容器网络。创建这一对设备只提供链路,地址、路由、桥接关系、转发和访问策略都需要另外配置。不能把接口已经出现,等同于完整网络已经准备好。 坏掉的样子 接口显示管理状态 UP,仍可能因为 veth 对端关闭而没有载波。只读取 UP 标志,会让尚未完成的配置看起来已经就绪。应检查两个端点,并区分管理状态与 LOWER_UP。路由也可能已经指向这个接口,但链路仍无法承载有效流量,所以路由列表本身不是连通性检查,更不能代替一次真实应用交换。 veth 对不会把 IP 载荷限制为对端接口自身地址。路由选择报文经过哪个链路,随后以太网转发或接收网络栈决定下一步处理。发往容器直接邻居之外的流量,也可以通过这一对设备到达路由器或网桥。声称“不发给对端的包都会留在本地”,混淆了链路交付与 IP 目的地选择,容易误导容器出口排障。 两端生命周期也相互关联。删除一个 veth 端点,会移除另一个端点,即使对端由另一个命名空间管理。因此宿主侧清理动作可能删除容器接口。影响活跃工作负载时,应有意识地安排停止流量、调整路由和删除设备的顺序。命名空间分离了管理视图,却没有让成对设备变成两项互不影响的资源。 快速验证 将脚本保存为 check-veth.py,准备 Docker 后执行 python3 check-veth.py。固定 Linux 容器使用 Python 3.11.16、iproute2 6.1.0,以及用于嵌套命名空间的 NET_ADMIN 和 SYS_ADMIN。它只创建一次性虚拟设备。左右命名空间各获得一个端点,分别配置 192.0.2.1/30 和 192.0.2.2/30,没有移动现有宿主网卡: from pathlib import Path from tempfile import TemporaryDirectory import json,subprocess,uuid recipe='FROM python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84\nRUN apt-get update && apt-get install -y --no-install-recommends iproute2=6.1.0-3 iputils-ping=3:20221126-1+deb12u1 nftables=1.0.6-2+deb12u2 tcpdump=4.99.3-1 traceroute=1:2.1.2-1 procps=2:4.0.2-3 && rm -rf /var/lib/apt/lists/*\nCMD ["python3"]\n' program=r""" from contextlib import ExitStack,contextmanager from pathlib import Path from tempfile import TemporaryDirectory import json,os,select,signal,subprocess,time def run(*args,check=True,input=None,timeout=8): result=subprocess.run(args,input=input,capture_output=True,text=True,timeout=timeout) if check:assert result.returncode==0,(args,result.returncode,result.stdout,result.stderr) return result def ns(name,*args,**kwargs):return run('ip','netns','exec',name,*args,**kwargs) def ip(name,*args,**kwargs):return ns(name,'ip',*args,**kwargs) def stop(process): if process.poll() is None: process.terminate() try:process.wait(timeout=2) except subprocess.TimeoutExpired:process.kill();process.wait(timeout=2) def line(process,timeout=3): assert select.select([process.stdout],[],[],timeout)[0],'child response deadline exceeded' value=process.stdout.readline();assert value,'child exited without a response' return value def spawn(stack,command): process=subprocess.Popen(command,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True) stack.callback(stop,process);return process @contextmanager def lab(names): with TemporaryDirectory(prefix='net-path-') as directory,ExitStack() as stack: root=Path(directory);proc=root/'proc';proc.mkdir();run('mount','-t','proc','proc',str(proc)) stack.callback(run,'umount',str(proc)) for name in names: run('ip','netns','add',name);stack.callback(run,'ip','netns','delete',name,check=False) ip(name,'link','set','lo','up') def setting(name,key,value): assert name in names path=proc/'sys'/key.replace('.','/') return ns(name,'python3','-c',f'from pathlib import Path; p=Path({str(path)!r}); p.write_text({str(value)!r}); print(p.read_text().strip())').stdout.strip() yield root,stack,setting def wire(left,left_if,right,right_if,left_addresses,right_addresses): run('ip','link','add',left_if,'type','veth','peer','name',right_if) for name,interface,addresses in [(left,left_if,left_addresses),(right,right_if,right_addresses)]: run('ip','link','set',interface,'netns',name) for address in addresses:ip(name,'address','add',address,'dev',interface,*(['nodad'] if ':' in address else [])) ip(name,'link','set',interface,'up') def nft(name,script):return ns(name,'nft','-f','-',input=script) def counters(name): data=json.loads(ns(name,'nft','-j','list','ruleset').stdout);found={} for entry in data['nftables']: rule=entry.get('rule',{}) if 'comment' in rule: for expression in rule['expr']: if 'counter' in expression:found[rule['comment']]=expression['counter']['packets'] return found def server(stack,name,address,port=18080,label='server'): code=r''' from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer import json,socket,sys address,port,label=sys.argv[1],int(sys.argv[2]),sys.argv[3] class Handler(BaseHTTPRequestHandler): def do_GET(self): body=json.dumps({'server':label,'peer':self.client_address,'path':self.path}).encode() self.send_response(200);self.send_header('Content-Length',str(len(body)));self.end_headers();self.wfile.write(body) def log_message(self,*args):pass class Server(ThreadingHTTPServer): address_family=socket.AF_INET6 if ':' in address else socket.AF_INET def server_bind(self): if self.address_family==socket.AF_INET6:self.socket.setsockopt(socket.IPPROTO_IPV6,socket.IPV6_V6ONLY,1) super().server_bind() service=Server((address,port),Handler);print('ready',flush=True);service.serve_forever() ''' process=spawn(stack,['ip','netns','exec',name,'python3','-u','-c',code,address,str(port),label]) assert line(process).strip()=='ready';return process def request(name,address,port=18080): code=r''' import http.client,json,sys client=http.client.HTTPConnection(sys.argv[1],int(sys.argv[2]),timeout=.7) try: client.request('GET','/lab-visible-message');response=client.getresponse();body=json.loads(response.read()) assert response.status==200;print(json.dumps({'ok':True,'status':response.status,'body':body})) except OSError as error:print(json.dumps({'ok':False,'type':type(error).__name__,'errno':error.errno})) finally:client.close() ''' return json.loads(ns(name,'python3','-c',code,address,str(port)).stdout) with lab(['left','right']) as (root,stack,setting): run('ip','link','add','left0','type','veth','peer','name','right0') run('ip','link','set','left0','netns','left');run('ip','link','set','right0','netns','right') ip('left','addr','add','192.0.2.1/30','dev','left0');ip('right','addr','add','192.0.2.2/30','dev','right0') ip('left','link','set','left0','up') before=json.loads(ip('left','-j','link','show','left0').stdout)[0] assert 'LOWER_UP' not in before['flags'] failed=ns('left','ping','-n','-c','1','-W','1','192.0.2.2',check=False);assert failed.returncode!=0 ip('right','link','set','right0','up') after={name:json.loads(ip(name,'-j','link','show',interface).stdout)[0] for name,interface in [('left','left0'),('right','right0')]} assert all('LOWER_UP' in v['flags'] for v in after.values()) ip('left','neigh','flush','dev','left0');ip('right','neigh','flush','dev','right0') successful=ns('left','ping','-n','-c','1','-W','1','192.0.2.2');assert successful.returncode==0 daemon=server(stack,'right','192.0.2.2',label='right');response=request('left','192.0.2.2');assert response['ok'] ip('right','addr','add','203.0.113.9/32','dev','lo');ip('left','route','add','203.0.113.9/32','via','192.0.2.2') server(stack,'right','203.0.113.9',label='beyond-link-address');routed_response=request('left','203.0.113.9');assert routed_response['ok'] ip('left','link','delete','left0');gone=ip('right','link','show','right0',check=False);assert gone.returncode!=0 and 'does not exist' in gone.stderr print(json.dumps({'one_end_up_flags':before['flags'],'one_end_up_ping_exit':failed.returncode, 'both_up_flags':{n:v['flags'] for n,v in after.items()},'both_up_ping_exit':successful.returncode,'http':response,'other_ip_destination':routed_response, 'deleting_one_removes_peer':True,'peer_absent_diagnostic':gone.stderr.strip()})) """ name='xbcatm-netpath-'+uuid.uuid4().hex[:10] def docker(*args,**kwargs):return subprocess.run(['docker',*args],capture_output=True,text=True,timeout=300,**kwargs) try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory);(root/'Dockerfile').write_text(recipe) build=docker('build','-t',name,str(root));assert build.returncode==0,build.stderr result=docker('run','--rm','-i','--name',name,'--network','none','--cap-add','NET_ADMIN','--cap-add','SYS_ADMIN', '--memory','256m','--pids-limit','128',name,'python3','-',input=program) assert result.returncode==0,result.stderr print(json.dumps(json.loads(result.stdout),sort_keys=True)) finally: docker('rm','-f',name);docker('image','rm',name) 起初只启用 left0,它的标志包含 UP 和 NO-CARRIER,没有 LOWER_UP,因为 right0 仍关闭。单次 ping 失败。这里同时提供链路状态和实际失败交换,没有用“接口创建成功”来替代对端已经配置正确的证据。管理状态说明操作者希望设备工作,载波状态则反映配对链路当前能否成立。 ...

十一月 21, 2027 · 2 分钟 · XB Field Notes
只借助内存完成、不触发读盘的虚拟内存缺页。

缺页并不总是意味着读盘

把两类缺页一概当作慢磁盘看待会混淆差别;对内存大户调优时,‘次要还是主要’这个区分才是关键。 次缺页只用映射 缺页也分主次:缺页时如果页面其实已经常驻内存,只是进程还没有把这张页表映射,就只需补一张页表——把虚拟地址指到那块已存在的物理内存即可。这也解释了为什么「新开一页」这类动作是便宜的:它不过是在页表上多一条指针,不读磁盘、不耗内存。首次接触刚归零的区域、共享库被别的进程先行加载、或一次从 swap 换入还没有短缺时,走的都是这条路。缺页省下的成本几近于零,也几乎不构成性能问题。 主缺页要读磁盘 主缺页就完全不一样了。它意味着所需数据必须落地到磁盘(或从 swap 读出来),这一步通常几十到几百微秒、甚至毫秒级,比次缺页慢好几个量级。进程冷启动、首次读入大二进制、或又要从 swap 里找回被换出的页面,最常见的表现就是一大串主缺页。两者的差别,主要是「这数据是不是已经在本地」——主缺页多,说明缓存与前读没有把你要的那片东西预先备好。 分开统计才有意义 工具把一个数字拆成两个,不是没事找事,而是为了分清病因。ps、sar、/proc/self/stat 都会分别报 minor/major。一个健康的服务器应该是「次缺页常年不断、主缺页几乎为零」。看的是比例而非总数:主缺页密集、次缺页稀疏,多半是缓存未命中与冷启动;次缺页猛增却没什么主缺页,往往是在页面表上抖动,而不是磁盘紧张。把两者分开看,一旦主缺页冒头,第一反应是补缓存与预读,而不是去怪磁盘。 坏掉的样子 主缺页风暴看起来像延迟毛刺,本质却是缓存未命中。每次发布、容器重启或新节点上线后,所有工作进程会同时第一次触碰二进制、共享库和数据页。这时用 perf stat -e minor-faults,major-faults 观察,会看到主缺页从接近于零突然涨到每秒数千,而处理器利用率反而很低,尾延迟被拉长到数百毫秒。此时加核或加线程没有意义,正确做法是预热工作集、保持镜像常驻,或者错开重启节奏,避免所有进程同时挤兑同一批页面,否则毛刺会随实例数放大。 交换分区会让同样的信号产生共振。当常驻工作集超过物理内存,内核会把不常用的页换出到交换设备,下次访问又要从磁盘换回来。主缺页随换入一起上涨,负载攀升,每个请求都要替磁盘等待,即便代码一行没改。有人会加线程扛延迟,结果工作集更大,抖动更严重,甚至进入换入换出互相等待的恶性循环。此时应该先算内存账:让热数据真正装进内存,或者把冷数据搬出请求主路径,而不是提高并发。 第三类真实失败是误读 mmap 大文件顺序读。顺序扫描一个文件映射,第一次触碰每一页同样会缺页,主缺页很多并不代表随机读坏了,也不代表存储变慢。把所有缺页都当成磁盘寻道,就会去调预读或怪硬盘,真正该问的是这些页当初该不该映射进来。改输入输出参数之前,先用 mincore 确认常驻情况,再决定是预读、缓存还是改访问方式,避免把映射策略问题误判成存储故障。 快速验证 把下面程序保存为 fault_probe.py,运行 python3 fault_probe.py 即可。它只用标准库,不需要特权,在常见发行版和苹果系统上都能直接运行。程序用 resource.getrusage 读取进程的次缺页与主缺页计数,在全新匿名映射上做两轮逐页触碰,并用 time.perf_counter 记录每轮耗时,前后对比即可区分两类缺页,也能直观看出映射建立前后的时间差异。 import mmap import resource import time PAGE = 4096 PAGES = 20000 # 约八十兆:足够看出差值,又不会给持续集成带来负担 def counters(): usage = resource.getrusage(resource.RUSAGE_SELF) return usage.ru_minflt, usage.ru_majflt def touch(mapping): for offset in range(0, len(mapping), PAGE): mapping[offset] = 1 def main(): mapping = mmap.mmap(-1, PAGES * PAGE) base_min, base_maj = counters() start = time.perf_counter() touch(mapping) first_time = time.perf_counter() - start mid_min, mid_maj = counters() start = time.perf_counter() touch(mapping) # 此时页面已经映射好 second_time = time.perf_counter() - start end_min, end_maj = counters() mapping.close() first_min = mid_min - base_min first_maj = mid_maj - base_maj second_min = end_min - mid_min second_maj = end_maj - mid_maj print(f"first touch: minflt={first_min} majflt={first_maj} time={first_time:.4f}s") print(f"second touch: minflt={second_min} majflt={second_maj} time={second_time:.4f}s") assert first_min > 1000, f"expected thousands of minor faults, got {first_min}" assert first_maj == 0, f"anonymous first touch should not cause majors, got {first_maj}" assert second_min < first_min // 10, f"mapped pages should barely fault: {second_min} vs {first_min}" assert second_maj == 0, f"unexpected major faults on second pass: {second_maj}" print("PASS: first touch faults as minors without disk reads; second touch is almost fault-free") if __name__ == "__main__": main() 第一轮逐页写会触发大量次缺页,预期次缺页增加数千,主缺页几乎不变,而且耗时明显更长。第二轮访问已经映射好的页面,预期新增缺页接近于零,耗时也显著缩短。这个反差正是结论:匿名内存第一次触碰只产生次缺页,不需要读盘,第二次访问已经不需要建立映射。 ...

十一月 13, 2027 · 2 分钟 · XB Field Notes
管理器持续持有监听套接字,兼容的工作进程可以按需要启动与结束。

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
重启决策取决于退出分类、等待时间与启动速率限制。

systemd 重启策略要明确失败与限流规则

重启策略决定当前进程结束后,服务管理器是否应该再启动一个进程。它可以帮助恢复短暂故障,但修不好缺失配置或必然重复的程序错误。一个有用的契约需要分别回答三个问题:哪些结束方式应该重试、重试前等多久、一个时间窗口内允许启动多少次。这些设置各管一件事,不能把它们笼统理解为“失败后会自己恢复”。 坏掉的样子 Restart=on-failure 不代表任何信号结束都会重启。对于 oneshot 以外的服务类型,通常的 SIGHUP、SIGINT、SIGTERM 和 SIGPIPE 终止,默认被视为干净结束;非零退出码,以及 SIGKILL 这样的异常信号,才符合默认失败规则。SuccessExitStatus 和专门的重启例外指令还能改变分类,因此要检查最终生效的单元,不能只看其中一行策略名字。 on-abnormal 也不是 on-failure 的“更严格同义词”:它排除了普通的非零进程退出码,保留异常信号以及部分超时或看门狗故障。选择依据应是程序怎样报告可恢复失败与主动结束。如果进程始终存活,却已经不再提供有用工作,还需要单独配置发现这种状态的机制;基于退出的重启逻辑不会自动推断业务是否健康。 RestartSec 提供下一次重启前的等待时间。在下面的 systemd 252 实验里,它是固定延迟,不是越来越长的指数退避。StartLimitIntervalSec 和 StartLimitBurst 限制窗口内的启动次数,手动启动也算在内;它们既不是整个生命周期的重试预算,也不会在超限后自动预约下一窗口边界再尝试一次。限流与恢复触发应分别理解。 快速验证 在一次性的 Linux systemd 环境中,以管理员身份运行这个 Python 脚本。它创建带有唯一名字的运行时单元和临时工作程序,完成后删除。实测环境为 Debian 12、systemd 252.39。工作程序起初每次都以状态七退出,同时记录每次真实调用: from pathlib import Path from tempfile import TemporaryDirectory import subprocess,time,uuid unit='xbcatm-restart-lab-'+uuid.uuid4().hex[:8]+'.service' path=Path('/run/systemd/system')/unit assert not path.exists() def ctl(*args): result=subprocess.run(['systemctl',*args],text=True,capture_output=True,timeout=10) assert result.returncode==0,(args,result.stderr) return result.stdout.strip() def value(key):return ctl('show',unit,'-p',key,'--value') def wait_for(check): deadline=time.monotonic()+5 while not check(): assert time.monotonic()<deadline,'state transition timed out' time.sleep(0.02) with TemporaryDirectory(prefix='xbcatm-restart-',dir='/run') as directory: root=Path(directory);worker=root/'worker.py';calls=root/'calls';healthy=root/'healthy' worker.write_text("""from pathlib import Path import sys,time root=Path(__file__).parent with (root/'calls').open('a') as output: output.write('started\\n') if not (root/'healthy').exists(): sys.exit(7) time.sleep(60) """) path.write_text(f'''[Unit] StartLimitIntervalSec=10s StartLimitBurst=3 [Service] Type=exec ExecStart=/usr/bin/python3 {worker} Restart=on-failure RestartSec=100ms ''') try: ctl('daemon-reload');ctl('start',unit) wait_for(lambda: value('ActiveState')=='failed') assert calls.read_text().splitlines()==['started']*3 assert value('Result')=='exit-code' subprocess.run(['journalctl','--sync'],check=True) logs=subprocess.run(['journalctl','-u',unit,'--no-pager','-o','cat'],capture_output=True,text=True,check=True).stdout assert 'Start request repeated too quickly' in logs print('three starts; failed/exit-code with start-rate-limit journal message') healthy.touch();ctl('reset-failed',unit);ctl('start',unit) wait_for(lambda: len(calls.read_text().splitlines())==4) first=value('MainPID');assert first!='0' ctl('kill','--kill-who=main','--signal=TERM',unit) wait_for(lambda: value('ActiveState')=='inactive') assert value('NRestarts')=='0' print('on-failure treats ordinary SIGTERM as clean; no restart') ctl('start',unit);first=value('MainPID') ctl('kill','--kill-who=main','--signal=KILL',unit) wait_for(lambda: value('MainPID') not in ('0',first)) assert value('NRestarts')=='1' print('SIGKILL triggers one restart') ctl('stop',unit);time.sleep(0.2) assert value('ActiveState')=='inactive' print('explicit stop remains stopped') finally: ctl('stop',unit) if value('ActiveState')=='failed':ctl('reset-failed',unit) path.unlink();ctl('daemon-reload') 第一阶段允许十秒内启动三次,自动重启间隔设得很短且固定。程序自己写出的文件恰好包含三项记录,所以这里统计的是实际进程启动,而不是先猜某个管理器计数器的含义,再把那个数字当作执行证据。之后单元留在失败状态,日志明确说明启动请求过于频繁,新的启动被速率规则挡住。 ...

十一月 7, 2027 · 1 分钟 · XB Field Notes
procfs 在路径删除后仍可展示打开对象,并在受控进程被回收后移除其目录。

按文档读取不断变化的进程 procfs 视图

Linux procfs 通过文件系统接口暴露进程和内核信息。许多入口在读取时生成内容,而不是保存普通磁盘文件字节;每个入口都有具体含义、权限模型和格式。连续读取多个入口,不会冻结进程,也不会得到事务一致快照。因此,有用的观察必须对应正确进程实例和正确解释,不能因为内容都来自 proc,就假定它们是同一时刻的完整状态。 坏掉的样子 普通文件假设可能立刻失效。proc 条目报告大小为零,却仍然返回有用内容;逐进程目录可能随着进程退出消失,数字 PID 之后也可能指向另一个实例。读取失败不一定表示系统安装损坏,某个编号也不会永久代表同一工作负载。生命周期变化本来就是观测需要处理的条件,应避免把正常退出当成异常环境。 不同入口需要按各自格式解析。status 包含有标签字段,stat 则使用固定字段顺序,其中括号包住的命令名可以包含空格和右括号。直接把整行按空白拆分,会让预期字段错位。cmdline 与 environ 使用空字符分隔,普通按行或空格切分,会破坏参数和值的边界。解析方式必须来自接口文档,而不是文件看起来像文本这一点。 视图本身也有语义边界。environ 描述程序执行时建立的环境区域,不保证是所有后续环境修改的实时字典;命令行内容也可以被进程修改。描述符链接以 deleted 结尾,不表示打开对象已经消失。这些入口提供具体状态证据,却不是可以互相替代的身份凭据或访问保证,需要先明确每项观察到底回答什么问题。 快速验证 把脚本保存为 procview.py,在一次性 Linux 环境中运行 python3 procview.py。实测使用 Linux 6.12.76 和 Python 3.11.2。脚本启动一个受控子进程,显式提供少量环境值,只读取自己及该子进程的 proc 条目,不写入内核调节接口: from pathlib import Path from tempfile import TemporaryDirectory import json,os,subprocess,sys worker=r''' from pathlib import Path import ctypes,json,os,sys assert ctypes.CDLL(None).prctl(15,b'worker ) sample',0,0,0)==0 os.environ['XB_PROC_TEST']='changed-after-start' path=Path(sys.argv[1]);path.write_bytes(b'fixture-data') with path.open('rb') as handle: info=os.fstat(handle.fileno());path.unlink() print(json.dumps({'fd':handle.fileno(),'device':info.st_dev,'inode':info.st_ino,'current_environment':os.environ['XB_PROC_TEST']}),flush=True) assert sys.stdin.readline()=='done\n' ''' with TemporaryDirectory(prefix='xbcatm-proc-') as directory: path=Path(directory)/'data' process=subprocess.Popen([sys.executable,'-c',worker,str(path),'argument with spaces'],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True,env={'PATH':'/usr/bin:/bin','XB_PROC_TEST':'fixture-value'}) try: metadata=json.loads(process.stdout.readline());root=Path('/proc')/str(process.pid) status_path=root/'status';status_text=status_path.read_text() assert status_path.stat().st_size==0 and len(status_text)>0 status=dict(line.split(':',1) for line in status_text.splitlines() if ':' in line) assert int(status['Pid'])==process.pid and int(status['PPid'])==os.getpid() raw=(root/'stat').read_text();pid_text,rest=raw.split(' (',1);comm,fields=rest.rsplit(') ',1) fields=fields.split();assert int(pid_text)==process.pid and comm=='worker ) sample' assert int(fields[1])==os.getpid() and int(fields[19])>0 assert raw.split()[2] not in ('R','S','D','T','Z') print('proc files can have size zero but readable data; stat comm can contain spaces and a closing parenthesis') args=(root/'cmdline').read_bytes().rstrip(b'\0').split(b'\0') environment=(root/'environ').read_bytes().rstrip(b'\0').split(b'\0') assert args[-1]==b'argument with spaces' and b'XB_PROC_TEST=fixture-value' in environment assert metadata['current_environment']=='changed-after-start' assert os.readlink('/proc/self')==str(os.getpid()) print('cmdline and environ use NUL separators; environ retains the initial value here; self resolves for the reader') entry=root/'fd'/str(metadata['fd']);target=os.readlink(entry);actual=entry.stat() assert target==str(path)+' (deleted)' and not path.exists() assert (actual.st_dev,actual.st_ino)==(metadata['device'],metadata['inode']) assert entry.read_bytes()==b'fixture-data' print('a deleted pathname remains inspectable through an open descriptor subject to access checks') output,error=process.communicate('done\n',timeout=5) assert process.returncode==0,error assert not root.exists() print('after the controlled child exits and is reaped, its proc directory disappears') finally: if process.poll() is None:process.kill();process.wait() 子进程把短名称设为带空格和右括号的 worker ) sample,打开临时文件,再删除路径但保持描述符打开。随后它仅报告样本元数据并等待,让控制进程拥有一份已知存活对象。这个名称刻意触发 stat 格式边界,使解析验证不依赖“进程名碰巧没有空格”的理想情况。 ...

十月 29, 2027 · 2 分钟 · XB Field Notes