为什么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之前也非常可以,要有个先来后到嘛。

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

相关推荐
码出极致1 小时前
支付平台资金强一致实践:基于 Seata TCC+DB 模式的余额扣减与渠道支付落地案例
后端·面试
walking9572 小时前
JavaScript 神技巧!从 “堆代码” 到 “写优雅代码”,前端人必看
前端·面试
walking9572 小时前
前端 er 收藏!高性价比 JS 工具库,轻量又强大
前端·面试
walking9572 小时前
效率党必藏! JavaScript 自动化脚本,覆盖文件管理、天气查询、通知提醒(含详细 demo)
前端·面试
walking9573 小时前
前端开发中常用的JavaScript方法
前端·面试
大舔牛3 小时前
图片优化全景策略
前端·面试
FogLetter3 小时前
Vite vs Webpack:前端构建工具的双雄对决
前端·面试·vite
骑着猪狂飙3 小时前
iOS技术之通过Charles抓包http、https数据
网络协议·http·ios·https
wycode4 小时前
# 面试复盘(2)--某硬件大厂前端
前端·面试