GCD初探

210 阅读19分钟

GCD任务和队列

GCD的概念

Grand Central Dispatch(GCD) 是 Apple 开发的一个多核编程的较新的解决方法。它主要用于优化应程序以支持多核处理器以及其他对称多处理系统。它是一个在线程池模式的基础上执行的并发任务。在 Mac OS X 10.6中首次推出,也可在 iOS 4 及以上版本使用。(来自百度百科)

翻译过来就是大规模中央调度,官方文档中介绍它运行在系统级别,通过向他管理的调度队列中提交任务,来达到满足应用需求和资源占用。这套机制可以让我们充分利用多核性能,并且不用再使用底层的线程API,把这些调度工作交给GCD去做。

GCD 的核心就是为了解决如何让程序有序、高效的运行,由此衍生出队列等概念和一系列的方法。

任务的概念

就是执行操作的意思,换句话说就是在线程中执行的那段代码。在 GCD 中是放在 block 中的。

执行任务有两种方式:『同步执行』『异步执行』。两者的主要区别是:是否等待队列的任务执行结束,以及是否具备开启新线程的能力。

就像在网络那一节提过的那样,同步和异步主要关注的是消息的通讯机制,他们最主要的区别是是否会等待回调结果。

  • 同步执行(sync)

    • 同步添加任务到指定的队列中,在添加的任务执行结束之前,会一直等待,直到队列里面的任务完成之后再继续执行。
    • 只能在当前线程中执行任务,不具备开启新线程的能力。
  • 异步执行(async)

    • 异步添加任务到指定的队列中,它不会做任何等待,可以继续执行任务。
    • 可以在新的线程中执行任务,具备开启新线程的能力。

注意:

**异步执行(async)**虽然具有开启新线程的能力,但是并不一定开启新线程。这跟任务所指定的队列类型有关(下面会讲)。

队列的概念

队列指执行任务的等待队列,即用来存放任务的队列。队列是一种特殊的线性表,采用FIFO的原则。

在GCD中有两种队列:『串行队列』『并发队列』。两者都符合 FIFO(先进先出)的原则。两者的主要区别是:执行顺序不同,以及开启线程数不同。

  • 串行队列(Serial Dispatch Queue)

    • 每次只有一个任务被执行。让任务一个接着一个地执行。(只开启一个线程,一个任务执行完毕后,再执行下一个任务)
  • 并发队列(Concurrent Dispatch Queue)

    • 可以让多个任务并发(同时)执行。(可以开启多个线程,并且同时执行任务)

注意:并发队列 的并发功能只有在异步(dispatch_async)方法下才有效。

在开始之前 我们看看关于任务都有哪些需求:

  1. 默认情况下,程序是按代码顺序执行的,但我们有时希望应用能同时做多件事情,比如同时下载歌词和音乐。这就有了第一个需求:让多个任务同时进行。
  2. 对于下载这个任务,可以一次下载多首音乐,各下各的,不需要互相等待;然而当全部下载完了播放时,通常是一首接一首的播放,播放一首音乐这个任务是需要等待前面的播放任务完成了才能进行。这就有了第二个需求:有的任务需要等待它完成了才能进行下一个任务,有的任务不需要等待它完成。
  3. 如果一首音乐还没下载,我们就点了播放键,我们看看需要做哪些事情:它需要把歌词、音乐分别下载了,等他们都下载完了,告诉应用你可以播放了,然后应用把歌词、音乐同时播放。那我们怎么知道歌词、音乐都下载完了呢?这就有了第三个需求:如果有个东西能把几个任务捆绑到一起就好了,当整个包都完成了再通知我。
  4. 还是下载,如果我们勾选了一堆的音乐要下载,中间我想暂停一下,过一会再让它继续,这就要求这一系列的下载任务要可以暂停和继续。
  5. 一般下载工具都可以设置同时最大下载数,这就要求有一个方法可以控制同时进行的任务数。
  6. 很多播放器会有一个功能:播放 20 分钟后就停止,非常适合睡觉前用。这个时候需要有个任务,在 20 分钟后把音乐关了。延迟执行任务就是它需要的特性。

以上列举了 6 个经典的任务执行需要的特性,在 GCD 中分别提供了以下方法来支持它们:

  1. 串行队列、并行队列
  2. 同步任务、异步任务
  3. 任务组、栅栏任务
  4. 挂起、唤醒队列
  5. 信号量
  6. 延迟加入队列

创建队列

GCD提供了三种主要类型的队列:

主队列(Main queue)

主队列是一个串行队列,它主要处理 UI 相关任务,也可以处理其他类型任务,但为了性能考虑,尽量让主队列执行 UI 相关或少量不耗时间和资源的操作。它通过类属性获取:

let mainQueue = DispatchQueue.main

全局队列(Global queues)

全局并发队列,存在 5 个不同的 QoS 级别,可以使用默认优先级,也可以单独指定:

let globalQueue = DispatchQueue.global() //  qos: .default
let globalQueue = DispatchQueue.global(qos: .background) // 后台运行级别

自定义队列(custom queues)

这个使我们自己创建的队列,可以是并行可以是串行。但是这些队列中的请求最终都会进入上面系统提供的六条队列之一。

串行队列

系统为串行队列一般只分配一个线程(也有特例,下一章任务特性部分有解释),队列中如果有任务正在执行时,是不允许队列中的其他任务插队的(即暂停当前任务,转而执行其他任务),这个特性也可以理解为:串行队列中执行任务的线程不允许被当前队列中的任务阻塞(此时会死锁),但可以被别的队列任务阻塞。

创建时指定 label 便于调试,一般使用 Bundle Identifier 类似的命名方式:

let queue = DispatchQueue(label: "com.xxx.xxx.queueName")

并行队列

系统会为并行队列至少分配一个线程,线程允许被任何队列的任务阻塞。

let queue = DispatchQueue(label: "com.xxx.xxx.queueName", attributes: .concurrent)

添加队列任务

有些任务我们必须等待它的执行结果才能进行下一步,这种执行任务的方式称为同步,简称同步任务;有些任务只要把它放入队列就可以不管它了,可以继续执行其他任务,按这种方式执行的任务,称为异步任务。

同步任务

串行队列中新增同步任务

func testSyncTaskInSerialQueue() {
    print("📢串行队列中新增同步任务")
    helper.printCurrentThread(with: "开始测试")
    serialQueue.sync {
        print("⚠️串行队列中新增同步任务")
        helper.printCurrentThread(with: "串行队列")
    }
    helper.printCurrentThread(with: "结束测试")
    print("---------------------------")
}

📢串行队列中新增同步任务

开始测试的当前线程:

<NSThread: 0x2823d86c0>{number = 1, name = main}

⚠️串行队列中新增同步任务

串行队列的当前线程:

<NSThread: 0x2823d86c0>{number = 1, name = main}

结束测试的当前线程:

<NSThread: 0x2823d86c0>{number = 1, name = main}

---------------------------

执行结果,任务是在主线程中执行的,结束后又回到了主线程,可以理解为这个同步任务把主线程阻塞了,让自己优先插队执行

串行队列任务中嵌套本队列的同步任务

func testSyncTaskNestedInSameSerialQueue() {
    print("📢串行队列任务中嵌套本队列的同步任务")
    helper.printCurrentThread(with: "开始测试")
    serialQueue.sync {
        helper.printCurrentThread(with: "被嵌套的串行队列")
        serialQueue.sync {
            print("⚠️串行队列任务中嵌套本队列的同步任务")
            self.helper.printCurrentThread(with: "串行队列")
        }
    }
    helper.printCurrentThread(with: "结束测试")
    print("---------------------------")
}

📢串行队列任务中嵌套本队列的同步任务

开始测试的当前线程:

<NSThread: 0x2823d86c0>{number = 1, name = main}

被嵌套的串行队列的当前线程:

<NSThread: 0x2823d86c0>{number = 1, name = main}

(lldb) Thread 1: EXC_BREAKPOINT (code=1, subcode=0x1025bf4f8)

执行结果崩溃 是因为死锁造成的,因为串行队列中执行任务的线程不允许被当前队列中的任务阻塞。 我理解队列的作用就是这样,规定了task的执行顺序,如果不按照顺序来,就会造成死锁。

下面例子中串行队列嵌套其他队列的同步任务,这样虽然是阻塞了当前的串行队列,但是没有改变串行队列中task的运行顺序,所以可以正常运行。

串行队列中嵌套其他队列的同步任务

func testSyncTaskNestedInOtherSerialQueue() {
    print("📢串行队列中嵌套其他队列的同步任务")
    // 创新另一个串行队列
    let serialQueue2 = DispatchQueue(
        label: "com.test.serialQueue2")
    helper.printCurrentThread(with: "开始测试")
    serialQueue.sync {
        helper.printCurrentThread(with: "被嵌套的串行队列")
        serialQueue2.sync {
            print("⚠️串行队列中嵌套其他队列的同步任务")
            helper.printCurrentThread(with: "嵌套的串行队列")
        }
    }
    helper.printCurrentThread(with: "结束测试")
    print("---------------------------")
}

📢串行队列中嵌套其他队列的同步任务

开始测试的当前线程:

<NSThread: 0x282c9c700>{number = 1, name = main}

被嵌套的串行队列的当前线程:

<NSThread: 0x282c9c700>{number = 1, name = main}

⚠️串行队列中嵌套其他队列的同步任务

嵌套的串行队列的当前线程:

<NSThread: 0x282c9c700>{number = 1, name = main}

结束测试的当前线程:

<NSThread: 0x282c9c700>{number = 1, name = main}

---------------------------

并行队列中新增同步任务

func testSyncTaskInConcurrentQueue() {
    print("📢并行队列中新增同步任务")
    helper.printCurrentThread(with: "开始测试")
    concurrentQueue.sync {
        print("⚠️并行队列中新增同步任务")
        helper.printCurrentThread(with: "并行队列")
    }
    concurrentQueue.sync {
        print("⚠️并行队列中再整一个同步任务")
        helper.printCurrentThread(with: "并行队列")
    }
    concurrentQueue.sync {
        print("⚠️并行队列中又整一个同步任务")
        helper.printCurrentThread(with: "并行队列")
    }
    helper.printCurrentThread(with: "结束测试")
    print("---------------------------")
}

📢并行队列中新增同步任务

开始测试的当前线程:

<NSThread: 0x282c9c700>{number = 1, name = main}

⚠️并行队列中新增同步任务

并行队列的当前线程:

<NSThread: 0x282c9c700>{number = 1, name = main}

⚠️并行队列中再整一个同步任务

并行队列的当前线程:

<NSThread: 0x282c9c700>{number = 1, name = main}

⚠️并行队列中又整一个同步任务

并行队列的当前线程:

<NSThread: 0x282c9c700>{number = 1, name = main}

结束测试的当前线程:

<NSThread: 0x282c9c700>{number = 1, name = main}

---------------------------

从执行结果中我们可以看到,同步任务直接在当前线程运行。

异步任务

特性:任务提交后不会阻塞当前线程,会由队列安排另一个线程执行。

并行队列中新增异步任务

func testAsyncTaskInConcurrentQueue() {
    print("📢并行队列中新增异步任务")
    helper.printCurrentThread(with: "开始测试")
    concurrentQueue.async {
        print("⚠️并行队列中新增异步任务")
        self.helper.printCurrentThread(with: "并行队列")
        print("-----------")
    }
    concurrentQueue.async {
        print("⚠️并行队列中再整一个异步任务")
        self.helper.printCurrentThread(with: "并行队列")
        print("-----------")
    }
    concurrentQueue.async {
        print("⚠️并行队列中又整一个异步任务")
        self.helper.printCurrentThread(with: "并行队列")
        print("-----------")
    }
    helper.printCurrentThread(with: "结束测试")
    print("---------------------------")
}
    

📢并行队列中新增异步任务

开始测试的当前线程:

<NSThread: 0x282c9c700>{number = 1, name = main}

结束测试的当前线程:

<NSThread: 0x282c9c700>{number = 1, name = main}

---------------------------

⚠️并行队列中新增异步任务

⚠️并行队列中再整一个异步任务

并行队列的当前线程:

<NSThread: 0x282cd8800>{number = 7, name = (null)}

-----------

⚠️并行队列中又整一个异步任务

并行队列的当前线程:

<NSThread: 0x282cd0c00>{number = 8, name = (null)}

-----------

并行队列的当前线程:

<NSThread: 0x282cd8800>{number = 7, name = (null)}

-----------

从运行结果中我们可以看到,并行队列里的异步任务都自己开了个新线程去运行了。

这里我发现了一个好玩的事情,上面的代码段和前面向并行队列中添加同步任务只相差了一个“a”,将sync改为了async。但是当我改为异步任务时,尾随闭包中的代码报错了,他提示我必须要写出self才能让捕获的语义明确。

这里强制用self的原因是因为使用DispatchQueue.async在dispatch queue上安排了一个异步任务。

显而易见,这个闭包任务的生命周期会比async的作用时间要长,闭包可能会逃离函数的作用域,所以这里是一个逃逸闭包。虽然这种不写self的方式确实很方便,但是也同时带来一个问题--循环引用,在闭包中访问了当前的对象中的任意属性或实例方法,闭包会持续持有当前对象。

当然在同步任务中不会出现循环引用,他的闭包默认是非逃逸的,非逃逸的闭包可以保证在函数返回时闭包会释放他所持有的对象。

但是这个在异步任务的逃避闭包中很致命,所以编译器强制要求在逃逸闭包中显式的写出self,来强迫我们考虑潜在的循环引用。

串行队列中新增异步任务

func testAsyncTaskInSerialQueue() {
    print("📢串行队列中新增异步任务")
    helper.printCurrentThread(with: "开始测试")
    serialQueue.async {
        print("⚠️串行队列中新增异步任务")
        self.helper.printCurrentThread(with: "串行队列")
        print("-----------")
    }
    serialQueue.async {
        print("⚠️串行队列中再整一个异步任务")
        sleep(1)
        self.helper.printCurrentThread(with: "串行队列")
        print("-----------")
    }
    serialQueue.async {
        print("⚠️串行队列中又整一个异步任务")
        self.helper.printCurrentThread(with: "串行队列")
        print("-----------")
    }
    helper.printCurrentThread(with: "结束测试")
    print("---------------------------")
}

📢串行队列中新增异步任务

开始测试的当前线程:

<NSThread: 0x282c9c700>{number = 1, name = main}

⚠️串行队列中新增异步任务

结束测试的当前线程:

<NSThread: 0x282c9c700>{number = 1, name = main}

---------------------------

串行队列的当前线程:

<NSThread: 0x282cd9340>{number = 10, name = (null)}

-----------

⚠️串行队列中再整一个异步任务

串行队列的当前线程:

<NSThread: 0x282cd9340>{number = 10, name = (null)}

-----------

⚠️串行队列中又整一个异步任务

串行队列的当前线程:

<NSThread: 0x282cd9340>{number = 10, name = (null)}

-----------

从这里的执行结果我们可以看到,串行队列在非主线程开始了他的异步任务,这个结果也符合预期,串行队列中的所有任务都在同一个线程中运行。

但是注意下面这个例子

串行队列任务中嵌套本队列的异步任务

func testAsyncTaskNestedInSameSerialQueue() {
    print("📢串行队列任务中嵌套本队列的异步任务")
    helper.printCurrentThread(with: "开始测试")
    serialQueue.sync {
        helper.printCurrentThread(with: "被嵌套的串行队列")
        serialQueue.async {
            print("⚠️串行队列任务中嵌套本队列的异步任务")
            self.helper.printCurrentThread(with: "嵌套的串行队列")
            print("-----------")
            self.serialQueue.async {
                print("⚠️我愿意称它为套中套1")
                self.helper.printCurrentThread(with: "套中套1")
                print("-----------")
            }
        }
        serialQueue.async {
            print("⚠️串行队列任务中再嵌套本队列的异步任务")
            self.helper.printCurrentThread(with: "再嵌套的串行队列")
            print("-----------")
            self.serialQueue.async {
                print("⚠️我愿意称它为套中套2")
                self.helper.printCurrentThread(with: "套中套2")
                print("-----------")
            }
        }
        serialQueue.async {
            print("⚠️串行队列任务中又嵌套本队列的异步任务")
            self.helper.printCurrentThread(with: "又嵌套的串行队列")
            print("-----------")
        }
        print("‼️串行队列任务中嵌套本队列的异步任务添加完成")
        helper.printCurrentThread(with: "被嵌套的串行队列")
    }
    helper.printCurrentThread(with: "结束测试")
    print("---------------------------")
}
    

📢串行队列任务中嵌套本队列的异步任务

开始测试的当前线程:

<NSThread: 0x282c9c700>{number = 1, name = main}

被嵌套的串行队列的当前线程:

<NSThread: 0x282c9c700>{number = 1, name = main}

‼️串行队列任务中嵌套本队列的异步任务添加完成

被嵌套的串行队列的当前线程:

<NSThread: 0x282c9c700>{number = 1, name = main}

结束测试的当前线程:

<NSThread: 0x282c9c700>{number = 1, name = main}

---------------------------

⚠️串行队列任务中嵌套本队列的异步任务

嵌套的串行队列的当前线程:

<NSThread: 0x282cd9340>{number = 10, name = (null)}

-----------

⚠️串行队列任务中再嵌套本队列的异步任务

再嵌套的串行队列的当前线程:

<NSThread: 0x282cd9340>{number = 10, name = (null)}

-----------

⚠️串行队列任务中又嵌套本队列的异步任务

又嵌套的串行队列的当前线程:

<NSThread: 0x282cd9340>{number = 10, name = (null)}

-----------

⚠️我愿意称它为套中套1

套中套1的当前线程:

<NSThread: 0x282cd9340>{number = 10, name = (null)}

-----------

⚠️我愿意称它为套中套2

套中套2的当前线程:

<NSThread: 0x282cd9340>{number = 10, name = (null)}

-----------

从运行结果中我们发现了一个震惊的事情,这次串行队列用了两个线程,他的同步任务直接在主线程运行了,但是在嵌套中添加的异步任务却切换了线程。


这里我们就能得出队列和任务的特性了:

串行队列同一时间只会使用同一线程、运行同一任务,并严格按照任务顺序执行。

并行队列同一时间可以使用多个线程、运行多个任务,执行顺序不分先后。

如何避免死锁:不要在串行或主队列中嵌套执行同步任务。

栅栏任务

栅栏任务的主要特性是可以对队列中的任务进行阻隔,执行栅栏任务时,它会先等待队列中已有的任务全部执行完成,然后它再执行,在它之后加入的任务也必须等栅栏任务执行完后才能执行。 这个特性更适合并行队列,而且对栅栏任务使用同步或异步方法效果都相同。 其实串行队列用这个并没有什么意义,因为串行队列还是会按照已有的顺序进行

func barrierTask() {
    print("📢栅栏任务测试")
    let queue = concurrentQueue
    let barrierTask = DispatchWorkItem(flags: .barrier) {
        self.helper.printCurrentThread(with: "📌添加栅栏任务")

    }
    helper.printCurrentThread(with: "开始测试")

    queue.async {
        print("⚠️并行队列中新增异步任务1")
        self.helper.printCurrentThread(with: "并行队列")
        print("-----------")
    }
    queue.async {
        print("⚠️并行队列中新增异步任务2")
        self.helper.printCurrentThread(with: "并行队列")
        print("-----------")
    }
    queue.async {
        print("⚠️并行队列中新增异步任务3")
        self.helper.printCurrentThread(with: "并行队列")
        print("-----------")
    }

    queue.async(execute: barrierTask)

    queue.async {
        print("⚠️并行队列中新增异步任务4")
        self.helper.printCurrentThread(with: "并行队列")
        print("-----------")
    }
    queue.async {
        print("⚠️并行队列中新增异步任务5")
        self.helper.printCurrentThread(with: "并行队列")
        print("-----------")
    }
    queue.async {
        print("⚠️并行队列中新增异步任务6")
        self.helper.printCurrentThread(with: "并行队列")
        print("-----------")
    }
}
    

📢栅栏任务测试

开始测试的当前线程:

<NSThread: 0x600003d849c0>{number = 1, name = main}

⚠️并行队列中新增异步任务1

并行队列的当前线程:

<NSThread: 0x600003d07bc0>{number = 7, name = (null)}

-----------

⚠️并行队列中新增异步任务2

⚠️并行队列中新增异步任务3

并行队列的当前线程:

<NSThread: 0x600003dc4b40>{number = 6, name = (null)}

并行队列的当前线程:

<NSThread: 0x600003d07bc0>{number = 7, name = (null)}

-----------

-----------

📌添加栅栏任务的当前线程:

<NSThread: 0x600003dc4b40>{number = 6, name = (null)}

⚠️并行队列中新增异步任务4

⚠️并行队列中新增异步任务5

并行队列的当前线程:

<NSThread: 0x600003dc4b40>{number = 6, name = (null)}

并行队列的当前线程:

<NSThread: 0x600003d07bc0>{number = 7, name = (null)}

-----------

⚠️并行队列中新增异步任务6

-----------

并行队列的当前线程:

<NSThread: 0x600003df5280>{number = 8, name = (null)}

-----------

从结果中可以看到,栅栏任务很好的阻隔了任务,就像一个栅栏一样,将在他之前添加进队列的任务和在他之后添加进队列的任务阻隔开来。

迭代任务

func concurrentPerformTest() {
    for i in 1...100000{
        print(i)
        globalQueue.async {
            DispatchQueue.concurrentPerform(iterations: 99) { index in
            	self.helper.printCurrentThread(with: "输出\(index)")
        	}
        }
    }

截屏2021-09-14 上午10.58.27 截屏2021-09-08 下午3.09.49 上面是我在虚拟机和iPhone 12 Pro Max上测试得到的结果,发现他们都能跑满设备的全部核心,分别同时跑了12和6个线程。

在串行队列嵌套任务的问题探讨

首先明确如下的前提:

1. 我们这里探讨的串行队列是一个严格的FIFO队列,严格遵守先入先出的执行顺序
2. 串行队列同一时间只有一个任务在执行,同一时间也只会使用一个线程
3. 任务入队是在当前执行的线程里面入队的,和异步任务的线程无关,当然如果入队操作发生在异步线程也是可以的。
4. 同步任务会直接在当前线程执行,没有创建新线程的能力

有了这几个前提,我们来探讨一下这个在串行队列嵌套任务的问题:

首先,我们有四个异步任务:A, B, C, D;

其中,A,B,D都是在主线程入队,C嵌套在任务B之中,所以任务C的入队线程是在任务B的执行线程中。

在任务B和任务D之前有一个让当前线程睡眠的Sleep(5)。

问题:

1. 如果没有sleep,这四个任务的执行顺序是什么
2. 加上sleep,这四个任务的执行顺序是什么
3. 如果将这四个任务作为一个同步执行的任务添加到串行队列中,执行顺序是什么

第一个问题:

如果没有这个sleep,那么执行顺序应该是:A-->B-->D-->C

因为A,B,D 都在主线程入队,执行到B的时候才会将C加入这个串行队列里面,所以C会被放在串行队列的最后,而这个是串行队列,在同一时间只能有一个任务在执行,所以就算他们是异步任务,也只能开一个新线程来处理这个队列里的任务。

第二个问题:

来看看这个sleep,这个sleep在D入队前,所以在D入队之前的五秒,我们的串行队列没有任务加入进来。入队操作是在主线程的,但是他们是异步任务,所以串行队列会开一个线程来执行队列中的任务,如果在五秒之内,队列中执行完了A,并且执行到B中将C入队的操作,那么执行顺序就会变为:A-->B-->C-->D,因为D是在C之后入队的,串行队列按顺序执行。

!!!注意这里D使用的线程不一定和ABC一样,这里使用哪个线程是由GCD决定的!!!

通常来说: 同步任务会阻塞当前线程,并在当前线程执行。 异步任务不会阻塞当前线程,并在与当前线程不同的线程执行。

第三个问题:

我们知道串行队列同一时间只能执行一个线程一个任务,同时同步任务是不具备开启新线程的能力的,只能在当前线程执行,而我们的任务们却又嵌套在这个同步任务中,所以我们的任务A,B会先行入队,入队后执行主线程睡眠(因为我们正在执行当前队列的同步任务,所以不能执行已经入队的异步任务),主线程睡眠结束后,继续执行任务D入队,入队完成之后才会另开新线程,执行已入队的任务A,B,D。和不带睡眠的队列一样,C会在任务B执行的线程入队,然后在最后执行,所以问题三的执行顺序是:A-->B-->D-->C