news 2026/10/17 2:46:57

JRuby 多线程反引号偶发 127:GC 提前释放了 posix_spawn 的 argv

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JRuby 多线程反引号偶发 127:GC 提前释放了 posix_spawn 的 argv

背景

一条集成测试用 JRuby 并发执行外部命令:开 5 个线程,每个线程用反引号跑同一个转换工具,最后断言 5 个返回码都正常。绝大多数时候它都能通过,但 CI 上偶尔会红一次,结果是:

[127, 50, 50, 50, 50]

(这里 50 是该工具约定的正常返回码。)

127 在 shell 里的意思是command not found。可这 5 路执行的是同一条命令,另外 4 路都正常,单独跑也从来没失败过。本地反复跑,就是复现不出来。

这篇文章记录了从定位到稳定复现的过程、背后的原理和规避方法,文末附完整的复现脚本、Java agent 源码和日志。

结论先说:这是 JRuby 底层 jnr 库的一个 use-after-free 问题,GC 在子进程读完参数之前,就把命令串所在的 native 内存释放了。

术语表

文中的缩写和专有名词先列在这里,读到不熟的词可以回来查。

退出码与 shell

词说明
127shell 的退出码,表示 command not found,要执行的命令找不到
2shell 的退出码,这里表示命令串有语法错误
EFAULTLinux 错误码 Bad address,传给系统调用的地址无效
sh -c让/bin/sh把后面的字符串当一条命令执行
反引号 /%x{}Ruby 执行外部命令并拿到输出的写法

进程启动

词说明
argv传给新进程的参数数组,这里是["sh", "-c", 命令]
posix_spawnpC 库里创建子进程并执行程序的函数
execve用新程序替换当前进程的系统调用,子进程在这里读取argv
glibcLinux 上的标准 C 库,malloc、free、posix_spawnp都在里面

JVM 与内存

词说明
GCGarbage Collection,垃圾回收,JVM 自动回收没有引用的对象
栈函数的局部变量和参数,调用时自动分配,返回时自动释放
堆运行中用malloc临时申请的内存,用完要free。命令串在这里
native 内存JVM 堆以外、用malloc申请给 C 代码用的内存,也属于上面的堆
finalizer / CleanerJava 对象被回收时执行的清理逻辑,这里会free掉 native 内存
use-after-free内存已经释放,还有代码在读写它
JITJust-In-Time 编译,JVM 把热点代码编译成机器码
竞争窗口(race window)内存已经可以回收、但还有人要读它的那段时间

JRuby 底层

词说明
JRuby跑在 JVM 上的 Ruby 实现
CRuby(MRI)官方的 C 语言 Ruby 实现,没有 JVM GC,也不经过 jnr
GILGlobal Interpreter Lock,CRuby 同一时刻只让一个线程执行 Ruby 代码
jnr-ffiJRuby 用来从 Java 调用 C 函数的库
jnr-posix基于 jnr-ffi 封装的 POSIX 调用,posix_spawnp从这里调
generated stubjnr-ffi 默认为每个 C 函数自动生成的调用代码,速度快
reflection invoker(反射调用)jnr-ffi 的另一条较慢路径,调用期间一直持有参数
PostInvocationjnr-ffi 的回调接口,让参数对象在 C 调用结束后才释放
Reference.reachabilityFenceJDK 方法,保证某个对象到这一行之前都不会被 GC 回收

复现工具

词说明
Java agentJVM 启动时加载的插件,可以在类加载时修改字节码
java.lang.instrumentJDK 提供的 agent 接口
ASM读写 Java 字节码的库,JDK 内部自带一份

规避方式

词说明
ProcessBuilderJDK 自己的启动外部进程接口,不经过 jnr
Process.spawnRuby 启动子进程的方法,在 JRuby 里同样走 jnr

1. 现场线索

CI 的 stderr 里有这样两行:

sh: line 1: $'\214\177...': command not found

两个线索:

  1. sh收到的命令名是乱码。不是工具找不到,是 shell 拿到的命令串本身已经坏了。
  2. 乱码里有\177。它不是某个字段的名字,而是一个字节的显示方式。下一节把它还原成一个堆地址。

另外,失败的那一路几乎不耗时:工具根本没启动,sh在解析命令名时就退出了。

2. 命令串怎么变成了一个地址

这块内存一开始放的就是命令,比如tool --input /path/to/input.dat ...。失败时sh读到的却是一个地址。中间发生了三件事:命令放在堆上,GC 把这块内存释放了,分配器又往里面写了下一个空闲块的地址。

2.1 一个进程里的几块内存

一台机器上的内存是同一块,分类看的是谁在用、什么时候借、什么时候还。一个进程里常见的就这几块:

种类放什么谁管
代码程序本身的指令启动时就定好,一直在
全局数据全局变量、常量启动时就定好,一直在
栈函数的局部变量、参数函数调用时自动借,返回时自动还
堆运行中才知道要多大的数据程序自己malloc,用完自己free

命令串是运行时才拼出来的,长度不固定,所以用malloc放在堆上。

Java 程序里还要再分一层:

  • Java 堆:new出来的对象,由 GC 回收。代表这块命令的 Java 对象在这里。
  • native 内存:JVM 外面、用malloc申请的 C 内存。命令串的内容在这里,也属于上面说的堆。

2.2 GC 把代表它的对象收走了

GC(Garbage Collection,垃圾回收)判断对象是否还活着,只看还有没有引用指向它,不看你“打不打算继续用”。什么时候跑,程序没法精确控制。

jnr 把命令拷进 native 内存后,用一个 Java 对象代表它。这个对象一旦没有引用,GC 就回收它;回收时对象的清理逻辑(finalizer / Cleaner)会free掉那块 native 内存。

打个比方:你把一张写着命令的纸条递给同事,自己手里没留底。同事还没看清,保洁阿姨(GC)看到“这张纸没人拿着”,就收走了。

于是有一条铁律:C 函数还在用这块内存的时候,代表它的 Java 对象必须一直被引用着。

2.3free之后写进去的是下一个空闲块的地址

free不会把内容擦成空白。分配器要记住“下一块空闲内存在哪”,就把那个地址写进刚释放的这块内存。sh还以为这里是命令,拿去执行,读到的就是这个地址的字节,于是报command not found。

2.4\177是这个地址里的一个字节

\177是八进制写法,等于十六进制的0x7f。这个字节没法当字符显示,shell 就写成\177。

堆地址经常长成0x00007fxxxxxxxxxx。整段才是一个地址,xxxxxxxxxx表示后面的数字每次运行都不同。例如0x00007f1234567890是一个完整的指针,1234567890不是存在这个地址里的值。

64 位 Linux 给程序用的地址最高到0x00007fffffffffff。分配器向系统要的大块堆内存,通常放在靠近这个上限的位置,所以地址以0x00007f开头。代码和栈不在这一带,开头不是这个样子。

地址在内存里按小端序存放,低位字节在前。0x00007f1234567890实际摆成:

90 78 56 34 12 7f 00 00

sh把这几个字节当成命令名,其中的7f就显示成\177。旁边的\214、\005是同一个地址里的其他字节。乱码里出现\177,说明这里已经不是命令,而是分配器写进去的指针。

3. JRuby 执行反引号的调用链

在 JRuby 里执行`cmd 2>&1`,大致会走:

Kernel#`(backquote) → 命令里有 > & 等 shell 元字符,决定通过 /bin/sh -c 执行 → jnr-posix: posix_spawnp("/bin/sh", ["sh", "-c", cmd], envp) → jnr-ffi: CharSequenceArrayParameterConverter.toNative 把 argv 里每个字符串拷进 native 内存,生成一个 StringArray,返回它的地址 → glibc posix_spawnp → 子进程 sh 读取 argv → execve

问题出在toNative生成的StringArray:它把地址交给 native 调用之后,Java 侧不再持有它的引用。jnr-ffi 的这个转换器没有实现 PostInvocation,不会在调用结束前保持对象存活。

如果 GC 恰好发生在这个窗口里:

时间 ─────────────────────────────────────────────> Java 线程: argv 拷到 native 内存 → 交出地址 → [等待 posix_spawnp] GC: ↑ StringArray 不可达,被回收,finalizer free 掉内存 glibc: ↑ 往刚 free 的块里写空闲链表指针 子进程 sh: ↑ 读 argv,读到的是指针字节

结果就是:

  • 命令串被改写:sh报command not found,返回 127;
  • 命令串里恰好混进反引号之类的字符:syntax error,返回 2;
  • 被释放的是 argv 指针数组本身:Errno::EFAULT: Bad address。

因为报错发生在sh解析命令名阶段,命令末尾的2>&1还没生效,所以反引号捕获不到任何输出,报错直接打在了父进程的 stderr 上。

4. 竞争窗口,以及用 sleep 拉大窗口

4.1 竞争窗口

上面时序图里,从“Java 交出地址、不再引用StringArray”到“子进程sh读完 argv”,中间有一段时间。这段时间里内存已经可以被回收,但还有人要读它。只要 GC 落在这段时间里,就会出错;落在外面就什么事都没有。这段时间就叫竞争窗口(race window)。

这个窗口非常短。实测把 GC 往后推 100 微秒,就几乎碰不上了,所以它是微秒到毫秒级的。这就是问题偶发的根本原因:GC 每次都正常运行,只是极少数时候恰好落进这个窄缝里。

4.2 用 sleep 拉大窗口

这是调试多线程 bug 的经典手法。假设你怀疑线程 A 在第 1 步和第 2 步之间有个漏洞,线程 B 要正好插进来才会出错。窗口只有 1 微秒,可能跑一万次才碰上一次。那就在第 1 步和第 2 步之间临时加一句sleep(0.1),把窗口从 1 微秒拉到 100 毫秒,线程 B 几乎每次都能插进来,bug 就变得稳定可复现。

原本: A: 第1步 |1µs| 第2步 B 要正好插在这 1µs 里 加 sleep:A: 第1步 |---- sleep 100ms ----| 第2步 B 很容易插进来

它只是把本来就存在的窗口拉长,不制造新的错误,所以被认为是合法的复现手段。

4.3 这次不能直接加 sleep

这次的窗口在 jnr 的 native 调用内部:Java 交出地址之后,执行就进了 C 代码,posix_spawnp什么时候 fork、子进程什么时候读 argv,都在 C 和内核里。没法在中间插 sleep,也不想改 JRuby。

所以反过来做:窗口拉不长,就让 GC 准时出现在窗口开始的那一刻。这就是下面 Java agent 做的事。它和加 sleep 是同一个思路,都是放大本来就存在的竞争,不改写内存、不伪造错误。

注意区分:复现脚本里的假命令sleep 0.2; /bin/echo ...中的sleep 0.2,和上面说的“加 sleep 拉大窗口”无关。它只是让子进程跑久一点,模拟真实工具的耗时。

5. 本地难以复现的原因

要出错,必须同时满足:

  • 某个线程正处在posix_spawnp的竞争窗口里;
  • GC 正好在这个窗口里完成回收,finalizer 也执行了free。

这两件事各自都很常见,但碰到同一瞬间的概率很低。CI 机器上同一个 JVM 里跑了大量用例,垃圾多,GC 频繁,所以偶尔会撞上;本地只跑一条用例,几乎碰不到。

6. 复现过程

6.1 概率复现:后台狂跑 GC

开 5 个线程,用反引号循环执行一条超过 256 字节的命令;另开一个线程不停调用 GC。完整脚本见附录 A.1。

我在 JRuby 9.2 上大约每 1000 次调用命中 0–3 次,在 JRuby 10.0 上更容易一些。能出现,但很难稳定。

6.2 卡时机:用 Java agent 把 GC 放到窗口开始的时刻

写了一个很小的 Java agent,用 JDK 自带的 instrument 机制,只在CharSequenceArrayParameterConverter.toNative返回前插一句调用,唤醒一个后台线程立刻执行 GC。完整源码见附录 B。

agent 不读写任何内存。释放仍然是 jnr 自己的 finalizer 做的,agent 只是把原本随机的 GC 挪到了窗口里。

效果对比(JRuby 9.2,每组 1000 次调用):

条件命中次数
不挂 agent0
挂 agent10–14 次,大部分是 127
挂 agent,但 GC 前延迟 ≥100 µs基本为 0
挂 agent,模拟真实节奏(5 线程、每轮各调一次、子进程耗时)5–17 次,几乎全是 127

最后用真实的转换命令、和测试完全相同的写法跑,第 97 轮打出了 127,日志见附录 C。乱码特征和 CI 完全一致。

这一轮 5 路全挂,是因为 agent 把 GC 卡在了 5 个线程同时 spawn 的时刻,一次 GC 把所有人的 argv 一起回收了。CI 上 GC 是自然发生的,只碰巧落在一路的窗口里,所以只坏了一路。

6.3 两个反直觉的发现

  • 限制 CPU 反而更难复现。我本以为 CPU 紧张会让子进程更晚读 argv、窗口更长。可实测绑到 2 个核以后,4000 次调用一次都没命中。这个问题要求 GC 线程和 spawn 线程真正同时在跑,核少了只能轮流执行,反而撞不上。
  • 把堆调小帮助不大。关键在 GC 发生的时机,不在 GC 的频率。

7. 影响范围

写法是否受影响
反引号、%x{}是(已验证)
Process.spawn是(已验证,GC 压力下出现过EFAULT)
system、IO.popen很可能是(推断走同一路径,未单独验证)
java.lang.ProcessBuilder否(不经过 jnr)
CRuby(MRI)否,没有 JVM GC,也不经过 jnr

我测过的 JRuby 9.2.21.0(jnr-ffi 2.2.11)和 JRuby 10.0.2.0(jnr-ffi 2.2.17)都有这个问题,所以升级 JRuby 不能直接解决。

8. 怎么避免

8.1 推荐:在 JRuby 里改用ProcessBuilder启动子进程

JDK 的ProcessBuilder走 JDK 自己的进程创建代码,参数在创建子进程期间由 JDK 持有,不经过 jnr,也就没有这个窗口。

原来的公共方法是反引号版本,测试文件里几十处都在调用它:

def run_capture(cmd) output = `#{cmd} 2>&1` return $?.exitstatus, output end

有两种改法。

做法一:直接改公共run_capture

最直接的是把方法体换成 JDK 的ProcessBuilder,方法名和返回值不变,所有调用处自动生效。redirectErrorStream(true)就是原来的2>&1:

require "java" def run_capture(cmd) pb = java.lang.ProcessBuilder.new(["/bin/sh", "-c", cmd]) pb.redirectErrorStream(true) process = pb.start output = String.from_java_bytes(process.getInputStream.readAllBytes) return process.waitFor, output end

不要换成Process.spawn,它在 JRuby 里还是走 jnr:

pid = Process.spawn("/bin/sh", "-c", cmd) # 不行

做法一的缺点是影响面大:这个方法被几十处调用,改了之后所有用例都换了启动方式,而出问题的只有并发启动的那几条。

做法二(推荐):新增方法,只给并行用例用

不改公共方法,另加一个方法,只让出问题的并行用例调用:

require "java" def run_capture_with_process_builder(cmd) pb = java.lang.ProcessBuilder.new("/bin/sh", "-c", cmd) pb.redirectErrorStream(true) # 相当于 2>&1 process = pb.start output = String.from_java_bytes(process.getInputStream.readAllBytes) [process.waitFor, output] end

并行用例里把run_capture(cmd)换成run_capture_with_process_builder(cmd),返回值仍然是[退出码, 输出],断言不用改。

  • 只改并行用例:公共方法被几十处调用,问题只在多线程并发启动时出现。单独加一个方法,影响范围最小,其他用例保持原样。
  • 会不会拖慢测试:不会。多出来的只是一次进程启动方式的差别,是毫秒级;被调用的工具本身每轮要跑几十秒。
  • 这不是反射调用:这里是通过 JRuby 的 Java 互操作直接调用 JDK 的ProcessBuilder,完全绕开 jnr。下面 8.2 的反射调用是 jnr 内部的另一条路,进程仍然经过 jnr 启动。

如果不需要 shell 特性(管道、重定向、通配符),更好的做法是直接传 argv,连 shell 解析都省掉:

pb = java.lang.ProcessBuilder.new("/path/to/tool", "--input", input, "--output", output)

验证情况:在 JRuby 10.0 上,后台线程持续强制 GC 的压力下,ProcessBuilder版本跑了 3 轮各 1000 次,0 次出错;同样条件下,反引号版本每轮命中 3–31 次。用真实工具五路并行,结果全部正常。还没有在 agent 卡时机的条件下验证。

8.2 另一条路:关闭 jnr 的 generated stub

jnr-ffi 默认为 C 函数生成 generated stub,问题就出在这段代码交出地址后不再持有参数。可以用一个系统属性关掉它,让 jnr-ffi 改走 reflection invoker(反射调用)。反射调用会把参数一直持有到posix_spawnp返回,窗口就没了:

export JRUBY_OPTS="${JRUBY_OPTS:+$JRUBY_OPTS }-J-Djnr.ffi.asm.enabled=false"

它能一次覆盖进程里所有的反引号、system、popen,但有两个代价:

  • 所有经过 jnr 的调用都会变慢。不只是启动进程,File.exist?这类文件操作也走 jnr。实测 20 万次File.exist?从 0.28 秒增加到 0.42 秒,单次约多 0.7 微秒;整套测试会慢多少要另外测。
  • 要改全局启动参数。一般加在测试框架的环境脚本里,所有测试进程都受影响。框架不归你管时,这不是改一个用例能决定的事。

改不了 JRuby、也不方便动框架时,用 8.1 只改出问题的用例。上游对应的 issue 是 jnr/jnr-ffi#358。

8.3 不要用Process.spawn来“修”

Process.spawn在 JRuby 里同样走 jnr 的posix_spawnp,GC 压力下出现过EFAULT。从反引号换成Process.spawn,只是换了个写法,问题还在。

8.4 加锁串行化 spawn:不能当修复

直觉上会想:给反引号加一把Mutex,让 5 个线程排队 spawn,是不是就好了?这能减少“一个线程的 GC 撞上另一个线程的窗口”,但 GC 的来源不只是这几个线程,JVM 里任何地方分配内存都可能触发 GC,单个 spawn 的窗口依然存在。所以它最多降低概率,不能消除问题,而且会让并行测试变成串行,失去测试意义。我没有用数据验证过它的效果,不推荐。

8.5 测试代码里顺手要改的

  • 多线程收集结果要线程安全。复现那一轮 5 路全部失败,结果数组里却只有 4 个值:

text results=[127, 127, 2, 127]

原因是多个线程同时往同一个 Array 里<<。CRuby 有 GIL 兜着,问题不明显;JRuby 的线程是真并行的,Array 不是线程安全的,并发追加会丢元素。改法:

ruby results = threads.map(&:value) # 每个线程把 rc 作为返回值 # 或者 lock = Mutex.new lock.synchronize { results << rc }

  • 失败时把每一路的输出打出来。原测试把输出丢掉了(rc, _out = ...),CI 失败时只看得到[127, 50, ...],看不到是哪一路、报了什么。断言信息里带上失败那一路的输出,下次一眼就能定位。

8.6 如果你是写 native 绑定的库作者

根本原则:把内存地址交给 C 函数期间,代表这块内存的 Java 对象必须保持可达。常见做法:

  • native 调用结束后再用一次这个对象,或者调用java.lang.ref.Reference.reachabilityFence(obj),告诉 JIT 和 GC 它在这之前一直活着;
  • 在 jnr-ffi 里实现ToNativeConverter.PostInvocation,让框架在调用结束后才释放参数对象。

8.7 上报上游

这是 jnr-ffi / JRuby 的缺陷,上游已有 issue jnr/jnr-ffi#358,可以附上附录里的复现脚本和 agent 补充。在上游修复之前,业务代码按 8.1 绕开。

这次的处理

这是 JRuby 底层 jnr 库的 GC 问题,JRuby 本身改不了。只在并行用例里改用 Java 的ProcessBuilder启动命令,绕开 jnr,不影响其他用例,也不拖慢测试。

并行用例偶发 127,是sh拿到的命令串变成了乱码,被测工具根本没启动。反引号通过 jnr 把命令拷进 native 内存后,不再持有对应的 Java 对象,GC 正好在子进程读命令之前把它释放了。

改法是给并行用例加一个用ProcessBuilder启动命令的方法,参数由 JDK 持有,不再经过 jnr。公共的run_capture不动,其他用例照旧。也可以在进程启动参数里关掉 jnr 的 generated stub,但所有 jnr 调用都会变慢,所以只改用例更合适。

9. 排查经验小结

  1. 先看退出码和耗时。127 加上几乎为 0 的耗时,说明命令根本没执行,不要去查被调用的程序。
  2. 乱码里有\177/0x7f,就往 use-after-free 的方向想。这是 64 位堆指针的高位字节。
  3. “本地复现不了”不等于“环境问题”。时序类 bug 要主动放大条件:加并发、加 GC 压力、卡准时机。
  4. 区分“复现”和“制造问题”。放大自然条件(更多 GC、卡准时机、拉大窗口)是复现;直接改写内存只能验证后果,证明不了成因。
  5. 确认实际运行的版本。测试框架里的子进程用的 JRuby 版本,可能和你 PATH 里的不一样。我一开始就分析错了版本。

一句话总结:GC 只认引用,不认意图。交给 C 函数的内存,只要 Java 侧手里没拿着,就可能被提前收走。


附录 A:复现脚本(Ruby)

运行环境:JRuby 9.2.21.0,Java 21。

A.1 概率复现:后台狂跑 GC(repro_backtick_gc.rb)

require 'java' LONG = "/tmp/" + ("p" * 300) stop = false hits = [] lock = Mutex.new gc = Thread.new do until stop java.lang.System.gc java.lang.System.runFinalization Array.new(2000) { "x" * 100 } end end workers = 5.times.map do |i| Thread.new do 200.times do |n| cmd = "/bin/echo OK #{i}-#{n} #{LONG} 2>&1" begin out = `#{cmd}` rc = $?.exitstatus lock.synchronize { hits << [i, n, rc, out] } if rc != 0 || !out.start_with?("OK") rescue => e lock.synchronize { hits << [i, n, e.class.to_s, e.message[0, 80]] } end end end end workers.each(&:join) stop = true gc.join hits.first(5).each { |h| p h } puts "hits=#{hits.size} of 1000"

运行:

JAVA_HOME=/usr/lib/jvm/java-21 jruby -J-Xmx64m repro_backtick_gc.rb

A.2 echo 连续调用,配合 agent(echo_timed.rb)

不开后台 GC 线程,GC 时机完全由 agent 控制。run_capture和原测试的写法一致。

def run_capture(cmd) output = `#{cmd} 2>&1` return $?.exitstatus, output end LONG = "/tmp/" + ("p" * 300) hits = [] lock = Mutex.new workers = 5.times.map do |i| Thread.new do 200.times do |n| begin rc, out = run_capture("/bin/echo OK #{i}-#{n} #{LONG}") lock.synchronize { hits << [i, n, rc, out[0, 160]] } if rc != 0 || !out.start_with?("OK") rescue => e lock.synchronize { hits << [i, n, e.class.to_s, e.message[0, 80]] } end end end end workers.each(&:join) hits.first(3).each { |h| p h } puts "hits=#{hits.size} of 1000 rc127=#{hits.count { |h| h[2] == 127 }}"

运行:

JAVA_HOME=/usr/lib/jvm/java-21 jruby -J-javaagent:/path/to/crgc-agent.jar echo_timed.rb

A.3 模拟真实测试节奏(pattern_sim.rb)

每轮开 N 个线程,每个线程只调用一次,子进程先sleep 0.2模拟耗时,和原测试的调用节奏一致。

def run_capture(cmd) output = `#{cmd} 2>&1` return $?.exitstatus, output end threads_n = (ENV["THREADS"] || "5").to_i iters = (ENV["ITERS"] || "200").to_i LONG = "/tmp/" + ("p" * 300) calls = 0 bad = [] lock = Mutex.new iters.times do |it| ths = threads_n.times.map do |i| Thread.new do begin rc, out = run_capture("sleep 0.2; /bin/echo OK #{it}-#{i} #{LONG}") lock.synchronize { bad << [it, i, rc, out[0, 120]] } if rc != 0 rescue => e lock.synchronize { bad << [it, i, e.class.to_s] } end end end ths.each(&:join) calls += threads_n end bad.first(2).each { |b| p b } puts "threads=#{threads_n} calls=#{calls} hits=#{bad.size} rc127=#{bad.count { |b| b[2] == 127 }}"

运行:

THREADS=5 ITERS=200 JAVA_HOME=/usr/lib/jvm/java-21 \ jruby -J-javaagent:/path/to/crgc-agent.jar pattern_sim.rb

A.4 真实命令复现(real_cmd_repro.rb)

和原测试的写法一致:5 个线程,每轮各执行一次真实转换命令,期望返回码为 50。下面用通用路径代替当时的工具和输入文件。

require "fileutils" CONVERTER = "/path/to/converter" INPUT_FILE = "/path/to/input.dat" KEY_FILE = "/path/to/key" EXPECTED_RC = 50 def run_capture(cmd) output = `#{cmd} 2>&1` return $?.exitstatus, output end def one_iteration threads = [] results = [] outputs = {} 5.times do |i| threads << Thread.new do out_file = File.join("/tmp", "parallel_out_#{i}_#{Time.now.to_f}.bin") cmd = "#{CONVERTER} --input #{INPUT_FILE} --key #{KEY_FILE} --output #{out_file}" rc, out = run_capture(cmd) FileUtils.rm_f(out_file) results << rc # 保留原测试的写法(非线程安全,见 8.5) outputs[i] = out unless rc == EXPECTED_RC end end threads.each(&:join) [results, outputs] end max = (ENV["MAX_ITER"] || "100").to_i max.times do |n| t0 = Time.now begin results, outputs = one_iteration rescue => e puts "iteration #{n + 1}: exception #{e.class}: #{e.message[0, 200]}" next end puts "iteration #{n + 1}: results=#{results.inspect} (#{(Time.now - t0).round(1)}s)" $stdout.flush next if results.all? { |rc| rc == EXPECTED_RC } outputs.each { |i, out| puts " thread #{i} output: #{out.inspect[0, 600]}" } puts "Not all parallel executions returned rc=#{EXPECTED_RC}: #{results.inspect}" break if results.include?(127) end

运行:

MAX_ITER=150 JAVA_HOME=/usr/lib/jvm/java-21 nice -n 19 \ jruby -J-javaagent:/path/to/crgc-agent.jar real_cmd_repro.rb > real_run.log 2>&1

附录 B:卡 GC 时机的 Java agent 源码

三个类,只依赖 JDK 21 自带的java.lang.instrument和 JDK 内置的 ASM(jdk.internal.org.objectweb.asm)。

B.1Agent.java

package crgc; import java.lang.instrument.Instrumentation; import java.util.Map; import java.util.Set; public final class Agent { public static void premain(String args, Instrumentation inst) { Module self = Agent.class.getModule(); Module javaBase = Object.class.getModule(); // 让 agent 能访问 JDK 内置的 ASM inst.redefineModule(javaBase, Set.of(), Map.of("jdk.internal.org.objectweb.asm", Set.of(self)), Map.of(), Set.of(), Map.of()); inst.addTransformer(new Transformer(inst)); System.err.println("[crgc] agent installed"); } }

B.2Transformer.java

package crgc; import java.lang.instrument.ClassFileTransformer; import java.lang.instrument.Instrumentation; import java.security.ProtectionDomain; import java.util.Map; import java.util.Set; import jdk.internal.org.objectweb.asm.ClassReader; import jdk.internal.org.objectweb.asm.ClassVisitor; import jdk.internal.org.objectweb.asm.ClassWriter; import jdk.internal.org.objectweb.asm.MethodVisitor; import jdk.internal.org.objectweb.asm.Opcodes; final class Transformer implements ClassFileTransformer { private static final String TARGET = "jnr/ffi/provider/converters/CharSequenceArrayParameterConverter"; private static final String DESC = "([Ljava/lang/CharSequence;Ljnr/ffi/mapper/ToNativeContext;)Ljnr/ffi/Pointer;"; private final Instrumentation inst; Transformer(Instrumentation inst) { this.inst = inst; } @Override public byte[] transform(Module module, ClassLoader loader, String className, Class<?> redefined, ProtectionDomain pd, byte[] bytes) { if (!TARGET.equals(className)) return null; try { // jruby.jar 是具名模块,需要让它能读到 agent 所在的模块 if (module.isNamed()) { inst.redefineModule(module, Set.of(Trigger.class.getModule()), Map.of(), Map.of(), Set.of(), Map.of()); } ClassReader cr = new ClassReader(bytes); ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_MAXS); cr.accept(new ClassVisitor(Opcodes.ASM9, cw) { @Override public MethodVisitor visitMethod(int access, String name, String desc, String sig, String[] ex) { MethodVisitor mv = super.visitMethod(access, name, desc, sig, ex); if (!"toNative".equals(name) || !DESC.equals(desc)) return mv; return new MethodVisitor(Opcodes.ASM9, mv) { @Override public void visitInsn(int opcode) { // 在 toNative 返回前插入 Trigger.fire() if (opcode == Opcodes.ARETURN) { super.visitMethodInsn(Opcodes.INVOKESTATIC, "crgc/Trigger", "fire", "()V", false); } super.visitInsn(opcode); } }; } }, 0); System.err.println("[crgc] instrumented " + className + " (module=" + module + ")"); return cw.toByteArray(); } catch (Throwable t) { System.err.println("[crgc] transform failed: " + t); return null; } } }

B.3Trigger.java

package crgc; import java.util.concurrent.Semaphore; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.LockSupport; /** jnr 生成 native argv 后,唤醒后台线程立刻执行一次真实的 GC + finalization。 */ public final class Trigger { private static final Semaphore PENDING = new Semaphore(0); public static final AtomicLong FIRES = new AtomicLong(); public static final AtomicLong GCS = new AtomicLong(); private static final long DELAY_NS = Long.getLong("crgc.delayUs", 0L) * 1000L; static { Thread t = new Thread(() -> { while (true) { PENDING.acquireUninterruptibly(); PENDING.drainPermits(); if (DELAY_NS > 0) LockSupport.parkNanos(DELAY_NS); System.gc(); System.runFinalization(); GCS.incrementAndGet(); } }, "crgc-trigger"); t.setDaemon(true); t.start(); Runtime.getRuntime().addShutdownHook(new Thread(() -> System.err.println("[crgc] toNative fires=" + FIRES.get() + " gcs=" + GCS.get()))); } private Trigger() {} public static void fire() { FIRES.incrementAndGet(); PENDING.release(); } }

-Dcrgc.delayUs=<微秒>可以在 GC 前加延迟,用来测窗口有多宽。

B.4 编译和打包

JH=/usr/lib/jvm/java-21 mkdir -p out $JH/bin/javac --add-exports java.base/jdk.internal.org.objectweb.asm=ALL-UNNAMED -d out src/crgc/*.java printf 'Premain-Class: crgc.Agent\n' > manifest.txt $JH/bin/jar cfm crgc-agent.jar manifest.txt -C out .

使用时给 JRuby 加-J-javaagent:/path/to/crgc-agent.jar即可。

附录 C:关键日志

C.1 真实命令复现(real_cmd_repro.rb,第 97 轮命中)

[crgc] agent installed [crgc] instrumented jnr/ffi/provider/converters/CharSequenceArrayParameterConverter (module=module org.jruby.dist) iteration 1: results=[50, 50, 50, 50, 50] (277.6s) iteration 2: results=[50, 50, 50, 50, 50] (288.3s) ... iteration 95: results=[50, 50, 50, 50, 50] (80.0s) iteration 96: results=[50, 50, 50, 50, 50] (79.7s) sh: line 1: $'\360\f': command not found sh: line 1: $'\360': command not found sh: -c: line 1: unexpected EOF while looking for matching ``' sh: -c: line 2: syntax error: unexpected end of file sh: line 1: $'G\005$\244\177': command not found sh: line 1: $'\360R': command not found iteration 97: results=[127, 127, 2, 127] (0.1s) thread 1 output: "" thread 2 output: "" thread 3 output: "" thread 0 output: "" thread 4 output: "" Not all parallel executions returned rc=50: [127, 127, 2, 127] [crgc] toNative fires=970 gcs=245

怎么读:

  • 前 96 轮同一条命令都正常,第 97 轮 5 路命令串全被改写成乱码。
  • \244\177是堆地址高位字节,说明这块内存被 glibc 当成空闲块写进了指针。
  • 本轮只用 0.1 秒,正常一轮约 80 秒,说明真实工具一次都没启动。
  • 一路返回 2:乱码里恰好有个反引号字符,触发了语法错误。
  • 5 路都失败,results只有 4 个值:多线程results << rc丢了一个元素。
  • 每一路的output都是空的:报错发生在2>&1生效之前,打到了父进程的 stderr。

中途有十几轮因为环境原因返回 1,与本问题无关。

C.2 echo 连续调用(echo_timed.rb)

### without agent hits=0 of 1000 rc127=0 ### with agent round 1 sh: line 1: 0*: command not found [2, 82, "Errno::EFAULT", "Bad address - /bin/echo OK 2-82 /tmp/ppppppppppppppppppppppppppppppppppppppppppp"] hits=14 of 1000 rc127=6 ### with agent round 2 sh: line 1: $'0\v\002\214t\177': command not found [3, 149, 127, ""] hits=13 of 1000 rc127=9 ### with agent round 3 sh: line 1: PN: command not found hits=10 of 1000 rc127=7

$'0\v\002\214t\177'中的\214…\177,和 CI 日志里的乱码是同一种特征。Errno::EFAULT是被释放的恰好是 argv 指针数组本身。

C.3 GC 延迟对比(echo_timed.rb,-Dcrgc.delayUs)

delayUs=0 : hits=6, 7 delayUs=100 : hits=0, 0 delayUs=300 : hits=1, 0 delayUs=1000 : hits=0, 0 delayUs=3000 : hits=0, 0

GC 只要晚 100 微秒,就几乎碰不上,说明竞争窗口在微秒级。

C.4 模拟真实节奏(pattern_sim.rb)

[83, 4, 127, ""] [83, 0, 127, ""] threads=5 calls=1000 hits=17 rc127=16 threads=5 calls=1000 hits=5 rc127=5 threads=20 calls=1000 hits=15 rc127=11

[83, 4, 127, ""]表示第 83 轮、4 号线程、返回 127、没有捕获到输出。同一轮里经常有多个线程一起中招。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 2:46:54

思科模拟器搭建4子网RIPV2局域网完整配置与排错

第一次用思科模拟器搭小型局域网的时候&#xff0c;我干过一件特别蠢的事&#xff1a;设备全部拖进工作区&#xff0c;线连好&#xff0c;四个网段的IP也照着教程填完&#xff0c;最后点下 ping&#xff0c;一个都不通。当时整个人是懵的——命令明明没抄错&#xff0c;接口状态…

作者头像 李华
网站建设 2026/10/17 2:46:46

纯CSS实现导航栏凸起效果:伪元素与圆角实战

1. 项目效果与设计思路拆解1.1 凸起导航栏到底是个什么效果先把这个项目标题翻译成大白话&#xff1a;导航栏练习&#xff0c;凸起导航栏效果。意思就是在一个常规的横向导航栏里&#xff0c;让当前选中项或者鼠标悬停的某一项&#xff0c;在视觉上“鼓起来”“顶起来”&#x…

作者头像 李华
网站建设 2026/10/17 2:45:31

MFC对话框集成SQLite:从C API封装到CListCtrl刷新的完整实践

简介&#xff1a;这是一份面向C/Windows开发者的MFC对话框程序集成SQLite3完整示例&#xff0c;适合刚开始在桌面应用中引入嵌入式数据库的初学者参考。压缩包共38个文件&#xff0c;主要包含8个h头文件与4个cpp源码文件、sqlite3的dll和lib库、exe可执行程序、db数据库文件&am…

作者头像 李华
网站建设 2026/10/17 2:45:16

【AI大模型】Checkpoint 保存策略:训练到一半断电了也不怕

【AI大模型】Checkpoint 保存策略:训练到一半断电了也不怕 写在前面:训练是长跑,随时能“存进度”才安心 大模型微调常常要跑几个小时甚至几天。最怕的就是训练到一半断电、断网、显存 OOM,进度全部清零。解决办法就是 Checkpoint(检查点)——定时把训练进度存盘,断了…

作者头像 李华