RunLoop 浅析
一个小应用
首先我们需要编写一个应用,这个小应用的要求很简单:它需要执行一些比较耗时的操作,在执行耗时操作的同时还需要可以继续响应用户的操作。
那么首先想到的就是使用两个线程,一个 Main
一个 Worker
,在 Main
中响应用户的操作,而将实际的耗时任务放到 Worker
中。
首先看看在不使用 RunLoop 时的代码是如何实现的:
objc
//
// main.m
// Downloader
//
// Created by mconintet on 11/23/15.
// Copyright © 2015 mconintet. All rights reserved.
//
#import <Foundation/Foundation.h>
// 『消息队列(messages queue)』这个名词想必是家喻户晓了
// 这里 commands 就相当于一个消息队列的作用
// 主线程在收到了用户的 command 之后并不是
// 立即处理它们,转而将其添加到这个 queue 中,
// 然后 Worker 会逐个的处理这个命令
static NSMutableArray* commands;
// NSMutableArray 并不是 thread-safety,所以
// 需要 @synchronized 来保证数据完整性
void pushCommand(NSString* cmd)
{
@synchronized(commands)
{
[commands addObject:cmd];
}
}
NSString* popCommand()
{
@synchronized(commands)
{
NSString* ret = [commands lastObject];
[commands removeLastObject];
return ret;
}
}
@interface Worker : NSThread
@end
@implementation Worker
- (void)main
{
// 如你所见,在 Worker 中我们
// 采用了『轮询』的方式,就是不断的
// 询问消息队列,是不是有新消息来了
while (1) {
NSString* last = popCommand();
// 如果通过不断的轮询得到新的命令
// 那么就处理那个命令
while (last) {
NSLog(@"[Worker] executing command: %@", last);
sleep(2); // 模拟耗时的计算所需的时间
NSLog(@"[Worker] executed command: %@", last);
last = popCommand();
}
}
}
@end
int main(int argc, const char* argv[])
{
@autoreleasepool
{
commands = [[NSMutableArray alloc] init];
Worker* worker = [[Worker alloc] init];
[worker start];
int c = 0;
do {
c = getchar();
// 忽略输入的换行
// 这样 Log 内容更加清晰
if (c == '\n')
continue;
NSString* cmd = [NSString stringWithCharacters:(const unichar*)&c length:1];
pushCommand(cmd);
// 在主线程中 Log 这条信息,
// 以此来表示主线程可以继续响应
NSLog(@"[Main] added new command: %@", cmd);
} while (c != 'q');
}
return 0;
}
运行下这个程序,然后切换到 Debug navigator
,会看到这样的结果:
Worker 让 CPU 几乎满了 😂,看来 Worker 轮询消息队列的方式有很大的性能问题。回看 Worker 中这样的代码:
objc
while (1) {
NSString* last = popCommand();
while (last) {
NSLog(@"executint command: %@", last);
sleep(2); // 模拟耗时的计算所需的时间
NSLog(@"executed command: %@", last);
last = popCommand();
}
}
上面代码作用就是采用轮询的方式不断的向消息队列询问是否有新消息到达。这样的模式会有一个严重的问题:如果在很长一段时间内用户并没有输入新的 command,子线程还是会不断的轮询,就是因为这些不断的轮询导致 CPU 资源被占满。
Worker 不断轮询消息队列的模式已经被我们证明是具有性能问题的了,那么是不是可以换一种思路?如果可以让 Main 和 Worker 的协作变为这样:
- Main 不断地接收到用户输入,将输入放到消息队列中,然后通知 Worker 说『Wake up,你有新的任务需要处理』
- Worker 开始处理消息队列中任务,任务处理完成之后,自动进入休眠,不再继续占用 CPU 资源,直到接收到下一次 Main 的通知
为了完成这个模式,我们可以采用 RunLoop。
RunLoop
在使用 RunLoop 之前,先了解下它。具体的在 Run Loops,扼要的说:
- 每个线程都有一个与之相关的 RunLoop
- 与线程相关联的 RunLoop 需要手动的运行,以此让其开始处理任务。主线程已经为你自动的启动了与其关联的 RunLoop(注意命令行程序的主线程并没有这个自动开启的动作)
- RunLoop 需要以特定的 mode 去运行。『common mode』实际上是一组 modes,有相关的 API 可以向其中添加 mode
- RunLoop 的目的就是监控 timers 和 run loop sources。每一个 run loop source 需要注册到特定的 run loop 的特定 mode 上,并且只有当 run loop 运行在相应的 mode 上时,mode 中的 run loop source 才有机会在其准备好时被 run loop 所触发
- RunLoop 在其每一次的循环中,都会经历几个不同的场景,比如检查 timers、检查其他的 event sources。如果有需要被触发的 source,那么会触发与那个 source 相关的 callback
- 除了使用 run loop source 之外,还可以创建 run loop observers 来追踪 run loop 的处理进度
如果要更加深入的了解 RunLoop 推荐阅读 深入理解RunLoop。
使用 RunLoop 来改写程序
下面的代码使用 RunLoop 来改写上面的程序:
objc
//
// main.m
// Downloader
//
// Created by mconintet on 11/23/15.
// Copyright © 2015 mconintet. All rights reserved.
//
#import <Foundation/Foundation.h>
static NSMutableArray* commands;
void pushCommand(NSString* cmd)
{
@synchronized(commands)
{
[commands addObject:cmd];
}
}
NSString* popCommand()
{
@synchronized(commands)
{
NSString* ret = [commands lastObject];
[commands removeLastObject];
return ret;
}
}
// run loop source 相关的回调函数
// 在外部代码标记了 run loop 中的某个 run loop source
// 是 ready-to-be-fired 时,那么在未来的某一时刻 run loop
// 发现该 run loop source 需要被触发,那么就会调用到这个与其
// 相关的回调
void RunLoopSourcePerformRoutine(void* info)
{
// 如果该方法被调用,那么说明其相关的 run loop source
// 已经准备好。在这个程序中就是 Main 通知了 Worker 『任务来了』
NSString* last = popCommand();
while (last) {
NSLog(@"[Worker] executing command: %@", last);
sleep(2); // 模拟耗时的计算所需的时间
NSLog(@"[Worker] executed command: %@", last);
last = popCommand();
}
}
// Main 除了需要标记相关的 run loop source 是 ready-to-be-fired 之外,
// 还需要调用 CFRunLoopWakeUp 来唤醒指定的 RunLoop
// RunLoop 是不能手动创建的,所以必须注册这个回调来向 Main 暴露 Worker
// 的 RunLoop,这样在 Main 中才知道要唤醒谁
static CFRunLoopRef workerRunLoop = nil;
// 这也是一个 run loop source 相关的回调,它发生在 run loop source 被添加到
// run loop 时,通过注册这个回调来获取 Worker 的 run loop
void RunLoopSourceScheduleRoutine(void* info, CFRunLoopRef rl, CFStringRef mode)
{
workerRunLoop = rl;
}
@interface Worker : NSThread
@property (nonatomic, assign) CFRunLoopSourceRef rlSource;
@end
@implementation Worker
- (instancetype)initWithRunLoopSource:(CFRunLoopSourceRef)rlSource
{
if ((self = [super init])) {
_rlSource = rlSource;
}
return self;
}
- (void)main
{
NSLog(@"[Worker] is running...");
// 往 RunLoop 中添加 run loop source
// 我们的 Main 会通过 rls 和 Worker 协调工作
CFRunLoopAddSource(CFRunLoopGetCurrent(), _rlSource, kCFRunLoopDefaultMode);
// 线程需要手动运行 RunLoop
CFRunLoopRun();
NSLog(@"[Worker] is stopping...");
}
@end
// 告诉 Worker 任务来了
// 把 Worker 拎起来干事
void notifyWorker(CFRunLoopSourceRef rlSource)
{
if (workerRunLoop) {
CFRunLoopSourceSignal(rlSource);
CFRunLoopWakeUp(workerRunLoop);
}
}
int main(int argc, const char* argv[])
{
@autoreleasepool
{
NSLog(@"[Main] is running...");
commands = [[NSMutableArray alloc] init];
// run loop source 的上下文
// 就是一些 run loop source 相关的选项以及回调
// 另外我们这的第一个参数是 0,必须是 0
// 这样创建的 run loop source 就被添加在
// run loop 中的 _sources0,作为用户创建的
// 非自动触发的
CFRunLoopSourceContext context = {
0, NULL, NULL, NULL, NULL, NULL, NULL,
RunLoopSourceScheduleRoutine,
NULL,
RunLoopSourcePerformRoutine
};
CFRunLoopSourceRef runLoopSource = CFRunLoopSourceCreate(NULL, 0, &context);
Worker* worker = [[Worker alloc] initWithRunLoopSource:runLoopSource];
[worker start];
int c = 0;
do {
c = getchar();
if (c == '\n')
continue;
NSString* cmd = [NSString stringWithCharacters:(const unichar*)&c length:1];
pushCommand(cmd);
NSLog(@"[Main] added new command: %@", cmd);
notifyWorker(runLoopSource);
} while (c != 'q');
NSLog(@"[Main] is stopping...");
}
return 0;
}
可以运行一下看下性能如何:
可以看到,在没有新的用户输入到达,且消息队列中没有需要处理的任务时,整个应用程序没有持续的霸占 CPU 资源,这就归功于 RunLoop。
最后简单概括下为什么 RunLoop 有这么『神奇』的功能吧。
首先 RunLoop 内部核心也是一个 loop 循环(和它的名字呼应),然后这个循环中做了一些有意思的事情:
- 首先每一次的循环中,都会检查被添加到其中的 timers 和 run loop sources,如果它们之中有符合条件的,那么自然是需要触发相关的回调操作
- 如果没有 timers 或者 run loop sources 或者 run loop 被手动的停止了 那么 run loop 会退出内部的循环
- 如果被添加到内部的 timers 和 run loop sources 都没有准备好被触发,那么 run loop 就会进行一个系统调用,使线程进入休眠
- 进入休眠了就不会占用 CPU 资源,那么唤醒的工作就需要其外部的代码进行,比如上面代码中
Main
中的notifyWorker
这都是嘛
有这么几个名词真是非常的饶人:RunLoop
、RunLoop Source
、RunLoop Mode
、CommonMode
...
『这些都是嘛?』这就是我刚见到它们的感觉,如果你也有这样的感觉,那么再次推荐你先看下 深入理解RunLoop,我也是看了其中内容,然后下载了 RunLoop 的源码,自己动手分析分析,接下来将是我分析的备忘。
首先是看下 RunLoop 的结构:
c
struct __CFRunLoop {
CFMutableSetRef _commonModes;
CFMutableSetRef _commonModeItems;
CFRunLoopModeRef _currentMode;
CFMutableSetRef _modes;
}
于是看到,与 RunLoop
有直接关系的是 RunLoop Mode
。那么看看 RunLoop Mode
的结构:
c
struct __CFRunLoopMode {
CFStringRef _name;
CFMutableSetRef _sources0;
CFMutableSetRef _sources1;
CFMutableArrayRef _observers;
CFMutableArrayRef _timers;
}
发现与 RunLoop Mode
有关的是 RunLoop source
和 timer
以及 observer
。
于是就有了这个图:
lua
+---------------------------------------------------------+
| |
| RunLoop |
| |
| +----------------------+ +----------------------+ |
| | | | | |
| | RunLoopMode | | RunLoopMode | |
| | | | | |
| | +----------------+ | | +----------------+ | |
| | | RunLoopSources | | | | RunLoopSources | | |
| | +----------------+ | | +----------------+ | |
| | | | | |
| | +-----------+ | | +-----------+ | |
| | | Observers | | | | Observers | | |
| | +-----------+ | | +-----------+ | |
| | | | | |
| | +--------+ | | +--------+ | |
| | | Timers | | | | Timers | | |
| | +--------+ | | +--------+ | |
| | | | | |
| +----------------------+ +----------------------+ |
| |
+---------------------------------------------------------+
然后看看 Common Mode
是干什么的,首先看看这个函数:
c
void CFRunLoopAddCommonMode(CFRunLoopRef rl, CFStringRef modeName);
就是往 RunLoop 中添加 Common Mode,而 Common Mode 在 RunLoop 中以 Set
的结构去存放(见上面 RunLoop 数据结构中的 CFMutableSetRef _commonModes;
),也就是 RunLoop 中可以有多个 Common Mode,而且注意到添加时是以 Mode Name 去代表具体的 Mode 的。
然后再看下这个函数:
c
void CFRunLoopAddSource(
CFRunLoopRef rl,
CFRunLoopSourceRef rls,
CFStringRef modeName
);
这里就不放函数体了,有兴趣的可以下载源码去看,大概的意思就是:
如果 CFRunLoopAddSource
被调用时,形参 modeName
的实参值为 kCFRunLoopCommonModes
时,就会将 rls
添加到 RunLoop 中的 _commonModeItems
中。上面我知道了 _commonModes
其实是一个 Set
,里面存放的是 Mode Names,于是下一步 RunLoop 就会迭代 _commonModes
这个 Set
中的元素。对于迭代时的元素,很明显都是 Mode Name,然后通过 __CFRunLoopFindMode
方法,根据 Mode Name 找出存储在 RunLopp 中的 _modes
中的 Mode,然后将 rls
添加到那些 Mode 中。
如果觉得很乱的话,只要知道为什么这么干就行了:
RunLoop 中是有多个 Mode 的,而 RunLoop 需要以指定的 Mode 去运行,并且一旦运行就无法切换到其他 Mode 中。那么当你将一个 rls(run loop source)
添加到 RunLoop 的某一个 Mode 之后,一旦 RunLoop 不是运行在 rls
被添加到的 Mode 上,那么 rls
将无法被检测并触发到,为了解决这个问题,可以将 rls
添加到 RunLoop 中的所有 Modes 中就行了,这样无论 RunLoop 工作在哪一个 Mode 上 rls
都有机会被检测和触发。
这是关于上面描述的一个具体例子:
应用场景举例:主线程的 RunLoop 里有两个预置的 Mode:kCFRunLoopDefaultMode 和 UITrackingRunLoopMode。这两个 Mode 都已经被标记为"Common"属性。DefaultMode 是 App 平时所处的状态,TrackingRunLoopMode 是追踪 ScrollView 滑动时的状态。当你创建一个 Timer 并加到 DefaultMode 时,Timer 会得到重复回调,但此时滑动一个TableView时,RunLoop 会将 mode 切换为 TrackingRunLoopMode,这时 Timer 就不会被回调,并且也不会影响到滑动操作。
那么怎么将 rls
添加到 RunLoop 所有的 Modes 中呢?于是提供了这样的方法:
c
CFRunLoopAddSource(
CFRunLoopRef rl,
CFRunLoopSourceRef rls,
CFStringRef kCFRunLoopCommonModes // 注意到 kCFRunLoopCommonModes 了吗
);
暂时就这么多,enjoy!