第一章:Go语言段子级源码洞察:从hello world到runtime的黑色幽默
Go 的 hello world 看似天真无邪,实则早已被 runtime 悄悄“绑架”。当你写下 fmt.Println("hello world"),编译器不会直接调用系统 write 系统调用——它先绕道 runtime.gopark 做一次轻量级协程调度检查,哪怕你只启动了 GOMAXPROCS=1 的单线程程序。这不是过度设计,这是 Go 用乐观并发写就的黑色寓言:连打招呼都要提前申请 Goroutine 资源配额。
hello world 的三重幻觉
- 表层幻觉:
main()是程序入口 → 实际入口是runtime.rt0_go(汇编),它初始化栈、m、p、g 结构后才跳转到main.main - 中层幻觉:
fmt是标准库 → 它重度依赖runtime.printlock和runtime.lockOSThread,连字符串格式化都可能触发runtime.mallocgc - 底层幻觉:“静态链接” →
go build默认嵌入libgcc风格的运行时 stub,ldd hello显示not a dynamic executable,但readelf -d hello | grep NEEDED会沉默地告诉你:它不需要 libc,它需要自己
运行时自嘲式注释实录
翻阅 $GOROOT/src/runtime/proc.go,你会撞见这行注释:
// goroutines created by the runtime are not counted in GOMAXPROCS,
// because they're "special" — like interns who fix the coffee machine
// but aren't on the org chart.
执行以下命令可亲眼见证 runtime 如何“假装没看见”你的 main goroutine:
# 编译带符号表的二进制,便于调试
go build -gcflags="-S" -o hello.s hello.go 2>&1 | grep -A5 "main\.main"
# 输出中将出现:CALL runtime.morestack_noctxt(SB) —— 即便函数只有 3 行,也先栈扩容检查
为什么 panic 会打印 runtime 源码路径?
当 panic("oops") 触发时,runtime.gopanic 会调用 runtime.runtimeStack 获取完整调用帧,而该函数硬编码了 $GOROOT 路径前缀。这意味着:
✅ 你在 Docker 容器里跑 go run,panic 信息仍显示 /usr/local/go/src/runtime/panic.go:XXX
❌ 但若你把 Go 源码移到 /tmp/go/ 并修改 GOROOT,panic 就会诚实地暴露 /tmp/go/src/...
这种“路径忠诚度”,是 Go runtime 对自身存在感最固执的黑色幽默。
第二章:Goroutine调度器——那个总在凌晨三点偷偷改你GOMAXPROCS的“夜班运维”
2.1 GMP模型图解:为什么你的goroutine比你老板还难被kill掉
Goroutine 的“不死性”源于其与 OS 线程解耦的设计哲学——它不绑定内核调度器,而由 Go 运行时(runtime)自主管理。
调度核心三元组
- G(Goroutine):轻量协程,栈初始仅 2KB,可动态伸缩
- M(Machine):OS 线程,承载 G 的执行,受系统信号约束
- P(Processor):逻辑处理器,持有运行队列和调度上下文
// runtime/proc.go 中关键字段节选
type g struct {
stack stack // 栈边界:stack.lo ~ stack.hi
status uint32 // _Grunnable / _Grunning / _Gdead 等状态
m *m // 关联的 M(可能为 nil)
sched gobuf // 保存寄存器现场,用于抢占式切换
}
g.sched是 goroutine 的“逃生舱”:当 M 被系统信号中断(如SIGURG),Go 运行时通过g.sched恢复 G 的执行上下文,绕过 OS kill 流程;status字段控制生命周期,但无外部强制终止接口。
为何无法被 kill -9 直接终结?
| 信号源 | 对 G 的影响 | 原因 |
|---|---|---|
kill -9 <pid> |
仅终止整个进程(含所有 M) | G 无独立 PID,不响应信号 |
runtime.Goexit() |
主动退出当前 G | 需 G 自愿调用,非强制 |
graph TD
A[main goroutine] -->|启动| B[G0: 系统栈]
B --> C[P0: 本地运行队列]
C --> D[G1: 用户 goroutine]
D -->|阻塞 syscall| E[M1: 绑定 OS 线程]
E -->|系统调用返回| F[自动重入 P0 队列]
G 的“难杀”,本质是 Go 运行时在用户态构建了信号屏蔽层与状态隔离机制。
2.2 work-stealing窃取算法实战:手写一个假P来骗过schedule()函数
Go 运行时调度器依赖 P(Processor)作为工作队列的持有者。schedule() 函数在无本地任务时会主动调用 findrunnable() 尝试从其他 P 窃取任务。
构造伪造 P 的核心思路
- 复用现有
runtime.p结构体布局(无需分配新内存) - 仅篡改
runqhead/runqtail和runq数组指针,使其指向可控内存区域 - 避免触发
p.status == _Prunning校验(需配合g0切换上下文)
关键代码片段
// 假P结构体偏移量(基于Go 1.22.5 runtime/p.go)
type fakeP struct {
pad0 [8]byte
runq [256]guintptr // 实际指向我们预设的goroutine链表
runqhead uint32
runqtail uint32
}
逻辑分析:
runqhead与runqtail控制环形队列游标;runq数组存储guintptr(goroutine 指针+状态位),需确保地址对齐且未被 GC 扫描。该结构体不参与真实调度,仅用于欺骗getg().m.p的读取路径。
窃取触发流程
graph TD
A[schedule()] --> B{local runq empty?}
B -->|yes| C[findrunnable()]
C --> D[stealWork()]
D --> E[遍历allp尝试pop]
E -->|命中fakeP| F[执行伪造goroutine]
2.3 goroutine泄漏的10种姿势:从defer闭包捕获到chan未关闭的社死现场
defer中闭包意外持参
func leakByDefer() {
ch := make(chan int, 1)
for i := 0; i < 10; i++ {
go func(v int) { // ❌ 捕获循环变量i(若直接用i会全部为10)
defer func() { _ = fmt.Sprintf("done %d", v) }() // v被闭包持有,goroutine无法GC
<-ch
}(i)
}
}
v 是值拷贝,但闭包生命周期绑定至 goroutine,若 ch 永不接收,goroutine 持有 v 并常驻内存。
未关闭的 channel 导致 recv 阻塞
| 场景 | 是否泄漏 | 原因 |
|---|---|---|
ch := make(chan int) + <-ch |
✅ | nil channel 永久阻塞 |
ch := make(chan int, 1) + close(ch); <-ch |
❌ | close 后可立即读完并退出 |
超时未设的 select
go func() {
select {
case <-time.After(5 * time.Second): // ✅ 安全
}
}()
go func() {
select {
case <-ch: // ❌ 若 ch 永不发送,goroutine 泄漏
}
}()
2.4 系统调用阻塞时的G状态迁移:syscall.Syscall如何让M暂时“失联”又悄悄回归
当 Go 协程(G)执行阻塞式系统调用(如 read、accept),运行它的线程(M)会陷入内核态等待,此时 Go 运行时需避免 M 长期占用而阻塞其他 G。
G 的状态跃迁路径
Grunning→Gsyscall(进入系统调用)Gsyscall→Grunnable(若 M 被窃取或被抢占)- 系统调用返回后,M 通过
exitsyscall尝试“原地回归”:先尝试获取 P,成功则恢复Grunning;失败则将 G 放入全局队列,自身休眠。
关键机制:M 的“隐身”与“唤醒”
// runtime/proc.go 中 exitsyscall 的简化逻辑
func exitsyscall() {
mp := getg().m
if !mp.puintptr.CompareAndSwap(nil, mp.oldp) { // 尝试重绑定原P
// 绑定失败:将当前G放入全局运行队列,M转入休眠
globrunqput(getg())
mPark()
}
}
mp.oldp是系统调用前缓存的 P 指针;CompareAndSwap原子尝试恢复绑定。失败说明 P 已被调度器复用,此时 G 必须移交调度权。
状态迁移对比表
| 场景 | G 状态 | M 状态 | 是否持有 P |
|---|---|---|---|
| 刚进入 syscall | Gsyscall | running | 是 |
| syscall 阻塞中 | Gsyscall | sleeping | 否(P 被解绑) |
| syscall 返回成功绑定 | Grunning | running | 是 |
| syscall 返回绑定失败 | Grunnable | parked | 否 |
graph TD
A[Grunning] -->|syscall.Syscall| B[Gsyscall]
B --> C{M是否能立即重获P?}
C -->|是| D[Grunning]
C -->|否| E[Grunnable→全局队列]
E --> F[M parked]
2.5 trace分析实操:用go tool trace定位那个永远不醒来的goroutine幽灵
当程序中出现看似“卡死”的 goroutine,runtime.Gosched() 或 channel 阻塞可能让其悄然沉睡——go tool trace 是唤醒它的照妖镜。
启动 trace 收集
go run -trace=trace.out main.go
# 或在程序中动态启用:
import "runtime/trace"
trace.Start(os.Stderr) // 注意:生产环境慎用 stderr,建议文件句柄
defer trace.Stop()
-trace 会注入轻量级事件钩子(调度、GC、阻塞、网络等),开销约 1–3%;trace.Start() 更灵活,支持运行时启停。
分析关键视图
| 视图 | 诊断价值 |
|---|---|
| Goroutines | 查看状态(runnable/blocked) |
| Scheduler | 识别 M/P 绑定异常与饥饿 |
| Network I/O | 定位未关闭的 conn 或超时缺失 |
定位幽灵 goroutine
graph TD
A[启动 trace] --> B[访问 http://localhost:8080/debug/trace]
B --> C[点击 'View trace']
C --> D[按 'G' 键聚焦 Goroutines]
D --> E[筛选状态为 'blocked' 且持续 >5s]
幽灵常藏身于 select{} 默认分支缺失、time.After 未消费或 mutex 死锁链中。
第三章:内存管理——GC不是清洁工,是边扫地边拆你家承重墙的装修队
3.1 三色标记法手绘推演:为什么STW只停0.1ms,但你老板觉得像过了三天
核心矛盾:心理延迟 vs 时钟精度
人脑感知延迟的阈值约 100ms(Web响应黄金标准),而G1的初始标记(Initial Mark)STW仅 0.1ms——比一次CPU缓存未命中还短,却常被业务方投诉“卡顿”。
三色标记关键阶段(简化版)
// G1中SATB写屏障伪代码(JDK 12+)
void write_barrier(Object *field, Object *new_value) {
if (new_value != null &&
is_in_young_gen(new_value) &&
!is_marked_in_satb_buffer(new_value)) {
satb_queue.enqueue(new_value); // 记录跨代引用,避免漏标
}
}
✅
satb_queue是无锁并发队列,enqueue平均耗时 SATB 缓冲区扩容(如突增10万条跨代引用),会短暂阻塞 mutator 线程——这0.08ms的抖动,恰落在人类感知最敏感的「亚阈值卡顿」区间。
STW时间分布(典型G1日志采样)
| 阶段 | 平均耗时 | 触发条件 |
|---|---|---|
| Initial Mark | 0.09ms | 每次混合回收前 |
| Remark | 0.12ms | SATB缓冲区溢出时 |
| Cleanup | 0.03ms | 固定开销,与堆大小无关 |
心理学真相:注意力锚点效应
graph TD
A[用户点击按钮] --> B[UI线程提交异步请求]
B --> C[JS主线程被GC STW抢占0.1ms]
C --> D[渲染帧从16ms掉到16.1ms]
D --> E[用户感知为“按钮没反应”→重试点击]
E --> F[二次请求堆积→后端RT飙升]
老板的“三天”,其实是三次重试 + 两次超时 + 一次告警电话构成的事件链延迟放大器。
3.2 mspan与mcache的暧昧关系:从allocSpan到freeList的“借而不还”行为分析
mcache 作为 P 级本地缓存,优先从 mspan 获取内存块,但其 nextFreeIndex 仅在本地维护,不立即同步回 mspan.freeList。
数据同步机制
- 分配时:
mcache.allocSpan()借用 span 后,直接切分并更新本地free数组; - 归还时:仅当
mcache满(128 个对象)或 GC 触发,才批量 flush 回mspan.freeList; - 这种延迟归还导致
mspan.freeList.len()长期滞后于实际空闲数。
// src/runtime/mcache.go
func (c *mcache) refill(spc spanClass) {
s := c.allocSpan(spc)
c.alloc[s.class] = s // 仅记录指针,不触碰 s.freeList
}
allocSpan 返回已预切分的 span,mcache 完全接管其 free 管理权,s.freeList 被逻辑冻结,形成“借而不还”的弱一致性。
| 行为 | 是否修改 mspan.freeList | 同步时机 |
|---|---|---|
| allocSpan | ❌ | 无 |
| freeOne | ❌(仅更新 mcache.free) | flush 时批量写入 |
| GC sweep | ✅(强制清空并重置) | STW 期间 |
graph TD
A[allocSpan] --> B[mcache 切分 free 数组]
B --> C[分配对象,索引递增]
C --> D{mcache.free 满?}
D -->|是| E[flush 到 mspan.freeList]
D -->|否| C
3.3 内存逃逸的5个经典误判:那些你以为在栈上、其实早被编译器连夜送进堆里的变量
为什么 &x 是逃逸的“红灯信号”
Go 编译器对取地址操作极其敏感——只要变量地址被可能逃出当前函数作用域,即触发逃逸分析。
func NewCounter() *int {
x := 42 // 看似局部
return &x // ❌ 逃逸:地址返回到调用方
}
逻辑分析:x 生命周期本应随函数结束而销毁,但 &x 被返回,编译器必须将其分配在堆上以保证指针有效性。参数说明:-gcflags="-m -l" 可验证输出 moved to heap: x。
5类高频误判场景(精简版)
- 函数返回局部变量地址
- 局部变量赋值给全局/包级变量
- 作为 goroutine 参数传入(即使未取址)
- 赋值给 interface{}(含隐式装箱)
- 切片底层数组容量超出栈帧安全阈值
| 误判表象 | 真实归属 | 触发条件示例 |
|---|---|---|
make([]int, 10) |
堆 | 长度 > 64KB 或逃逸分析不确定 |
sync.Once{} |
堆 | 字段含函数指针,需持久化 |
graph TD
A[声明局部变量] --> B{是否取地址?}
B -->|是| C[检查是否返回/存储到全局]
B -->|否| D[检查是否传入goroutine/interface]
C -->|是| E[强制堆分配]
D -->|是| E
第四章:接口与反射——interface{}不是万能钥匙,是插错孔就炸锁芯的物理攻击
4.1 iface与eface二分天下:空接口与非空接口在底层如何互相翻白眼
Go 的接口实现暗藏玄机:iface(含方法集)与 eface(空接口)共享同一顶层结构,却因方法表指针的有无而泾渭分明。
底层结构对比
| 字段 | eface(empty) | iface(non-empty) |
|---|---|---|
_type |
✅ 类型元信息 | ✅ |
data |
✅ 数据指针 | ✅ |
tab(itab) |
❌ nil | ✅ 方法表+类型绑定 |
// runtime/runtime2.go 精简示意
type eface struct {
_type *_type
data unsafe.Pointer
}
type iface struct {
tab *itab // 包含方法签名哈希、函数指针数组等
data unsafe.Pointer
}
tab是iface的灵魂——它在接口赋值时动态生成,缓存方法查找结果;而eface因无方法调用需求,跳过此开销,直连数据与类型。
方法调用路径分歧
graph TD
A[接口变量调用方法] --> B{是否含方法?}
B -->|是 iface| C[查 itab → 函数指针 → 调用]
B -->|否 eface| D[编译报错:no method on interface{}]
eface仅支持类型断言与反射;iface支持动态调度,但需itab初始化成本;- 二者内存布局差异导致 GC 扫描策略不同。
4.2 reflect.Value.Call的性能陷阱:为什么用反射调用方法比直接调用慢37倍(附bench对比)
基准测试结果(Go 1.22)
| 调用方式 | 时间/操作 | 相对开销 |
|---|---|---|
| 直接调用 | 2.1 ns | 1× |
reflect.Value.Call |
78.3 ns | 37× |
关键开销来源
- 方法签名动态检查(类型擦除后重建)
- 参数切片分配与反射值包装(
[]reflect.Value) - 运行时函数指针解析(非内联、无JIT优化)
示例代码与分析
func (u User) GetName() string { return u.Name }
// 反射调用(高开销路径)
rv := reflect.ValueOf(u).MethodByName("GetName")
result := rv.Call(nil) // ← 分配[]reflect.Value,执行类型校验、栈帧切换
rv.Call(nil)触发完整反射调用链:参数转换 → 方法查找 → 栈帧准备 → 安全检查 → 实际调用。而直接调用u.GetName()编译期绑定,零运行时成本。
graph TD
A[Call Method] --> B{是否反射调用?}
B -->|是| C[构建reflect.Value数组]
B -->|否| D[静态函数地址跳转]
C --> E[类型一致性校验]
C --> F[堆上分配参数切片]
E --> G[进入通用调用桩]
4.3 类型断言失败的panic溯源:从runtime.ifaceE2I到你的panic日志里那行“interface conversion: interface {} is…”
当 x.(T) 断言失败且 x 非 nil,Go 运行时触发 panic,源头直指 runtime.ifaceE2I —— 将空接口(eface)转换为具名接口(iface)的核心函数。
panic 触发路径
// runtime/iface.go 简化逻辑
func ifaceE2I(inter *interfacetype, x unsafe.Pointer) (ret iface) {
// 若 x 的动态类型不实现 inter 接口 → 调用 panicdottypeE
if !implements(x._type, inter) {
panicdottypeE(inter, x._type, nil) // ← 此处生成 "interface conversion: ..." 消息
}
...
}
x._type 是实际类型元数据,inter 是目标接口的 *interfacetype;implements 检查方法集子集关系。不满足即终止并构造 panic 字符串。
典型 panic 日志组成
| 字段 | 示例值 | 说明 |
|---|---|---|
| 源类型 | interface {} |
断言左值的静态类型(总是 interface{}) |
| 动态类型 | *http.Request |
x 实际持有的类型(由 _type 解析) |
| 目标类型 | io.Reader |
断言右值 T 的接口类型 |
graph TD
A[interface{} x] --> B{assert x.(io.Reader)}
B --> C[runtime.ifaceE2I]
C --> D[implements?]
D -- no --> E[panicdottypeE → “interface conversion: ...”]
4.4 unsafe.Pointer绕过类型系统:用uintptr续命指针的正确(且违法)姿势
Go 的类型安全是基石,unsafe.Pointer 却是唯一能桥接任意指针类型的“紧急逃生舱门”。但直接保存 unsafe.Pointer 会阻碍垃圾回收器追踪——它可能指向已回收对象。
为何必须转为 uintptr?
unsafe.Pointer可被 GC 跟踪(若作为变量或字段存在);uintptr是纯整数,不参与 GC 标记,一旦原对象被回收,再转回unsafe.Pointer就成悬垂指针。
p := &x
uptr := uintptr(unsafe.Pointer(p)) // 合法:瞬时转换
// ... 中间无指针引用 ...
newPtr := (*int)(unsafe.Pointer(uptr)) // ⚠️ 违法:原对象可能已回收
逻辑分析:
uptr仅保存地址数值,GC 无法识别其关联性;unsafe.Pointer(uptr)是“重新解释”,不重建可达性。参数uptr必须在同一表达式中立即转回,否则违法。
安全边界:唯一合法模式
- ✅
uintptr仅作中间计算(如偏移),且全程不脱离unsafe.Pointer上下文; - ❌ 离开作用域、存入 map/struct、跨 goroutine 传递。
| 场景 | 是否合法 | 原因 |
|---|---|---|
(*T)(unsafe.Pointer(uintptr(p) + offset)) |
✅ | 单表达式内完成转换与解引用 |
var u uintptr = uintptr(p); ... (*T)(unsafe.Pointer(u)) |
❌ | u 隔离了 GC 可达性 |
graph TD
A[获取 unsafe.Pointer] --> B[转 uintptr 计算偏移]
B --> C[立即转回 unsafe.Pointer]
C --> D[解引用使用]
D --> E[整个链在单表达式/短生命周期内]
第五章:Runtime终极冷笑话:我们修好了bug,但没人敢合这个PR
一次深夜紧急修复的诞生
凌晨2:17,监控告警刺破静默——iOS端App在启动后3秒内随机崩溃,Crash率从0.02%飙升至19.3%,仅影响搭载A14及以上芯片的iPhone 12+机型,且仅在启用动态字体缩放(Accessibility > Display & Text Size > Larger Text)时触发。堆栈指向-[NSInvocation invoke]底层调用,而符号化后定位到一段看似无害的KVO观察者移除逻辑:
// 旧代码:在dealloc中暴力遍历移除所有observer
- (void)dealloc {
for (NSString *keyPath in self.observedKeyPaths) {
[self removeObserver:self forKeyPath:keyPath];
}
}
问题根源在于:当系统因动态字体变更触发UIContentSizeCategoryDidChangeNotification时,UIKit会并发调用多个对象的KVO响应链,而removeObserver:forKeyPath:在非主线程调用可能引发NSKeyValueObservation内部状态竞争——这正是Apple私有Runtime中一段未公开的弱引用管理缺陷。
修复方案的三重悖论
| 方案 | 有效性 | 兼容性风险 | 审查阻力 |
|---|---|---|---|
| 补丁式加锁(@synchronized(self)) | ✅ 临时止血 | ❌ iOS 13以下偶发死锁 | 中(需证明锁粒度安全) |
| 彻底弃用KVO,改用ReactiveSwift绑定 | ✅ 根治 | ⚠️ 需重写12个VC的数据流 | 高(影响3个核心业务模块) |
| 向Apple提交FB9876543并等待iOS 17.4修复 | ✅ 终极解法 | ❌ 用户已崩溃超48小时 | 极高(PR被标记“won’t fix”) |
最终团队选择折中路径:用objc_setAssociatedObject为每个观察目标注入线程安全的观察者容器,并通过dispatch_once确保移除逻辑单例执行。补丁代码行数仅17行,却覆盖了NSKeyValueObserving、NSProxy和objc_msgSend三处Runtime黑箱交互。
Code Review现场实录
Reviewer A(iOS架构师):
“这段objc_getAssociatedObject的key用的是&_kObserverContainerKey,但ARC下&取地址可能触发编译器优化导致key漂移——你验证过LLVM 15.0.0的-O3构建吗?”Reviewer B(Security Lead):
“method_exchangeImplementations劫持了_NSSetObjectValueAndNotify,这属于Apple明确禁止的API hooking行为,App Store审核指南5.2.3条会直接拒审。”Reviewer C(SRE):
“CI流水线里缺少A14芯片真机的Nightly Regression测试用例,当前仅覆盖模拟器——你怎么证明这个PR不会让崩溃率从19.3%变成21.7%?”
Mermaid流程图:PR合并阻塞链
flowchart TD
A[PR提交] --> B{静态扫描通过?}
B -->|否| C[自动打回:Clang-Tidy检测到objc_msgSend内联警告]
B -->|是| D[真机自动化测试]
D --> E{A14+设备崩溃率≤0.05%?}
E -->|否| F[插入性能探针重跑]
E -->|是| G[安全审计]
G --> H{发现method_exchangeImplementations?}
H -->|是| I[法务要求签署Runtime修改豁免协议]
H -->|否| J[等待3位TL签字]
J --> K[其中1位TL正在巴塞罗那参加WWDC外围会议]
被遗忘的第七种方案
在Git历史中挖出2021年某次未合入的分支feature/ios15-kvo-fix,其commit message写着:“Use KVO’s built-in thread-safety flag _kCFRuntimeFlagIsThreadSafe — but undocumented and crashes on iOS 15.0.1”。该flag在iOS 16.2中悄然移除,而我们的补丁恰好在+load方法中动态探测了该flag的存在性,若存在则走原生路径,否则降级为关联对象方案——这种防御性Runtime探测让CI耗时增加23秒,也成了另一个反对合并的理由。
线上灰度的诡异现象
将PR部署至0.1%用户后,崩溃率确实降至0.03%,但New Relic数据显示:这部分用户的平均启动耗时从420ms升至890ms,且__CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__的调用频次激增37倍。深入Instrument分析发现,补丁引入的dispatch_once_t全局锁在CFRunLoop每次迭代时被争抢——这暴露了iOS底层RunLoop与Objective-C Runtime在GCD队列调度上的隐式耦合。
PR描述的最后一行
“此修复已通过iPhone 14 Pro Max(iOS 17.3.1)、iPad Air 5(iOS 16.6.1)、MacBook Pro M1(macOS 13.6.5)三端真机交叉验证,但请勿在Xcode 15.2以下版本执行Archive操作——ld64会错误链接
_objc_sync_enter符号。”
持续集成日志片段
[CI-2024-05-17T03:14:22Z] Running test suite 'RuntimeStabilityTests' on iPhone 13 (iOS 17.4)
[CI-2024-05-17T03:15:08Z] ⚠️ Test case 'testKVOThreadSafety' passed with 12ms variance (allowed: ±5ms)
[CI-2024-05-17T03:15:09Z] ❗ Detected 3 occurrences of objc_msgSend_stret in stack trace — blocked by AppStore policy scanner
[CI-2024-05-17T03:15:10Z] 🔄 Re-running with -fno-objc-msgsend-selector-stubs flag... 