ShannonChenCHN / iOSDevLevelingUp

A journey of leveling up iOS development skills and knowledge.
365 stars 105 forks source link

多线程与 Runloop #16

Open ShannonChenCHN opened 7 years ago

ShannonChenCHN commented 7 years ago

多线程知识点总结


相关示例代码合集

ShannonChenCHN commented 7 years ago

参考资料:

ShannonChenCHN commented 6 years ago

并发编程

一、简介

1. 线程和进程、任务(Thread, Process and Task)

进程(process):指的是一个正在运行中的可执行文件。每一个进程都拥有独立的虚拟内存空间和系统资源,包括端口权限等,且至少包含一个主线程和任意数量的辅助线程。另外,当一个进程的主线程退出时,这个进程就结束了。

线程(thread):指的是一个独立的代码(汇编指令)执行路径,也就是说线程是代码执行路径的最小分支。在 iOS 中,线程的底层实现是基于 POSIX threads API 的,也就是我们常说的 pthreads 。(⭐️强烈推荐读一读这篇文章-->POSIX Threads Programming: What is a Thread?

任务(task):指的是我们需要执行的工作,是一个抽象的概念,用通俗的话说,就是一段代码。

在现代的操作系统中,线程才是 CPU 调度的基本单位。而进程作为线程的容器,是资源管理的单位。线程的执行也是串行的,采用与进程相同的调度算法使其并发执行,每个 CPU 密集型线程同样得到真实 CPU 速度的 1/N,但线程使进程的执行分割为多个子任务,因此线程也被称为轻量级进程。它的好处是当某个线程因为 I/O 操作阻塞时,还可以去执行其他线程从而最大化利用进程时间片。

每个进程都拥有独立且受保护的内存空间,用来存放程序正文和数据以及其打开的文件、子进程、即将发生的报警、信号处理程序、账号信息等。线程只拥有程序计数器、寄存器、堆栈等少量资源,但与其他线程共享该进程的整个内存空间。

image 图一 Unix 进程

image 图二 一个 Unix 进程中的线程

线程一般有三个状态:

image

参考:

2. 串行和并行(Serial VS. Concurrent)

从本质上来说,串行和并发的主要区别在于允许同时执行的任务数量。

这两种,均遵循 FIFO 的入队顺序原则。

举一个简单的例子,在三个任务中输出1、2、3,串行队列输出是有序的1、2、3,但是并行队列的先后顺序就不一定了。

那么,并行队列又是怎么在执行呢?

虽然可以同时多个任务的处理,但是并行队列的处理量,还是要根据当前系统状态来。如果当前系统状态最多处理2个任务,那么1、2会排在前面,3什么时候操作,就看1或者2谁先完成,然后3接在后面。

3. 同步和异步(Sync VS. Async)

串行与并行针对的是队列,而同步与异步,针对的则是线程。 同步和异步操作的主要区别在于是否等待任务执行完成,亦即是否阻塞当前线程。 同步操作会阻塞当前线程,必须要等同步线程中的任务执行完成后,返回之后,再继续执行接下来的代码。 而异步操作则恰好相反,它会在调用后立即返回,不会等待。

4. 队列和线程(Queue VS. Thread)

一个队列由一个或多个任务组成,当这些任务要开始执行时,系统会分别把他们分配到某个线程上去执行。当有多个系统核心时,为了高效运行,这些核心会将多个线程分配到各核心上去执行任务,对于系统核心来说并没有任务的概念。

对于一个并行队列来说,其中的任务可能被分配到多个线程中去执行,即这个并行队列可能对应多个线程。对于串行队列,它每次对应一个线程,这个线程可能不变,可能会被更换。

每一时刻,一个线程都只能执行一个任务。一个线程也可能是闲置或者挂起的,因此线程存在时不一定就在执行任务。

队列和线程可以说是两个层级的概念。队列是为了方便使用和理解的抽象结构,而线程是系统级的进行运算调度的单位,他们是上下层级之间的关系。

在 iOS 中,有两种不同类型的队列,分别是串行队列和并发队列。

正如我们上面所说的,串行队列一次只能执行一个任务,而并发队列则可以允许多个任务同时执行。iOS 系统就是使用这些队列来进行任务调度的,它会根据调度任务的需要和系统当前的负载情况动态地创建和销毁线程,而不需要我们手动地管理。

参考:

5. 并发与并行(Concurrency VS. Parallel)

下面这段话摘自维基百科词条 Concurrency (computer science)

According to Rob Pike, concurrency is the composition of independently executing computations,[2] and concurrency is not parallelism: concurrency is about dealing with lots of things at once but parallelism is about doing lots of things at once. Concurrency is about structure, parallelism is about execution, concurrency provides a way to structure a solution to solve a problem that may (but not necessarily) be parallelizable.

5.1 它们最关键的点就是:是否是『同时』

image

它们最关键的点就是:是否是『同时』。

5.2 “并行”概念是“并发”概念的一个子集

如果某个系统支持两个或者多个动作(Action)同时存在,那么这个系统就是一个并发系统。如果某个系统支持两个或者多个动作同时执行,那么这个系统就是一个并行系统。并发系统与并行系统这两个定义之间的关键差异在于“存在”这个词。

在并发程序中可以同时拥有两个或者多个线程。这意味着,如果程序在单核处理器上运行,那么这两个线程将交替地换入或者换出内存。这些线程是同时“存在”的——每个线程都处于执行过程中的某个状态。如果程序能够并行执行,那么就一定是运行在多核处理器上。此时,程序中的每个线程都将分配到一个独立的处理器核上,因此可以同时运行。

我相信你已经能够得出结论——“并行”概念是“并发”概念的一个子集。也就是说,你可以编写一个拥有多个线程或者进程的并发程序,但如果没有多核处理器来执行这个程序,那么就不能以并行方式来运行代码。因此,凡是在求解单个问题时涉及多个执行流程的编程模式或者执行行为,都属于并发编程的范畴。

5.3 是不是说并发就是一个线程,并行是多个线程?

并发和并行都可以是很多个线程,就看这些线程能不能同时被(多个)cpu执行,如果可以就说明是并行,而并发是多个线程被(一个)cpu 轮流切换着执行。

5.4 从原理上理解并发和并行的本质区别

并行可以在计算机的多个抽象层次上运用,这里仅讨论任务级并行(程序设计层面),不讨论指令级并行等。

并发指能够让多个任务在逻辑上同时执行的程序设计,而并行则是指在物理上真正的同时执行。并行是并发的子集,属于并发的一种实现方式。通过时间片轮转实现的多任务同时执行是通过调度算法实现逻辑上的同步执行,属于并发,他们不是真正物理上的同时执行,不属于并行。当通过多核 CPU 实现并发时,多任务是真正物理上的同时执行,才属于并行。

参考

二、多线程技术

1. 什么是多线程?

下面这句话摘自 并发编程 - objc.io

Concurrency describes the concept of running several tasks at the same time. This can either happen in a time-shared manner on a single CPU core, or truly in parallel if multiple CPU cores are available.

下面的图片摘自《Objective-C 高级编程》: image

2. 为什么会出现多线程?为什么要使用多线程技术?

下面的图片摘自《Objective-C 高级编程》: image

下面的图片摘自《程序员的自我修养》: image image

使用多线程可以充分利用现在的多核 CPU、减少 CPU 的等待时间、防止主线程阻塞等。除了性能上的提升,对于批量任务,使用多线程也能使代码逻辑更加清晰。

不过如果某个进程内有大量的 CPU 密集型线程,那么多线程对效率的提升没有半点好处,反而会因为线程上下文的频繁切换增大 CPU 开销(对于单核 CPU 来说更加影响效率)。相对来说,多线程更适合 I/O 密集型任务,正在处理 I/O 的线程大部分时间都处在等待状态,它们不占用 CPU 资源。

参考:

三、iOS 中的并发编程模型

在其他许多语言中,为了提高应用的并发性,我们往往需要自行创建一个或多个额外的线程,并且手动地管理这些线程的生命周期,这本身就已经是一项非常具有挑战性的任务了。此外,对于一个应用来说,最优的线程个数会随着系统当前的负载和低层硬件的情况发生动态变化。因此,一个单独的应用想要实现一套正确的多线程解决方案就变成了一件几乎不可能完成的事情。而更糟糕的是,线程的同步机制大幅度地增加了应用的复杂性,并且还存在着不一定能够提高应用性能的风险。

然而,值得庆幸的是,在 iOS 中,苹果采用了一种比传统的基于线程的系统更加异步的方式来执行并发任务(GCD 和 NSOperation)。与直接创建线程的方式不同,我们只需定义好要调度的任务,然后让系统帮我们去执行这些任务就可以了。我们可以完全不需要关心线程的创建与销毁、以及多线程之间的同步等问题,苹果已经在系统层面帮我们处理好了,并且比我们手动地管理这些线程要高效得多

因此,我们应该要听从苹果的劝告,珍爱生命,远离线程。不过话又说回来,尽管队列是执行并发任务的首先方式,但是毕竟它们也不是什么万能的灵丹妙药。所以,在以下三种场景下,我们还是应该直接使用线程的:

四、iOS 中的几种多线程方案

GCD 和 NSOperation 的对比:

推荐阅读:

五、RunLoop

在苹果的Threading Programming Guide 文档中,提到线程管理中可能需要自己设置 run loop:

When writing code you want to run on a separate thread, you have two options. The first option is to write the code for a thread as one long task to be performed with little or no interruption, and have the thread exit when it finishes. The second option is put your thread into a loop and have it process requests dynamically as they arrive. The first option requires no special setup for your code; you just start doing the work you want to do. The second option, however, involves setting up your thread’s run loop.

OS X and iOS provide built-in support for implementing run loops in every thread. The app frameworks start the run loop of your application’s main thread automatically. If you create any secondary threads, you must configure the run loop and start it manually.

那么什么是 run loop 呢?下面是苹果 Run Loops文档中关于 run loop 的介绍:

Run loops are part of the fundamental infrastructure associated with threads. A run loop is an event processing loop that you use to schedule work and coordinate the receipt of incoming events. The purpose of a run loop is to keep your thread busy when there is work to do and put your thread to sleep when there is none.

Run loop management is not entirely automatic. You must still design your thread’s code to start the run loop at appropriate times and respond to incoming events. Both Cocoa and Core Foundation provide run loop objects to help you configure and manage your thread’s run loop. Your application does not need to create these objects explicitly; each thread, including the application’s main thread, has an associated run loop object. Only secondary threads need to run their run loop explicitly, however. The app frameworks automatically set up and run the run loop on the main thread as part of the application startup process.

更多细节见:Run Loop 总结

六、多线程下的线程安全问题

更多细节见 线程安全问题总结

七、参考

ShannonChenCHN commented 6 years ago

GCD(《Objective-C高级编程》学习总结)

示例代码见:https://github.com/ShannonChenCHN/iOSDevLevelingUp/blob/master/ReadingBooks/%E3%80%8AObjective-C%20%E9%AB%98%E7%BA%A7%E7%BC%96%E7%A8%8B%E3%80%8B%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0/ProObjcBookDemo/GCDDemo/GCDAPIDemo1/GCDAPIDemo1/main.m

一、简介

1. 什么是 GCD

2. 多线程编程

二、GCD 中的 API

1. Dispatch Queue

1.1 什么是 Dispatch Queue?什么是队列?(画图理解)

1.2 Serial Dispatch Queue 和 Concurrent Dispatch Queue

Serial Dispatch Queue 是要等待上一个执行完,再执行下一个的,也就是串行队列。

Concurrent Dispatch Queue 是不需要上一个执行完,就能执行下一个的,叫做并行队列。

这两种,均遵循 FIFO 原则。

1.3 队列和线程的区别与联系

串行与并行针对的是队列,而同步与异步,针对的则是线程。最大的区别在于,同步线程要阻塞当前线程,必须要等待同步线程中的任务执行完,返回以后,才能继续执行下一任务;而异步线程则是不用等待。

问题:使用 GCD Concurrent Queue 和 Serial Queue 异步执行任务时,系统是怎么管理线程的?

每创建一个 Serial Queue 并异步执行队列中的任务,系统就会创建一个对应的线程。所以,要控制异步执行 Serial Queue 的数量,避免创建过多的线程。

而 Concurrent Queue 异步执行多个并发任务是由系统管理的,并行执行的处理数量取决于当前系统的状态,iOS 和 OS X的核心——XNU 内核决定应当使用的线程数,并只生成所需的线程执行处理。所以,使用 Concurrent Queue 时不需要担心线程过多的问题。

2. dispatch_queue_create函数

Snip20210612_1 Snip20210612_2

4. dispatch_set_target_queue

5. dispatch_after 函数

6. Dispatch Group

(1)示例代码

dispatch_group_async 的使用:

    dispatch_queue_t queue1 = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
    dispatch_queue_t queue2 = dispatch_queue_create("com.shannon.gcd.queue2", DISPATCH_QUEUE_CONCURRENT);
    dispatch_group_t group = dispatch_group_create();

    dispatch_group_async(group, queue1, ^{
        NSLog(@"线程 %@ 执行任务1", [NSThread currentThread]);
    });
    dispatch_group_async(group, queue1, ^{
        NSLog(@"线程 %@ 执行任务2", [NSThread currentThread]);
    });
    dispatch_group_async(group, queue2, ^{
        NSLog(@"线程 %@ 执行任务3", [NSThread currentThread]);
    });

        // 这个函数不会阻塞当前线程,等待 group 中所有任务完成后,执行 block 中的最终任务
    dispatch_group_notify(group, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        NSLog(@"线程 %@ 执行最终任务", [NSThread currentThread]);
    });

    // 这个函数会阻塞当前线程,等待 group 中所有任务完成,最多等待 3 秒
   long flag = dispatch_group_wait(group, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3ull * NSEC_PER_SEC)));

dispatch_group_enterdispatch_group_leave 的使用:

dispatch_queue_t queue1 = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
    dispatch_queue_t queue2 = dispatch_queue_create("com.shannon.gcd.queue2", DISPATCH_QUEUE_CONCURRENT);
    dispatch_group_t group = dispatch_group_create();

    // 与上面注释掉的代码等价
    dispatch_group_enter(group);
    dispatch_async(queue1, ^{
        NSLog(@"线程 %@ 执行任务1", [NSThread currentThread]);
        dispatch_group_leave(group);
    });

    dispatch_group_enter(group);
    dispatch_async(queue1, ^{
        NSLog(@"线程 %@ 执行任务2", [NSThread currentThread]);
        dispatch_group_leave(group);
    });
    dispatch_group_enter(group);
    dispatch_async(queue2, ^{
        NSLog(@"线程 %@ 执行任务3", [NSThread currentThread]);
        dispatch_group_leave(group);
    });

    // 这个函数不会阻塞当前线程,等待 group 中所有任务完成后,执行 block 中的最终任务
    dispatch_group_notify(group, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        NSLog(@"线程 %@ 执行最终任务", [NSThread currentThread]);
    });

(2)应用案例:

7. dispatch_barrier_async 函数

(1)示例代码

        __block int num = 1;
    dispatch_queue_t queue = dispatch_queue_create("com.shannon.gcd.ForBarrier", DISPATCH_QUEUE_CONCURRENT);

    // 前动作
    for (int i = 0; i < 10; i++) {
        dispatch_async(queue, ^{
            NSLog(@"读取数据%d: %d", i, num);
        });
    }

    // 中间动作
    dispatch_barrier_async(queue, ^{ // 相应的还有  dispatch_barrier_sync 函数

        num += 1;
        NSLog(@"写入数据: %d", num);

    });

    // 后动作
    for (int i = 10; i < 20; i++) {
        dispatch_async(queue, ^{
            NSLog(@"再次读取数据%d: %d", i, num);
        });
    }

(2)应用案例

8. dispatch_sync 函数

8.1 dispatch_syncdispatch_async 有什么区别?

dispatch_syncdispatch_async 都是需要两个参数,第一个是 Dispatch Queue,第二个是要执行的 block,它们的共同点是,block 都会在你指定的队列上执行(无论队列是并行队列还是串行队列),不同的是 dispatch_sync 会阻塞当前线程直到 block 执行结束,而 dispatch_async 则不阻塞当前线程,异步执行 block。

- (void)func {
    dispatch_async(someQueue, ^{
        //do some work.
        NSLog(@"Here 1.");
    });
    NSLog(@"Here 2.");

}

因为 dispatch_async 异步非阻塞,所以 Here 1.Here 2.的打印顺序不确定;

- (void)func {
    dispatch_sync(someQueue, ^{
        //do some work.
        NSLog(@"Here 1.");
    });
    NSLog(@"Here 2.");}

因为dispatch_sync 阻塞当前操作知道 block 返回,所以打印顺序一定是 Here 1. 然后再打印 Here 2.

参考:

8.2 GCD 死锁

案例一:

NSLog(@"1"); // 任务1

dispatch_sync(dispatch_get_main_queue(), ^{

    NSLog(@"2"); // 任务2

});

NSLog(@"3"); // 任务3

image

案例二:

    dispatch_queue_t mainQueue = dispatch_get_main_queue();
    dispatch_async(mainQueue, ^{
       dispatch_sync(mainQueue, ^{
           NSLog(@"线程 %@", [NSThread currentThread]);
       });
    });

案例三:

dispatch_queue_t queue = dispatch_queue_create("com.demo.serialQueue", DISPATCH_QUEUE_SERIAL);

NSLog(@"1"); // 任务1

dispatch_async(queue, ^{

    NSLog(@"2"); // 任务2

    dispatch_sync(queue, ^{  

        NSLog(@"3"); // 任务3

    });

    NSLog(@"4"); // 任务4

});

NSLog(@"5"); // 任务5

image

参考:

8.4 dispatch_syncdispatch_async与串行、并行的组合

dispatch_sync dispatch_async
串行队列 阻塞当前线程,将串行任务添加到队列后,在当前线程顺序执行 不阻塞当前线程,将串行任务添加到队列后,新建一个线程顺序执行
并发队列 阻塞当前线程,在当前线程并发执行 不阻塞当前线程,新建多个线程并发执行
主队列 阻塞主线程,将串行任务添加到队列后,出现死锁 不阻塞主线程,将串行任务添加到队列后,在主线程中顺序执行

dispatch_sync+并发”的组合为什么会是并发执行?详见iOS中的多线程技术中的分析。

9. dispatch_apply 函数

函数声明:

void dispatch_apply(size_t iterations, dispatch_queue_t queue, void (^block)(size_t));

功能: Submits a single block to the dispatch queue and causes the block to be executed the specified number of times.

10. dispatch_suspend 函数和 dispatch_resume 函数

11. Dispatch Semaphore(信号量)

什么是信号量?(见 《深入理解计算机系统》第 12.5.2 节。) 信号量就像是一个交通信号灯一样,信号量计数为 0 时等待,信号量计数大于 0 时,减去 1 而且不等待。

当并行执行的任务更新数据时,会产生数据不一致的情况,有时程序还会异常退出。虽然使用 Serial Dispatch Queue 和 dispatch_barrier_async 函数可以避免此类问题,但是 Dispatch Semaphore 可以提供粒度更细的隔离控制。

相关考题:https://github.com/ShannonChenCHN/algorithm-and-data-structure/issues/33#issuecomment-869016451

11.1 使用 Dispatch Semaphore 实现互斥锁:

    dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

    // 初始为 1,实现互斥锁
    dispatch_semaphore_t semaphore = dispatch_semaphore_create(1);

    NSMutableArray *array = [NSMutableArray array];

    for (int i = 0; i < 1000; i++) {
        dispatch_async(queue, ^{
            NSLog(@"子线程中准备添加第 %@ 个元素", @(i));

            // semaphore 计数 -1
            // 如果 -1 之后结果小于 0,这个函数不会立即返回,而是会等待 dispatch_semaphore_signal 发出信号
            dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);

            NSObject *obj = [NSObject new];
            NSLog(@"已有%@个元素,成功添加第%@个元素", @(array.count), @(i));
            [array addObject:obj];

            // semaphore 计数 +1
            // 如果 +1 之前计数小于 0 ,就唤醒调一个用 dispatch_semaphore_wait 等待的线程,并返回非 0,否则就直接返回 0
            dispatch_semaphore_signal(semaphore);
        });

    }

11.2 注意点

使用 Dispatch Semaphore 时有一个很容易被忽略、也是最容易造成 App 崩溃的地方,就是信号量的释放。

Important

Calls to dispatch_semaphore_signal must be balanced with calls to wait(). Attempting to dispose of a semaphore with a count lower than value causes an EXC_BAD_INSTRUCTION exception.

dispatch_semaphore_t semephore = dispatch_semaphore_create(1);
dispatch_semaphore_wait(semephore, DISPATCH_TIME_FOREVER);
//重新赋值或者将semephore = nil都会造成崩溃,因为此时信号量还在使用中
semephore = dispatch_semaphore_create(0);

// BUG IN CLIENT OF LIBDISPATCH: Semaphore object deallocated while in use

参考:

11.3 应用案例

AFNetworking 3.1.0YYText 1.0.1GPUImage 0.1.7中都有用到过 Dispatch Semaphore。

12. dispatch_once 函数

13. Dispatch I/O

14. Dispatch Source

Dispatch Source 的种类有:

我们可以使用 DISPATCH_SOURCE_TYPE_TIMER 创建计时器/定时器:

    // 创建定时器
    dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0));
    // 15秒后执行,且不重复执行,允许延迟1秒
    dispatch_source_set_timer(timer, dispatch_time(DISPATCH_TIME_NOW, 15 * NSEC_PER_SEC), DISPATCH_TIME_FOREVER, 1ull * NSEC_PER_SEC);

    // 时间到了之后要处理的任务
    dispatch_source_set_event_handler(timer, ^{
        NSLog(@"wakeup!");
        // 取消定时器
        dispatch_source_cancel(timer);
    });

    dispatch_source_set_cancel_handler(timer, ^{
        NSLog(@"canceled");

    });

    NSLog(@"start timer!");
    dispatch_resume(timer);

三、其它

1. GCD 和 Runloop

SDWebImage 中有这样一个宏定义:

    #ifndef dispatch_main_async_safe
    #define dispatch_main_async_safe(block)\
    if (strcmp(dispatch_queue_get_label(DISPATCH_CURRENT_QUEUE_LABEL), dispatch_queue_get_label(dispatch_get_main_queue())) == 0) {\
        block();\
    } else {\
        dispatch_async(dispatch_get_main_queue(), block);\
    }
    #endif

它的作用是判断一下当前队列是不是主队列,如果是则直接执行,如果不是则异步将任务加到主队列中执行。

在下面这个例子中:

- (void)viewDidLoad {
    [super viewDidLoad];
    NSLog(@"before submits a block on the main queue : %@", [NSThread currentThread]);

    dispatch_async(dispatch_get_main_queue(), ^{
        NSLog(@"invoke th block on the main queue");
    });

    NSLog(@"after submits a block on the main queue : %@", [NSThread currentThread]);

}

输出结果如下:

2021-06-12 14:37:16.232115+0800 before submits a block on the main queue : <NSThread: 0x6000018d05c0>{number = 1, name = main}
2021-06-12 14:37:16.232376+0800 after submits a block on the main queue : <NSThread: 0x6000018d05c0>{number = 1, name = main}
2021-06-12 14:44:47.174962+0800 invoke th block on the main queue

我们可以看到提交到主队列的 block 是最后执行的。

根据苹果在dispatch_get_main_queue函数的文档中所说,在 iOS 应用程序中,当我们把 block 添加到主队列中之后,系统会通过三种方式来回调提交的 block 任务:

The system automatically creates the main queue and associates it with your application’s main thread. Your app uses one (and only one) of the following three approaches to invoke blocks submitted to the main queue:

通过断点调试上面的示例代码,我们可以看到 block 的调用堆栈,原来是 RunLoop 调用的:

Snip20210612_3

在苹果开源的 libdispatch(也可以在线阅读)中,我们可以看到 _dispatch_main_queue_callback_4CF 的实现:

_dispatch_main_queue_callback_4CF
    _dispatch_main_queue_drain
        _dispatch_continuation_pop_inline
            _dispatch_continuation_invoke_inline
        _dispatch_client_callout

至于 RunLoop 的调用 _dispatch_main_queue_callback_4CF 的时机需要看 CFRunLoop 的实现了,详见 RunLoop 总结

2. GCD 和 -performSelector: 系列方法

- (void)performSelector:(SEL)aSelector withObject:(nullable id)anArgument afterDelay:(NSTimeInterval)delay;
- (void)performSelector:(SEL)aSelector withObject:(nullable id)anArgument afterDelay:(NSTimeInterval)delay;

下面的例子中:


@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    NSLog(@"before submits a block on the main queue : %@", [NSThread currentThread]);

    // __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__ ()
    dispatch_async(dispatch_get_main_queue(), ^{
        NSLog(@"dispatch main queue callback");
    });

    // 先直接返回,然后再在 Runloop 中回调
    // __CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__ ()
    [self performSelector:@selector(print_0) withObject:nil afterDelay:0];

    // 先直接返回,然后再在 Runloop 中回调
    // __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ ()
    [self performSelectorOnMainThread:@selector(print_1) withObject:nil waitUntilDone:NO];

    NSLog(@"after submits a block on the main queue : %@", [NSThread currentThread]);

}

- (void)print_0 {
    NSLog(@"this is invoked by -performSelector:withObject:afterDelay:");
}

- (void)print_1 {
    NSLog(@"this is invoked by -performSelectorOnMainThread:withObject:waitUntilDone:");
}

@end

输出结果是:

2021-06-12 17:00:45.444193+0800  before submits a block on the main queue : <NSThread: 0x6000006ec880>{number = 1, name = main}
2021-06-12 17:00:45.444753+0800  after submits a block on the main queue : <NSThread: 0x6000006ec880>{number = 1, name = main}
2021-06-12 17:01:09.531265+0800  this is invoked by -performSelectorOnMainThread:withObject:waitUntilDone:
2021-06-12 17:01:27.739875+0800  invoke th block on the main queue
2021-06-12 17:01:40.501056+0800  this is invoked by -performSelector:withObject:afterDelay:

可以看到,最先被调用的是 -performSelectorOnMainThread:withObject:waitUntilDone:,然后是 dispatch main queue 中的 block,最后才是 performSelector:withObject:afterDelay:

这三种方式最终都是通过 RunLoop 来回调的,它们在 RunLoop 中的回调函数分别是:

更多细节见 RunLoop 总结

参考:

四、GCD 的实现

详见 GCD 的实现

参考

ShannonChenCHN commented 6 years ago

NSOperation 和 NSOperationQueue

示例代码

一、简介

后台异步执行任务一般有 GCD 和 NSOperation 这两种选择。 相对于GCD来说,NSOperaton 提供的是面向对象的方式,可控性更强,并且可以加入操作依赖。NSOperation 和 NSOperationQueue 实际上是在 GCD 的基础上构建的。

二、任务、线程和进程

一个进程可以包含几个不同线程,一个线程可以同时执行多个不同的任务。 主线程一般执行 UI 相关的任务,子线程中执行一些比较耗时的任务,比如读取文件、网络请求。

iOS 中多线程编程的几种方式:

  • POSIX Threads API(pthreads)
  • GCD
  • NSOperation
  • NSThread

三、什么是 NSOperation?

NSOperation是一个抽象的基类,表示一个独立的计算单元,可以为子类提供有用且线程安全的建立状态,优先级,依赖和取消等操作。

NSOperation 的 3 种使用形式

四、NSOperation 和 GCD 的对比

简单来说,GCD 是苹果基于 C 语言开发的,一个用于多核编程的解决方案,主要用于优化应用程序以支持多核处理器以及其他对称多处理系统。而 Operation Queues 则是一个建立在 GCD 的基础之上的,面向对象的解决方案。它使用起来比 GCD 更加灵活,功能也更加强大。

五、NSOperation 的应用场景

很多执行任务类型的案例都很好的运用了NSOperation,包括网络请求,图像压缩,自然语言处理或者其他很多需要返回处理后数据的、可重复的、结构化的、相对长时间运行的任务。

在很多的优秀开源项目中都能看到 NSOperation 的身影,比如 SDWebImage、AFNetworking 等。

六、NSOperationQueue 与 NSOperation 的结合使用

七、如何使用 NSOperation

1. 状态的管理

NSOperation包含了一个十分优雅的状态机来描述每一个操作的执行。

isReady → isExecuting → isFinished

2. 启动、暂停和取消操作

2.1 启动

(1)手动调用 start 方法 我们直接通过调用 start 方法来执行一个 operation ,但是这种方式并不能保证 operation 是异步执行的。NSOperation 类的 isConcurrent 方法的返回值标识了一个 operation 相对于调用它的 start 方法的线程来说是否是异步执行的。在默认情况下,isConcurrent 方法的返回值是 NO ,也就是说会阻塞调用它的 start 方法的线程。

如果我们想要自定义一个并发执行的 operation ,那么我们就必须要编写一些额外的代码来让这个 operation 异步执行。比如,为这个 operation 创建新的线程、调用系统的异步方法或者其他任何方式来确保 start 方法在开始执行任务后立即返回。

(2)添加到 queue 中后自动启动 我们一般是通过将 operation 添加到一个 operation queue 的方式来执行 operation 的,operation 被添加到 queue 中后,就会在队列中自动排队等待执行。

在绝大多数情况下,我们都不需要去实现一个并发的 operation 。如果我们一直是通过将 operation 添加到 operation queue 的方式来执行 operation 的话,我们就完全没有必要去实现一个并发的 operation 。因为,当我们将一个非并发的 operation 添加到 operation queue 后,operation queue 会自动为这个 operation 创建一个线程。因此,只有当我们需要手动地执行一个 operation ,又想让它异步执行时,我们才有必要去实现一个并发的 operation 。

(3) start 方法和 main 方法

start :start 方法是一个 operation 的起点。这个方法的默认实现是更新 operation 的状态并调用 main 方法。这个方法的内部在执行任务前会检查 cancelled 和 finished 的值,以确保任务需要被执行。 所有并发执行的 operation 都必须要重写这个方法,并替换掉 NSOperation 类中的默认实现。需要特别注意的是,在我们重写的 start 方法中一定不要调用 super。我们可以在这里配置任务执行的环境,另外,还要记得追踪 operation 的状态,并且进行合适的状态切换。 在任务执行完毕后,我们需要手动触动 isExecuting 和 isFinished 的 KVO 通知。

main :负责执行 operation 对象中的非并发部分的操作,非必须实现。通常这个方法就是专门用来实现与该 operation 相关联的任务的。尽管我们可以直接在 start 方法中执行我们的任务,但是用 main 方法来实现我们的任务可以使设置代码和任务代码得到分离,从而使 operation 的结构更清晰。 这个方法的默认实现什么都没有做,我们在重写时,不要调用 super。

2.2 暂停和恢复

如果我们想要暂停和恢复执行 operation queue 中的 operation ,可以通过调用 operation queue 的 setSuspended: 方法来实现这个目的。不过需要注意的是,暂停执行 operation queue 并不能使正在执行的 operation 暂停执行,而只是简单地暂停调度新的 operation 。另外,我们并不能单独地暂停执行一个 operation ,除非直接 cancel 掉。

2.3 取消

3. 优先级

通过设置 queuePriority 属性可以控制队列中操作执行的优先级:

queuePriority 属性决定队列中操作相互之间的依赖关系,因此使用 queuePriority 的前提是没有通过 addDependency 方法设置过操作之间的 dependency

4. 依赖性

当一个任务需要在另一个任务执行完后在执行时,可以通过设置任务之间的 dependency 关系来实现。

比如说,对于服务器下载并压缩一张图片的整个过程,你可能会将这个整个过程分为两个操作(可能你还会用到这个网络子过程再去下载另一张图片,然后用压缩子过程去压缩磁盘上的图片)。显然图片需要等到下载完成之后才能被调整尺寸,所以我们定义网络子操作是压缩子操作的依赖,通过代码来说就是:

[resizingOperation addDependency:networkingOperation];
[operationQueue addOperation:networkingOperation];
[operationQueue addOperation:resizingOperation];

注意点:

5. completionBlock

每当一个NSOperation执行完毕或者被取消,它就会调用它的completionBlock属性一次,这提供了一个非常好的方式让你能在视图控制器(View Controller)里或者模型(Model)里加入自己更多自己的代码逻辑。比如说,你可以在一个网络请求操作的completionBlock来处理操作执行完以后从服务器下载下来的数据。

注意:completionBlock 被回调时,不能确保是在主线程,所以需要你自己控制是否回到主线程。

八、如何自定义 NSOperation 子类

示例代码

我们可以通过重写 main 或者 start 方法 来定义自己的 operations 。

使用 main 方法非常简单,开发者不需要管理一些状态属性(例如 isExecuting 和 isFinished),当 main 方法返回的时候,这个 operation 就结束了。这种方式使用起来非常简单,但是灵活性相对重写 start 来说要少一些,因为main方法执行完就认为operation结束了,所以一般可以用来执行同步任务。

如果你希望拥有更多的控制权,或者想在一个操作中可以执行异步任务,那么就重写 start 方法, 但是注意:这种情况下,你必须手动管理操作的状态, 只有当发送 isFinished 的 KVO 消息时,才认为是 operation 结束。

当实现了start方法时,默认会执行start方法,而不执行main方法。

为了让操作队列能够捕获到操作的改变,需要将状态的属性以配合 KVO 的方式进行实现。如果你不使用它们默认的 setter 来进行设置的话,你就需要在合适的时候发送合适的 KVO 消息。 需要手动管理的状态有:

为了能使用操作队列所提供的取消功能,你需要在适当的时机检查 isCancelled 属性。

九、总结

我们应该尽可能地直接使用队列而不是线程,让系统去与线程打交道,而我们只需定义好要调度的任务就可以了。一般情况下,我们也完全不需要去自定义一个并发的 operation ,因为在与 operation queue 结合使用时,operation queue 会自动为非并发的 operation 创建一个线程。Operation Queues 是对 GCD 面向对象的封装,它可以高度定制化,对依赖关系、队列优先级和线程优先级等提供了很好的支持,是我们实现复杂任务调度时的不二之选。

参考

ShannonChenCHN commented 6 years ago

多线程的安全问题

一、简介

1. 什么是线程安全?

线程安全就是指,多线程访问时,采用了加锁机制,当一个线程访问该类的某个数据时,进行保护,其他线程不能进行访问直到该线程读取完,其他线程才可使用。不会出现数据不一致或者数据污染。 线程不安全就是不提供数据访问保护,有可能出现多个线程先后更改数据造成所得到的数据是脏数据。

2. 什么是原子性?

原子(atom)本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意为"不可被中断的一个或一系列操作"。

原子性(atomicity),就是指不被线程调度器中断的操作,同一时间只有一个线程进行操作,若存在多个线程同时操作的话,就存在线程安全的因素了,是非原子性的。

比如 i++; 自增操作在多线程环境下会很容易出现错误,是因为这个操作被编译为汇编代码之后不止一条指令,因此在执行的时候可能执行了一半,就被调度系统打断,去执行别的代码。而单条汇编指令的操作就是上面所说的原子操作(atomic operation),因为 CPU 执行单条指令时是不会被打断的(不能进一步分割)。

二、并发编程中面临的挑战

1. 资源共享

并发编程中许多问题的根源就是在多线程中访问共享资源。资源可以是一个属性、一个对象,通用的内存、网络设备或者一个文件等等。在多线程中任何一个共享的资源都可能是一个潜在的冲突点,你必须精心设计以防止这种冲突的发生。

竞态条件:在多线程里面访问一个共享的资源,如果没有一种机制来确保在线程 A 结束访问一个共享资源之前,线程 B 就不会开始访问该共享资源的话,资源竞争的问题就总是会发生。如果你所写入内存的并不是一个简单的整数,而是一个更复杂的数据结构,可能会发生这样的现象:当第一个线程正在写入这个数据结构时,第二个线程却尝试读取这个数据结构,那么获取到的数据可能是新旧参半或者没有初始化。为了防止出现这样的问题,多线程需要一种互斥的机制来访问共享资源。

线程同步(synchronize):同步就是协同步调,按预定的先后次序进行运行。如:你说完,我再说。这里的同步千万不要理解成那个同时进行,应是指协同、协助、互相配合。线程同步是指多线程通过特定的设置(如互斥量,事件对象,临界区)来控制线程之间的执行顺序(即所谓的同步)也可以说是在线程之间通过同步建立起执行顺序的关系,如果没有同步,那线程之间是各自运行各自的!

线程互斥(mutex):是指对于共享的进程系统资源,在各单个线程访问时的排它性。当有若干个线程都要使用某一共享资源时,任何时刻最多只允许一个线程去使用,其它要使用该资源的线程必须等待,直到占用资源者释放该资源。线程互斥可以看成是一种特殊的线程同步。

2. 互斥锁

互斥访问的意思就是同一时刻,只允许一个线程访问某个特定资源。为了保证这一点,每个希望访问共享资源的线程,首先需要获得一个共享资源的互斥锁,一旦某个线程对资源完成了操作,就释放掉这个互斥锁,这样别的线程就有机会访问该共享资源了。

3. 死锁

互斥锁解决了竞态条件的问题,但很不幸同时这也引入了一些其他问题,其中一个就是死锁。当多个线程在相互等待着对方的结束时,就会发生死锁,这时程序可能会被卡住。

维基百科上关于死锁的介绍:

死锁(英语:deadlock),又译为死结,计算机科学名词。当两个以上的运算单元,双方都在等待对方停止运行,以获取系统资源,但是没有一方提前退出时,就称为死锁。

例如,一个进程 p1占用了显示器,同时又必须使用打印机,而打印机被进程p2占用,p2又必须使用显示器,这样就形成了死锁。 因为p1必须等待p2发布打印机才能够完成工作并发布屏幕,同时p2也必须等待p1发布显示器才能完成工作并发布打印机,形成循环等待的死锁。

死锁的四个条件是:

看看下面的代码,它交换两个变量的值:

void swap(A, B)
{
    lock(lockA);
    lock(lockB);
    int a = A;
    int b = B;
    A = b;
    B = a;
    unlock(lockB);
    unlock(lockA);
}

大多数时候,这能够正常运行。但是当两个线程使用相反的值来同时调用上面这个方法时:

swap(X, Y); // 线程 1
swap(Y, X); // 线程 2

此时程序可能会由于死锁而被终止。线程 1 获得了 X 的一个锁,线程 2 获得了 Y 的一个锁。 接着它们会同时等待另外一把锁,但是永远都不会获得。

所以,你在线程之间共享的资源越多,你使用的锁也就越多,同时程序被死锁的概率也会变大。这也是为什么我们需要尽量减少线程间资源共享,并确保共享的资源尽量简单的原因之一

关于死锁的实际案例分析见这里

参考:

4. 资源饥饿(Starvation)

我们知道多线程执行中有线程优先级这个东西,优先级高的线程能够插队并优先执行,这样如果优先级高的线程一直抢占优先级低线程的资源,导致低优先级线程无法得到执行,这就是饥饿。

当然还有一种饥饿的情况,一个线程一直占着一个资源不放而导致其他线程得不到执行,与死锁不同的是饥饿在以后一段时间内还是能够得到执行的,如那个占用资源的线程结束了并释放了资源。

5. 优先级反转

背景知识:

(1)什么是优先级反转

优先级反转是指程序在运行时低优先级的任务阻塞了高优先级的任务,有效的反转了任务的优先级。由于 GCD 提供了拥有不同优先级的后台队列,甚至包括一个 I/O 队列,所以我们最好了解一下优先级反转的可能性。

百度百科的定义:

当一个高优先级任务(A)通过信号量机制访问共享资源时,该信号量已被一低优先级任务(C)占有,因此造成高优先级任务被许多具有较低优先级任务(B1,B2...)阻塞,实时性难以得到保证。(例子中的任务优先级:A > B1,B2... > C)

(2)优先级反转是怎么发生的

高优先级和低优先级的任务之间共享资源时,就可能发生优先级反转。当低优先级的任务获得了共享资源的锁时,该任务应该迅速完成,并释放掉锁,这样高优先级的任务就可以在没有明显延时的情况下继续执行。然而高优先级任务会在低优先级的任务持有锁的期间被阻塞。如果这时候有一个中优先级的任务(该任务不需要那个共享资源),那么它就有可能会抢占低优先级任务而被执行,因为此时高优先级任务是被阻塞的,所以中优先级任务是目前所有可运行任务中优先级最高的。此时,中优先级任务就会阻塞着低优先级任务,导致低优先级任务不能释放掉锁,这也就会引起高优先级任务一直在等待锁的释放。

(3)如何解决

objc.io中给出的建议是:通常就是不要使用不同的优先级。通常最后你都会以让高优先级的代码等待低优先级的代码来解决问题。当你使用 GCD 时,总是使用默认的优先级队列(直接使用,或者作为目标队列)。如果你使用不同的优先级,很可能实际情况会让事情变得更糟糕。

百度百科中给出的建议:

解决优先级翻转问题有优先级天花板(priority ceiling)和优先级继承(priority inheritance)两种办法。

优先级天花板是当任务申请某资源时, 把该任务的优先级提升到可访问这个资源的所有任务中的最高优先级, 这个优先级称为该资源的优先级天花板。这种方法简单易行, 不必进行复杂的判断, 不管任务是否阻塞了高优先级任务的运行, 只要任务访问共享资源都会提升任务的优先级。

优先级继承是当任务A 申请共享资源S 时, 如果S正在被任务C 使用,通过比较任务C 与自身的优先级,如发现任务C 的优先级小于自身的优先级, 则将任务C的优先级提升到自身的优先级, 任务C 释放资源S 后,再恢复任务C 的原优先级。这种方法只在占有资源的低优先级任务阻塞了高优先级任务时才动态的改变任务的优先级,如果过程较复杂, 则需要进行判断。

三、atomic 和 nonatomic

共享状态,多线程共同访问某个对象的property,在iOS编程里是很普遍的使用场景,我们就从Property的多线程安全说起。

我们可以简单的将property分为值类型和对象类型,值类型是指primitive type,包括int, long, bool等非对象类型,另一种是对象类型,声明为指针,可以指向某个符合类型定义的内存区域。

@property (atomic, strong) NSString* userName;

当我们访问属性 userName 的时候,访问的有可能是 userName 本身,也有可能是 userName 所指向的内存区域。

当我们讨论多线程安全的时候,其实是在讨论多个线程同时访问一个内存区域的安全问题。针对同一块区域,我们有两种操作,读(load)和写(store),读和写同时发生在同一块区域的时候,就有可能出现多线程不安全。

1. 多线程是如何同时访问内存的?

从上图中可以看出,我们只有一个地址总线,一个内存。即使是在多线程的环境下,也不可能存在两个线程同时访问同一块内存区域的场景,内存的访问一定是通过一个地址总线串行排队访问的。

几个结论:

2. atomic 和 nonatomic 的区别是什么?atomic 就一定是安全的吗?

atomic 的作用只是给 getter 和 setter 加了个锁,atomic 只能保证代码进入 getter 或者 setter 函数内部时是安全的,一旦出了 gette r和 setter,多线程安全只能靠程序员自己保障了。所以atomic属性和使用property的多线程安全并没什么直接的联系。另外,atomic由于加锁也会带来一些性能损耗,所以我们在编写iOS代码的时候,一般声明property为nonatomic,在需要做多线程安全的场景,自己去额外加锁做同步。

所以,我们更倾向于使用基于队列的并发编程 API :GCD 和 operation queue 。它们通过集中管理一个被大家协同使用的线程池,来解决上面遇到的问题。

3. atomic 的实现原理

atomic 的作用只是给 getter 和 setter 加了个锁。

nonatomic 属性的 getter 和 setter 方法:

@property(nonatomic, retain) NSString *userName;

//Generates roughly
- (NSString *) userName {
    return _userName;
}

- (void)setUserName:(NSString *)userName {
    [userName retain];
    [_userName release];
    _userName = userName;
}

atomic 属性的 getter 和 setter 方法:

@property(retain) NSString *userName; // 默认是 atomic

//Generates roughly
- (NSString *)userName {
    NSString *retval = nil;
    @synchronized(self) {
        retval = [[userName retain] autorelease];
    }
    return retval;
}

- (void)setUserName:(NSString *)userName {
    @synchronized(self) {
        [userName retain];
        [_userName release];
        _userName = userName;
    }
}

四、如何做到多线程安全?

做到多线程安全的关键是 atomicity(原子性),只要做到原子性,小到一个primitive type变量的访问,大到一长段代码逻辑的执行,原子性能保证代码串行的执行,能保证代码执行到一半的时候,不会有另一个线程介入。

原子性是个相对的概念,它所针对的对象,粒度可大可小。

1. 加锁

我们在做多线程安全的时候,并不是通过给 property 加 atomic 关键字来保障安全,而是将 property 声明为 nonatomic(nonatomic没有getter,setter的锁开销),然后自己对操作属性/数据的代码片段进行加锁。值得注意的是,读和写都需要加锁。

iOS 中有 8 中锁(详见):

  • 互斥锁 NSLock
  • 互斥锁 @synchronized
  • 信号量 dispatch_semaphore
  • 条件锁 NSCondition
  • 条件锁 NSConditionLock
  • 递归锁 NSRecursiveLock
  • 自旋锁 OSSpinLock
  • 互斥锁 pthread_mutex

比如下面这段代码就是通过加锁(而不是声明 atomic 关键字)来保证对属性 stringA 的操作是原子性的:

    //thread A write
    [_lock lock];
    for (int i = 0; i < 100000; i ++) {
    if (i % 2 == 0) {
        self.stringA = @"a very long string";
    } else {
        self.stringA = @"string";
    }
        NSLog(@"Thread A: %@\n", self.stringA);
    }
    [_lock unlock];
    //thread B read
    [_lock lock];
    if (self.stringA.length >= 10) {
        NSString* subStr = [self.stringA substringWithRange:NSMakeRange(0, 10)];
    }
    [_lock unlock];

2. Atomic Operations

其实除了各种锁之外,iOS上还有另一种办法来获取原子性,使用Atomic Operations,相比锁的损耗要小一个数量级左右,在一些追求高性能的第三方Framework代码里可以看到这些Atomic Operations的使用(YYWebImage 中的 _YYWebImageSetter 类就用到了 OSAtomicIncrement32() 函数)。

Atomic Operation只能应用于32位或者64位的数据类型,在多线程使用NSString或者NSArray这类对象的场景,还是得使用锁。

大部分的Atomic Operation都有OSAtomicXXX,OSAtomicXXXBarrier两个版本,Barrier就是前面提到的memory barrier,在多线程多个变量之间存在依赖的时候使用Barrier的版本,能够保证正确的依赖顺序。

六、总结

1. 多线程安全比多线程性能更重要

对于平时编写应用层多线程安全代码,推荐使用更易用的 @synchronized,NSLock,或者dispatch_semaphore_t,多线程安全比多线程性能更重要,应该在前者得到充分保证,犹有余力的时候再去追求后者。

2. 尽量避免多线程的设计

并发编程中,无论是看起来多么简单的 API ,它们所能产生的问题会变得非常的难以观测,而且要想调试这类问题往往也都是非常困难的。我们应该尽可能避免盲目使用多线程技术,而不是去追求高明的使用锁的技能。

3. 尽可能地把具体的线程控制交给系统去处理

正如 Concurrent Programming: APIs and Challenges 中所说的:

我们建议采纳的安全模式是这样的:从主线程中提取出要使用到的数据,并利用一个操作队列在后台处理相关的数据,最后回到主队列中来发送你在后台队列中得到的结果。使用这种方式,你不需要自己做任何锁操作,这也就大大减少了犯错误的几率。

不论使用 pthread 还是 NSThread 来直接对线程操作,都是相对糟糕的编程体验,这种方式并不适合我们以写出良好代码为目标的编码精神。

直接使用线程可能会引发的一个问题是,如果你的代码和所基于的框架代码都创建自己的线程时,那么活动的线程数量有可能以指数级增长。这在大型工程中是一个常见问题。例如,在 8 核 CPU 中,你创建了 8 个线程来完全发挥 CPU 性能。然而在这些线程中你的代码所调用的框架代码也做了同样事情(因为它并不知道你已经创建的这些线程),这样会很快产生成成百上千的线程。代码的每个部分自身都没有问题,然而最后却还是导致了问题。使用线程并不是没有代价的,每个线程都会消耗一些内存和内核资源。

参考

ShannonChenCHN commented 6 years ago

多线程安全问题——锁

示例代码

一、简介

二、iOS 保证线程安全的几种方式

iOS 保证线程安全的几种方式有:

1. NSLock

_lock = [[NSLock alloc] init];

- (void)testMethod {
    [lock lock];
    self.name = @"another name";
    [lock unlock];
}

NSLock 遵循 NSLocking 协议,lock 方法是加锁,unlock 方法是解锁,tryLock 方法是尝试加锁,如果失败的话返回 NO,lockBeforeDate: 是在指定Date之前尝试加锁,如果在指定时间之前都不能加锁,则返回NO。

使用 lock 方法添加的互斥锁会使得线程阻塞,阻塞的过程又分两个阶段,第一阶段是会先空转,可以理解成跑一个 while 循环,不断地去申请加锁,在空转一定时间之后,线程会进入 waiting 状态,此时线程就不占用CPU资源了,等锁可用的时候,这个线程会立即被唤醒。

tryLock 方法并不会阻塞线程。[lock tryLock] 能加锁返回 YES,不能加锁返回 NO,然后都会执行后续代码。

2. @synchronized

- (void)testMethod {
    @synchronized(self) {
        self.name = @"another name";
    }
}

@synchronized(object) 指令使用的 object 为该锁的唯一标识,只有当标识相同时,才满足互斥,所以如果线程 2 中的 @synchronized(self) 改为@synchronized(self.view),则线程2就不会被阻塞。

@synchronized 指令实现锁的优点就是我们不需要在代码中显式的创建锁对象,便可以实现锁的机制。但作为一种预防措施,@synchronized 块会隐式的添加一个异常处理例程来保护代码,该处理例程会在异常抛出的时候自动的释放互斥锁。@synchronized 还有一个好处就是不用担心忘记解锁了。

如果在 @sychronized(object){} 内部 object 被释放或被设为 nil,从测试的结果来看,的确没有问题,但如果 object 一开始就是 nil,则失去了锁的功能。不过虽然 nil 不行,但 @synchronized([NSNull null]) 是完全可以的。

@synchronized{} 的实现原理

实际上,@synchronized{} 的代码转成汇编后就变成这样了:

_objc_sync_enter
...// {} 中的代码
_objc_sync_exit

objc_sync_enter 的实现:

// Begin synchronizing on 'obj'. 
// Allocates recursive mutex associated with 'obj' if needed.
// Returns OBJC_SYNC_SUCCESS once lock is acquired.  
int objc_sync_enter(id obj)
{
    int result = OBJC_SYNC_SUCCESS;

    if (obj) {
        SyncData* data = id2data(obj, ACQUIRE);
        assert(data);
        data->mutex.lock();
    } else {
        // @synchronized(nil) does nothing
        if (DebugNilSync) {
            _objc_inform("NIL SYNC DEBUG: @synchronized(nil); set a breakpoint on objc_sync_nil to debug");
        }
        objc_sync_nil();
    }

    return result;
}

结论:

一些建议:

延伸阅读

3. dispatch_semaphore

dispatch_semaphore 是 GCD 用来同步的一种方式,与他相关的有三个函数:

一个 dispatch_semaphore_wait() 函数必须要对应一个 dispatch_semaphore_signal() 函数,看起来像 NSLock 的 lockunlock。 两者的区别在于,NSLock 所限制的是一次只能一个线程访问被保护的临界区,而 dispatch_semaphore 有信号量这个参数,如果 dispatch_semaphore 的信号量初始值为 x ,则可以有 x 个线程同时访问被保护的临界区。 所以,也可以这样理解,dispatch_semaphore 作为信号量,当信号总量设为 1 时也可以当作锁来。

dispatch_semaphore_t signal = dispatch_semaphore_create(1);   // 一次可以有 1 条线程同时访问
dispatch_time_t overTime = dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC); // 超时时长为 10 秒

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    dispatch_semaphore_wait(signal, overTime);
    sleep(3); // 3 秒后开锁
    NSLog(@"线程1, %@", [NSThread currentThread]);
    dispatch_semaphore_signal(signal);
});

4. NSCondition

NSCondition 的对象实际上作为一个锁和一个线程检查器,锁上之后可以保护任务中访问的资源,线程检查器可以根据条件决定是否继续执行任务。当条件不满足时,当前线程就会被阻塞。等到其它线程中的同一个锁执行 signal 或者 broadcast 方法时,线程被唤醒,再根据条件决定是否继续运行之后的任务。

几个常用方法:

NSCondition 的使用步骤:

  1. 锁住 condition 对象。
  2. 根据一个布尔条件,来决定是否要执行后面的任务。
  3. 如果这个布尔条件为假,就调用 condition 对象的 wait 方法或者 waitUntilDate: 方法来阻塞当前线程。一旦 wait 方法返回了,当前线程就不再阻塞,接着回到步骤 2,重新检查布尔条件。
  4. 如果这个布尔条件为真,就接着执行后面的任务。
  5. 如果需要的话,更新一些影响条件判断的参数或者发送信号量给 condition 对象。
  6. 任务完成,不再锁住 condition 对象。

使用伪代码来表示的话,就是下面这样:

lock the condition
while (!(boolean_predicate)) {
    wait on condition
}
do protected work
(optionally, signal or broadcast the condition again or change a predicate value)
unlock the condition

当一个线程在等待一个条件时,也就是调用 wait 方法时,condition 对象会解开之前加上的锁,同时阻塞当前线程。等到这个 condition 对象被信号量唤醒时,当前线程也就不再被阻塞了。这个 condition 对象在 waitwaitUntilDate: 方法返回前,又会重新加上锁。因此,我们可以看做当前线程一直是安全的。

    NSCondition *lock = [[NSCondition alloc] init];
    NSMutableArray *array = [[NSMutableArray alloc] init];

    //线程1
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        [lock lock];
        while (array.count == 0) {
            NSLog(@"线程1处于等待状态, %@", [NSThread currentThread]);
            [lock wait];
        }
        [array removeAllObjects];
        NSLog(@"array removeAllObjects");
        [lock unlock];
    });

    //线程2
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        sleep(1);//以保证让线程2的代码后执行
        [lock lock];
        NSLog(@"线程2, %@", [NSThread currentThread]);
        [array addObject:@1];
        NSLog(@"array addObject:@1");
        [lock signal];
        [lock unlock];
    });

5. NSConditionLock

NSConditionLock 是一种条件锁,NSConditionLock 和 NSLock 一样,都遵循 NSLocking 协议,方法也很类似,只是多了一个 condition 属性,以及每个操作都多了一个更新 condition 属性的方法。

只有 condition 参数与初始化时候的 condition 相等,lock 才能正确进行加锁操作。而 unlockWithCondition: 并不是当 Condition 符合条件时才解锁,而是解锁之后,修改 Condition 的值。

    NSConditionLock *lock = [[NSConditionLock alloc] initWithCondition:0];

    //线程1
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        NSLog(@"线程1准备加锁");
        [lock lockWhenCondition:1];
        NSLog(@"线程1");
        sleep(2);
        [lock unlock];
    });

    //线程2
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        sleep(1);   // 以保证让线程2的代码后执行
        if ([lock tryLockWhenCondition:0]) {
            NSLog(@"线程2");
            [lock unlockWithCondition:2];
            NSLog(@"线程2解锁成功");
        } else {
            NSLog(@"线程2尝试加锁失败");
        }
    });

    //线程3
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        sleep(2);       // 以保证让线程3的代码后执行
        if ([lock tryLockWhenCondition:2]) {
            NSLog(@"线程3");
            [lock unlock];
            NSLog(@"线程3解锁成功");
        } else {
            NSLog(@"线程3尝试加锁失败");
        }
    });

    //线程4
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        sleep(3);        // 以保证让线程4的代码后执行
        if ([lock tryLockWhenCondition:2]) {
            NSLog(@"线程4");
            [lock unlockWithCondition:1];
            NSLog(@"线程4解锁成功");
        } else {
            NSLog(@"线程4尝试加锁失败");
        }
    });

6. NSRecursiveLock

NSRecursiveLock 是递归锁,他和 NSLock 的区别在于,NSRecursiveLock 可以在一个线程中重复加锁(反正单线程内任务是按顺序执行的,不会出现资源竞争问题),NSRecursiveLock 会记录上锁和解锁的次数,当二者平衡的时候,才会释放锁,其它线程才可以上锁成功。

NSRecursiveLock *lock = [[NSRecursiveLock alloc] init];

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{

    static void (^RecursiveBlock)(int);
    RecursiveBlock = ^(int value) {
        [lock lock];
        if (value > 0) {
            NSLog(@"value:%d", value);
            RecursiveBlock(value - 1);
        }
        [lock unlock];
    };
    RecursiveBlock(2);
});

如上面的示例,如果用 NSLock 的话,lock 先锁上了,但未执行解锁的时候,就会进入递归的下一层,而再次请求上锁,阻塞了该线程,线程被阻塞了,自然后面的解锁代码不会执行,而形成了死锁。而 NSRecursiveLock 递归锁就是为了解决这个问题。

7. 自旋锁 OSSpinLock

OSSpinLock 是一种自旋锁,也只有加锁,解锁,尝试加锁三个方法。NSLock 请求加锁失败的话,会先轮询,但一秒过后便会使线程进入 waiting 状态,等待唤醒。而 OSSpinLock 会一直轮询,等待时会消耗大量 CPU 资源,不适用于较长时间的任务。

使用示例:

__block OSSpinLock theLock = OS_SPINLOCK_INIT;
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    OSSpinLockLock(&theLock);
    NSLog(@"线程1");
    sleep(10);
    OSSpinLockUnlock(&theLock);
    NSLog(@"线程1解锁成功");
});

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    sleep(1);
    OSSpinLockLock(&theLock);
    NSLog(@"线程2");
    OSSpinLockUnlock(&theLock);
});

拿上面的输出结果和上文 NSLock 的输出结果做对比,会发现 sleep(10) 的情况,OSSpinLock 中的“线程 2”并没有和”线程 1解锁成功“在一个时间输出,而是有一点时间间隔,而 NSLock 这里是同一时间输出,所以 OSSpinLock 一直在做着轮询,而不是像 NSLock 一样先轮询,再 waiting 等唤醒。

OSSpinLock 自旋锁,性能最高的锁。原理很简单,就是一直 do while 忙等。它的缺点是当等待时会消耗大量 CPU 资源,所以它不适用于较长时间的任务。 不过最近YY大神在自己的博客不再安全的 OSSpinLock中说明了OSSpinLock已经不再安全,在 macOS 10.12 中已经被 deprecate 了。

8. pthread_mutex

pthread pthread_mutex 是 C 语言下多线程加互斥锁的方式,使用时需要通过 #import <pthread.h> 导入头文件。

几个相关函数:

8.1 普通锁

跟 NSLock 的效果类似:

void testPthreadMutexNormalLock() {

    __block pthread_mutex_t theLock;
    pthread_mutex_init(&theLock, NULL); // NULL 代表锁类型为 PTHREAD_MUTEX_NORMAL

    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        pthread_mutex_lock(&theLock);
        NSLog(@"需要线程同步的操作1 开始");
        sleep(3);
        NSLog(@"需要线程同步的操作1 结束");
        pthread_mutex_unlock(&theLock);

    });

    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        sleep(1);
        pthread_mutex_lock(&theLock);
        NSLog(@"需要线程同步的操作2");
        pthread_mutex_unlock(&theLock);

    });
}
8.2 递归锁
void testPthreadMutexRecursiveLock() {
    __block pthread_mutex_t theLock;

    pthread_mutexattr_t attr;
    pthread_mutexattr_init(&attr);
    pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE); // 递归锁
    pthread_mutex_init(&theLock, &attr);
    pthread_mutexattr_destroy(&attr);

    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{

        static void (^RecursiveMethod)(int);

        RecursiveMethod = ^(int value) {

            pthread_mutex_lock(&theLock);
            if (value > 0) {

                NSLog(@"value = %d", value);
                sleep(1);
                RecursiveMethod(value - 1);
            }
            pthread_mutex_unlock(&theLock);
        };

        RecursiveMethod(5);
    });

}

这是 pthread_mutex 为了防止在递归的情况下出现死锁而出现的递归锁。作用和 NSRecursiveLock 递归锁类似。 如果使用 pthread_mutex_init(&theLock, NULL); 初始化锁的话,上面的代码会出现死锁现象。如果使用递归锁的形式,就没有问题。

三、总结

ibireme 在 不再安全的 OSSpinLock 中,通过 benchmark (https://github.com/ibireme/tmp/blob/master/iOSLockBenckmark/iOSLockBenckmark/ViewController.m)对不同的锁做出了性能对比分析

值得注意的是,OSSpinLock 性能最高,但它已经不再安全,如果一个低优先级的线程获得锁并访问共享资源,这时一个高优先级的线程也尝试获得这个锁,由于它会处于轮询的忙等状态从而占用大量 CPU。此时低优先级线程无法与高优先级线程争夺 CPU 时间,从而导致任务迟迟完不成、无法释放 lock。这样就很容易导致优先级反转的问题。

另外,如果不考虑性能,只是图个方便的话,那就使用 @synchronized。

最后用一句来解释线程安全怎么解决:解决线程安全的问题,无非就是加锁,等待(阻塞),解锁。

参考

ShannonChenCHN commented 6 years ago

Run Loops

关于 Run Loop,我们大概需要知道三点:

  • 为什么需要 Run Loop?Run Loop 是用来干什么的?
  • Run Loop 的原理机制
  • 平时日常开发中,什么时候会用到 Run Loop?

一、简介

1. 什么是 Runloop?

Runloop 是什么?Runloop 还是比较顾名思义的一个东西,说白了就是一种循环,只不过它这种循环比较高级。一般的 while 循环会导致 CPU 进入忙等待状态,而 Runloop 则是一种“闲”等待,这部分可以类比 Linux 下的 epoll。当没有事件时,Runloop 会进入休眠状态,有事件发生时, Runloop 会去找对应的 Handler 处理事件。Runloop 可以让线程在需要做事的时候忙起来,不需要的话就让线程休眠。

举个例子,一个应用开始运行以后,如果不对它进行任何操作,这个应用就像静止了一样,不会自发的有任何动作发生,但是如果我们点击界面上的一个按钮,这个时候就会有对应的按钮响应事件发生。给我们的感觉就像应用一直处于随时待命的状态,在没人操作的时候它一直在休息,在让它干活的时候,它就能立刻响应。其实,这就是 Runloop 的功劳。

2. 为什么需要 Runloop?

一般来说一个线程一次只能执行一个任务,任务执行完成这个线程就会退出。 某些情况下我们需要这个线程一直运行着,不管有没有任务执行(比方说App的主线程),所以需要一种机制来维持线程的生命周期,通常的代码逻辑是这样的:

function loop() {
    initialize();
    do {
        var message = get_next_message();
        process_message(message);
    } while (message != quit);
}

这种模型通常被称作 Event Loop。 Event Loop 在很多系统和框架里都有实现,比如 Node.js 的事件处理,比如 Windows 程序的消息循环,再比如 OSX/iOS 里的 RunLoop,安卓里面的 Looper 机制。实现这种模型的关键点在于:如何管理事件/消息,如何让线程在没有处理消息时休眠以避免资源占用、在有消息到来时立刻被唤醒。

3. iOS 中 Runloop

RunLoop 实际上就是一个对象,这个对象管理了其需要处理的事件和消息,并提供了一个入口函数来执行上面 Event Loop 的逻辑。线程执行了这个函数后,就会一直处于这个函数内部 “接受消息->等待->处理” 的循环中,直到这个循环结束(比如传入 quit 的消息),函数返回。

OSX/iOS 系统中,提供了两个这样的对象:NSRunLoop 和 CFRunLoopRef。 CFRunLoopRef 是在 CoreFoundation 框架内的,它提供了纯 C 函数的 API,所有这些 API 都是线程安全的。 NSRunLoop 是基于 CFRunLoopRef 的封装,提供了面向对象的 API,但是这些 API 不是线程安全的。

二、RunLoop 与线程的关系

一些线程执行的任务是一条直线,起点到终点;而另一些线程要干的活则是一个圆环,不断循环,直到通过某种方式将它终止。比如,简单的 Hello World 命令行程序就是一种直线执行的线程,一旦执行完毕,它的生命周期便结束了,像昙花一现那样;而像 iOS 应用的主线程那样的圆形线程 ,一直运行直到退出应用。 在 iOS 中,圆形的线程就是通过 Runloop 不停的循环实现的。

实际上,run loop和线程是紧密相连的,可以这样说run loop是为了线程而生,没有线程,它就没有存在的必要。Run loops是线程的基础架构部分,Cocoa和CoreFundation都提供了run loop对象方便配置和管理线程的run loop。每个线程,包括程序的主线程(main thread)都有与之相应的run loop对象。

主线程的 run loop 默认是启动的。对其它线程来说,run loop 默认是没有启动的。另外,苹果不允许直接创建 RunLoop,不过我们可以通过:

NSRunLoop *runloop = [NSRunLoop currentRunLoop];

来获取到当前线程的 run loop。

从 CFRunLoopRef 的源码可以看出,线程和 RunLoop 之间是一一对应的,其关系是保存在一个全局的 Dictionary 里。线程刚创建时并没有 RunLoop,如果你不主动获取,那它一直都不会有。RunLoop 的创建是发生在第一次获取时,RunLoop 的销毁是发生在线程结束时。你只能在一个线程的内部获取其 RunLoop(主线程除外)。

运行 App,点击暂停按钮,我们可以看到除了主线程中的 Run Loop 之外,还有一个叫做 com.apple.uikit.eventfetch-thread (8) 的线程中,从名字上看上去是专门处理事件的线程,它也有一个 Run Loop 调用了 mach_msg 进入内核态进行休眠:

image

三、RunLoop 的构成

在 CoreFoundation 里面关于 RunLoop 有5个类:

他们的关系如下:

一个 Thread 包含一个 CFRunLoop,一个 RunLoop 包含若干个 Mode,每个 Mode 又包含若干个 Source/Timer/Observer。每次调用 RunLoop 的主函数时,只能指定其中一个 Mode,这个Mode被称作 CurrentMode。如果需要切换 Mode,只能退出 Loop,再重新指定一个 Mode 进入。

上面的 Source/Timer/Observer 被统称为 mode item,一个 item 可以被同时加入多个 mode。但一个 item 被重复加入同一个 mode 时是不会有效果的。如果一个 mode 中一个 item 都没有,则 RunLoop 会直接退出,不进入循环。

1. RunLoop 的 Mode

CFRunLoopMode 和 CFRunLoop 的结构大致如下:

struct __CFRunLoopMode {
    CFStringRef _name;            // Mode Name, 例如 @"kCFRunLoopDefaultMode"
    CFMutableSetRef _sources0;    // Set
    CFMutableSetRef _sources1;    // Set
    CFMutableArrayRef _observers; // Array
    CFMutableArrayRef _timers;    // Array
    ...
};

struct __CFRunLoop {
    CFMutableSetRef _commonModes;     // Set
    CFMutableSetRef _commonModeItems; // Set<Source/Observer/Timer>
    CFRunLoopModeRef _currentMode;    // Current Runloop Mode
    CFMutableSetRef _modes;           // Set
    ...
};

_modes:用来保存多个 __CFRunLoopMode _commonModes:一个 Mode 可以将自己标记为”Common”属性(通过将其 ModeName 添加到 RunLoop 的 “commonModes” 中)。每当 RunLoop 的内容发生变化时,RunLoop 都会自动将 _commonModeItems 里的 Source/Observer/Timer 同步到具有 “Common” 标记的所有Mode里。

APP 启动后系统默认注册了5个Mode:

苹果公开提供的 Mode 有两个:kCFRunLoopDefaultMode (NSDefaultRunLoopMode) 和 UITrackingRunLoopMode,你可以用这两个 Mode Name 来操作其对应的 Mode。

同时苹果还公开提供了一个操作 Common 标记的字符串:kCFRunLoopCommonModes (NSRunLoopCommonModes),你可以用这个字符串来操作 Common Items,或标记一个 Mode 为 “Common”。

2. CFRunLoopTimer

CFRunLoopTimerRef 是基于时间的触发器,它和 NSTimer 是toll-free bridged 的,可以混用。其包含一个时间长度和一个回调(函数指针)。当其加入到 RunLoop 时,RunLoop会注册对应的时间点,当时间点到时,RunLoop会被唤醒以执行那个回调。

3. CFRunLoopSource

CFRunLoopSourceRef 是事件产生的地方。Source有两个版本:Source0 和 Source1。

4. CFRunLoopObserver

我们可以通过如下 API 注册 observer:

CF_EXPORT void CFRunLoopAddObserver(CFRunLoopRef rl, CFRunLoopObserverRef observer, CFRunLoopMode mode);

每次 loop 只会以一种 mode 运行,以该 mode 运行的时候,就只执行和该 mode 相关的任务,只通知该 mode 注册过的 observer。

CFRunLoopObserverRef 是观察者,每个 Observer 都包含了一个回调(函数指针),当 RunLoop 的状态发生变化时,观察者就能通过回调接受到这个变化。可以观测的时间点有以下几个:

typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
    kCFRunLoopEntry         = (1UL << 0), // 即将进入Loop
    kCFRunLoopBeforeTimers  = (1UL << 1), // 即将处理 Timer
    kCFRunLoopBeforeSources = (1UL << 2), // 即将处理 Source
    kCFRunLoopBeforeWaiting = (1UL << 5), // 即将进入休眠
    kCFRunLoopAfterWaiting  = (1UL << 6), // 刚从休眠中唤醒
    kCFRunLoopExit          = (1UL << 7), // 即将退出Loop
};

四、Runloop 的运行逻辑

下面是官方文档中的描述:

The Run Loop Sequence of Events

Each time you run it, your thread’s run loop processes pending events and generates notifications for any attached observers. The order in which it does this is very specific and is as follows:

  1. Notify observers that the run loop has been entered.
  2. Notify observers that any ready timers are about to fire.
  3. Notify observers that any input sources that are not port based are about to fire.
  4. Fire any non-port-based input sources that are ready to fire.
  5. If a port-based input source is ready and waiting to fire, process the event immediately. Go to step 9.
  6. Notify observers that the thread is about to sleep.
  7. Put the thread to sleep until one of the following events occurs:
    • An event arrives for a port-based input source.
    • A timer fires.
    • The timeout value set for the run loop expires.
    • The run loop is explicitly woken up.
  8. Notify observers that the thread just woke up.
  9. Process the pending event.
    • If a user-defined timer fired, process the timer event and restart the loop. Go to step 2.
    • If an input source fired, deliver the event.
    • If the run loop was explicitly woken up but has not yet timed out, restart the loop. Go to step 2.
  10. Notify observers that the run loop has exited.

简化版的核心逻辑:

__CFRunLoopRun() {
  while (alive) {
    performTask() //执行任务
    callout_to_observer() //通知外部观察者
    sleep() //休眠
  }
}

performTask() {
  __CFRunLoopDoBlocks(rl, rlm);  // 执行 CFRunLoopPerformBlock() 插入的 block
  __CFRunLoopDoSources0(rl, rlm, stopAfterHandle); // 执行 source0
  __CFRunLoopDoSource1(rl, rlm, rls, msg, msg->msgh_size, &reply); // 执行 source1
  __CFRunLoopDoTimers(rl, rlm, mach_absolute_time()); // 执行注册的 timer,比如 NSTimer
  _dispatch_main_queue_callback_4CF(msg); // 执行加入到 dispatch_main_queue 中的 block
}

callout_to_observer() {
  __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeTimers); // 通知 Observer 即将调用 DoTimers
  __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeSources); // 通知 Observer 即将处理 Sources
  DoObservers-Activity... // 其他事件
}

sleep() {
  if (__CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), poll ? 0 : TIMEOUT_INFINITY)) {
    goto handle_msg;
  }
}

CFRunLoop 核心部分源码解读:

    /// 1. 通知 Observers: RunLoop 即将进入 loop。
    __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopEntry);

    /// 内部函数,进入loop
    __CFRunLoopRun(runloop, currentMode, seconds, returnAfterSourceHandled) {

        Boolean sourceHandledThisLoop = NO;
        int retVal = 0;
        do {

            /// 2. 通知 Observers: RunLoop 即将触发 Timer 回调。
            __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeTimers);
            /// 3. 通知 Observers: RunLoop 即将触发 Source0 (非port) 回调。
            __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeSources);
            /// 执行被加入的block
            __CFRunLoopDoBlocks(runloop, currentMode);

            /// 4. RunLoop 触发 Source0 (非port) 回调。
            sourceHandledThisLoop = __CFRunLoopDoSources0(runloop, currentMode, stopAfterHandle);
            /// 执行被加入的block
            __CFRunLoopDoBlocks(runloop, currentMode);

            /// 5. 如果有 Source1 (基于port) 处于 ready 状态,直接处理这个 Source1 然后跳转去处理消息。
            if (__Source0DidDispatchPortLastTime) {
                Boolean hasMsg = __CFRunLoopServiceMachPort(dispatchPort, &msg)
                if (hasMsg) goto handle_msg;
            }

            /// 通知 Observers: RunLoop 的线程即将进入休眠(sleep)。
            if (!sourceHandledThisLoop) {
                __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeWaiting);
            }

            /// 7. 调用 mach_msg 等待接受 mach_port 的消息。线程将进入休眠, 直到被下面某一个事件唤醒。
            /// • 一个基于 port 的Source 的事件。
            /// • 一个 Timer 到时间了
            /// • RunLoop 自身的超时时间到了
            /// • 被其他什么调用者手动唤醒
            __CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort) {
                mach_msg(msg, MACH_RCV_MSG, port); // thread wait for receive msg
            }

            /// 8. 通知 Observers: RunLoop 的线程刚刚被唤醒了。
            __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopAfterWaiting);

            /// 收到消息,处理消息。
            handle_msg:

            /// 9.1 如果一个 Timer 到时间了,触发这个Timer的回调。
            if (msg_is_timer) {
                __CFRunLoopDoTimers(runloop, currentMode, mach_absolute_time())
            } 

            /// 9.2 如果有dispatch到main_queue的block,执行block。
            else if (msg_is_dispatch) {
                __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(msg);
            } 

            /// 9.3 如果一个 Source1 (基于port) 发出事件了,处理这个事件
            else {
                CFRunLoopSourceRef source1 = __CFRunLoopModeFindSourceForMachPort(runloop, currentMode, livePort);
                sourceHandledThisLoop = __CFRunLoopDoSource1(runloop, currentMode, source1, msg);
                if (sourceHandledThisLoop) {
                    mach_msg(reply, MACH_SEND_MSG, reply);
                }
            }

            /// 执行加入到Loop的block
            __CFRunLoopDoBlocks(runloop, currentMode);

            if (sourceHandledThisLoop && stopAfterHandle) {
                /// 进入loop时参数说处理完事件就返回。
                retVal = kCFRunLoopRunHandledSource;
            } else if (timeout) {
                /// 超出传入参数标记的超时时间了
                retVal = kCFRunLoopRunTimedOut;
            } else if (__CFRunLoopIsStopped(runloop)) {
                /// 被外部调用者强制停止了
                retVal = kCFRunLoopRunStopped;
            } else if (__CFRunLoopModeIsEmpty(runloop, currentMode)) {
                /// source/timer/observer一个都没有了
                retVal = kCFRunLoopRunFinished;
            }

            /// 如果没超时,mode里没空,loop也没被停止,那继续loop。
        } while (retVal == 0);
    }

    /// 10. 通知 Observers: RunLoop 即将退出。
    __CFRunLoopDoObservers(rl, currentMode, kCFRunLoopExit);

五、RunLoop 的底层实现

RunLoop 的核心是基于 mach port 的,其进入休眠时调用的函数是 mach_msg()。

为了实现消息的发送和接收,mach_msg() 函数实际上是调用了一个 Mach 陷阱 (trap),即函数mach_msg_trap(),陷阱这个概念在 Mach 中等同于系统调用。当你在用户态调用 mach_msg_trap() 时会触发陷阱机制,切换到内核态;内核态中内核实现的 mach_msg() 函数会完成实际的工作。

RunLoop 调用这个函数去接收消息,如果没有别人发送 port 消息过来,内核会将线程置于等待状态。例如你在模拟器里跑起一个 iOS 的 App,然后在 App 静止时点击暂停,你会看到主线程调用栈是停留在 mach_msg_trap() 这个地方。

六、我们看不到的 Runloop 都做了些什么

1. AutoreleasePool

下面是摘录的一段描述(注意:下面的描述跟现在的实现有些不同):

App启动后,苹果在主线程 RunLoop 里注册了两个 Observer,其回调都是 _wrapRunLoopWithAutoreleasePoolHandler()。

  • 第一个 Observer:监视的事件是 Entry(即将进入Loop),其回调内会调用 _objc_autoreleasePoolPush() 创建自动释放池。

  • 第二个 Observer 监视了两个事件:

    • BeforeWaiting(准备进入休眠) 时调用_objc_autoreleasePoolPop() 和 _objc_autoreleasePoolPush() 释放旧的池并创建新池。
    • Exit(即将退出Loop) 时调用 _objc_autoreleasePoolPop() 来释放自动释放池。

在主线程执行的代码,通常是写在诸如事件回调、Timer回调内的。这些回调会被 RunLoop 创建好的 AutoreleasePool 环绕着,所以不会出现内存泄漏,开发者也不必显示创建 Pool 了。(子线程就不一定了)

通过设置 CFRunLoopAddObserver()符号断点,我们可以看到 App 启动时,主线程会在创建 Autorelease pool 时,在 runn loop 中注册一个 Observer,但是不确定是不是用来回调 autoreleasepool drain 的:

image

设置 objc_autoreleasePoolPush()符号断点,启动 app 后,会发现有很多处调用: image

另外,我通过一个 demo 观察了 -viewDidLoad 方法中的局部变量持有的对象的销毁时机,观察发现,它并不是在 -viewDidLoad 方法执行完就销毁了,而是 -viewWillAppear:执行完之后才被销毁,这是因为 -loadView-viewDidLoad-viewWillAppear: 这几个方法都是在同一个 run loop 中调用的,而自动释放池的 objc_autoreleasePoolPop() 方法是在下一个 run loop 中调用的。

截屏2021-06-05 下午4 02 32

2. 事件响应

苹果注册了一个 Source1 (基于 mach port 的) 用来接收系统事件,其回调函数为 __IOHIDEventSystemClientQueueCallback()。

当一个硬件事件(触摸/锁屏/摇晃等)发生后,首先由 IOKit.framework 生成一个 IOHIDEvent 事件,并由 SpringBoard 接收。SpringBoard 只接收按键(锁屏/静音等),触摸,加速,接近传感器等几种 Event,随后用 mach port 转发给需要的App进程。

随后苹果注册的那个 Source1 就会触发回调,并调用 _UIApplicationHandleEventQueue() 进行应用内部的分发。_UIApplicationHandleEventQueue() 会把 IOHIDEvent 处理并包装成 UIEvent 进行处理或分发,其中包括识别 UIGesture/处理屏幕旋转/发送给 UIWindow 等。

3. 手势识别

当上面的 _UIApplicationHandleEventQueue() 识别了一个手势时,其首先会调用 Cancel 将当前的 touchesBegin/Move/End 系列回调打断。随后系统将对应的 UIGestureRecognizer 标记为待处理。

苹果注册了一个 Observer 监测 BeforeWaiting (Loop即将进入休眠) 事件,这个Observer的回调函数是 _UIGestureRecognizerUpdateObserver(),其内部会获取所有刚被标记为待处理的 GestureRecognizer,并执行GestureRecognizer的回调。

当有 UIGestureRecognizer 的变化(创建/销毁/状态改变)时,这个回调都会进行相应处理。

4. 界面更新

当在操作 UI 时,比如改变了 Frame、更新了 UIView/CALayer 的层次时,或者手动调用了 UIView/CALayer 的 setNeedsLayout/setNeedsDisplay方法后,这个 UIView/CALayer 就被标记为待处理,并被提交到一个全局的容器去。

苹果注册了一个 Observer 监听 BeforeWaiting(即将进入休眠) 和 Exit (即将退出Loop) 事件,这个Observer的回调函数是 _ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()。这个函数里会遍历所有待处理的 UIView/CAlayer 以执行实际的绘制和调整,并更新 UI 界面。

5. 定时器

下面是苹果官方文档中的介绍:

Timer sources deliver events synchronously to your threads at a preset time in the future. Timers are a way for a thread to notify itself to do something.

Although it generates time-based notifications, a timer is not a real-time mechanism. Like input sources, timers are associated with specific modes of your run loop. If a timer is not in the mode currently being monitored by the run loop, it does not fire until you run the run loop in one of the timer’s supported modes. Similarly, if a timer fires when the run loop is in the middle of executing a handler routine, the timer waits until the next time through the run loop to invoke its handler routine. If the run loop is not running at all, the timer never fires.

(1)NSTimer

NSTimer 其实就是 CFRunLoopTimerRef,他们之间是 toll-free bridged 的。一个 NSTimer 注册到 RunLoop 后,RunLoop 会为其重复的时间点注册好事件。例如 10:00, 10:10, 10:20 这几个时间点。RunLoop为了节省资源,并不会在非常准确的时间点回调这个Timer。Timer 有个属性叫做 Tolerance (宽容度),标示了当时间点到后,容许有多少最大误差。

如果某个时间点被错过了,例如执行了一个很长的任务,则那个时间点的回调也会跳过去,不会延后执行。

(2)CADisplayLink

CADisplayLink 是一个和屏幕刷新率一致的定时器(但实际实现原理更复杂,和 NSTimer 并不一样,其内部实际是操作了一个 Source)。如果在两次屏幕刷新之间执行了一个长任务,那其中就会有一帧被跳过去(和 NSTimer 相似),造成界面卡顿的感觉。在快速滑动TableView时,即使一帧的卡顿也会让用户有所察觉。

6. PerformSelecter

当调用 NSObject 的 performSelecter:afterDelay: 后,实际上其内部会创建一个 Timer 并添加到当前线程的 RunLoop 中。所以如果当前线程没有 RunLoop,则这个方法会失效。

当调用 performSelector:onThread: 时,实际上其会创建一个 Timer 加到对应的线程去,同样的,如果对应线程没有 RunLoop 该方法也会失效。

因此,如果是在子线程上调用上面的方法,我们首先需要确认当前线程是否已经开启了 Runloop。

7. 关于GCD

当调用 dispatch_async(dispatch_get_main_queue(), block)时,libDispatch 会向主线程的 RunLoop 发送消息,RunLoop会被唤醒,并从消息中取得这个 block,并在回调 CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE() 里执行这个 block,我们通过断点调试可以看到相关函数调用栈。

但这个逻辑仅限于 dispatch 到主线程,dispatch 到其他线程仍然是由 libDispatch 处理的。

8. 关于网络请求

(1)网络架构分层 iOS 中,关于网络请求的接口自下至上有如下几层:

CFSocket 
CFNetwork       -> ASIHttpRequest
NSURLConnection -> AFNetworking
NSURLSession    -> AFNetworking2, Alamofire

(2)NSURLConnection 的工作过程

七、Runloop 在实际开发中的应用(When and How)

对 runloop 的应用大致可以分为两类,一是开发者通过 runloop 执行自己的任务,比如 mainQueue,timer 等。另一类就是通过 runloop 观测分析主线程的运行状态。

The only time you need to run a run loop explicitly is when you create secondary threads for your application. The run loop for your application’s main thread is a crucial piece of infrastructure. As a result, the app frameworks provide the code for running the main application loop and start that loop automatically. The run method of UIApplication in iOS (or NSApplication in OS X) starts an application’s main loop as part of the normal startup sequence. If you use the Xcode template projects to create your application, you should never have to call these routines explicitly.

For secondary threads, you need to decide whether a run loop is necessary, and if it is, configure and start it yourself. You do not need to start a thread’s run loop in all cases. For example, if you use a thread to perform some long-running and predetermined task, you can probably avoid starting the run loop. Run loops are intended for situations where you want more interactivity with the thread. For example, you need to start a run loop if you plan to do any of the following:

  • Use ports or custom input sources to communicate with other threads.
  • Use timers on the thread.
  • Use any of the performSelector… methods in a Cocoa application.
  • Keep the thread around to perform periodic tasks.

1. 以+ scheduledTimerWithTimeInterval...的方式触发的timer,在滑动页面上的列表时,timer会暂定回调,为什么?如何解决?

一个 Timer 一次只能加入到一个 RunLoop 中。我们日常使用的时候,通常就是加入到当前的 runLoop 的 default mode 中,而 ScrollView 在用户滑动时,主线程 RunLoop 会转到 UITrackingRunLoopMode 以保证 ScrollView 的流畅滑动:只能在NSDefaultRunLoopMode 模式下处理的事件会影响ScrollView的滑动。因此这个时候, Timer 就不会运行。

有如下两种解决方案:

第一种: 设置RunLoop Mode,例如NSTimer,我们指定它运行于 NSRunLoopCommonModes ,这是一个Mode的集合。注册到这个 Mode 下后,无论当前 runLoop 运行哪个 mode ,事件都能得到执行。 第二种: 另一种解决Timer的方法是,我们在另外一个线程执行和处理 Timer 事件,然后在主线程更新UI。

在 AFNetworking 3.0 中,就采用了第一种方法,代码如下:

- (void)startActivationDelayTimer {
    self.activationDelayTimer = [NSTimer
    timerWithTimeInterval:self.activationDelay target:self selector:@selector(activationDelayTimerFired) userInfo:nil repeats:NO];
    [[NSRunLoop mainRunLoop] addTimer:self.activationDelayTimer forMode:NSRunLoopCommonModes];
}

2. AFNetworking 2.x

使用 NSOperation + NSURLConnection 的并发模型都会面临 NSURLConnection 下载完成前线程退出导致 NSOperation 对象接收不到回调的问题。

AFURLConnectionOperation 这个类是基于 NSURLConnection 构建的,其希望能在后台线程接收 Delegate 回调。为此 AFNetworking 单独创建了一个线程,并在这个线程中启动了一个 RunLoop:

+ (void)networkRequestThreadEntryPoint:(id)__unused object {
    @autoreleasepool {
        [[NSThread currentThread] setName:@"AFNetworking"];
        NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
        [runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
        [runLoop run];
    }
}

+ (NSThread *)networkRequestThread {
    static NSThread *_networkRequestThread = nil;
    static dispatch_once_t oncePredicate;
    dispatch_once(&oncePredicate, ^{
        _networkRequestThread = [[NSThread alloc] initWithTarget:self selector:@selector(networkRequestThreadEntryPoint:) object:nil];
        [_networkRequestThread start];
    });
    return _networkRequestThread;
}

从上面的代码可以看出,AFN创建了一个新的线程命名为 AFNetworking ,然后在这个线程中创建了一个 RunLoop ,在上面2.3章节 RunLoop 运行机制中提到了,一个RunLoop中如果source/timer/observer 都为空则会退出,并不进入循环。所以,AFN在这里为 RunLoop 添加了一个 NSMachPort ,这个port开启相当于添加了一个Source1事件源(当没有 source 或者 timer 要处理时,run loop 会通过调用 mach_msg 进入内核态休眠),但是这个事件源并没有真正的监听什么东西,只是为了不让 RunLoop 退出。

遗留问题:在子线程使用 NSURLSession 时,还需要手动启动 Runloop 来实现线程保活吗?

3. 实现 UITableView 中平滑滚动延迟加载图片

当没有任何用户交互时,默认的 Mode 是 NSDefaultRunLoopMode。当用户正在滑动 UIScrollView 时,RunLoop 将切换到 UITrackingRunLoopMode 接受滑动手势和处理滑动事件(包括减速和弹簧效果),此时,其他 Mode (除 NSRunLoopCommonModes 这个组合 Mode)下的事件将全部暂停执行,来保证滑动事件的优先处理,这也是 iOS 滑动顺畅的重要原因。

利用 CFRunLoopMode 的特性,可以将图片的加载放到 Run Loop 的 NSDefaultRunLoopMode 中,这样在滚动列表时(UITrackingRunLoopMode), 图片加载也不会影响到列表滚动的流畅度:

UIImage *downloadedImage = ...;
[self.avatarImageView performSelector:@selector(setImage:)
     withObject:downloadedImage
     afterDelay:0
     inModes:@[NSDefaultRunLoopMode]];

4. 处理崩溃让程序继续运行

如果App运行遇到 Exception 就会直接崩溃并且退出,其实真正让应用退出的并不是产生的异常,而是当产生异常时,系统会结束掉当前主线程的 RunLoop ,RunLoop 退出主线程也就退出了,所以应用才会退出。

有时候,我们可以让应用在崩溃时依然可以正常运行: (1)提升用户体验 应用遇到BUG崩溃时一般会给使用者造成非常不好的用户体验,其实,当应用崩溃时,我们可以让用户选择退出还是继续运行。

(2)收集崩溃日志 苹果提供了产生 Exception 的处理方法,我们可以在相应的方法中处理产生的异常,但是这个时间非常的短,之后应用就会退出,具体多长时间我们也不清楚,很被动。不过我们可以在应用崩溃时,通过控制 Runloop 来争取足够的时间收集日志并上传到服务器。

把下面的代码添加到 Exception 的handle方法中,此时获取了当前线程(主线程)的 RunLoop ,让这个 RunLoop 在所有的 Mode 下面一直不停的跑,保证主线程不会退出,我们的应用也就存活下来了。

CFRunLoopRef runLoop = CFRunLoopGetCurrent();
NSArray *allModes = CFBridgingRelease(CFRunLoopCopyAllModes(runLoop));
while (1) {
     for (NSString *mode in allModes) {
          CFRunLoopRunInMode((CFStringRef)mode, 0.001, false);
     }
}

5. AsyncDisplayKit

AsyncDisplayKit(现在已经被迁移到了 Texture) 是 Facebook 推出的用于保持界面流畅性的框架。

AsyncDisplayKit 创建了一个名为 ASDisplayNode 的对象,并在内部封装了 UIView/CALayer。开发者可以只通过 Node 来操作其内部的 UIView/CALayer,这样就可以将排版和绘制放入了后台线程。然后再在某个时刻将相关属性同步到主线程的 UIView/CALayer 去。

ASDK 仿照 QuartzCore/UIKit 框架的模式,实现了一套类似的界面更新的机制:即在主线程的 RunLoop 中添加一个 Observer,监听了 kCFRunLoopBeforeWaiting 和 kCFRunLoopExit 事件,在收到回调时,遍历所有之前放入队列的待处理的任务,然后一一执行。

6. 使用 NSInputStream 异步逐行读取文件

在 objc.io 的第二期中有一个应用 NSInputStream 逐行读取文件的例子,与 URL connections 类似,输入的 streams 通过 run loop 来传递它的事件。这里采用 main run loop 来分发事件,然后将数据处理过程派发至后台操作线程里去处理。

- (void)enumerateLines:(void (^)(NSString*))block
            completion:(void (^)())completion
{
    if (self.queue == nil) {
        self.queue = [[NSOperationQueue alloc] init];
        self.queue.maxConcurrentOperationCount = 1;
    }
    self.callback = block;
    self.completion = completion;
    self.inputStream = [NSInputStream inputStreamWithURL:self.fileURL];
    self.inputStream.delegate = self;
    [self.inputStream scheduleInRunLoop:[NSRunLoop currentRunLoop]
                                forMode:NSDefaultRunLoopMode];
    [self.inputStream open];
}

7. 利用 RunLoop 原理来监控卡顿

如果 RunLoop 的线程,进入睡眠前方法的执行时间过长而导致无法进入睡眠,或者线程唤醒后接收消息时间过长而无法进入下一步的话,就可以认为是线程受阻了。如果这个线程是主线程的话,表现出来的就是出现了卡顿。

所以,如果我们要利用 RunLoop 原理来监控卡顿的话,就是要关注这两个阶段。RunLoop 在进入睡眠之前和唤醒后的两个 loop 状态定义的值,分别是 kCFRunLoopBeforeSources 和 kCFRunLoopAfterWaiting ,也就是要触发 Source0 回调和接收 mach_port 消息两个状态。

参考

ShannonChenCHN commented 6 years ago

“死锁现场”

触发场景:

点击城市列表搜索框,快速输入文字,出现 APP 卡死的 bug。

追踪

卡死后,点击 Xcode 的 pause 按钮,可以通过查看函数调用堆栈找出线索。

分析:

每个服务对应一个 task,每个 task 有一个 connection 引用和一个 token。

threadStateManager 是一个单例,管理着各个 task 的状态,比如取消 task、判断是否被取消。

取消 task 的过程:

方法调用栈: <error: 图片已删除>

发起请求的过程:

方法调用栈: <error: 图片已删除>

结论

在子线程发送/接受请求时需要对 connection 和 threadStateManager(单例) 加锁,但是同时在主线程取消服务时,也需要对 threadStateManager(单例) 和同一个 connection 加锁。这样就会出现死锁的情况。

伪代码如下:

void send(connection) {
    lock(connectionLock);
    lock(singletonLock);

   // do something with connection and singleton
   // ...
    unlock(singletonLock);
    unlock(connectionLock);
}

void cancel(connection) {
    lock(singletonLock);
    lock(connectionLock);

   // do something with connection and singleton
   // ...
    unlock(connectionLock);
    unlock(singletonLock);
}

dispatch_async(someQueue, ^{
        send();
});

cancel();

死锁的示意图(图片来源:objc.io): image

ShannonChenCHN commented 3 years ago

多线程开发实践典型案例

参考:

ShannonChenCHN commented 3 years ago

GCD 的实现

1. Dispatch Queue

通常,应用程序中线程管理的代码要在系统级实现,也就是 iOS 和 OS X 的核心 XNU 内核上。

GCD 的 Dispatch Queue 的实现包括三个部分:

用于实现 Dispatch Queue 而使用的框架/库:

我们平时开发使用的 GCD 的 API 都在libdispatch 库(开源)中。

2. GCD 各个 API 的实现

3. GCD 的线程池是如何实现的

参考:

ShannonChenCHN commented 3 years ago

pthreads(POSIX)

sleep() 的使用:

#include <stdio.h>
#include <unistd.h> // notice this! you need it!

int main(){
    printf("Hello,");
    sleep(5); // format is sleep(x); where x is # of seconds.
    printf("World");
    return 0;
}

参考