Runloop就像一台引擎,它控制着程序的运行。图为GENX发动机在加拿大温尼伯进行寒冷天气测试,
序言
做iOS/MacOS开发的同学或多或少都会接触到Runloop,尽管这部分内容不常用,但是在某些特定场合比如:性能优化、线程工作状态监控等,依然有着不可替代的作用。关于Runloop的资料,总体上过多地描述技术细节而忽略了该技术的出发点,这些无形中给Runloop蒙上了神秘的面纱。对于初次接触的同学而言明白Runloop是什么,比了解Runloop的技术细节更为重要,那么我们接下来就从Runloop做了什么聊起。
Runloop用来控制程序的暂停和恢复
这里我们从大家都熟悉的main函数说起,用C语言输出“HelloWorld”。
int main() {
printf("Hello world!");
return 0;
}
编译运行之后控制台输出"Hello world!"然后程序退出,相信大家对这个现象应该习以为常,但是如何保持程序持续运行呢?我们会通过scanf
函数来接收用户输入,这个函数会自动暂停程序运行并等待输入,收到键盘输入后又继续处理输入数据,直到收到退出指令。其实我们日常所有操作都是基于这种工作模式,Runloop就是用来控制程序的暂停和恢复的。
int main(int argc, const char * argv[]) {
// insert code here...
printf("Hello, World!\n");
bool isExit = false;
while (!isExit) {
char scanStr[10];
scanf("%s", scanStr);
if (strcmp(scanStr, "0")==0) {
isExit = true;
printf("scanf will exit!\n");
}
}
return 0;
}
进入
scanf()
函数后程序进入暂停等待状态,此时是不消耗CPU资源的,接收到键盘消息后程序被唤醒并处理输入的数据
Runloop是线程运行的一套控制机制
更精确地说Runloop控制的是线程的休眠和唤醒,是线程运行的一套控制机制(不控制线程就一溜烟跑完啦)。线程的休眠和唤醒,通过NSThread的sleep方法也能实现。
[NSThread sleepForTimeInterval:10];
只不过通过sleep方法休眠的线程只能按照休眠时设置的时间来唤醒,不能够通过其他方式,例如:某个事件到达而触发线程唤醒。所以就诞生了Runloop,它的强大在于构建了一套完善的线程休眠和唤醒机制而不必仅依赖于休眠时设置的超时。想象一下如果你能够自由地控制线程的休眠和唤醒,是不是一件很棒的事情。
while (1) {
[[NSThread sleepForTimeInterval:.1];
if (hasTask) {
doTask();
}
}
当然通过
sleep
方法我们可以写一个循环,线程在超时后恢复,通过轮询的方式来处理到达的事件,但是会有两个问题:1、事件处理不及时,2、多数无效的轮询会消耗额外的CPU资源,使用Runloop则可以避免这样的问题。
Runloop如何控制线程唤醒
以线程的控制为切入点,我们先一步一步了解Runloop的功能组成,最后我们再来整体描述Runloop的工作流程。
首先线程需要被唤醒必定有事件的发生,Runloop可以使线程被多种类型的事件唤醒:
-
Runloop支持线程唤醒的事件类型
- 基于端口的事件
Cocoa和Core Foundation都供了基于端口的对象用于线程或进程间的通信(NSPort
),Runloop允许接受到端口消息时唤醒线程。这类事件称为基于端口的输入源
,例如CFMessagePort
, 这类事件需要被持续响应,添加到Runloop才有可能唤醒线程,后面会解释为什么说“有可能”。
[[NSRunLoop currentRunLoop] addPort:port forMode:NSRunLoopCommonModes];
- 自定义事件
perform selector
是cocoa自定义的输入源,接受该消息的线程如果处于休眠,则有可能会被立即唤醒,关于如何自定义源我们在这里不做展开,有兴趣的可以参考官方文档。
[self performSelector:@selector(taskDo) onThread:thread withObject:nil waitUntilDone:NO modes:@[NSDefaultRunLoopMode]];
- 基于时间的定时事件
我们在使用NSTimer
的时候会看到这个方法
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
将timer添加到Runloop就是让线程能够在该timer所定义的时间点被唤醒,这类事件称为
定时源
,这类事件需要被持续响应,添加到Runloop才有可能唤醒线程。 - 基于端口的事件
线程进入休眠,都是通过调用以下方法实现的,可以看出所有类型的事件都是基于port实现,自定义和timer事件会分配有特定的port,而基于端口的事件则是自定义的port
```objective-c
__CFPortSet waitSet = rlm->_portSet;
mach_port_t livePort = MACH_PORT_NULL;
mach_msg_header_t *msg = NULL;
// 调用此方法会使线程休眠,直到有监听的事件到达,事件到达后将会通过过此方法返回事件参数和端口号等信息。
__CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort, poll ? 0 : TIMEOUT_INFINITY)
- **线程唤醒的被动性**
线程的唤醒过程是被动的,线程一旦休眠将不能执行任何操作包括唤醒自身。线程的唤醒必须要借助外力。任何一种唤醒事件都是由外部(非当前线程)产生,像各种触摸事件、网络事件和定时事件等被转换成不同的源,来唤醒线程,但总能追溯到对应的外部推动者:触摸事件的发生来自于用户的操作、网络事件来自于网络传输状态的改变,定时事件的发生也与系统的计时密不可分。至于Runloop如何与内核交互,如何实现接收到事件消息后唤醒线程等等,对于理解Runloop机制已不再重要,我们需要知道Runloop支持通过哪些事件唤醒线程。
- **Runloop Mode 是什么?**
在讲Runloop支持线程唤醒的事件类型的时候,我们为Runloop添加响应事件(源)并指定了一个`NSRunLoopMode`,那这个Mode是做什么的呢?简单地说这个Mode只是一个标识,用来标记不同的事件,它是对事件(源)的分类。Runloop开始运行时也会指定一个Mode,这个Mode是用来表明Runloop运行于某种模式下,运行在某个模式下的Runloop只能响应对应模式的事件,如果此时发生了被其他Mode标记的事件,那么该事件的处理将会被延迟处理(直到Runloop重新运行于相同的模式下)或者被忽略,`selector`消息会被延迟处理,定时事件会被忽略。
| Mode | Name | Description |
| :------------ | :------------ | :------------ |
| Default | NSDefaultRunLoopMode(Cocoa) kCFRunLoopDefaultMode (Core Foundation) | Runloop启动的默认模式,Runloop必须以此模式启动,启动后可以切换到其他模式 |
| Connection | NSConnectionReplyMode(Cocoa) | 用于NSConnection系统所使用 |
| Modal | NSModalPanelRunLoopMode(Cocoa) | 模态面板 |
| Event tracking | NSEventTrackingRunLoopMode(Cocoa) | 用于限制用户界面跟踪相关的事件传入 |
| Common modes | NSRunLoopCommonModes(Cocoa) kCFRunLoopCommonModes (Core Foundation) | 这是一组可配置的常用模式(是多个具体模式的集合),与此关联的事件(源),将会自动与组中所有模式相关联 |
我们常用的模式为 Default 和 Common modes,其余模式一般为系统所定义和使用。通过以下代码,我们来认识这事情:
1. Runloop只会响应与之运行模式相一致的源,selector消息的mode与当前Runloop mode不一致时将会在Runloop切换到该mode时被处理。
```objective-c
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
[self performSelector:@selector(print) onThread:[NSThread currentThread] withObject:nil waitUntilDone:NO modes:@[NSDefaultRunLoopMode]];
}
- (void)print {
NSRunLoop *currentRunloop = [NSRunLoop currentRunLoop];
NSRunLoopMode mode = [currentRunloop currentMode];
NSLog(@"print: current mode: %@", mode);
}
```
> Scrollview 滚动时Runloop会被切换到`UITrackingRunLoopMode`运行,非`UITrackingRunLoopMode`的事件(源)将被暂停响应,通过切换mode程序可以优先处理交互相关的事件,当交互结束后Runloop会切换到`NSDefaultRunLoopMode`运行,print方法会在此时被执行。
2. 在不指定mode时,perform selector方法会给Runloop发送以当前Runloop mode所标记的消息。
```objective-c
[self performSelector:@selector(print) withObject:nil];
```
> 如果发送selector消息时不指定mode,那么消息将以`currentMode`标记,在上述代码中`currentMode`为`UITrackingRunLoopMode`,print方法将被立即执行。
#### 如何启动Runloop
------------
Runloop的启动我们可以从`NSRunloop`的几个方法说起。
- **`- (void)run`**
该方法的调用将会启动一个无限制运行的Runloop,Runloop会控制线程反复在休眠和工作中切换,这时线程会一直停留在`run`方法内部,直到Runloop不再监听任何事件(源)或者显式停止(调用CFRunLoopStop),线程才会走出`run`方法。`run`方法内部其实构建了一个无限循环(线程的唤醒和休眠),线程走入循环后将会一直被困在循环中,与普通死循环不同的是线程执行完任务后会被休眠。如果启动时没有为Runloop添加任何与Runloop运行模式相一致的事件(源),那么Runloop不会启动并且直接return出`run`方法。`run`方法会以默认的`NSDefaultRunLoopMode`启动Runloop,Runloop每次循环会有大致的这几个过程:
1、通知observer runloop即将开始任务
2、开始任务
3、通知observer结束任务即将进入休眠
4、休眠线程
5、线程被唤醒
6、通知observer线程唤醒
7、开始任务
8、循环条件检测
- **`- (void)runUntilDate:(NSDate *)limitDate`**
了解了`- (void)run`的内部原理,这里的区别在于,为Runloop的退出增加了一个超时条件,一旦到达设定的超时时间,线程就会被主动唤醒,并且结束runloop循环。
下面我们构建了一段代码:通过`- (void)runUntilDate:(NSDate *)limitDate`启动子线程的Runloop,点击屏幕时向子线程发送selector消息。
```objective-c
- (void)viewDidLoad {
[super viewDidLoad];
self.thread = [[NSThread alloc]initWithTarget:self selector:@selector(threadRun) object:nil];
[self.thread start];
// Do any additional setup after loading the view, typically from a nib.
}
- (void)threadRun {
NSRunLoop *runloop = [NSRunLoop currentRunLoop];
// 要想让runloop持续工作,在runloop启动前必须添加一个需要持续监听的事件
// 作为初始事件,你可以这样添加一个NSPort
// [runloop addPort:[NSPort new] forMode:NSRunLoopCommonModes];
// 作为初始事件,你可以这样添加一个timer
// NSTimer *timer = [NSTimer timerWithTimeInterval:1000000 target:self selector:@selector(touch) userInfo:nil repeats:YES];
// [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
// 甚至这样,只要让runloop持续监听事件它就能持续运行
[self performSelector:@selector(description) withObject:nil afterDelay:CGFLOAT_MAX];
[runloop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:100]];
NSLog(@"Runloop exit!");
}
- (void)touch {
NSLog(@"touch began!");
}
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
[self performSelector:@selector(touch) onThread:self.thread withObject:nil waitUntilDone:NO modes:@[NSRunLoopCommonModes]];
}
测试的打印结果:
2016-11-29 18:09:28.900 RunloopRun[46336:666495] touch began!
2016-11-29 18:09:29.405 RunloopRun[46336:666495] touch began!
2016-11-29 18:09:29.884 RunloopRun[46336:666495] touch began!
2016-11-29 18:09:30.389 RunloopRun[46336:666495] touch began!
2016-11-29 18:09:30.807 RunloopRun[46336:666495] Runloop exit!
从打印结果不难发现子线程的Runloop一直保持循环,并在到达设定时间后停止。如果我用- (void)run
启动Runloop,那么Runloop exit!
将不会被执行。
-
- (BOOL)runMode:(NSRunLoopMode)mode beforeDate:(NSDate *)limitDate
这个方法比较特殊,相当于启动了一个循环次数为1的Runloop,简单地说,线程只走一遍循环,线程一旦被唤醒并且在执行完相关任务后,便会退出Runloop。换个角度来看,这个方法也相当于Runloop循环的一个循环体,因此我们也可以通过它来创建一个我们自定义的Runloop,这里我我们创建了一个在循环次数为10的Runloop。
int count = 10;
while (count) {
NSLog(@"runloop开始");
[runloop runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:1]];
NSLog(@"runloop结束");
count--;
}
NSLog(@"runloop退出");
- 如何注册runloop observer
CFRunLoopObserverContext ctx = {0, (__bridge void *)(self), NULL, NULL, NULL};
CFRunLoopObserverRef observer = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopAllActivities, true, 0, runLoopObserverCallBack, &ctx);
if (observer) {
CFRunLoopRef cfLoop = [runloop getCFRunLoop];
CFRunLoopAddObserver(cfLoop, observer, kCFRunLoopDefaultMode);
}
Runloop应用
-
UI在什么时候渲染
先看一段代码:self.albumView.hidden = YES; ...... self.albumView.hidden = NO;
某些时候我们会看这样的代码,理论上来讲view应该会闪烁以下,但是实际情况中我们并没有看到view先隐藏再显示,或许是因为太快了。然而即使中间代码执行的时间再长,view也不会闪烁。其实UI不是立即渲染的,CA在runloop中注册了一个即将进入休眠的observer,在休眠之前对已提交的请求进行集中渲染,
drawRect:
、layoutSubviews
等方法都是在这个时机调用的,这也解释了为什么我们不需要手动调用这些方法。
在下一个runloop执行任务
为了让UI不被后续的任务阻塞,我们通常会选择延迟之行,其实我们只要将耗时任务放到下一个runloop循环之行就好了,因为UI会在本次runloop休眠前提交渲染。
// 一个runloop循环至多有一次之行`dispatch_main`的机会,两次嵌套必定是在下个runloop
dispatch_async(dispatch_get_main_queue(), ^{
dispatch_async(dispatch_get_main_queue(), ^{
......
});
});
- Runloop之于性能优化
CADisplayLink是一个特殊的定时源,runloop接收到屏幕刷新信号时被唤醒。如果我们的渲染工作(cpu和gpu运算)不能在该信号到来之前完成,那么系统就会用上一次的渲染数据进行刷新,于是就产生了画面的卡顿感,卡顿的时候你可能看到的是这样:1、2、2、2、5、6、7.....
,画面从2突变到5视觉上就形成了卡顿感
由于我们对gpu的控制有限,优化一般从cpu入手,结合屏幕刷新的特性,优化的方向主要有两点:
1、耗时任务尽量移动到子线程执行,无法移动的可以将大任务分成多个串行的小任务,这样在任务的间隙程序有机会去渲染UI
2、在刷新信号即将到来的时候避免执行新任务,避免阻塞渲染。