背景
一条集成测试用 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
| 词 | 说明 |
|---|---|
127 | shell 的退出码,表示 command not found,要执行的命令找不到 |
2 | shell 的退出码,这里表示命令串有语法错误 |
EFAULT | Linux 错误码 Bad address,传给系统调用的地址无效 |
sh -c | 让/bin/sh把后面的字符串当一条命令执行 |
反引号 /%x{} | Ruby 执行外部命令并拿到输出的写法 |
进程启动
| 词 | 说明 |
|---|---|
argv | 传给新进程的参数数组,这里是["sh", "-c", 命令] |
posix_spawnp | C 库里创建子进程并执行程序的函数 |
execve | 用新程序替换当前进程的系统调用,子进程在这里读取argv |
glibc | Linux 上的标准 C 库,malloc、free、posix_spawnp都在里面 |
JVM 与内存
| 词 | 说明 |
|---|---|
| GC | Garbage Collection,垃圾回收,JVM 自动回收没有引用的对象 |
| 栈 | 函数的局部变量和参数,调用时自动分配,返回时自动释放 |
| 堆 | 运行中用malloc临时申请的内存,用完要free。命令串在这里 |
| native 内存 | JVM 堆以外、用malloc申请给 C 代码用的内存,也属于上面的堆 |
| finalizer / Cleaner | Java 对象被回收时执行的清理逻辑,这里会free掉 native 内存 |
| use-after-free | 内存已经释放,还有代码在读写它 |
| JIT | Just-In-Time 编译,JVM 把热点代码编译成机器码 |
| 竞争窗口(race window) | 内存已经可以回收、但还有人要读它的那段时间 |
JRuby 底层
| 词 | 说明 |
|---|---|
| JRuby | 跑在 JVM 上的 Ruby 实现 |
| CRuby(MRI) | 官方的 C 语言 Ruby 实现,没有 JVM GC,也不经过 jnr |
| GIL | Global Interpreter Lock,CRuby 同一时刻只让一个线程执行 Ruby 代码 |
| jnr-ffi | JRuby 用来从 Java 调用 C 函数的库 |
| jnr-posix | 基于 jnr-ffi 封装的 POSIX 调用,posix_spawnp从这里调 |
| generated stub | jnr-ffi 默认为每个 C 函数自动生成的调用代码,速度快 |
| reflection invoker(反射调用) | jnr-ffi 的另一条较慢路径,调用期间一直持有参数 |
PostInvocation | jnr-ffi 的回调接口,让参数对象在 C 调用结束后才释放 |
Reference.reachabilityFence | JDK 方法,保证某个对象到这一行之前都不会被 GC 回收 |
复现工具
| 词 | 说明 |
|---|---|
| Java agent | JVM 启动时加载的插件,可以在类加载时修改字节码 |
java.lang.instrument | JDK 提供的 agent 接口 |
| ASM | 读写 Java 字节码的库,JDK 内部自带一份 |
规避方式
| 词 | 说明 |
|---|---|
ProcessBuilder | JDK 自己的启动外部进程接口,不经过 jnr |
Process.spawn | Ruby 启动子进程的方法,在 JRuby 里同样走 jnr |
1. 现场线索
CI 的 stderr 里有这样两行:
sh: line 1: $'\214\177...': command not found两个线索:
sh收到的命令名是乱码。不是工具找不到,是 shell 拿到的命令串本身已经坏了。- 乱码里有
\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 00sh把这几个字节当成命令名,其中的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 次调用):
| 条件 | 命中次数 |
|---|---|
| 不挂 agent | 0 |
| 挂 agent | 10–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. 排查经验小结
- 先看退出码和耗时。127 加上几乎为 0 的耗时,说明命令根本没执行,不要去查被调用的程序。
- 乱码里有
\177/0x7f,就往 use-after-free 的方向想。这是 64 位堆指针的高位字节。 - “本地复现不了”不等于“环境问题”。时序类 bug 要主动放大条件:加并发、加 GC 压力、卡准时机。
- 区分“复现”和“制造问题”。放大自然条件(更多 GC、卡准时机、拉大窗口)是复现;直接改写内存只能验证后果,证明不了成因。
- 确认实际运行的版本。测试框架里的子进程用的 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.rbA.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.rbA.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.rbA.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, 0GC 只要晚 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、没有捕获到输出。同一轮里经常有多个线程一起中招。