多线程的难点在哪里
不管在哪门开发语言中,多线程都是绕不过去的开发方式。多线程本身也不是开发语言的一部分,只是开发语言会对多线程进行语言级别的包装。
多线程属于计算机原理的一部分。每个应用都需要操作系统分配一个进程后才能在进程中执行,为了实现并发效果,才有了多进程方案。而进程切换开销太大,这才有了多线程方案。乃至于多线程也是有不小的内存和 CPU 开销,后面的协程才开始起家。
但是协程已经不属于计算机的范畴,协程是单线程的,需要语言级别实现的。有些语言并没有实现协程,也只能使用多线程的方案对 CPU 资源进行深一步的榨干。
像 iOS 开发里面,每个应用都是独立的沙盒,官方并没有提供多进程的方案 (操作系统级别肯定要支持),也没有提供协程方案,如果想更有效的利用 CPU 资源,只能从多线程上面入手。
iOS 里面多线程虽然理解起来有点绕,尤其任务和队列嵌套的时候。但只要多使用几次并加以过程分析,多线程的使用也就能很好的过关了。因为 GCD 本身已经对多线程有了非常棒对封装,只要不自己作死,串型队列和同步任务一起处理的时候小心一些,就不会出现队列等待导致死锁。即使死锁了,像这样的系统中断性问题,也很容易排查和处理。
那多线程,难点在哪里呢?显然不是 Api 级别的多线程接口调用。
移动端界面开发,要保障不出现重大事故,很大程度上不在于 UI。因为 UI 是寄托于数据展示的,只要数据不出现大问题,UI 都是写好的机器代码,基本不会出问题。
但如果数据有间歇性的不稳定,那么对于 App 来说就是一个隐藏的地雷,因为谁也不知道哪个时刻会发生数组越界,或者数据为空。
所以,数据的稳定性和完整性,非常重要。
在多线程情况下,我们会操作 UI 吗?显然不会,多线程更多处理的都是数据。所以多线程难点可以从数据下手。如果多线程情况下数据的可靠性无法保证,宁愿放弃多线程 (FMDB 就是一个例子) 。
多线程情况下,数据的稳定性和完整性的保障,很复杂吗?其实也并不复杂,因为就两个要素点:原子性粒度的大小和锁。
原子性粒度的大小,不会太难控,有些编程经验,不会处理的太差。
而锁机制翻来覆去就那些,系统能够支持的甚至更少。在 Java 里面就有很多不同的锁,在 iOS 里面算来算去就那么几个,还是新瓶装旧酒。
那多线程,难点主要在哪里呢?其实还是数据。难点在于数据的处理!复杂度上的消耗,计算机时间片的消耗。
多线程编程,最终考量的其实是数据结构和算法!
对于上面的诸多观点,下面一一进行分析:
GCD 线程操作的理解
iOS 下面,官方提供的多线程方案有 Pthreads、NSThread、GCD、NSOperation。
Pthreads 是完全 C 开发的,相关函数调用感觉会非常小巧,感受一下,如 pthread_create(x ...)
,pthread_exit(NULL)
这样。我一直很喜欢这种面向过程式的函数调用开发方式,显然这样的函数也是和面向对象格格不入的。
NSThread 是对 Pthreads 的 OC 封装,方便使用了一些。但是他们两个都需要自行管理线程生命周期。既然已经面向对象开发,连内存都可以自动释放了,我们还是应该使用更加一体化的多线程方式,那就是 GCD 和 NSOperation。
NSOperation 将多线程的面向对象更加具体化,在处理比较复杂和大型的多线程场景下,非常适用,因为代码理解性和可读性非常高。
其实,iOS 开发人员使用的基本都是 GCD,无出其右。因为工作场景下,GCD 完全可以完成多线程任务了,性能也足够好,使用又方便,代码简短易懂。如果不是复杂和大型的多线程场景,基本也不会去用 NSOperation。
而且 GCD 还有一个大杀器,那就是线程安全锁。GCD 将多线程操作和线程安全都涵盖了,我们可以很方便的使用 dispatch_barrier_async
写出读写锁,也可以使用 dispatch_semaphore
写出二元和多元信号量锁。
可以说,使用 GCD,把多线程开发的大部分问题一套带走了。当然还有一个没有带走的,就是上面提到的 “数据结构和算法”。
很多人对 GCD 理解困难,其实是被三个方面困住了。一个是不理解队列这种数据结构,一个是不理解任务这种执行方式,一个是不写代码进行测试和分析执行过程。
同步任务和异步任务
一个任务是同步还是异步,是依靠线程的。因为我们函数执行过程中,只可能在一个线程里面执行。如果是同步任务,那么不可能换线程,如果是异步任务,那么必须要换线程。
函数执行是一个函数调用执行栈空间,通过 rbp 和 rsp 两个寄存器不断上下移动栈指针位置来实现的。而一个函数调用执行栈就专属于一个线程。
如果是同步任务,只能在当前函数调用栈执行,所以开启不了新线程。
如果是异步任务,必须要脱离当前函数调用栈,必须要开启新线程。(不开启新线程也可以,就是协程方案。但是 OC 不支持协程,所以只能开启新线程。)
同步任务要点:
- 同步任务立刻被放入队尾(但是不一定立刻执行,因为队列里面可能已经有任务 X 和 Y,则必须等 X 和 Y 出队 [如果是同步任务还必须执行完] 后才能执行被放入队尾的同步任务)。
- 同步任务一定要被执行完后才能继续后面的代码执行。
- 不具备开启新线程能力。同步任务被调用的时候在 A 线程,执行也一定在 A 线程。
异步任务要点:
- 异步任务立刻被放入队尾(但是不一定立刻执行,因为队列里面可能已经有任务 X 和 Y,则必须等 X 和 Y 出队后才能执行被放入队尾的异步任务)。
- 异步任务因为肯定会开启新线程,所以后续代码立刻执行。
- 具备开启新线程能力,而且一定要开启。但是开启线程数量由队列决定。异步任务被调用的时候在 A 线程,执行一定不在 A 线程。
串行队列和并发队列
队列 (Queue) 是非常基本的数据结构,基于数组或者链表这两种物理结构实现。队列它的特点就是:外部数据从队尾入队,内部数据从队头出队。
比如这个队列:队尾->A->B->C->队头
,如果现在加入外部数据 D
,那么 D 只能添加在 A 的后面,想插队添加到指定 index 是不可能的。而如果内部数据想被删除,只能先删除 C,然后才能继续删除 B 和 A。想插队删除元素,也不可能。
串行队列要点:
- 允许开启线程,最多开启 1 个线程,是否开启线程由任务决定。
- 所有任务必须依次出队,必须上一个出队的任务处理完,下一个任务才允许出队并执行。
并发队列要点:
- 允许开启线程,可以开启多个线程 (100 以上都有可能,依靠系统调度),是否开启线程由任务决定。
- 所有任务必须依次出队,但是下一个任务出队不需要上一个任务执行完。
用上面 ABC 队列举例,如果 A、B、C 任务分别需要执行 10s。
相关伪代码如下:
1 | // 被外界业务调用的函数 |
在串行队列下有下面可能:
- X 为 thread1,任务为 thread1,执行过程为:X_thread1->(C->10s 后 ->B->10s 后 ->A->10s 后)_thread1->Y_thread1(A、B、C 均为同步任务)
- X 为 thread1,任务为 thread2,执行过程为:X_thread1->(C->10s 后 ->B->10s 后 ->A->10s 后)_thread2->Y_thread1(A、B、C 均为异步任务)
- X 为 thread1,任务为 thread1 和 thread2,执行过程为:X_thread1->(C->10s 后)_thread1->(B)_thread2->(A->10s 后)_thread1->Y_thread1(A、C 均为同步任务,B 异步)
- 分析一下:因为 C 是同步任务,所以必须 C 执行 10s 后,后面的任务才能出队。因为 B 是异步任务,所以 C 执行完后,B 先出队,但 A 不用等 B 执行完即可出队执行。所以 B 和 A 可以说是并发执行的。
- A 出队后,只有当 A 被执行完成,后面的业务代码才能继续执行。
在并发队列下有下面可能:
- X 为 thread1,任务为 thread1,执行过程为:X_thread1->(C->10s 后 ->B->10s 后 ->A->10s 后)_thread1->Y_thread1(A、B、C 均为同步任务)
- X 为 thread1,任务为 threadx,执行过程为:X_thread1->Y_thread1->(C)_threadx->(B)_threadx->(A)_threadx(A、B、C 均为异步任务)
- 分析一下:首先 ABC 都是异步任务,在并发队列里面都会开启新线程,所以 X 执行完后把 ABC 添加到队列后,不会等 ABC 的执行过程,直接就会执行 Y 了。为什么呢?因为 ABC 在其他线程,由其他线程负责执行,ABC 的代码执行调用栈都不在 thread1 上面,而是在他们各自对应的线程。
- 其次,ABC 三个任务,C 会先出队,然后是 B,然后是 A。他们出队顺序是固定的,但是因为他们各自在各自的执行线程,所以执行的先后顺序是不确定的。
- ABC 是否开启多个线程有系统决定。如果系统开启 3 个线程,那么 ABC 会各自在自己的线程执行,没有先后顺序。如果系统仅仅开启 2 个线程,那么 A 会被分配到 C 或者 B 的执行线程,这个时候 A 就必须要等 C 或者 B 执行完才能执行(代码执行依靠调用栈,当前在执行 C 或者 B,就不可能执行 A,只能等 C 或者 B 执行完,当前调用栈结束,才能继续执行 A)。
- X 为 thread1,任务为 thread1 和 thread2,执行过程为:X_thread1->(C->10s 后)_thread1->(B)_thread2->(A->10s 后)_thread1->Y_thread1(A、C 均为同步任务,B 异步)
- 分析过程和串行队列一致
上面的结果都只是一小部分。因为任务可能会有多个同步和异步穿插,所以整体执行过程会更复杂 (如 A 同步 + B 异步 + C 同步 + D 异步等)。但只要记住上面同步异步及串行并发的要点部分,整体抽丝剥茧来分析,过程并不难理解。
多写测试代码多分析
多写一些测试代码并分析过程,GCD 的内容很快就能理解。
多写一些串行队列嵌套同步任务,很容易出现死锁,很快就能根据上面的几个要点分析出来死锁原因。
所以死锁和主队列没多大关系,只要是串行队列嵌套同步任务,都可能出现死锁。
主队列就是串行队列的特殊形式,因为主队列比串行队列更严苛,主队列不能开启新线程,只能在主线程运行,这就是主队列的要点,其他和串行队列一致。
比如下面这个多线程代码分析:
1 | // 被外界业务调用的函数 |
它的执行流程是:X_thread1->(C->1s)_thread1->(B->2s->C->2s)_thread1->Y_thread1
。
分析:C 被异步加入到串行队列里面,所以这个时候队列里面已经有 C。因为 C 是异步加入的,所以 B 代码也会立刻执行并被立刻加入串行队列。但是 B 没有办法执行,因为 C 需要 1s 才能执行完成。当 1s 过后,B 才能出队并执行。所以代码会在 B 处停 3s 然后才能将 A 加入队列并执行。
多线程数据处理之原子性粒度
如果上面 GCD 你已经能够熟练的分析并使用了,可能会有种大悟的感觉,原来多线程也不过如此。
可千万不要认为,上面 GCD 的使用,就是多线程的全部,相关多线程的坑来说,上面都是皮毛,多线程的坑完全不是串行队列死锁那么简单。
我们看最简单的一种情况
1 | @interface VC () { |
上面,我们多个异步线程操作_number 成员变量,分别进行 10000 次增减 1,最后结果不是 0。这就是多线程的一个坑。
出现上面的原因就是,self->_number = self->_number + 1;
这行代码不是原子的。
你可能会觉得,那这样的等号赋值可能有问题,那 ++self->_number;
这样的方式进行增 1 操作会不会正常?
结果就是,一样不正常。不正常的原因,依旧因为 ++self->_number;
这行代码也不是原子的。
那什么是原子的操作?
一条 CPU 执行的一个单指令,就是原子的。++
、--
这样的高级语言,在汇编后都会被编译成好几个操作指令。当计算机把操作指令执行了一半的时候,另一个线程也会开始执行,这个时候前一个线程就会被系统调度打断,去执行下一个线程的指令。所以,数据这个时候就产生了紊乱,导致 ++
、--
操作完全乱套了。
问题已经讲述清楚了,那该怎么解决呢?就是要手动制造原子性。一个操作指令是原子的,但是我们不可能把高级语言写出操作指令的形式,那样就回到汇编时代了。所以我们需要在外部制造更大的原子性区域,在这个区域里面,同一时间只能有一个线程操作。这样,就不会出现区域里面的代码执行一半转而另一个线程闯进来了。
原子性是有粒度大小的,如果粒度过大,则多线程间接变成单线程。如果粒度过小,则可能不足以保障原子性。
下面举例分析:
粒度过大:
1 | - (void)test { |
上面我们的多线程操作主要是 add 和 sub 两个函数,业务处理也都在这两个函数里面。但是我们在调用 add 和 sub 函数的时候,通过 synchronized 锁临时添加了原子性区域,这就导致 add 和 sub 里面的 2000 多行代码,同一时间只能一条线程执行,变成了单线程操作。多线程形同实亡。
粒度过小:
1 | @property (atomic) int num; |
我们定义了一个原子性 (atomic) 的 num 变量,但是最后打印的 num 依旧不为 0。原因就是 num 虽然已经在 set 和 get 方法里面添加了 synchronized 锁,但这个锁只能保障 num 变量在读和取的时候是原子性的。如果两个线程同时读,这个时候两个线程获取到的值是一样的,但是一个线程增 1,一个线程减 1,最后两个线程原子性调用 set 方法赋值。显然,num 的值这个时候就以最后调用 set 方法的线程值为准,不在准确了。
这个时候就是原子性粒度过小,导致虽然添加了锁,但依旧值不准确。
所以为了多线程处理能力最大化 (足够榨干 CPU 资源),也为了数据依旧稳定和准确,原子性的粒度需要考量一个合适的区域。
多线程数据处理之锁
原子性粒度考量完成后,下面就是如何保障这个原子性区域的问题了。上面我们简化说明都使用了 synchronized 锁,但是多线程本身还有其他各种锁,synchronized 只是其中使用最方便但是效率也最低的一种。
iOS 下锁比较好理解,因为不像 JAVA,iOS 下锁就那么多,下面根据多线程锁机制来分析。
《程序员的自我修养 - 链接、装载与库》一书中,在说到线程安全锁机制的时候,概括说了 5 种锁,而 iOS 里面所有锁就是基于其中 4 种来的。
5 种线程锁分别是:信号量、互斥量、临界区、读写锁、条件变量。
信号量:是线程级别的,任何一个线程均可以自行加锁和解锁,任何一个线程也可以对另一个线程已加的锁进行解锁。
互斥量:也是线程级别的,但是比信号量严苛一些。任何一个线程均可以自行加锁和解锁,但是 A 线程不能对 B 线程已加的锁进行解锁,必须有 B 线程自己解锁。(iOS 里面,A 也可以解 B 加的锁,没有报错。)
临界区:进程级别的。比互斥量严苛了一些。加锁解锁被称作进入临界区
、离开临界区
。A 进程创建的临界区,只有 A 进程可以进出,其他进程不能操作。因为 iOS 是沙盒机制,对于单个 App 来说,不存在多进程,所以临界区在 iOS 开发里面用不到。不过系统肯定是需要临界区的,不过那是操作系统的事情了。
读写锁:读的时候数据不需要保障稳定性,所以可以并发读,但是写一定要独立,写的时候只能一个一个写,而且写的时候不能有读操作。也叫共享-独占锁
。
条件变量:在达到某个特定的条件下,线程才能加锁和解锁。条件可以预先设置好,后面的加锁和解锁就根据条件来触发。
iOS 的锁有二十种左右,但更多都是对几个特定锁对封装。举例来说:
- OSSpinLock 自旋锁(特别说明,虽然性能非常高,但是已经被废弃)。本质是互斥锁,ABC 同时访问对时候,C 先进去并加锁,然后 AB 不断循环访问是否解锁,如果解锁,立刻进入并加锁。所以被挡在锁外面对线程没有休息,而是不停对查询。
- CPU 消耗很大,因为挡在锁外面对线程一直在不停查询。
- 因为优先级反转原因,该锁已经被苹果弃用。比如优先级为:A>B>C。这个时候 C 提前进入加锁并执行代码,但是 A 优先级太高,导致 A 不停查询并占用了非常多对时间片,最后 C 用了很久才执行完并解锁。这个过程中,有太多时间片都浪费在了查询上。
- os_unfair_lock 互斥锁的一种,OSSpinLock 的替代品。自旋锁会不停的查询并忙等,os_unfair_lock 会在加锁的情况下,对线程进行休眠。当解锁后继续执行。
- 只要是锁,优先级反转都会出现。但是不同于自旋锁,互斥锁会让挡在外面对线程处于休眠状态,在解锁后激活并执行。这样对 CPU 的消耗会很低。
- dispatch_semaphore 信号量。可以实现二元信号量和多元信号量。通过信号量还可以做限制并发操作。
- pthread_mutex 互斥锁。mutex 是互斥锁的完整体现。基于 mutex 可以实现互斥锁、递归锁、条件锁。效率非常高。
- 普通互斥锁:性能很高的锁,挡在锁外面的线程会休眠,不会出现优先级反转后时间片浪费情况。
- 递归锁:当 A 线程因为递归等原因,在没有释放锁的情况下,又重新加锁。这个时候互斥锁是不能加锁的,因为之前已经加过锁了。递归锁可以解决这个问题,在递归锁下,同一个线程可以一次加锁,然后一次解锁。
- 条件锁:mutex 实现的条件锁,不能根据条件自动加锁解锁。需要动手激活指定条件然后加锁或解锁。
- NSLock 互斥锁。对 mutex 普通互斥锁的封装,面向对象。
- NSRecursiveLock 递归锁。对 mutex 递归锁的封装,面向对象。
- NSCondition 条件锁。对 mutex 条件锁的封装,面向对象。可以预设条件,在条件到达后,自行加锁解锁。相关 mutex 自行实现,代码过程更加自动化。
- synchronized 递归锁。对 mutex 递归锁的封装,使用最方便,不需要手动加锁和解锁。但是性能也是所有锁里面最低的。
- dispatch_rwlock 读写锁。可以保障写操作的互斥独立,读操作是重入可并发的。
- dispatch_barrier_async 栅栏锁。可以分割一段任务队列(警告:必须使用自定义队列,不能使用主队列和全部队列)。也可以用来模拟读写锁,iOS 的属性修饰符 atomic 完全可以通过 dispatch_barrier_async 来实现。
- dispatch_group_t 栅栏锁。和 barrier 类似,功能方向略有差异,group 栅栏锁可以实现组的操作。
还有其他一些锁,但可以发现,大差不差,都是对多线程 5 种锁的实现和封装,这 5 种锁分别是:信号量、互斥量、临界区(iOS 开发层面没必要使用)、读写锁、条件变量。
在 iOS 开发里面具体使用哪些锁问题已经不大,只要不使用 OSSpinLock,其他锁都可以试一试。目前来看,业务层面开发,NSLock 和 synchronized 用的较多。组件库方面,os_unfair_lock 和 mutex 使用的较多。
多线程数据安全总结
Runtime 源码里面,对于 SideTable 中 weak 表的实现,就是原子性粒度和锁的解释说明。
Runtime 对 weak 表的整个实现,都标记为线程不安全,并且在外部 SizeTable 中定义了 spinlock 自旋锁来限制原子性区域。
所以 weak 表的实现里面,原子性区域还是比较大的,整个 weak 表内部的数据的处理都处于不安全状态,通过最外界的函数调用处,给予自旋锁来保障线程安全。
相关代码如下所示:
1 | struct SideTable { |
还有 Runtime 里面_read_images 函数里面操作 SEL 的代码:
1 | // 将所有SEL都注册到哈希表中,是另外一张哈希表 |
这里对原子性粒度把控的就很细,用的读写锁。
多线程下数据结构和算法的重要性
到这里,多线程下数据安全基本已经可以告一段落了。但是,还没有结束。
我们之前提过,多线程下数据安全通过原子性粒度的把控和锁机制,已经可以比较好的实现。而粒度控制小心一些,锁就那么多,用的恰当一些,数据安全就没有问题了。
后面的问题,就是原子性区域内部的代码执行效率的问题了。因为原子性区域内部都是单个线程在执行,所以执行效率一定是要很高的。
我们举个例子,如果原子性区域里面,代码耗时需要 1s,那么多线程操作下,是不是有很多线程都会被原地休眠?整个执行效率肯定低下的要死。
所以,这个时候,就要使用合适的数据结构和算法,来提高代码执行的效率。
我们用 YYCache 的内存缓存举例,YYCache 使用 pthread_mutex 互斥锁,原子性粒度控制的很小,在对数据进行操作的时候才开始加锁和解锁。
缓存使用来 LRU 算法,通过双向链表来实现数据对增和删的复杂度为 O (1)。
但是链表的查询复杂度是比较高的,因为链表无法做随机寻址,也没法用数组的空间局部性缓存加速。
所以作者通过空间换时间的方式,引入了 hash map,将缓存数据存入 hash map 中实现查询复杂度为 O (1)。
这样,整体内存缓存的数据的操作复杂度都将为 O (1)。
之前也写过文章说明数组和链表的优缺点,其中重要一点就是链表的增删复杂度为 1,数组的查复杂度为 1。为了更好的使用数组和链表的双方优点,所以 hash map 和链表常一块使用。
Runtime 里面也是各种 hash table 和 hash map table 的使用,甚至 hash 函数都为了高效,尽可能使用位操作来计算索引值。
所以多线程编程,数据安全能通过锁保障的也都能很好保障(像 FMDB 和 iOS UI 主线程,知道多线程数据安全的处理风险太大,索性就不支持多线程了),唯有算法这个环节,诡异且多变,最能体现价值。
今天是五四青年节日,我查了青年的年龄标准,是 14-28 周岁。突然很开心,我明年还能在过一次五四青年节。
历史前进的车轮肯定不会停下来,不管是文明,经济,抑或是网络,甚至自由。后浪必定比前浪更加优秀,这是毋庸置疑的,否则不符合历史规律。
每一波新一代,都拥有更棒的环境,更好的认知,更方便的学习方式。所以后浪们必定更加优秀和杰出,这是必然。
社会这个大团体,也一定会在后浪的推动下,一直向前,稳步向前。
但有一个重点我也想表达,随着历史长河的流逝,社会必然会进步。如果要进步的更快,那思想独立和思想解放必定占据非常大的比重,中国的新一代在这方面有很大短板。
我已经在职场 7 年了,虽然还是青年,但已经没有了青年的气质和气息。即使假装青年的疯癫,但眼角的复杂情绪却无法掩藏,也无法欺骗自己。
我显然不是青年了,很多时候我会无知于自己的未来,也对未来充满恐惧、失措、无助,而青年人不应该有这些拘束思想,他们应该是奔放的,激情四射的。
我想了一下,我脱离青年身份,应该是 3 年前,那年我 24 周岁,本命年。那年,我孩子出生。