iOS的多线程

在iOS开发中我们经常涉及到多线程的问题,多线程广泛用于耗时操作、并发、单例等方面。故总结一下多线程的相关知识及用法(pthread NSThread GCD NSOperation)。

基本概念

进程: 操作系统运行中的任务通常对应一个进程,当进程进入内存后,及变成一个进程。在iOS中每个启动的APP即为一个进程。进程之间独立且受保护。
线程: 进程内部每个顺序执行流就是一个线程,一个进程可以有多个线程。
任务: 一种抽象的概念,表示需要执行的工作。

多线程原理:单个处理器同一时间只能处理一个线程,通过快速在多个线程之间轮换执行,使得宏观上具有多个线程同时执行的效果。多线程具体内容见操作系统原理。

优缺点

多线程的优点不言而喻,比如可以提高程序执行效率、提高资源利用率、并在执行完任务后自动销毁。
但多线程的使用依然要适度,多线程存在一些缺点。

首先,开启多线程需要一定的内存开销: 根据官方文档说明,主要线程占用空间1MB(其实实际只占用了512k),次要线程每开启一个占用512kb(我们所创建的线程即是次要线程)。每次线程创建耗时90ms。所以,过度使用多线程反而会使性能降低。
其次,我们知道CPU是通过线程的轮换而实现多线程的,那么每次切换线程都会在上下文切换时产生性能开销。
最后,线程间通信也是一个需要考虑的问题,即生产者–消费者问题,增加了数据共享的难度。

iOS系统多线程原则及方案

iOS多线程原则

首先,我们先解释一个概念: UI线程/主线程
在开发中常提到”UI线程”或”主线程”,iOS系统在程序运行之后会默认开启一个线程来作为主线程,其一般用于刷新UI界面,处理UI事件。我们在没有指定任务在其他线程执行时,也是直接在主线程执行的。
所有UI更新操作都应该在主线程进行。因为iOS的UI控件默认都是线程不安全的,这是出于性能的考虑。如果在其他线程更新,有一定几率出现问题。
耗时操作不应放在主线程执行。正是因为UI更新都是在主线程完成的,那么如果我们将耗时操作放在主线程,将会阻塞主线程,导致页面卡住,降低用户体验。

iOS的多线程技术方案

iOS系统存在着4中多线程技术方案: pthread、NSThread、GCD、NSOperation。(Swift中名称略有不同)

多线程方案 语言 ARC下的内存管理 使用频率
pthread C 程序员手动管理 ★☆☆☆☆
NSThread OC/Swift(Thread) 程序员手动管理 ★★☆☆☆
GCD C/OC/Swift ARC自动管理(多数情况) ★★★★★
NSOperation OC/Swift ARC自动管理 ★★★★☆

Tips: 主要以OC讲解,在Swift 3.0+中名称与用法略有不同。(如NSThread -> Thread, dispatch_XXX -> DispatchQueue)

多线程的使用

pthread (POSIX threads)

这个是最老盘的多线程方案了,在类Unix操作系统(Unix、Linux、Mac OS X等)中,都使用pthread作为操作系统的线程。iOS开发中基本不会用到(如果你频繁使用pthread,那么受我一拜…)。

使用pthread需要导入头文件: #import <pthread.h>#import <pthread/pthread.h>

使用方法:

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
- (void)pthreadTest {
/*
创建线程使用pthread_create函数
函数原型: pthread_create(pthread_t _Nullable *restrict _Nonnull, const pthread_attr_t *restrict _Nullable, void * _Nullable (* _Nonnull)(void * _Nullable), void *restrict _Nullable)
参数:
1) 线程标识符指针,pthread_t
2) 线程属性(注意空地址为NULL,空对象是nil)
3) 要执行的函数指针
4) 执行函数的参数

返回值:
- 创建成功,返回0
- 创建失败,返回出错代码
*/
pthread_t id = NULL;
NSString *paraStr = @"Hello World";

int res = pthread_create(&id, NULL, taskFunction, (__bridge void *)(paraStr));

if (res == 0) {
NSLog(@"线程创建完成");
} else {
NSLog(@"线程创建失败 错误:%d", res);
}

pthread_detach(id);
}

void *taskFunction(void *params) {

NSString *str = (__bridge NSString *)(params);
NSLog(@"Thread:%@ -> Message:%@", [NSThread currentThread], str);

return NULL;
}

补充: __bridge

在OC与C进行混编时,会遇到OC对象与C变量之间的转换问题。在OC中id为万能指针,而C中void *为万能指针。在id类型或对象类型变量赋值给void *类型或反向赋值时,需要进行特定转换。

为什么要进行转换呢?这主要是因为面向对象涉及到的内存管理问题,OC中对象由MRC、ARC来进行管理,而其不能管理C变量,那么在转换时会造成内存管理的冲突。__bridge即告知编译器,该变量内存由程序员自行进行管理。

同时桥接转换还有另外两种类型: __bridge_retained__bridge_transfer,前者为是被赋值的变量持有该对象,后者为被赋值的变量持有该对象并使赋值的对象释放其持有。由于涉及到引用计数知识,在此不赘述。如果只是为了单纯赋值,那么使用__bridge即可

NSThread

由于pthread过于底层,复杂性不利于开发。故苹果封装成了NSThread,这是一款较轻量的多线程实现方案,使用面向对象语法,其生命周期需要程序员自行管理。在MacOS和iOS都可使用。(Swift中为Thread)。NSThread类代表线程,创建新线程也就是创建NSThread对象。

创建线程

NSThread创建分动态方法和静态方法两种:

1
2
3
4
5
6
7
8
9
10
/// 创建并启动新线程
+ (void)detachNewThreadSelector:(SEL)selector toTarget:(id)target withObject:(id)argument;
/// 仅创建新线程,返回NSThread对象,并未启动线程
- (id)initWithTarget:(id)target selector:(SEL)selector object:(id)argument;

/*
- SEL 所要执行的方法
- target selector消息的接收对象
- argument 方法唯一参数,或nil
*/

可以发现,前者与后者并没有太大区别,只是前者不会返回NSThread对象,创建线程后立即启动,而后者只是创建一个NSThread对象并返回,需要手动调用start方法启动线程。

同时还有一类隐式创建线程的方法:

1
- (void)performSelectorInBackground:(SEL)aSelector withObject:(id)arg;

performXxx 这一类方法,在创建线程时也是使用的NSThread。

线程的状态

首先我们来看一下线程执行的状态:

线程的状态

前面提到,多线程的原理是CPU轮换调度多个线程。所以某线程在某一时刻不一定正在被执行。简单解释一下线程的几个状态:
就绪: 线程准备就绪可以被执行时,会放入可调度线程池,供CPU调用。
阻塞: 当线程的正常执行被组止,比如调用了sleep方法或线程在等待同步锁,那么线程会进入阻塞状态。注意,此”阻塞”与常说的”耗时操作阻塞UI线程”并非同一概念,后者只是占用UI线程资源导致UI操作无法正常执行。
死亡: 当线程任务执行完毕后会自动销毁。同时当遇到异常时,或我们自行使用exit方法终止线程,线程都会死亡,不再可用。

NSThread提供的API

  • 优先级: 线程为我们提供了线程优先级属性以干预其执行,对应threadPriority属性,范围是0.0~1.0,权值越高优先级越高。但注意,该权值仅为线程在调度时的优先权重,并非绝对优先。
  • 线程是否执行完毕: 对应executing, finished两个属性,前者在线程正在执行时为YES,后者在执行完毕时为YES。
  • 线程取消标记: NSThread提供了cancel实例方法,对应的有cancelled属性,但是注意该方法并非真正终止该线程,而是将cancelled标记为YES,而后由程序员手动判断cancelled属性并作出决策。如果需要强制结束,应使用exit方法。

线程睡眠

如果需要让当前线程睡眠一段时间,可以调用sleepXXX 方法阻塞线程。

1
2
3
4
// 阻塞一段时间
+ (void)sleepForTimeInterval:(NSTimeInterval)ti;
// 阻塞到指定时间
+ (void)sleepUntilDate:(NSDate *)date;

被阻塞的线程是暂时不可执行的,所以不会进入可调度线程池。在其阻塞结束后,会进入可调度线程池并在CPU时间片到来时开始执行。

终止线程

前面提到,线程的终止有三种情况:

  • 线程执行体执行完成,线程正常结束。
  • 线程执行过程出现错误,异常退出。
  • 手动调用exit方法终止了线程。

提一个小问题: 当主线程结束时,其他线程会受影响吗? 答案是不会,当线程被启动,其本质上与主线程是相同地位的,并不会受到其影响。

线程安全问题

线程安全问题一直是多线程需要特别注意的,解决线程同步问题一般采用加锁的方式。
iOS提供了很多种锁的方案,本质上为同步锁、互斥锁两种。

互斥锁: 发现其他线程正在执行锁定代码后,线程会进入休眠(阻塞),待加锁的线程执行完毕打开锁时,会唤醒该休眠线程。
自旋锁: 发现其他线程正在执行锁定代码后,线程会采用死循环方式轮询,直到锁被打开。
简单来说,自旋锁由于采用了死循环轮询,会比互斥锁有更高的效率,但同时也造成更大的性能消耗。

OC对象的atomic属性修饰符就是使用了自旋锁,自旋锁同一时间可以有一个写者或多个读者,但是不能同时既有读者又有写者。

iOS常用的加锁方案有: @synchronized, NSLock, NSCondition。加锁方式和区别将在其他文章介绍。

几乎所有UIKit提供的类都是线程不安全的,所有包含’Mutable’的类都是线程不安全的。

死锁

当一组进程中的每个进程都在等待一个事件,而这一事件只能由这一组进程的另一进程引起,那么这组进程就处于死锁状态。死锁问题是操作系统的经典问题,详见操作系统原理。

死锁发生的必要条件:

  1. 互斥: 至少有一个资源必须处于非共享模式,如果另一线程申请该资源,那么申请线程必须等待该资源被释放为止。如互斥锁这一情况。
  2. 占有并等待: 该线程等待某资源,而该资源为其他线程所有。
  3. 非抢占: 资源不能被抢占,只能在线程完成任务后自动释放。
  4. 循环等待: 一组等待线程T1~Tn,其中Ti的资源被Ti+1所占有,造成循环等待。

GCD (Grand Central Dispatch)

NSThread是一款较为轻量的多线程实现,程序员需要自行控制同步和并发,较为复杂。iOS在4.0之后提供了GCD来实现多线程。其弱化了单个线程的概念,强调队列和任务两大核心概念。

队列: GCD为用户封装出了串行队列和并发队列两种队列。队列负责管理开发者提交的任务,采用FIFO(先进先出)的方式。串行队列提交的后一个任务只能等到前一个任务执行结束后才能开始执行;并发队列的任务按FIFO机制并发启动并执行多个任务。
任务: 开发者提交给队列的工作单元。

GCD的实现机制

GCD底层维护了一个可重用线程池,当线程结束后,系统并不第一时间销毁该线程,而是将其放入线程池中待用,若一段时间后仍未使用,则销毁。

串行队列底层的线程池只需要一个线程,因此只提供一个线程用来执行任务。
而并发队列需要多个线程来实现并发,所以线程池中维护了多个线程。

GCD使用步骤

我们可以使用系统自带的队列,获取方法如下

1
2
3
4
5
// 根据指令的优先级、额外旗标获取全局并发队列。
// 优先级id为DISPATCH_QUEUE_PRIORITY_XXX
dispatch_queue_t dispatch_get_global_queue(long identifier, unsigned long flags);
// 获取主线程所关联的队列(串行队列)
dispatch_queue_t dispatch_get_main_queue(void);

同时,我们也可以自行创建队列,但是ARC并不能管理自行创建的队列,所以需要我们自行管理内存:

1
2
3
4
5
6
7
8
9
/*
- 根据指定字符串标签创建队列
- label 为字符串标签
- attr 控制队列是串行队列还是并发队列,前者为DISPATCH_QUEUE_SERIAL,后者为DISPATCH_QUEUE_CONCURRENT。
*/
dispatch_queue_t dispatch_queue_create(const char *label, dispatch_queue_attr_t attr);

// 需要自行释放内存
dispatch_release()

同步/异步提交任务

GCD对于任务的提交,提供了同步/异步两种方式,同时支持Block和函数指针两种形式的任务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/// 异步
// Block
void dispatch_async(dispatch_queue_t queue, dispatch_block_t block);
// 函数
void dispatch_async_f(dispatch_queue_t queue, void *context, dispatch_function_t work);

/// 同步
// Block
void dispatch_sync(dispatch_queue_t queue, dispatch_block_t block);
// 函数
void dispatch_sync_f(dispatch_queue_t queue, void *context, dispatch_function_t work);

/// 延时任务
// Block
void dispatch_after(dispatch_time_t when, dispatch_queue_t queue, dispatch_block_t block);
// 函数
void dispatch_after_f(dispatch_time_t when, dispatch_queue_t queue, void *context, dispatch_function_t work);

同时,还提供了多次执行任务和单次执行任务,后者可以用于单例模式。

1
2
3
4
5
6
7
8
9
10
11
12
/// 多次执行
// Block
void dispatch_apply(size_t iterations, dispatch_queue_t queue, void (^block)(size_t));
// 函数
void dispatch_apply_f(size_t iterations, dispatch_queue_t queue, void *context, void (*work)(void *, size_t));

/// 单次执行
/* 需要提供一个dispatch_once_t的指针最为标记 */
// Block
void dispatch_once(dispatch_once_t *predicate, dispatch_block_t block);
// 函数
void dispatch_once_f(dispatch_once_t *predicate, void *context, dispatch_function_t function);

其他常见API

队列优先级
设置队列优先级与目标队列一致,实现对队列优先级的控制以及多个串行队列的有序执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*
- 参数1: 需要被设置优先级的队列。
- 参数2: 设置优先级时的目标队列。
*/
void dispatch_set_target_queue(dispatch_object_t object, dispatch_queue_t queue);

// 情况1: 目标队列为不同优先级的队列,被设置的队列将拥有和目标队列相同的优先级
dispatch_queue_t serialDispatchQueue =
dispatch_queue_create("serialDispatchQueue", NULL);
dispatch_queue_t backgroundDispatchQueue =
dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0);
dispatch_set_target_queue(serialDispatchQueue, backgroundDispatchQueue);

// 情况2: 目标队列为串行队列,而多个串行队列设置其为目标队列后将互相串行,同一时间只有其中一个队列执行串行任务。

调度组(Dispatch Group)
针对希望在追加到Dispatch Queue队列中的任务全部完成后执行某一特定任务的情况,提供了Dispatch Group这一特性。
创建线程组后,可以向其中追加任务,当所有任务全部完成时,会执行通知任务,通知任务的具体内容由程序员设置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 创建Dispatch Group
dispatch_group_t group = dispatch_group_create();
// 获取一个并发队列
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRORITY_DEFAULT, 0);

// 追加两个任务
dispatch_group_async(group, queue, ^{NSLog(@"No.1"); });
dispatch_group_async(group, queue, ^{NSLog(@"No.2"); });

//设置完成时的任务
dispatch_group_notify(group, dispatch_get_main_queue(), ^{NSLog(@"All task done")});

// Dispatch Group需要手动释放内存
dispatch_release(group)

Dispatch Barrier
为了解决线程安全问题,我们可以将读取处理放入并发队列中,将写入处理放入串行队列中,同时还要保证在没有读取操作时写入。我们可以通过队列优先级和调度组结合实现,但是这样过于复杂。GCD提供了dispatch_barrier_async函数来实现这一点。

dispatch_barrier_async函数会等待追加到并发队列中的任务全部完成后才将自己的处理追加到并发队列中,但此时并发队列仅运行该任务,待该任务执行完后,并发队列恢复正常继续并发执行。

1
2
3
4
5
6
7
8
9
10
11
12
// 创建并发队列
dispatch_queue_t queue = dispatch_queue_create("barrierTest", DISPATCH_QUEUE_CONCURRENT);

// 读取操作
dispatch_async(queue, readingBlock1);
dispatch_async(queue, readingBlock2);
dispatch_async(queue, readingBlock3);
// 写入操作
dispatch_barrier_async(queue, writingBlock1);
// 读取操作
dispatch_async(queue, readingBlock4);
dispatch_async(queue, readingBlock5);

线程的挂起/恢复

可以将线程暂时挂起,后面再进行恢复执行。但仅限于还未执行的任务,已执行的和正在执行的任务不受影响。

1
2
3
4
5
// 挂起线程
dispatch_suspend(queue);

// 恢复线程
dispatch_resume(queue);

Dispatch Semaphore(信号量)控制
为了精确控制并行处理,GCD提供了信号量

1
2
3
4
5
// 生成Dispatch Semaphore并指定信号量
dispatch_semaphore_t semaphore = dispatch_semaphore_create(1);

// 等待信号量大于等于1
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);

NSOperation

除了使用GCD之外,iOS还提供了NSOperation/NSOperationQueue这一多线程实现方式。

实现机制

NSOperation代表一个多线程任务。
NSOperationQueue代表一个FIFO的队列,其底部维护一个线程池,会按照先入先出的顺序执行提交给队列的NSOperation任务。

使用步骤

使用NSOperationQueue较为简单,其负责管理、执行所有NSOperation,开发者能够将注意力更多的放在任务上而非队列的使用上。其主要提供了以下API:

1
2
3
4
5
6
7
8
9
10
11
12
// 添加到队列中
- (void)addOperation:(NSOperation *)op;
// 添加并阻塞当前线程至所有任务完成
- (void)addOperations:(NSArray<NSOperation *> *)ops waitUntilFinished:(BOOL)wait;

// 取消所有操作
- (void)cancelAllOperations;

// 设置NSOperationQueue队列最大支持的并发线程数量
@property NSInteger maxConcurrentOperationCount;
// 设置和返回是是否暂停调度当前正在排队的任务,需要使用KVC设置
@property(getter=isSuspended) BOOL suspended;

NSOperation使用

NSOperation一般不直接使用,而是创建其子类来使用。继承NSOperation需要重写其main方法,该方法的方法体用于NSOperationQueue来执行任务。

而默认为我们提供了NSInvocationOperation和NSBlockOperation两种子类,分别对应了SEL和Block两种任务提交方式。

1
2
3
4
5
// NSInvocationOperation
- (instancetype)initWithTarget:(id)target selector:(SEL)sel object:(id)arg;

// NSBlockOperation
+ (instancetype)blockOperationWithBlock:(void (^)(void))block;

总结

本文主要总结iOS开发中的多线程概念、技术方案以及简单实用,具体的使用方式不再赘述。
开发中常用到GCD和NSOperation,而前者在功能丰富性和复杂性上也许有着更好的均衡。

同时本文还涉及到了线程安全、线程中锁的技术方案、线程的使用规则等问题,有兴趣的可以进一步研究。

相关资料

  1. iOS多线程编程指南: Threading Programming Guide

  2. iOS并行编程指南: Concurrency Programming Guide