很多人写 iOS 写了三五年,NSObject、dispatch_async、RunLoop 这些词天天挂在嘴边,但真要问一句"objc_msgSend 到底在哪儿实现的""RunLoop 没有任务的时候线程在干嘛",回答就变成"应该是……吧"。我第一次认真去找苹果官方开源代码,是因为一个多线程数据错乱的问题:崩溃栈里一半是 libdispatch 的符号,一半是 runtime 的符号,翻了几十篇博客都没讲透。后来想明白一件事——与其靠二手解读猜,不如直接去读苹果自己放出来的源码。苹果每年都会把 iOS 和 macOS 里相当一部分核心组件的实现开源出来,objc4、libdispatch、CoreFoundation、Foundation 这些都在里面,这就是标题里说的那批"官方开源代码"。这篇就把我这些年翻这些仓库的经验完整摊开:去哪儿找、怎么对版本、怎么把 objc4 编起来、RunLoop 和 GCD 的源码该从哪几个函数切入读,以及读大厂源码时那些没人明说的门道。不管你是刚准备面试的新人,还是想解决线上疑难杂症的老手,这些方法应该都能用得上。
1. 苹果官方开源代码的入口与版本对应关系
苹果把系统组件的实现开源这件事,年头已经很久了。很多人只听过opensource.apple.com,但真正好用、更新更及时的是 GitHub 上的apple-oss-distributions组织,这里按组件分仓库,每个版本打一个 tag。搞清楚这两套入口的关系,是读源码的第一课,否则你连"该下哪个版本"都定不下来。
1.1 两个入口的差别,别在旧站点上浪费时间
opensource.apple.com是老站点,按 macOS 或 iOS 版本号分门别类地列目录,比如objc4-750、CF-1153.18这种命名。它的好处是"一个版本对应一整套组件",下载下来是打包好的 tarball。缺点是更新慢,界面也原始。
而github.com/apple-oss-distributions是苹果这份资源的镜像组织,每个组件一个独立仓库,objc4、libdispatch、CF、libc、xnu全都在。它最大的价值是保留了提交历史:你可以直接看某个 tag 到下一个 tag 之间改了什么,这对理解"苹果为什么这么改"极其关键。我的习惯是先在 GitHub 这边定位到具体 tag,需要整套环境时再回老站点拿打包版本。
# 只拉单个组件的某个版本,速度快很多 git clone --depth 1 -b objc4-838.1 https://github.com/apple-oss-distributions/objc4.git--depth 1加上-b指定 tag,能避免把整个历史拉下来。objc4这种仓库动辄几十万行提交历史,全量 clone 慢且没必要。
1.2 关键坑:组件版本号和系统版本号是两套体系
这是新手最容易栽的地方。你拿一台 iOS 16 的设备,想当然去搜"iOS 16 runtime 源码",基本搜不到。因为objc4-838、CF-1153这类版本号跟 iOS 16 这种系统版本号完全不是一回事,它们是组件自己的发布编号。
那怎么知道自己设备上跑的到底是哪一版?最靠谱的办法是从设备上反推。比如 runtime 的版本,可以在代码里打印:
// 打印当前 runtime 版本,跟仓库里的 tag 对照 extern const char *objc_getVersion(void); NSLog(@"objc version: %s", objc_getVersion());另一种方式更通用——看崩溃日志里符号的偏移量,再拿着地址到对应 tag 的源码里找函数体,多试几个相邻 tag 就能锁定。实际调试中,这个"反推版本"的动作至少要花你半天时间,但锁定之后,你对照源码排查问题的效率会成倍提升,因为你能确定自己看的就是机器上真正运行的代码。
1.3 目录打开之后先看什么
刚 clone 下来一个陌生仓库,最容易犯的错是直接点开最大的那个.m文件从头读。正确姿势是先扫一遍根目录的README、Makefile、.xcodeproj,判断这个组件能不能独立编译、依赖哪些东西。以objc4为例,根目录会有objc.xcodeproj,里面能看到它依赖libSystem、libc++这些系统库;而libdispatch则有Makefile和CMakeLists.txt,说明它支持多种构建方式。
还有个细节:苹果的源码里会有大量#if条件编译,区分 macOS、iOS、模拟器、真机。读代码时如果发现某段逻辑"看不懂为什么没生效",多半就是被条件编译屏蔽了。养成先看宏定义再读逻辑的习惯,能省掉很多自我怀疑。
2. objc4 源码:从 objc_msgSend 到消息转发的完整链路
objc4是 Objective-C runtime 的开源实现,也就是-[NSObject performSelector:]、objc_msgSend、方法缓存、动态方法决议这些机制的"原产地"。理解了这套代码,你对 OC 这门语言的理解会从"会用"跳到"知道为什么"。
2.1 先记住几个核心文件
一上来就被几十个文件淹没是常态,我建议先锁定这几个:
| 文件 | 负责的内容 |
|---|---|
objc-msg-arm64.s | arm64 架构下的objc_msgSend汇编实现,消息发送的性能核心 |
objc-runtime-new.mm | 类、方法、缓存的加载与查找主逻辑 |
objc-cache.mm | 方法缓存的插入与扩容 |
objc-class.mm | 类的注册、load/initialize的调用时机 |
objc-msg-x86_64.s | x86 架构(模拟器)的消息发送实现 |
真机跑的是objc-msg-arm64.s,模拟器跑的是 x86_64 版本。这也解释了一个常见现象:某些依赖汇编行为的问题,模拟器上复现不了。
2.2 objc_msgSend 为什么是汇编写的
很多人好奇,为什么整个 runtime 都用 C/C++ 写,偏偏消息发送要用汇编。原因很简单——性能,以及一些 C 语言做不到的事。
Objective-C 的消息发送需要满足几个约束:不能破坏调用者的寄存器状态,不能额外入栈开销,还要处理参数(包括浮点、结构体)原样传递给真正的实现函数。用一段从源码里抽出来的简化流程说明:
// 简化后的 objc_msgSend 逻辑(arm64) cmp x0, #0 ; 判断接收者是否为 nil b.le LReturnZero ; 是 nil 直接返回 0 ldr x13, [x0] ; 取 isa 指针 // 从 isa 的缓存里查 selector // 命中 -> tail call 到 IMP // 未命中 -> 跳 _objc_msgSend_uncached这里有两个关键点值得记住。第一,receiver == nil时直接返回 0,这就是"给 nil 发消息不崩溃"的底层原理,不是语言特性魔法,是汇编写死的。第二,命中缓存后是tail call(尾调用)到方法的IMP,也就是"查完了直接跳过去执行,不额外压栈",这正是 Objective-C 动态派发虽然慢一点但仍然够快的原因。
2.3 缓存没命中:lookUpImpOrForward 的三段式查找
汇编查到缓存未命中后,会跳进 C++ 的lookUpImpOrForward。这个函数是理解"方法查找"的关键,它大致分三段:
- 本类方法列表查找:在类的
method_list里按 selector 比对,找到就写回缓存。 - 沿继承链向上找:本类没有,就顺着
superclass一路往上,直到NSObject。 - 动态方法决议与消息转发:实在找不到,才触发
resolveInstanceMethod:、forwardingTargetForSelector:、methodSignatureForSelector:、forwardInvocation:这一整套转发流程。
我印象最深的一次排查是一个"对象莫名收到了不存在的方法"的崩溃。最后定位到是某个 category 覆盖了父类的respondsToSelector:,导致转发链路判断错了。这种问题从业务代码层面基本看不出来,只有把lookUpImpOrForward的命中顺序读明白,才知道该从哪一层去找。
2.4 编译 objc4 的现实和坑
理论上,objc.xcodeproj打开就能编。实际动手你会发现它依赖一堆系统私有头文件——dyld的、libc++的、libSystem的。缺头文件会报一堆 "file not found"。
我试过几条路。最省事的是找社区已经整理好的可编译版本,很多人在官方基础上补齐了头文件引用,直接能出静态库。自己动手这条路则需要把 macOS SDK 里的私有头文件目录加进 header search path,再逐步解决链接错误,适合想彻底搞一遍的人。还有一条更轻量的路:不追求编成完整能跑的库,只把某个.mm文件抽出来单独看逻辑,配合在 Xcode 里对真机进程下断点观察实际行为。
提示:编译 runtime 时,哪怕只是加一行
printf观察方法查找顺序,也建议在真机上验证,模拟器的消息发送路径走的是 x86_64 汇编分支,两者行为在某些边界上不一致。
3. CFRunLoop 源码:事件循环到底在转什么
RunLoop 是 iOS 开发者面试的常客,但真正读过CFRunLoop.c的人不多。这份代码在CoreFoundation(GitHub 上的CF仓库)里,理解它之后,你对"主线程为什么不会退出""卡顿到底卡在哪"会有完全不同的认知。
3.1 RunLoop 和线程是一一对应的
先破除一个误解:RunLoop 不是你能随意 new 出来的对象,它和线程是捆绑的。每个线程有且只有一个 RunLoop,通过[NSRunLoop currentRunLoop]或CFRunLoopGetCurrent()获取,第一次取的时候才懒加载创建。源码里的核心是一张全局的字典,以线程为 key,RunLoop 对象为 value。
这也解释了为什么要用CFRunLoopGetCurrent()而不能手动init。子线程默认不创建 RunLoop,也就没有事件循环,一个while(1)之外什么都没跑起来过。你在子线程里调NSURLConnection之类依赖 RunLoop 的老 API 会失败,就是因为它没被 RunLoop 驱动。
3.2 Mode、Source、Timer、Observer 四件套
CFRunLoop.c里 RunLoop 的结构体大致长这样(简化):
struct __CFRunLoop { CFRuntimeBase _base; pthread_mutex_t _lock; CFMutableSetRef _commonModes; // 被标记为 common 的 mode CFMutableSetRef _commonModeItems; // common mode 共享的 Source/Timer/Observer CFRunLoopModeRef _currentMode; // 当前正在跑的 mode CFMutableSetRef _modes; // 所有 mode };一个 RunLoop 可以包含多个 mode,每个 mode 里装着四类东西:
- Source0:处理应用内部事件,需要手动唤醒 RunLoop。
- Source1:基于 mach port,用于系统级事件和线程间通信,能主动唤醒。
- Timer:定时器。
- Observer:观察 RunLoop 状态变化,比如即将进入休眠、即将退出。
CFRunLoopRun这个循环的主体会调用__CFRunLoopDoSources0、__CFRunLoopDoTimers、__CFRunLoopDoObservers,处理完 sources 之后如果没有立即要处理的事件,就调用__CFRunLoopServiceMachPort进入 mach port 等待,线程此时真正挂起,不占 CPU。
3.3 为什么"空转的 RunLoop 不耗电"
这是我觉得最值得讲清楚的一点。RunLoop 空闲时不是while(1)死循环空转 CPU,而是通过mach_msg在 mach port 上挂起,操作系统把线程标记为等待状态,调度器根本不会给它分配时间片。只有当有 Source1 事件、Timer 到期,或者别的线程主动wakeup时,内核才会唤醒它重新进入循环。
这直接决定了我们对主线程的正确认知:主线程从启动开始就在这个循环里,UIApplicationMain最后其实就是在跑CFRunLoopRun。你写的事件响应、UI 刷新、定时器回调,全部是被这个循环按顺序从各个 mode 里捞出来执行的。
3.4 卡顿排查里 RunLoop 的实战用法
理解 Observer 的用途之后,你会发现它天生适合做卡顿监控。监听kCFRunLoopBeforeSources和kCFRunLoopAfterWaiting两个状态之间的时间差,如果这个间隔超过阈值,说明某段代码在这个期间执行过久。这套逻辑本质上是在"两个状态之间埋时间戳"。
// 监听完这两个 activity 之间的耗时,是卡顿检测的经典做法 CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, kCFRunLoopBeforeSources | kCFRunLoopAfterWaiting, true, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) { // 记录时间戳,两次之间超过阈值即可怀疑卡顿 });我在实际项目里用这套方案抓到过一次隐蔽问题:某个第三方 SDK 在收到kCFRunLoopBeforeWaiting时同步做了磁盘 IO,导致主线程每次进休眠前都要卡十毫秒以上。如果不是跑通了源码对某个 observer activity 的定义,光看堆栈根本定位不到。
4. libdispatch 源码:GCD 的队列、线程池与死锁本质
GCD 用得很爽,但"串行并行""sync 死锁"这些概念,光看 API 文档永远是一知半解。libdispatch是 GCD 的开源实现,它在 GitHub 的apple-oss-distributions/libdispatch仓库里。啃下它的队列和线程池模型,很多曾经的"玄学问题"会变得非常清晰。
4.1 dispatch_queue 里的串行与并行到底差在哪
先把结论说在前面:串行队列和并行队列的差别,不在于队列本身有多少线程,而在于队列在派发任务时,能不能在"上一个任务还没执行完"的情况下继续派发下一个。
源码里队列的核心结构是dispatch_lane_t,它会维护队列上挂着的任务链表。串行队列在同一时刻只允许一个任务进入执行状态,_dispatch_lane_serial_drain负责按顺序把任务一个个取出来跑;并行队列则允许把多个任务同时投递到线程池的多个线程上。
这里要纠正一个超常见的误解:并行队列不等于"每个任务开一条新线程"。任务最终是交给一套全局的线程池去执行的,并行队列只是允许更多任务并发地被投递进去。
4.2 全局线程池与并发度控制
GCD 底层并不给每条队列配一套线程,而是维护一个共享的工作队列池(root queues / global queues)。这些 root queue 有不同的优先级,对应各种 QoS。任务被投递时,会根据队列的 QoS 选择对应的 root queue,再由它去驱动实际的线程。
线程数量和 CPU 核数有关,系统会根据负载动态调整,避免无限制开线程把机器拖垮。这解释了为什么你往一个并行队列里塞一千个任务,线程数并不会炸到一千——它们是在有限线程池里排队执行的。
需要控制并发度时,正经做法是dispatch_semaphore配合全局队列,而不是自己开一堆NSThread。我自己踩过这个坑:早期项目用过手动管理线程池,结果线程数失控导致内存暴涨,换成 GCD 加信号量,代码短了,稳定性也上来了。
4.3 死锁的源码级解释
"主队列 sync 主队列会死锁"这句话人人都会背,但为什么?答案藏在dispatch_sync的实现里。
dispatch_sync会把当前任务标记为"等待某个结果",然后让当前线程在半路上等待目标队列执行完这个 block。问题是,如果目标队列就是当前线程正在执行的那条串行队列,那么"当前 block 执行完"和"目标 block 被执行"互相等待——你等它执行,而它要等你这条队列腾出手来。两边都动不了,死锁。
// 经典死锁:主线程在主队列上同步派发 dispatch_sync(dispatch_get_main_queue(), ^{ // 主队列被当前任务占用,永远等不到执行 });换到串行队列上也是同理:队列上的任务只能顺序执行,你在其中某个任务里sync回这条队列,后面的任务就卡在你这条任务后面,而你在等后面的任务执行——死循环的等待。
从源码角度看,_dispatch_sync_wait里的等待逻辑正是这个"等结果"的实现,它不是查表判断"会不会死锁",而是实打实地挂起等待。理解了这点,你就知道为什么死锁不总是立刻被发现,有时候是间隔一跳才卡住。
5. 其他值得翻的官方仓库与高效阅读方法
除了 objc4、CF、libdispatch,苹果还有一批官方开源组件值得一读,加上一套合适的阅读方法,能让"读源码"这件事从痛苦变成有收获。
5.1 值得排进阅读清单的仓库
- libc / libmalloc:
malloc、free的实现,理解内存分配器、A/B 分区池、堆的元数据结构。内存暴涨问题排查的必备。 - swift-corelibs-foundation:Foundation 的开源版本,虽然是给非 Darwin 平台准备的,但
NSRunLoop那一层封装逻辑对理解 OC 的 Foundation 有帮助。 - CF(CoreFoundation):
CFRunLoop之外,还有CFString、CFDictionary等基础容器的实现。 - libobjc 之外的 dyld:动态链接器,理解
+load的调用顺序、符号绑定、启动优化。
这几个仓库的共同特点是:它们构成了 App 启动和运行的地基。当你遇到启动慢、卡顿、内存问题,能从这些层面去思考,而不是停留在"清缓存试试"。
5.2 从调用栈反查源码,是最快的入口
不推荐"从第一行读到最后一行",我一般从真实调用栈反着找。比如 App 崩溃时堆栈里出现了_dispatch_lane_serial_drain这样的符号,或者用 Instruments 抓运行时时看到lookUpImpOrForward,那就直接去对应仓库搜这个函数名。
这种方式的好处是:你有明确的上下文——"我要搞清楚这条堆栈为什么会走到这里",带着问题读代码,注意力集中,理解也更深。等你这样零散地读上二三十次,整个 runtime 和 GCD 的地图就自然拼出来了。
5.3 用版本对比看"苹果为什么改"
只看一个版本,你只能看到"它现在长什么样";对比两个版本,你才能看到"苹果遇上了什么问题、怎么解决的"。GitHub 的 compare 功能非常好用,选中相邻两个 tag,看 diff。你经常能发现某个函数从"每次加锁"改成了"无锁 CAS"、某个缓存策略容量变了——这些改动背后往往就是性能优化或安全修复,是极其优质的实战教材。
我自己有过一次收获:对比某个 libdispatch 版本的_dispatch_queue_push实现,发现苹果对唤醒线程的判断条件做了调整,减少了一些无效唤醒。把这个思路借鉴到自己项目里的任务队列设计上,纯 CPU 场景下的开销确实降了下来。
5.4 几个实操上的小提醒
- 别在办公室网络里拉 xnu:内核仓库体量极大,全量 clone 前先估算空间,用
--depth。 - 准备一个全局搜索工具:几万行源码,
ripgrep或 IDE 的全局搜索比手动翻快十倍。 - 断点比阅读更直观:像
objc_msgSend这种纯汇编的路径,看十遍不如在 LLDB 里对真机进程下个断点看一次参数和寄存器。 - 写下来才算读懂:我习惯每搞懂一段核心逻辑就写几行笔记,尤其把函数名、关键结构体字段记下来,过几个月再回头查的时候省很多事。
实际上,这批苹果官方开源代码真正的价值不在于"背下来应付面试",而在于它们给了你一个可以随时验证猜想的参照物。代码运行出了问题,与其在网络帖子之间来回猜测,不如直接翻到那一行实现,看它到底在做什么。我踩过的坑里,有相当一部分最终的答案就藏在某一行看着平平无奇的源码里,只是以前从来没有耐心去翻而已。