为什么runloop中先处理 blocks source0 再处理timer source1?

scss 复制代码
__CFRunLoopDoObservers(rl, currentMode, kCFRunLoopEntry);
do{
   
    //__CFRunLoopDoObservers(kCFRunLoopBeforeTimers);

    //__CFRunLoopDoObservers(kCFRunLoopBeforeSources);

    __CFRunLoopDoBlocks(rl, rlm); 
  
    __CFRunLoopDoSource0();
    
    __CFRunLoopDoBlocks(rl, rlm);

    //判断是否进入休眠
    if (__CFRunLoopServiceMachPort(dispatchPort, &msg, sizeof(msg_buffer), &livePort, 0, &voucherState, NULL)) {
        goto handle_msg;
    }

    //__CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeWaiting);
    
    //__CFRunLoopDoObservers(rl, rlm, kCFRunLoopAfterWaiting

    __CFRunLoopDoTimers(rl, rlm, mach_absolute_time())

    __CFRunLoopDoSource1(rl, rlm, rls, ...)

    __CFRunLoopDoBlocks(rl, rlm);

    //runloop是否继续
    if (sourceHandledThisLoop && stopAfterHandle) {

    }else {
    }...
}
__CFRunLoopDoObservers(rl, currentMode, kCFRunLoopExit);

既然是一个循环,那么如果我们要做A,B, C三件事,如果A,B,C不是类似A会定义B的具体内容,B定义C的具体内容这样的关系,那么ABC在一个循环中的顺序就可以是随意的那么为什么一个runloop的真正循环是这样的?为什么首次进入在判断是否进入休眠之前先处理source0,Blocks?

为了方便分析今天的问题,我将其中的通知也都注释了

可以看到runloop处理的四种任务:timer source0 source1 Blocks

这四种任务最终都通向了函数调用这个终点(_dispatch_block_invoke())

,也就是进入函数调用栈执行,再出栈即完成使命


具体我们在说一下__CFRunLoopDoBlocks,它会检查是否有pending的block,有的话,就逐个执行。

这个block它是 经过 GCD 包装后的 block 结构体,不再是原始的闭包语法,我么就认为它是一个闭包即可,不影响理解。

那么这个block是从哪里获取的呢?这里就要说到__CFRunLoopDoBlocks 的双输入源

1、rl->_blocks_head 这是一个RunLoop 自己维护的block 链表

生产者:**CFRunLoopPerformBlock()

2、主队列:GCD 主队列是一个独立的 dispatch_queue_t

生产者:所有dispatch_async(main_queue, ...) 的 block 都入队到这里,performSelector(onMainThread)也是终使用的gcd添加block到主队列

消费者:RunLoop 通过 _dispatch_runloop_root_queue_perform_4CF() 从它取 block,这部分就在__CFRunLoopDoBlocks中实现


为什么设计两套机制

rl->_blocks_head 的优势:Mode 精细控制

objectivec 复制代码
// 用户正在滚动 tableView(UITrackingRunLoopMode)
CFRunLoopPerformBlock(
    CFRunLoopGetMain(),
    CFSTR("UITrackingRunLoopMode"),  // ← 只在这个 mode 下执行
    updateUIBlock
);

如果用 dispatch_async,block 会在 kCFRunLoopCommonModes 立即执行,可能打断滚动等UI更新


GCD 与 RunLoop 协作机制:"生产-消费"模型GCD:负责入队(enqueue),通过 dispatch_async, performSelector, TimerRunLoop:负责 出队并执行(dequeue & execute), 通过 __CFRunLoopDoBlocks()

这是一个标准的 "多生产者 - 单消费者" 模型(主线程消费)


谁可以向主队列"入队"?

1、GCD(最常见)

DispatchQueue.main.async { self.updateUI() }

python 复制代码
DispatchQueue.main.async {
    self.updateUI()
}
  • 直接调用 GCD API
  • block 被加入主队列的 pending 队列

2、Foundation / UIKit(间接使用 GCD)

php 复制代码
// performSelector 系列
self.performSelector(onMainThread: #selector(loadData), with: nil, waitUntilDone: false)
// NSTimer 添加到 main runloop
Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(tick), userInfo: nil, repeats: true)
  • UIKit 内部调用 dispatch_async(main_queue, ...)
  • 最终还是 GCD 入队

3、系统框架(如 KVO、Notification)

php 复制代码
NotificationCenter.default.addObserver(
    forName: .dataUpdated,
    object: nil,
    queue: .main) { _ in
        self.refresh()
    }
  • queue: .main,表示回调在主队列执行
  • 内部使用 dispatch_async(main_queue, ...)

谁从主队列"出队"?(消费者)

只有一个:主线程的 RunLoop,

scss 复制代码
runloop每次循环都会多次调用__CFRunLoopDoBlocks(),检查是否有pending的block,有的话,就逐个执行。   

gcd和performselector都是主队列的生产者

从主队列这个视角看,gcd和performselector 、runloop就是一个多生产,单消费的模型

  • DispatchQueue.main.async成功将 block 入队到主队列
  • block 被存储在 GCD 的全局队列结构中
  • 它在 application:didFinishLaunching 返回后,由 RunLoop 在下一次循环中执行消费掉

主队列 ≠ RunLoop,主队列是 GCD 的概念,RunLoop 是 CF 的概念,但 RunLoop 负责"消费"主队列中的任务。


可以看出这个__CFRunLoopDoBlocks在runloop的每次循环中将会被调用多次,因为在runloop执行过程中随时都可能生成新的blocks

swift 复制代码
// AppDelegate.swift
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    
    self.window?.makeKeyAndVisible()  
    // → UIKit 内部 performSelector
    self.performSelector(#selector(setupUI), on: Thread.main, with: nil, waitUntilDone: false)
    DispatchQueue.main.async {
        self.loadData()
    }
    return true
}

这段代码实际做了什么,其中的函数在什么事机真正的执行?

主队列(main queue)在 main() 启动时就已创建,早于 application:didFinishLaunching

但是runlooprun()也就是runloop启动是在application:didFinishLaunching方法返回之后。

因此可以说application:didFinishLaunching中的操作无一例外,他们都不会是从runloop基础上放入调用栈的,而是直接入栈。

举个例子

js 复制代码
self.window?.makeKeyAndVisible()

它是一个 同步方法调用 ,在当前线程(主线程)立即执行,设置窗口为「主窗口」(key window)并使其可见,它本身不创建任何 RunLoop Source,而是直接修改对象状态

注意这里只是修改状态,而这些状态真正表现到UI上,这部分UI 重绘的代码是在什么时机执行?

makeKeyAndVisible() 修改了窗口状态后,UIKit 内部会安排一次 UI 重绘 ,这个"安排"才是关键,UI更新等最终会被转换为source0事件源,由runloop来处理。

而performselector和gcd也是一样,他们立即执行,但是只是将对应的block放入对应的待执行列表,runloop运行起来以后在__CFRunLoopDoBlocks中执行这些block对应的任务

一旦runloop运行起来,基本就是gcd,用户点击等唤醒runloop,runloop找到对应的任务,一一安排加入函数调用栈执行,没有任务时就休眠等待。

开发者手动唤醒runloop的方法:

1、使用 GCD(推荐)GCD 向主线程的 Mach Port 发送消息,触发 Source1 事件,mach_msg() 提前返回,RunLoop 执行 block

2、使用 CFRunLoopWakeUp(),内部向 RunLoop 的 wakeUp port 发送一个空消息,触发 Source1 事件,RunLoop 从 mach_msg() 中醒来

3、发 Mach 消息

为什么设计成"先配置,再启动"?

原则 说明
配置完整性 必须让你完成所有初始化(如设置 window、注册服务、检查登录状态)后,才开始响应事件
避免竞态 如果 RunLoop 先启动,可能会在你还没准备好时就收到通知、定时器、网络回调
控制权清晰 UIApplicationMain 掌握启动流程,你只负责"配置",它负责"执行"
兼容性 保证所有 App 都遵循相同的启动顺序

调用栈:

调用栈是动态确定 的,每一次函数调用都实时修改栈只有在程序运行起来后,调用栈才开始"生长"

js 复制代码
1. 程序启动:
   [main]
2. main 调用 UIApplicationMain:
   [UIApplicationMain]
   [main]
3. UIApplicationMain 调用 application:didFinishLaunching:
   [application:didFinishLaunching]
   [UIApplicationMain]
   [main]
4. 执行 self.window?.makeKeyAndVisible()  :
   [makeKeyAndVisible]  ← 临时帧
   [application:didFinishLaunching]
   [UIApplicationMain]
   [main]
5. makeKeyAndVisible 返回:
   [application:didFinishLaunching]
   [UIApplicationMain]
   [main]
6. application 函数返回:
   [UIApplicationMain]
   [main]
7. UIApplicationMain 进入 RunLoop 循环...

RunLoop 本身不操作栈,但它通过调度任务,间接导致调用栈的变化。 RunLoop 是"任务调度员",它决定"下一个谁上台表演";调用栈是"舞台上的演员",它记录"现在正在说什么台词"。调度员不背台词,但没有调度员,演员就不知道何时上场。

CPU 一直在执行"调用栈"------RunLoop 就是主线程调用栈上运行的一个"超级循环函数"。

不存在"执行调用栈" vs "处理 RunLoop" 的分离------RunLoop 是调用栈的一部分,调用栈是 CPU 执行的轨迹。

CPU 从 main() 开始,一路执行到 CFRunLoopRun(),然后在这个循环里不断调度任务,所有这些都记录在主线程的调用栈中。

swift 复制代码
func application(_ app: UIApplication, didFinishLaunchingWithOptions opts: [AnyHashable: Any]?) -> Bool {
    print("Tick")
    DispatchQueue.main.async {
        self.loadData()  
    }
    return true
}

print("Tick") 不是"任务",而是"当前执行流"

当前执行流 = 当前线程的调用栈(Call Stack)上正在执行的函数序列

  • 它不是通过 dispatch_asyncperformSelector 添加的
  • 它是 application:didFinishLaunching 函数体内的同步代码
  • 它在当前线程(主线程)上立即执行
  • 它不需要 RunLoop 循环来驱动,更不需要放入主队列
维度 当前执行流 任务
存储位置 调用栈(Stack) 堆(Heap)或队列
生命周期 函数调用期间 从注册到执行完成
触发方式 函数调用 调度器(RunLoop/GCD)触发
是否阻塞 是(同步) 否(异步)
上下文 当前栈帧 捕获的变量(closure)
例子 print(), if, for async, Timer, performSelector

前文提到makeKeyAndVisible() 修改了窗口状态后,UIKit 内部会安排一次 UI 重绘,这个"安排"才是关键,从这个关键开始我们逐渐解答标题中的疑问。

UI更新包括动画、 self.myView.setNeedsDisplay()self.myView.setNeedsLayout()的调用等最终会被转换为source0事件源,例如一个方法test

js 复制代码
test(){
//一些view的属性设置
myView.setNeedsDisplay() // 【调用 1】
//另一些view的属性设置
myView.setNeedsDisplay() // 【调用 2】
}
  1. 【调用 1】执行:myView的一个内部标志位(比如 _needsDisplay)被设置为true。系统会记下这个视图(myView)需要更新。同时,它会确保主线程RunLoop中用于处理UI更新的Source0事件已经被安排(scheduled)。如果这个 Source0 还没在队列里,就把它加进去。如果已经在队列里了,就什么都不做。

2.【调用 2】执行:它再次看到myView已经被标记为"需要重绘"了。所以它不会做任何额外的事情,不会创建第二个事件,也不会改变已有的安排。它只是简单地返回。

可以把它想象成一个待办事项清单:

  • 第一次调用 setNeedsDisplay():把 " 重绘 myView " 加到清单里。

  • 第二次调用 setNeedsDisplay():你看了看清单,"重绘 myView"这项已经在上面了。所以你不需要再做任何事。

source0,非系统事件,即用户自定义的事件,不能自动唤醒 runloop,需要先调用CFRunLoopSourceSignal(source)source置为待处理事件,然后再唤醒 runloop让其处理这个事件source1由RunLoop和内核管理,source1带有mach_port_t,可以接收内核消息并触发回调

这样理解下来,在判断是否休眠之前处理source0是非常合理的,毕竟UI都没有的话,用户无法操作,又何来source1事件源呢?

在判断是否休眠之前处理blocks也是非常合理的,毕竟runloop开始之前在application:didFinishLaunching方法中,开发者可能加入了很多blocks任务等待处理

那么为什么不在这时处理timers呢?

RunLoop 在进入休眠前,会主动查询"最近的 Timer 什么时候到期",然后把"这个时间差"作为 mach_msg() 休眠的最大时长,从而确保 RunLoop 不会"睡太久",Timer 一到点就能执行。

问题 解释
"RunLoop 怎么知道 Timer 到期了?" 它不知道,除非它"醒来"并检查时间
"能在休眠前检查吗?" 能,但只能通过 计算"最近 Timer 到期时间" 来决定"睡多久"
"为什么不直接执行?" 因为 Timer 可能还没到期!你只能"限制休眠",不能"预执行"
js 复制代码
else if (modeQueuePort != MACH_PORT_NULL && livePort == modeQueuePort) {
       CFRUNLOOP_WAKEUP_FOR_TIMER();
       if (!__CFRunLoopDoTimers(rl, rlm, mach_absolute_time())) {
           // Re-arm the next timer, because we apparently fired early
               __CFArmNextTimerInMode(rlm, rl);
        }
    }

最后,标题中的问题根本就不成立,为什么呢?看下部分__CFRunLoopRun源码

js 复制代码
Boolean sourceHandledThisLoop = __CFRunLoopDoSources0(rl, rlm, stopAfterHandle);
        if (sourceHandledThisLoop) {
            __CFRunLoopDoBlocks(rl, rlm);
    }
Boolean poll = sourceHandledThisLoop || (0ULL == timeout_context->termTSR);
  
    do {
            if (kCFUseCollectableAllocator) {
                // objc_clear_stack(0);
                // <rdar://problem/16393959>
                memset(msg_buffer, 0, sizeof(msg_buffer));
            }
            msg = (mach_msg_header_t *)msg_buffer;

            __CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort, poll ? 0 : TIMEOUT_INFINITY, &voucherState, &voucherCopy);

            if (modeQueuePort != MACH_PORT_NULL && livePort == modeQueuePort) {
                // Drain the internal queue. If one of the callout blocks sets the timerFired flag, break out and service the timer.
                while (_dispatch_runloop_root_queue_perform_4CF(rlm->_queue));
                if (rlm->_timerFired) {
                    //     Leave livePort as the queue port, and service timers below
                         rlm->_timerFired = false;
                    break;
                } else {
                    if (msg && msg != (mach_msg_header_t *)msg_buffer) free(msg);
                }
            } else {
                // Go ahead and leave the inner loop.
                break;
            }
        } while (1);

可以看出有source0消息处理时poll为0,也就是说基于我们之前在application:didFinishLaunching方法中的各种方法调用, 第一次进入runloop后__CFRunLoopServiceMachPort方法调用并不会休眠,而是直接返回,继续处理模式队列和timer,source1等,source0的处理放在source1之前也非常可以,要有个先来后到嘛。

有任何问题,欢迎读者、专家指正~

相关推荐
Dream it possible!7 分钟前
LeetCode 面试经典 150_回溯_全排列(100_46_C++_中等)
c++·leetcode·面试·回溯
LYFlied24 分钟前
【每日算法】LeetCode 70. 爬楼梯:从递归到动态规划的思维演进
算法·leetcode·面试·职场和发展·动态规划
YoungHong199227 分钟前
面试经典150题[073]:从中序与后序遍历序列构造二叉树(LeetCode 106)
leetcode·面试·职场和发展
regon1 小时前
第九章 述职11 交叉面试
面试·职场和发展·《打造卓越团队》
LYFlied1 小时前
【每日算法】LeetCode 105. 从前序与中序遍历序列构造二叉树
数据结构·算法·leetcode·面试·职场和发展
wvy1 小时前
Xcode 26还没有适配SceneDelegate的app建议尽早适配
ios
gis分享者2 小时前
如何在 Shell 脚本中如何使用条件判断语句?(中等)
面试·shell·脚本·语法·使用·判断·条件
游戏开发爱好者82 小时前
苹果 App 上架流程,结合 Xcode、CI 等常见工具
macos·ios·ci/cd·小程序·uni-app·iphone·xcode
前端老白2 小时前
webview在微信小程序中,安卓加载失败,IOS正常加载
android·ios·微信小程序·webview
2501_915106322 小时前
用 HBuilder 上架 iOS 应用时如何管理Bundle ID、证书与描述文件
android·ios·小程序·https·uni-app·iphone·webview