背景
一条集成测试用 JRuby 并发执行外部命令:开 5 个线程,每个线程用反引号跑同一个转换工具,最后断言 5 个返回码都正常。绝大多数时候它都能通过,但 CI 上偶尔会红一次,结果是:
text
[127, 50, 50, 50, 50]
(这里 50 是该工具约定的正常返回码。)
127 在 shell 里的意思是 command not found。可这 5 路执行的是同一条命令,另外 4 路都正常,单独跑也从来没失败过。本地反复跑,就是复现不出来。
这篇文章记录了从定位到稳定复现的过程、背后的原理和规避方法,文末附完整的复现脚本、Java agent 源码和日志。
结论先说:这是 JRuby 底层 jnr 库的一个 use-after-free 问题,GC 在子进程读完参数之前,就把命令串所在的 native 内存释放了。
1. 现场线索
CI 的 stderr 里有这样两行:
text
sh: line 1: $'\214\177...': command not found
两个线索:
sh收到的命令名是乱码。 不是工具找不到,是 shell 拿到的命令串本身已经坏了。- 乱码里有
\177,也就是0x7f。 在 x86-64 Linux 上,堆地址通常长成0x00007fxxxxxxxxxx,按小端序存放时,高位字节就是\177。命令串的位置被写进了一个指针,这是典型的"内存已释放、又被分配器拿去记账"的痕迹。
另外,失败的那一路几乎不耗时:工具根本没启动,sh 在解析命令名时就退出了。
2. 先补一点 GC 基础
GC(Garbage Collection,垃圾回收)是 JVM 自动回收内存的机制。它判断对象是否还活着,只看还有没有引用指向它,不看你"打不打算继续用"。GC 什么时候跑,程序没法精确控制。
JVM 调用 C 函数时,需要把数据放到 native 内存 ,也就是 malloc 出来的普通 C 内存,再把地址交给 C 函数。这块内存一般由一个 Java 对象"代表":Java 对象被 GC 回收时,它的清理逻辑(finalizer / Cleaner)会调用 free。
打个比方:你把一张写着命令的纸条递给同事,自己手里没留底。同事还没看清,保洁阿姨(GC)看到"这张纸没人拿着",就收走了;回收站又在纸上乱写了几个字。同事最后看到的,就是一串乱码。
于是有一条铁律:C 函数还在用这块内存的时候,代表它的 Java 对象必须一直被引用着。 否则 GC 一来,内存就被 free 了。
3. JRuby 执行反引号的调用链
在 JRuby 里执行 cmd 2>&1,大致会走:
text
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 恰好发生在这个窗口里:
text
时间 ─────────────────────────────────────────────>
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 就变得稳定可复现。
text
原本: 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,也就没有这个窗口。把原来的反引号版本:
ruby
def run_capture(cmd)
output = `#{cmd} 2>&1`
return $?.exitstatus, output
end
换成:
ruby
require "java"
def run_capture(cmd)
pb = java.lang.ProcessBuilder.new("/bin/sh", "-c", cmd)
pb.redirectErrorStream(true) # 相当于 2>&1
process = pb.start
out = String.from_java_bytes(process.getInputStream.readAllBytes)
return process.waitFor, out
end
调用方不用改,返回值仍然是 [退出码, 输出]。如果不需要 shell 特性(管道、重定向、通配符),更好的做法是直接传 argv,连 shell 解析都省掉:
ruby
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 不要用 Process.spawn 来"修"
Process.spawn 在 JRuby 里同样走 jnr 的 posix_spawnp,GC 压力下出现过 EFAULT。从反引号换成 Process.spawn,只是换了个写法,问题还在。
8.3 加锁串行化 spawn:不能当修复
直觉上会想:给反引号加一把 Mutex,让 5 个线程排队 spawn,是不是就好了?这能减少"一个线程的 GC 撞上另一个线程的窗口",但 GC 的来源不只是这几个线程,JVM 里任何地方分配内存都可能触发 GC,单个 spawn 的窗口依然存在。所以它最多降低概率,不能消除问题,而且会让并行测试变成串行,失去测试意义。我没有用数据验证过它的效果,不推荐。
8.4 测试代码里顺手要改的
- 多线程收集结果要线程安全。 复现那一轮 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.5 如果你是写 native 绑定的库作者
根本原则:把内存地址交给 C 函数期间,代表这块内存的 Java 对象必须保持可达。 常见做法:
- native 调用结束后再用一次这个对象,或者调用
java.lang.ref.Reference.reachabilityFence(obj),告诉 JIT 和 GC 它在这之前一直活着; - 在 jnr-ffi 里实现
ToNativeConverter.PostInvocation,让框架在调用结束后才释放参数对象。
8.6 上报上游
这是 jnr-ffi / JRuby 的缺陷,附上附录里的复现脚本和 agent,可以向上游提 issue,从根上修。在上游修复之前,业务代码按 8.1 绕开。
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)
ruby
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"
运行:
bash
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 和原测试的写法一致。
ruby
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 }}"
运行:
bash
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 模拟耗时,和原测试的调用节奏一致。
ruby
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 }}"
运行:
bash
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。工具路径和参数已替换为占位符。
ruby
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.4)
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
运行:
bash
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.1 Agent.java
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.2 Transformer.java
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.3 Trigger.java
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 编译和打包
bash
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 轮命中)
text
[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)
text
### 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)
text
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)
text
[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、没有捕获到输出。同一轮里经常有多个线程一起中招。