ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

苹果开源代码精读:objc4、RunLoop 与 libdispatch

苹果开源代码精读:objc4、RunLoop 与 libdispatch 很多人写 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.sarm64 架构下的objc_msgSend汇编实现消息发送的性能核心objc-runtime-new.mm类、方法、缓存的加载与查找主逻辑objc-cache.mm方法缓存的插入与扩容objc-class.mm类的注册、load/initialize的调用时机objc-msg-x86_64.sx86 架构模拟器的消息发送实现真机跑的是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的人不多。这份代码在CoreFoundationGitHub 上的CF仓库里理解它之后你对主线程为什么不会退出卡顿到底卡在哪会有完全不同的认知。3.1 RunLoop 和线程是一一对应的先破除一个误解RunLoop 不是你能随意 new 出来的对象它和线程是捆绑的。每个线程有且只有一个 RunLoop通过[NSRunLoop currentRunLoop]或CFRunLoopGetCurrent()获取第一次取的时候才懒加载创建。源码里的核心是一张全局的字典以线程为 keyRunLoop 对象为 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 / libmallocmalloc、free的实现理解内存分配器、A/B 分区池、堆的元数据结构。内存暴涨问题排查的必备。swift-corelibs-foundationFoundation 的开源版本虽然是给非 Darwin 平台准备的但NSRunLoop那一层封装逻辑对理解 OC 的 Foundation 有帮助。CFCoreFoundationCFRunLoop之外还有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 里对真机进程下个断点看一次参数和寄存器。写下来才算读懂我习惯每搞懂一段核心逻辑就写几行笔记尤其把函数名、关键结构体字段记下来过几个月再回头查的时候省很多事。实际上这批苹果官方开源代码真正的价值不在于背下来应付面试而在于它们给了你一个可以随时验证猜想的参照物。代码运行出了问题与其在网络帖子之间来回猜测不如直接翻到那一行实现看它到底在做什么。我踩过的坑里有相当一部分最终的答案就藏在某一行看着平平无奇的源码里只是以前从来没有耐心去翻而已。
返回列表