最近遇到一个需求,需要将Go编写的库通过Gomobile编译成.xcframework文件放在iOS App里运行。总的资源用量不大,但每次程序进入后台,都会很快被杀,好一顿排查后,发现和Goroutine的调度策略以及iOS的后台管理策略有关,下面我会就这个问题谈一下我的理解。
1. GMP 模型
要理解问题的全貌,首先要理解 Go 调度器的 GMP 模型。
Go 调度器通过三个核心实体来配合工作:
- G (Goroutine):协程,待执行的任务。
- M (Machine):线程,操作系统的物理线程。
- P (Processor):处理器,包含运行
Go代码所需的上下文和本地队列。
那么,这三个实体是如何配合运作的呢?
运作流程
- 本地队列与全局队列:每个
P维护一个本地G队列(上限 256 个)。新创建的G优先放入P的本地队列。如果本地队列满了,才会放入全局队列。 - 获取任务:
M必须绑定一个P才能执行G。它会不断从绑定的P的本地队列中获取G来运行。 - 任务切换:
Go采用抢占式调度。如果一个G运行时间过长(约 10ms),或者遇到了同步系统调用(如读写磁盘),调度器会保存其状态并切换到下一个G。
M 为什么会变多?
既然阻塞就会切换 G,为什么会有 M 被阻塞要创建新 M 的情况呢?
因为阻塞对Go调度器而言,分为“可控”的和“不可控”的(也可以理解为“知情的”和“不知情的”)。
- 可控阻塞:如网络 I/O。
Go通过netpoller(基于epoll/kqueue)实现了非阻塞等待,此时G会被挂起,但M可以继续去执行其他G。 - 不可控阻塞:如同步文件读写。这种操作会让内核将整个
M(线程)挂起。由于M被挂起,它绑定的P也就无法继续工作。此时Go的监控线程 (sysmon) 会剥离该P并寻找或创建一个新的M来接管,以保证 CPU 的利用率。
注意:
M的执行权是由操作系统调度的。Go调度器可以控制M执行哪个G,但无法干预操作系统何时剥夺或给予M的 CPU 时间片。这是为什么G已经脱离调度器的控制了,却还能继续执行的原因。
正是这种 M 的扩张机制,在 iOS 环境下引发了意想不到的连锁反应。
2. 问题分析:CGO 与 iOS 后台策略
实际上,Go 调度器“不知情”的阻塞除了同步系统调用,最常见的就是执行 CGO 代码。当你通过 Gomobile 让 Go 代码跑在 iOS 上时,绝大多数 iOS API 的调用都属于这种 CGO 阻塞。
线程爆炸的连锁反应
假设当前环境有:2P、4M、若干 G。
- 当
G进入 CGO 执行Objective-C或Swift代码时,Go会立即认为该M进入了系统调用状态。 - 为了不让
P闲置,Go会积极地创建(或从线程池找)一个新的M来接管P。 - 如果项目中有大量的
Goroutine同时在执行 CGO 代码(且执行时间较长),Go就会源源不断地创建新的M。
这种“积极”的扩张策略在桌面端通常是优势,但在 iOS 环境下却很致命。
iOS 的后台管理
iOS 对 App 的后台资源监控非常严苛。除了内存占用(jetsam),线程数量也是一个关键指标。当 App 进入后台,如果 Go 层因为 CGO 调用导致线程数瞬间激增,系统会认为该 App 存在异常行为(如资源泄漏或恶意占用),从而触发保护机制将其直接杀掉。这就是为什么看似资源用量不大的 Go 库,在 iOS 上却难以存活的原因。