要想知道一个 iOS 开发者屌不屌,先问问这 3 个:

  • 事件处理,响应者链条
  • Runtime
  • Runloop

进程是什么?

  • 进程是指在系统中 正在运行 的一个应用程序(如果该应用程序没有 正在运行,那么它仅仅只是一个应用程序而已)
  • 每个进程之间是 独立 的,每个进程均运行在其专用且受保护的内存空间内

举个例子:在 mac 上同时打开 Thunder 和 Xcode,系统就会分别启动两个进程,如下:

启动两个进程:Thunder 和 Xcode

在 mac 中可以通过 活动监视器 来查看系统运行来哪些进程以及该进程运行着多少个线程:

通过活动监视器查看正在运行的进程

线程是什么?

通过上图,我们可以看到有 373 个进程在运行,1893 个线程在运行,那么线程是什么呢?

首先我们要知道,1 个进程想要执行任务,就 必须 得有线程,而且,每个进程至少得有一条线程。

依稀记得在学习 Java 多线程的时候,李兴华老师是这样举例子的:在 Word 文档这个线程中,实时拼写检查是一个线程,把键盘输入的字显示在屏幕上也是一个线程,等等。

一个进程(就是正在运行的应用程序)的所有任务都是在线程中执行的。比如使用 QQ 音乐播放音乐,使用 Thunder 下载小电影,这些任务都是需要在线程中执行。

一个进程的任务都在线程中执行

线程的串行

在一个线程中,任务的执行是 串行 的,如果要在一个线程中执行多个任务,那么只能一个一个的按照顺序来执行这些任务,也就是说在同一个时间内,一个线程只能执行一个任务。

比如,在一个线程中下载 3 个文件(比如文件 1,文件 2,文件 3):

在一个线程中下载 3 个文件

如果是在一个线程中下载这 3 个文件,那么该线程会先下载文件 1,当文件 1 下载好之后,就下载文件 2,文件 2 下载好之后才下载文件 3。所以说,线程是进程中的 1 条执行路径(一条线程里面的任务是按照顺序执行的)。如果在这个进程中开了两条线程,那么它们就是进程中的两条执行路径。

多线程

刚刚也说到,一个进程可以开启多条线程,每条线程都可以 并行 执行不同的任务。多线程技术可以提高程序的执行效率。举一个不太恰当的例子:进程就相当于车间,而线程相当于这个车间的工人(后面说到为什么不太恰当)。

比如可以同时开启 3 条线程分别下载一个文件(分别是文件 1,文件 2,文件 3):

开启多条线程同时下载

多线程的工作原理:

  • 在同一时间,CPU 只能够处理 1 条线程。
  • 而多线程并发执行(同时执行),其实是 CPU 快速的在多条线程之间 调度(切换)
  • 如果 CPU 调度线程的时间足够快,就会造成了多线程并发执行的 假象。因此说刚才说得车间和工人的例子是不太恰当,因为工人真的可以同时工作。

但是,如果线程非常多,就会造成 CPU 调度频繁,消耗大量的 CPU 资源,导致每条线程被调度执行的频次降低,这样子一来,线程的执行效率就会降低。

所以说,多线程有它的优点,也有它的缺点,总结如下:

  • 多线程的优点
    • 能够适当提高程序的执行效率
    • 能够适当提高资源利用率(比如 CPU、内存利用率)
  • 多线程的缺点
    • 创建线程是有开销的,iOS 下主要成本包括:内核数据结构(大约 1KB)、栈空间(子线程 512KB、主线程 1MB,也可以使用 -setStackSize: 方法设置,但必须是 4K 的倍数,而且最小是 16K,一般不用自己设置),创建线程大约需要90毫秒的创建时间
    • 如果开启大量的线程,会降低程序的性能
    • 线程越多,CPU 在调度线程上的开销就越大
    • 程序设计更加复杂,比如线程之间的通信以及数据共享

既然这样,那么如果在 iOS 开发中应用多线程呢?

首先我们要了解一下什么是主线程:一个 iOS 程序运行之后,系统默认会开启 1 条线程,这条线程称为 主线程 或者 UI 线程。主要是用来:

  • 显示、刷新 UI 界面
  • 处理 UI 事件,比如点击事件、滚动事件、拖拽事件等

从它的作用,我们看到了一种 所见即所得,因此要特别注意不要将耗时的操作(比如下载图片文件,请求网络数据等)放到主线程,这些耗时的操作会卡住主线程,严重影响到 UI 的流畅度,给人看起来好像卡死了感觉。

举个例子:假如界面有一个 button,一个 textView,点击 button 的时候 NSLog 1w 次,当点击 button 的时候,我们发现是没有办法滑动 textView 的,而且 button 是没有马上恢复到普通状态的,用户看起来就是界面好像是卡死了,这样的体验是很差的(特别是在 iOS 这个高逼格系统上)。

所以,如果将耗时操作(比如上面说的 NSLog 1w 次)放到子线程(也称后台线程、非主线程)中的话,用户在点击 button 那一刻就能够反应,button 马上恢复到普通状态,用户也能够滑动 textView。即是在处理耗时的操作的同时也能够处理 UI 控件的事件。

哇擦,说得那么好,肯定要用起来啊,那么在 iOS 中多线程有哪些实现的方案呢?

技术方案 简介 语言 线程生命周期 使用频率
pthread 一套通用的多线程 API,适用于 Unix\Linux\Windows 等系统,跨平台,可移植,但是使用难度大 C 程序🐶管理 几乎不用
NSThread 使用更加面向对象,简单易用,可以直接操作线程对象 OC 程序🐶管理 偶尔使用
GCD 旨在替代 NSThread 等线程技术,能够充分 利用设备的多核 C 自动管理 经常使用
NSOperation 基于 GCD(底层是 GCD),比 GCD 多了一些简单实用的功能,使用更加面向对象 OC 自动管理 经常使用

注意:pthread 由程序🐶创建和销毁,而 NSThread 则只需要负责创建,系统自动销毁。虽然 GCD 和 NSOperation 是经常使用的,但是其他两种可以大致看一下。

iOS 中多线程的实现方案

1、pthread(了解)

pthread 就是 posix thread 的简称,大概知道有这么一回事就 OK 了。实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#import "ViewController.h"
#import <pthread.h>
@interface ViewController ()
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
pthread_t *thread;
// 启动一个线程
pthread_create(&thread, NULL, run, NULL);
}
void *run(void *param) {
for (NSInteger i = 0; i < 10000; i++) {
NSLog(@"i = %zd", i);
}
return NULL;
}
2、NSThread(掌握)

一个线程对象就是一个 NSThread 对象。注意,开发者只需要创建 NSThread 即可,当这个线程执行的方法完毕之后,系统会自动销毁它(检验方法:继承 NSThread,实现 dealloc 方法即可)。

  • 先看看 NSThread 有哪些常用的方法:
1
2
3
4
5
6
+ (NSThread *)mainThread; // 获得主线程
- (BOOL)isMainThread; // 是否为主线程
+ (BOOL)isMainThread; // 是否为主线程
+ (NSThread *)currentThread; // 获得当前线程
- (void)setName:(NSString *)name; // 设置线程的名称
- (NSString *)name; // 获取线程的名称
  • 创建和启动线程:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
- (void)viewDidLoad {
[super viewDidLoad];
// ① 使用 initWithTarget 创建(看起来和 UIButton 的创建一毛一样,参数可以是一个 NSString,NSInteger 之类的,注意不要把该 NSThread 对象传递进去!)
NSThread *thread = [[NSThread alloc] initWithTarget:self selector:@selector(log) object:nil];
[thread start];
// ② 使用 initWithBlock 创建,这两种方法创建的线程都要调用 start 方法才会启动线程
// 不过这个方法是 10.0 之后的🐶
NSThread *thread2 = [[NSThread alloc] initWithBlock:^{
for (NSInteger i = 0; i < 10000; i++) {
NSLog(@"i = %zd", i);
}
}];
[thread2 start];
// ③ 以下两种方法创建的线程会立刻启动,简单快捷,但是不可以设置线程的属性比如线程名称之类的
[NSThread detachNewThreadSelector:@selector(log) toTarget:self withObject:nil];
// ④ 这个创建方法也是 10.0 以后才有的
[NSThread detachNewThreadWithBlock:^{
for (NSInteger i = 0; i < 10000; i++) {
NSLog(@"i = %zd", i);
}
}];
// ⑤ performSelector 还有很多其他的,可以去看看
[self performSelectorInBackground:@selector(go:) withObject:@"我是参数"];
}
- (void)log {
for (NSInteger i = 0; i < 10000; i++) {
NSLog(@"i = %zd", i);
}
}
- (void)go:(NSString *)param {
NSLog(@"启动来一个后台线程,参数是:%@", param);
}
  • 线程的状态(了解)

大概说一下线程的状态(或者生命周期,怪当时候学 Java 时没有学好):

  1. 当创建了一个线程对象的时候,系统会分配一块内存给它,此时它的状态为 新建状态(New)
  2. 当这个线程对象调用 start 方法之后,系统会将该线程对象放到一个线程池(称为 可调度线程池,只有在这个线程池里面的线程,CPU 才会去调度它,让它去处理任务)里面,此时它的状态变成 就绪状态(Runnable),在这个状态下,它就会等着 CPU 去调度它。
  3. 如果 CPU 调用该线程,那么它就进入了 运行状态(Running),当 CPU 调度其他线程,那么该线程就会回到 就绪状态(Runnable),等待下次 CPU 调度它,所以该线程就会在 就绪状态(Runnable)运行状态(Running) 来回切换。
  4. 如果该线程调用了 sleep 方法或者等待同步锁,那么它就会进入 阻塞状态(Blocked),该线程也被移出 可调度线程池,CPU 就不会去调度它了。
  5. 当 sleep 完毕或者得到同步锁以后,它就会重新进入 就绪状态(Runnable),系统重新将它放回 可调度线程池,然后该线程又在 就绪状态(Runnable)运行状态(Running) 来回切换。
  6. 当线程任务执行完毕,或者出现异常,或者强制退出,该线程就会进入 死亡状态(Dead),该线程就会从内存中销毁。注意,一旦线程停止(死亡)了,那么就不能够再次开启任务。

相关方法:

1
2
3
4
5
6
7
- (void)start; // 启动线程,进入就绪状态或者运行状态
// 进入阻塞状态(暂停状态),此状态下它不会往下执行,直到时间到了
+ (void)sleepUntilDate:(NSDate *)date;
+ (void)sleepForTimeInterval:(NSTimeInterval)ti;
+ (void)exit; // 强制退出,注意以上 3 个方法都是类方法

对于 sleepUntilDate: 这个方法,如果想传入一个很遥远的时间,可以传入 [NSDate distantFuture] 或者 [NSDate distantPast]。

  • 多线程的安全隐患

多线程中最大的一个安全隐患就是 资源共享。一块资源可能会被多个线程共享,也就是 多个线程可能会访问同一块资源。比如说多个线程访问同一个对象,同一个变量,同一个文件。

当多个线程访问同一块资源时,很容易引发 数据错乱和数据安全问题

安全隐患示例 01 - 存钱取钱

安全隐患示例 02 - 车站卖票

  • 安全隐患的解决方案——互斥锁

// 以上的实例以及解决方案暂时不写,先学习 GCD 和 NSOperation。

额外的知识点:原子性和非原子性(属性)

在 Objective-C 中定义属性的时候有两种修饰可以选择:atomic 和 nonatomic。

atomic:原子属性,为 setter 方法加锁(默认就是 atomic,关于加锁,就是上面所说的互斥锁)。它是线程安全的,需要消耗大量的资源。

nonatomic:非原子属性,不会为 setter 方法加锁。它是非线程安全的,适合内存小的移动设备。

因此,对于在 iOS 开发时,建议:

  • 所有属性都声明为 nonatomic。
  • 尽量避免多线程抢夺同一块资源。
  • 尽量将加锁、资源抢夺的业务处理逻辑交给服务端处理,减少移动客户端的压力。
3、CGD(掌握)

GCD 比较重要,单独放到一篇文章记录。

4、NSOperation

NSOperation 是对 GCD 进一步封装,更加面向对象,单独放到另一篇文章记录。