iOS 中的多线程 - GCD

632 阅读31分钟

一. 基础篇

我们先来了解多线程中的几个基础概念

1.1 进程

比较官方的解释是进程是资源分配的最小单位,稍有抽象。通俗来讲,进程就是运行中的程序,我们运行起来的App,或者App附加的小组件程序都可以称为一个进程

1.2 线程

线程是进程的基本执行单元,一个进程至少有一个线程。

1.3 主线程

当一个程序启动时,就有一个进程被操作系统创建,与此同时也会有一个线程立即运行,该线程就被称为主线程。主线程又被称为UI线程,所有UI操作的刷新都必须在主线程上执行

同一个进程中只有一个主线程,其他的线程被称为子线程或者后台线程

1.4 单核与多核, 并行与并发

虽然名义上是多线程,但实际上对于CPU来讲,它同一时间只会选择一个线程来执行任务。

并发(Parallelism)

在单核CPU中,多线程可以算是一个伪概念,CPU利用时间分片技术,在不同线程中快速切换执行任务,造成了一个多线程同时在执行的假象。这种单CPU快速切换去执行任务的操作,被称为并发。

既然依旧是顺序执行,可能有人会有疑惑,单核CPU是否还有必要进行多线程编程,答案是肯定的。我们通常把任务分为计算密集型和IO密集型,对于计算密集型任务,我们并没有必要去开启多线程,但对于IO密集型任务,CPU的性能被大量闲置,任务越多,CPU的利用率越高(一定限度内)。开启多线程依旧会大大提高任务的执行效率。

并行 (Concurrency)

在多核CPU中,多线程才有了真正意义上的实现,多个线程可以被分配给多个核心去同时执行,被称为并行. 而能否真正在同一时刻同时执行多个任务,是判断并行还是并发的依据.

虽然并行和并发是两种不同概念,但由于这两者概念过于相近,在某些时候会混用。比如有时候说串行与并行,这个时候所说的并行并不只是多核并行,也有单核并发的概念在里面。

1.5 串行与并发, 同步与异步

GCD里面通常会涉及到三个基本概念,任务,队列,同步/异步

我们所有的目的在于执行任务,任务怎么执行,由队列和同步/异步共同决定

队列是串行和并发决定了任务的执行方式,是否可以同时执行(这个同时只是一个相对的概念)

同步/异步决定了任务是否立即在当前线程执行,是否具备开启新线程的能力。

我们可以把车道当做队列, 道路收费站窗口当做线程,车辆过收费站当做任务去理解这几个概念。

主队列是其中的一种相对特殊的串行队列,它只会在主线程工作。也就是对于主队列来讲,只允许有一个窗口,不允许有其他窗口存在。

道路上所有的车辆只能从一个车道行驶,这就是串行队列

道路上车辆可以任选一个车道行驶,这就是并发队列

同步就是不允许插队,而且只有一个收费窗口开启,所有的车辆只能从这一个窗口通过。

异步就是允许插队,而且有多个收费窗口开启,车辆可以任选一个窗口通过。

我们可以察觉到队列与同步异步之间有很奇妙的关系。

串行 + 同步: 道路只有一个车道,只有一个收费窗口开启,结果车辆只能在这个车道行驶,从这个收费窗口通过。
串行 + 异步: 道路只有一个车道,有多个收费窗口开启,结果车辆只能在这个车道行驶,从车道所在的窗口通过,允许插队。
并发 + 同步: 道路有多个车道,只有一个收费窗口,结果车辆只能从窗口所在的车道行驶通过。
并发 + 异步: 道路有多个车道,有多个收费窗口开启,结果车辆任选一个车道从所在的收费窗口通过,允许插队

允许插队是指,如果当前车辆因某些原因无法缴费,比如说熄火,没油,下一辆车可以直接越过他排在他前面(别纠结单车道
无法超车的问题),先行缴费通过。等该车辆恢复正常再按照当前顺序缴费通过。

即:
串行 + 同步 = 串行执行任务,未开启新线程
串行 + 异步 = 串行执行任务,开启了新线程,允许异步任务不立即执行
并发 + 同步 = 串行执行任务,未开启新线程
并发 + 异步 = 并发执行任务,开启了新线程,允许异步任务不立即执行

特别的,
主队列 + 同步 = 死锁
主队列 + 异步 = 串行执行任务,未开启新线程,允许异步任务不立即执行

注意: 同步异步很重要的一个区别就是是否立即执行任务,这是一个很重要的概念。 拿主队列的同步异步来讲,虽然都是串行执行任务,未开启新线程,但是同步需要立即执行当前任务,异步可以越过该任务,先执行下一个任务,这种差异会直接影响到队列是否死锁。

二:队列实战篇

2.1 队列的获取与创建

iOS中的队列主要有以下几种:主队列(Main Queue),全局队列(Global Queue),自定义队列(Custom Queue)

// 主队列 - 串行队列
dispatch_queue_t mainQueue = dispatch_get_main_queue();
// 全局队列 - 并发队列
dispatch_queue_t globalQueue = dispatch_get_global_queue(0, 0);
// 自定义串行队列
dispatch_queue_t serialQueue = dispatch_queue_create("serial", DISPATCH_QUEUE_SERIAL);
// 自定义并发队列
dispatch_queue_t concurrentQueue = dispatch_queue_create("concurrent", DISPATCH_QUEUE_CONCURRENT);

2.2 队列的同步异步

首先定义几个任务

- (void)doTask1 {
    for (int i = 1 ; i < 5; i++) {
        NSLog(@"任务1:%d 当前线程:%@", i, [NSThread currentThread]);
    }
}
- (void)doTask2 {
    for (int i = 1 ; i < 5; i++) {
        NSLog(@"任务2:%d 当前线程:%@", i, [NSThread currentThread]);
    }
}
- (void)doTask3 {
    for (int i = 1 ; i < 5; i++) {
        NSLog(@"任务3:%d 当前线程:%@", i, [NSThread currentThread]);
    }
}

我们知道,GCD的同步异步方法是这样:dispatch_sync/dispatch_async(queue, ^{});

所以我们说影响到任务的执行的因素有两个,同步/异步,队列。但是实际的应用中还有其他因素会影响到任务的执行么?

有,那就是当前队列的类型。

什么意思呢? dispatch_sync/dispatch_async方法是往某个队列中添加任务,但本身dispatch_sync/dispatch_async整体也是在一个队列中,这个队列的类型也会间接影响到任务的执行。

下面我们用代码实验一下

// 1. 当前队列串行,添加同步任务到当前串行队列
// 死锁
dispatch_async(serialQueue, ^{
    dispatch_sync(serialQueue, ^{
        [self doTask1];
    });
    [self doTask2];
});

// 2. 当前队列串行,添加同步任务到另一串行队列
// 无法开启新线程,串行执行任务
dispatch_async(serialQueue, ^{
    dispatch_sync(mainQueue, ^{
        [self doTask1];
    });
    [self doTask2];
});
任务1:1 当前线程:<_NSMainThread: 0x6000014b87c0>{number = 1, name = main}
任务1:2 当前线程:<_NSMainThread: 0x6000014b87c0>{number = 1, name = main}
任务1:3 当前线程:<_NSMainThread: 0x6000014b87c0>{number = 1, name = main}
任务1:4 当前线程:<_NSMainThread: 0x6000014b87c0>{number = 1, name = main}
任务2:1 当前线程:<NSThread: 0x60000148fa40>{number = 6, name = (null)}
任务2:2 当前线程:<NSThread: 0x60000148fa40>{number = 6, name = (null)}
任务2:3 当前线程:<NSThread: 0x60000148fa40>{number = 6, name = (null)}
任务2:4 当前线程:<NSThread: 0x60000148fa40>{number = 6, name = (null)}

// 3. 当前队列串行,添加异步任务到当前串行队列
// 无法开启新线程,先继续往下执行当前串行队列的任务,最后执行异步添加的任务
dispatch_async(serialQueue, ^{ 
   dispatch_async(serialQueue, ^{
      [self doTask1];
   });
   [self doTask2];
});
任务2:1 当前线程:<NSThread: 0x600002d38300>{number = 7, name = (null)}
任务2:2 当前线程:<NSThread: 0x600002d38300>{number = 7, name = (null)}
任务2:3 当前线程:<NSThread: 0x600002d38300>{number = 7, name = (null)}
任务2:4 当前线程:<NSThread: 0x600002d38300>{number = 7, name = (null)}
任务1:1 当前线程:<NSThread: 0x600002d38300>{number = 7, name = (null)}
任务1:2 当前线程:<NSThread: 0x600002d38300>{number = 7, name = (null)}
任务1:3 当前线程:<NSThread: 0x600002d38300>{number = 7, name = (null)}
任务1:4 当前线程:<NSThread: 0x600002d38300>{number = 7, name = (null)}
 
// 4. 当前队列串行,添加异步任务到另一串行队列(非主队列)
// 开启新线程,并发执行任务
dispatch_async(serialQueue, ^{ 
   dispatch_async(serialQueue2, ^{
      [self doTask1];
   });
   [self doTask2];
});
任务1:1 当前线程:<NSThread: 0x6000028b50c0>{number = 4, name = (null)}
任务2:1 当前线程:<NSThread: 0x6000028e4fc0>{number = 7, name = (null)}
任务1:2 当前线程:<NSThread: 0x6000028b50c0>{number = 4, name = (null)}
任务2:2 当前线程:<NSThread: 0x6000028e4fc0>{number = 7, name = (null)}
任务1:3 当前线程:<NSThread: 0x6000028b50c0>{number = 4, name = (null)}
任务2:3 当前线程:<NSThread: 0x6000028e4fc0>{number = 7, name = (null)}
任务2:4 当前线程:<NSThread: 0x6000028e4fc0>{number = 7, name = (null)}
任务1:4 当前线程:<NSThread: 0x6000028b50c0>{number = 4, name = (null)}

// 5. 当前队列串行,添加异步任务到主队列
// 无法开启新线程,先继续往下执行当前串行队列的任务,最后执行主队列的任务
dispatch_async(serialQueue, ^{ 
   dispatch_async(mainQueue, ^{
      [self doTask1];
   });
   [self doTask2];
});
任务2:1 当前线程:<NSThread: 0x600003575480>{number = 6, name = (null)}
任务2:2 当前线程:<NSThread: 0x600003575480>{number = 6, name = (null)}
任务2:3 当前线程:<NSThread: 0x600003575480>{number = 6, name = (null)}
任务2:4 当前线程:<NSThread: 0x600003575480>{number = 6, name = (null)}
任务1:1 当前线程:<_NSMainThread: 0x60000353c140>{number = 1, name = main}
任务1:2 当前线程:<_NSMainThread: 0x60000353c140>{number = 1, name = main}
任务1:3 当前线程:<_NSMainThread: 0x60000353c140>{number = 1, name = main}
任务1:4 当前线程:<_NSMainThread: 0x60000353c140>{number = 1, name = main}

// 6. 当前队列串行,添加同步任务到并发队列
// 无法开启新线程,串行执行任务
dispatch_async(serialQueue, ^{ 
   dispatch_sync(globalQueue, ^{
      [self doTask1];
   });
   [self doTask2];
});
任务1:1 当前线程:<NSThread: 0x600003f45c40>{number = 4, name = (null)}
任务1:2 当前线程:<NSThread: 0x600003f45c40>{number = 4, name = (null)}
任务1:3 当前线程:<NSThread: 0x600003f45c40>{number = 4, name = (null)}
任务1:4 当前线程:<NSThread: 0x600003f45c40>{number = 4, name = (null)}
任务2:1 当前线程:<NSThread: 0x600003f45c40>{number = 4, name = (null)}
任务2:2 当前线程:<NSThread: 0x600003f45c40>{number = 4, name = (null)}
任务2:3 当前线程:<NSThread: 0x600003f45c40>{number = 4, name = (null)}
任务2:4 当前线程:<NSThread: 0x600003f45c40>{number = 4, name = (null)}

// 7. 当前队列串行,添加异步任务到并发队列
// 开启新线程,并发执行任务
dispatch_async(serialQueue, ^{
    dispatch_async(globalQueue, ^{
        [self doTask1];
    });
    [self doTask2];
});
任务2:1 当前线程:<NSThread: 0x600003edf780>{number = 6, name = (null)}
任务1:1 当前线程:<NSThread: 0x600003eb2700>{number = 5, name = (null)}
任务2:2 当前线程:<NSThread: 0x600003edf780>{number = 6, name = (null)}
任务1:2 当前线程:<NSThread: 0x600003eb2700>{number = 5, name = (null)}
任务2:3 当前线程:<NSThread: 0x600003edf780>{number = 6, name = (null)}
任务1:3 当前线程:<NSThread: 0x600003eb2700>{number = 5, name = (null)}
任务2:4 当前线程:<NSThread: 0x600003edf780>{number = 6, name = (null)}
任务1:4 当前线程:<NSThread: 0x600003eb2700>{number = 5, name = (null)}

 
// 8. 当前队列并发,添加同步任务到当前并发队列
// 无法开启新线程,串行执行任务
dispatch_async(globalQueue, ^{
    dispatch_sync(globalQueue, ^{
        [self doTask1];
    });
    [self doTask2];
});
任务1:1 当前线程:<NSThread: 0x600000e08f80>{number = 7, name = (null)}
任务1:2 当前线程:<NSThread: 0x600000e08f80>{number = 7, name = (null)}
任务1:3 当前线程:<NSThread: 0x600000e08f80>{number = 7, name = (null)}
任务1:4 当前线程:<NSThread: 0x600000e08f80>{number = 7, name = (null)}
任务2:1 当前线程:<NSThread: 0x600000e08f80>{number = 7, name = (null)}
任务2:2 当前线程:<NSThread: 0x600000e08f80>{number = 7, name = (null)}
任务2:3 当前线程:<NSThread: 0x600000e08f80>{number = 7, name = (null)}
任务2:4 当前线程:<NSThread: 0x600000e08f80>{number = 7, name = (null)}

// 9.当前队列并发,添加同步任务到另一并发队列
// 无法开启新线程,串行执行任务
dispatch_async(globalQueue, ^{  
    dispatch_sync(concurrentQueue, ^{
       [self doTask1];
    });
    [self doTask2];
});
任务1:1 当前线程:<NSThread: 0x6000024d8b80>{number = 5, name = (null)}
任务1:2 当前线程:<NSThread: 0x6000024d8b80>{number = 5, name = (null)}
任务1:3 当前线程:<NSThread: 0x6000024d8b80>{number = 5, name = (null)}
任务1:4 当前线程:<NSThread: 0x6000024d8b80>{number = 5, name = (null)}
任务2:1 当前线程:<NSThread: 0x6000024d8b80>{number = 5, name = (null)}
任务2:2 当前线程:<NSThread: 0x6000024d8b80>{number = 5, name = (null)}
任务2:3 当前线程:<NSThread: 0x6000024d8b80>{number = 5, name = (null)}
任务2:4 当前线程:<NSThread: 0x6000024d8b80>{number = 5, name = (null)}

// 10. 当前队列并发,添加异步任务到当前并发队列
// 开启新线程,异步执行任务
dispatch_async(globalQueue, ^{
    dispatch_async(globalQueue, ^{
        [self doTask1];
    });
    [self doTask2];
});
任务2:1 当前线程:<NSThread: 0x600000001e00>{number = 8, name = (null)}
任务1:1 当前线程:<NSThread: 0x600000001f80>{number = 7, name = (null)}
任务2:2 当前线程:<NSThread: 0x600000001e00>{number = 8, name = (null)}
任务1:2 当前线程:<NSThread: 0x600000001f80>{number = 7, name = (null)}
任务2:3 当前线程:<NSThread: 0x600000001e00>{number = 8, name = (null)}
任务1:3 当前线程:<NSThread: 0x600000001f80>{number = 7, name = (null)}
任务2:4 当前线程:<NSThread: 0x600000001e00>{number = 8, name = (null)}
任务1:4 当前线程:<NSThread: 0x600000001f80>{number = 7, name = (null)}

// 11. 当前队列并发,添加异步任务到另一并发队列
// 开启新线程,异步执行任务
dispatch_async(globalQueue, ^{
    dispatch_async(concurrentQueue, ^{
        [self doTask1];
    });
    [self doTask2];
});
任务1:1 当前线程:<NSThread: 0x600003dbab80>{number = 7, name = (null)}
任务2:1 当前线程:<NSThread: 0x600003dcf6c0>{number = 5, name = (null)}
任务1:2 当前线程:<NSThread: 0x600003dbab80>{number = 7, name = (null)}
任务2:2 当前线程:<NSThread: 0x600003dcf6c0>{number = 5, name = (null)}
任务1:3 当前线程:<NSThread: 0x600003dbab80>{number = 7, name = (null)}
任务2:3 当前线程:<NSThread: 0x600003dcf6c0>{number = 5, name = (null)}
任务2:4 当前线程:<NSThread: 0x600003dcf6c0>{number = 5, name = (null)}
任务1:4 当前线程:<NSThread: 0x600003dbab80>{number = 7, name = (null)}

// 12. 当前队列并发,添加同步任务到串行队列
// 无法开启新线程,串行执行任务
dispatch_async(globalQueue, ^{
    dispatch_sync(serialQueue, ^{
        [self doTask1];
    });
    [self doTask2];
});

任务1:1 当前线程:<NSThread: 0x6000028ec0c0>{number = 7, name = (null)}
任务1:2 当前线程:<NSThread: 0x6000028ec0c0>{number = 7, name = (null)}
任务1:3 当前线程:<NSThread: 0x6000028ec0c0>{number = 7, name = (null)}
任务1:4 当前线程:<NSThread: 0x6000028ec0c0>{number = 7, name = (null)}
任务2:1 当前线程:<NSThread: 0x6000028ec0c0>{number = 7, name = (null)}
任务2:2 当前线程:<NSThread: 0x6000028ec0c0>{number = 7, name = (null)}
任务2:3 当前线程:<NSThread: 0x6000028ec0c0>{number = 7, name = (null)}
任务2:4 当前线程:<NSThread: 0x6000028ec0c0>{number = 7, name = (null)}

// 13. 当前队列并发,添加异步任务到串行队列
// 开启新线程,并发执行任务。
dispatch_async(globalQueue, ^{
    dispatch_async(serialQueue, ^{
        [self doTask1];
    });
    [self doTask2];
});
任务2:1 当前线程:<NSThread: 0x600000b6ee00>{number = 5, name = (null)}
任务1:1 当前线程:<NSThread: 0x600000b24fc0>{number = 6, name = (null)}
任务2:2 当前线程:<NSThread: 0x600000b6ee00>{number = 5, name = (null)}
任务1:2 当前线程:<NSThread: 0x600000b24fc0>{number = 6, name = (null)}
任务1:3 当前线程:<NSThread: 0x600000b24fc0>{number = 6, name = (null)}
任务2:3 当前线程:<NSThread: 0x600000b6ee00>{number = 5, name = (null)}
任务1:4 当前线程:<NSThread: 0x600000b24fc0>{number = 6, name = (null)}
任务2:4 当前线程:<NSThread: 0x600000b6ee00>{number = 5, name = (null)}
 

总结:

- 当前队列串行,添加同步任务到当前串行队列,死锁
- 当前队列串行,添加同步任务到另一串行队列,无法开启新线程,串行执行任务
- 当前队列串行,添加异步任务到当前串行队列,无法开启新线程,串行执行任务,先继续往下执行当前串行队列的任务,
  最后执行异步添加的任务
- 当前队列串行,添加异步任务到另一串行队列(非主队列),开启新线程,并发执行任务
- 当前队列串行,添加异步任务到主队列,无法开启新线程,串行执行任务
- 当前队列串行,添加同步任务到并发队列,串行执行任务
- 当前队列串行,添加异步任务到并发队列,并发执行任务

- 当前队列并发,添加同步添加任务到当前并发队列,无法开启新线程,串行执行任务
- 当前队列并发,添加同步任务到另一并发队列,无法开启新线程,串行执行任务
- 当前队列并发,添加异步任务到当前并发队列,开启新线程,并发执行任务
- 当前队列并发,添加异步任务到另一并发队列,开启新线程,并发执行任务
- 当前队列并发,添加同步任务到串行队列,无法开启新线程,串行执行任务
- 当前队列并发,添加异步任务到串行队列,开启新线程,并发执行任务。

对于任务执行顺序:

在串行队列执行同步或异步任务,在并发队列执行同步任务,都是在某一线程串行执行任务,任务执行顺序完全相同。
只有在并发队列执行异步任务,才真正做到了任务的并发执行

对于是否能够开启新线程

同步一定无法开启新线程
异步可以开启新线程,但往当前串行队列或者主队列异步添加任务,无法开启新线程

对于任务串行并发执行

同步任务一定串行执行
异步任务大部分情况是并发执行的,但往当前串行队列异步添加任务,任务则会串行执行,且异步添加的任务放在队列最后执行。

对于死锁

往当前串行队列添加同步任务,会卡住当前串行队列,产生死锁

2.3 死锁

借着上文的死锁情况,我们来分析一下,死锁究竟是怎么引发的?

// 1. 当前队列串行,添加同步任务到当前串行队列
// 死锁
dispatch_async(serialQueue, ^{
    dispatch_sync(serialQueue, ^{
        [self doTask1];
    });
    [self doTask2];
});

// 2. 当前队列串行,添加同步任务到另一串行队列
// 无法开启新线程,串行执行任务
dispatch_async(serialQueue, ^{
    dispatch_sync(serialQueue2, ^{
        [self doTask1];
    });
    [self doTask2];
});
 

可以发现一个很有趣的情况,往当前串行队列serialQueue中添加task1死锁,往其他串行队列添加task1,不会死锁

例1分析

把dispatch_async(serialQueue, ^{});中的block看做task0

同步要求当前线程立即执行同步任务。串行队列中添加同步任务task0后立即执行,task0中又往串行队列中添加同步任务task1,立即执行,但是队列是FIFO的,task1需要等待task0执行完成才能执行,task0又需要task1执行完成才算任务结束,两者相互等待,产生死锁。

截屏2022-01-21 下午6.50.00.png

例2分析

同步要求当前线程立即执行同步任务。串行队列中添加同步任务task0后立即执行,task0中又往另一个串行队列中添加同步任务task1,立即执行task1,执行完后执行task2,执行完task2后task0执行完成。

截屏2022-01-21 下午7.04.11.png

死锁最常见于主队列添加同步任务,看了例1分析,会很容易地明白其中死锁原理,本身主队列就是一个特殊的串行队列,往主队列添加同步任务,会与前一个任务互相等待,产生死锁。

串行队列的任务相互等待才是引起死锁的原因,所以

死锁是针对队列来讲的,不要和线程混为一谈

往当前串行队列添加同步任务,会卡住当前串行队列,产生死锁

三. 进阶篇

除了上述的GCD的基础用法,还有一些GCD的高级用法,主要是在复杂多线程场景中一些特殊的需求,包括队列组,栅栏函数,信号量等

3.1 队列组

队列组是一种对观察者设计模式的实现,主要用于对多个异步请求有依赖关系的任务。当多个异步任务都请求完成的时候,会通知队列组,以便做后续的操作。
官方是这么介绍的:

A group of blocks submitted to queues for asynchronous invocation.
一组提交给队列进行异步调用的代码块

具体啥意思呢?比如说我们要绘制一张依赖于另外两张图片的海报,所以先异步下载两张图片,然后在主线程绘制新海报。

如果用之前的异步队列应该怎么写?

dispatch_async(concurrentQueue, ^{
    NSLog(@"图片1开始下载啦");
    sleep(2); // 模拟耗时操作
    NSLog(@"图片1下载好啦");
    dispatch_async(concurrentQueue, ^{
        NSLog(@"图片2开始下载啦");
        sleep(3); // 模拟耗时操作
        NSLog(@"图片2下载好啦");
        dispatch_async(dispatch_get_main_queue(), ^{
            NSLog(@"绘制海报啦");
        });
    });
});
// 打印
2022-01-21 22:18:41.933859+0800  图片1开始下载啦
2022-01-21 22:18:43.935824+0800  图片1下载好啦
2022-01-21 22:18:43.936010+0800  图片2开始下载啦
2022-01-21 22:18:46.943587+0800  图片2下载好啦
2022-01-21 22:18:46.944198+0800  绘制海报啦

一方面,代码一层套一层,看着恶心
一方面,看看耗时,总耗时=图片1的耗时+图片2的耗时,很差劲!

这时候队列组就派上了用场。

dispatch_group_t group = dispatch_group_create();
dispatch_queue_t downloadQueue = dispatch_queue_create("download", DISPATCH_QUEUE_CONCURRENT);
dispatch_group_async(group, downloadQueue, ^{
    NSLog(@"图片1开始下载啦");
    sleep(2);
    NSLog(@"图片1下载好啦");
});
dispatch_group_async(group, downloadQueue, ^{
    NSLog(@"图片2开始下载啦");
    sleep(3);
    NSLog(@"图片2下载好啦");
});
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
    NSLog(@"绘制海报啦");
});
// 打印
2022-01-21 22:41:12.017102+0800 图片1开始下载啦
2022-01-21 22:41:12.017126+0800 图片2开始下载啦
2022-01-21 22:41:14.023804+0800 图片1下载好啦
2022-01-21 22:41:15.022048+0800 图片2下载好啦
2022-01-21 22:41:15.022342+0800 绘制海报啦

一方面,没有了多层嵌套,代码干净利落
一方面,总耗时 = max(图片1的耗时,图片2的耗时)

3.2 栅栏函数

顾名思义,栅栏函数就是设置一个栅栏,等前面的任务执行完再执行栅栏里的任务,和队列组的用法有些相似之处。

官方文档是这样介绍的

// Submits a barrier block for asynchronous execution and returns immediately.
// 提交一个栅栏任务去异步执行并且立即return
dispatch_barrier_async(dispatch_queue_t queue, dispatch_block_t block);
// Submits a barrier block object for execution and waits until that block completes.
// 提交一个栅栏任务去(同步)执行并且等待至任务完成
dispatch_barrier_sync(dispatch_queue_t queue, dispatch_block_t block);

详细文档

Calls to this function always return immediately after the block is submitted and never wait for the block to be invoked. When the barrier block reaches the front of a private concurrent queue, it is not executed immediately. Instead, the queue waits until its currently executing blocks finish executing. At that point, the barrier block executes by itself. Any blocks submitted after the barrier block are not executed until the barrier block completes. 

The queue you specify should be a concurrent queue that you create yourself using the dispatch_queue_create function. If the queue you pass to this function is a serial queue or one of the global concurrent queues, this function behaves like the dispatch_async function.

This function submits a barrier block to a dispatch queue for synchronous execution. Unlike dispatch_barrier_async, this function does not return until the barrier block has finished. Calling this function and targeting the current queue results in deadlock.

When the barrier block reaches the front of a private concurrent queue, it is not executed immediately. Instead, the queue waits until its currently executing blocks finish executing. At that point, the queue executes the barrier block by itself. Any blocks submitted after the barrier block are not executed until the barrier block completes.

The queue you specify should be a concurrent queue that you create yourself using the dispatch_queue_create function. If the queue you pass to this function is a serial queue or one of the global concurrent queues, this function behaves like the dispatch_syncfunction.

Unlike with dispatch_barrier_async, no retain is performed on the target queue. Because calls to this function are synchronous, it "borrows" the reference of the caller. Moreover, no Block_copy is performed on the block.

As an optimization, this function invokes the barrier block on the current thread when possible.

dispatch_barrier_async/dispatch_barrier_sync的文档给出了很多细节注意点:

  • 栅栏只能对同一并发队列进行任务节点控制,这点和队列组不一样,队列组可以跨队列。
  • 栅栏的队列必须是自定义并发队列,否则相当于dispatch_sync/dispatch_async
  • dispatch_barrier_async会开启新线程异步执行任务, dispatch_barrier_sync不会开启新线程
  • dispatch_barrier_async不用等待栅栏任务执行结束就执行下面的任务,dispatch_barrier_sync会等待栅栏里的任务执行完成再执行下面的任务。

用代码实验一下

// 异步栅栏+自定义并发队列
dispatch_async(concurrentQueue, ^{
    NSLog(@"图片1开始下载啦");
    sleep(2);
    NSLog(@"图片1下载好啦 %@", [NSThread currentThread]);
});
dispatch_async(concurrentQueue, ^{
    NSLog(@"图片2开始下载啦");
    sleep(2);
    NSLog(@"图片2下载好啦 %@",[NSThread currentThread]);
});

dispatch_barrier_async(concurrentQueue, ^{
    sleep(2);
    NSLog(@"要回到主线程绘制海报啦 当前线程 %@", [NSThread currentThread]);
});
NSLog(@"继续执行后面任务了");
    
// 打印
2022-01-24 15:04:57.002131+0800 Demo-OC[6197:186112] 继续执行后面任务了
2022-01-24 15:04:57.002133+0800 Demo-OC[6197:186170] 图片1开始下载啦
2022-01-24 15:04:57.002136+0800 Demo-OC[6197:186163] 图片2开始下载啦
2022-01-24 15:04:59.006487+0800 Demo-OC[6197:186170] 图片1下载好啦 <NSThread: 0x600003ea1200>{number = 6, name = (null)}
2022-01-24 15:04:59.006487+0800 Demo-OC[6197:186163] 图片2下载好啦 <NSThread: 0x600003ef2040>{number = 7, name = (null)}
2022-01-24 15:05:01.011588+0800 Demo-OC[6197:186163] 要回到主线程绘制海报啦 当前线程 <NSThread: 0x600003ef2040>{number = 7, name = (null)}

// 同步栅栏+自定义并发队列
dispatch_async(concurrentQueue, ^{
    NSLog(@"图片1开始下载啦");
    sleep(2);
    NSLog(@"图片1下载好啦 %@", [NSThread currentThread]);
});
dispatch_async(concurrentQueue, ^{
    NSLog(@"图片2开始下载啦");
    sleep(2);
    NSLog(@"图片2下载好啦 %@",[NSThread currentThread]);
});

dispatch_barrier_sync(concurrentQueue, ^{
    sleep(2);
    NSLog(@"要回到主线程绘制海报啦 当前线程 %@", [NSThread currentThread]);
});
NSLog(@"继续执行后面任务了");
// 打印
2022-01-24 15:05:53.837821+0800 Demo-OC[6218:187106] 图片2开始下载啦
2022-01-24 15:05:53.837823+0800 Demo-OC[6218:187108] 图片1开始下载啦
2022-01-24 15:05:55.839467+0800 Demo-OC[6218:187106] 图片2下载好啦 <NSThread: 0x6000010b1ec0>{number = 8, name = (null)}
2022-01-24 15:05:55.839467+0800 Demo-OC[6218:187108] 图片1下载好啦 <NSThread: 0x6000010adc00>{number = 6, name = (null)}
2022-01-24 15:05:57.840356+0800 Demo-OC[6218:187071] 要回到主线程绘制海报啦 当前线程 <_NSMainThread: 0x6000010a8000>{number = 1, name = main}
2022-01-24 15:05:57.840628+0800 Demo-OC[6218:187071] 继续执行后面任务了

除了做这种依赖任务性质的操作,栅栏函数还可以处理线程同步,这个放在线程安全里一起讲。

3.3 信号量

信号量也是iOS中观察者模式的一种实现,我们开启了N个线程,但是我们同时只允许5个线程工作。只有一个线程工作完了,才允许下一个线程进来。

对多线程编程来讲,这就是控制线程的最大并发数量。我们知道在NSOperationQueue中可以直接设置maxConcurrentOperationCount,在GCD中,我们用信号量来实现这个功能。

依旧用图片下载的例子, 如果我们现在同时有20张图片要下载,然后再回到主线程绘制海报,照之前队列组的做法全都异步下载,可不可以?可以,但是同时下载20张图片对性能影响可能比较大,最好同时只允许下载5张图片。

信号量的api很简单,总体来说就是三个方法

// 创建一个有初始值的信号量,value用来控制线程的最大并发数量
dispatch_semaphore_create(intptr_t value);
// 等待信号
// 如果信号量值>0,信号量初始值减1,继续执行下面代码
// 如果信号量值<=0,休眠等待直到信号量的值>0(timeout的值就是等待的时间)
dispatch_semaphore_wait(dispatch_semaphore_t dsema, dispatch_time_t timeout);
// 发送信号,让信号量的值加1
dispatch_semaphore_signal(dispatch_semaphore_t dsema);

用代码实现一下

- (void)viewDidLoad {
    [super viewDidLoad];
    _semaphore = dispatch_semaphore_create(5);
    for (int i = 0; i < 20; i++) {
        [[[NSThread alloc] initWithTarget:self selector:@selector(downloadImage) object:nil] start];
    }
}

- (void)downloadImage {
    dispatch_semaphore_wait(_semaphore, DISPATCH_TIME_FOREVER);
    NSLog(@"图片开始下载啦");
    sleep(2);
    NSLog(@"图片下载好啦");
    dispatch_semaphore_signal(_semaphore);
}
// 打印
2022-01-24 11:42:03.030225+0800 图片开始下载啦
2022-01-24 11:42:03.030240+0800 图片开始下载啦
2022-01-24 11:42:03.030229+0800 图片开始下载啦
2022-01-24 11:42:03.030319+0800 图片开始下载啦
2022-01-24 11:42:03.030321+0800 图片开始下载啦
2022-01-24 11:42:05.031118+0800 图片下载好啦
2022-01-24 11:42:05.031118+0800 图片下载好啦
2022-01-24 11:42:05.031137+0800 图片下载好啦
2022-01-24 11:42:05.031142+0800 图片下载好啦
2022-01-24 11:42:05.031142+0800 图片下载好啦
2022-01-24 11:42:05.031547+0800 图片开始下载啦
2022-01-24 11:42:05.031548+0800 图片开始下载啦
2022-01-24 11:42:05.031569+0800 图片开始下载啦
2022-01-24 11:42:05.031581+0800 图片开始下载啦
2022-01-24 11:42:05.031641+0800 图片开始下载啦
2022-01-24 11:42:07.035715+0800 图片下载好啦
2022-01-24 11:42:07.035715+0800 图片下载好啦
2022-01-24 11:42:07.035716+0800 图片下载好啦
2022-01-24 11:42:07.035715+0800 图片下载好啦
2022-01-24 11:42:07.035715+0800 图片下载好啦
2022-01-24 11:42:07.036092+0800 图片开始下载啦
2022-01-24 11:42:07.036107+0800 图片开始下载啦
2022-01-24 11:42:07.036117+0800 图片开始下载啦
2022-01-24 11:42:07.036129+0800 图片开始下载啦
2022-01-24 11:42:07.036130+0800 图片开始下载啦
2022-01-24 11:42:09.041318+0800 图片下载好啦
2022-01-24 11:42:09.041334+0800 图片下载好啦
2022-01-24 11:42:09.041347+0800 图片下载好啦
2022-01-24 11:42:09.041352+0800 图片下载好啦
2022-01-24 11:42:09.041350+0800 图片下载好啦
2022-01-24 11:42:09.041680+0800 图片开始下载啦
2022-01-24 11:42:09.041749+0800 图片开始下载啦
2022-01-24 11:42:09.041767+0800 图片开始下载啦
2022-01-24 11:42:09.041760+0800 图片开始下载啦
2022-01-24 11:42:09.041783+0800 图片开始下载啦
2022-01-24 11:42:11.045680+0800 图片下载好啦
2022-01-24 11:42:11.045680+0800 图片下载好啦
2022-01-24 11:42:11.047088+0800 图片下载好啦
2022-01-24 11:42:11.047116+0800 图片下载好啦
2022-01-24 11:42:11.047130+0800 图片下载好啦

四. 线程安全

看上面代码,信号量很好地控制了线程的最大并发数量,但是如果再加上一个需求,如果我们要在下载完8张图片后去做一件事情,这个时候又该怎么做?

- (void)viewDidLoad {
    [super viewDidLoad];
    _semaphore = dispatch_semaphore_create(5);
    _count = 0;
    for (int i = 1; i <= 20; i++) {
        [[[NSThread alloc] initWithTarget:self selector:@selector(downloadImage) object:nil] start];
    }
}

- (void)downloadImage {
    dispatch_semaphore_wait(_semaphore, DISPATCH_TIME_FOREVER);
    NSLog(@"图片开始下载啦");
    sleep(2);
    _count++;
    NSLog(@"第%d图片下载好啦", _count);
    if (_count ==  8) {
        NSLog(@"do something");
    }
    dispatch_semaphore_signal(_semaphore);
}
// 打印
2022-01-24 13:04:16.282125+0800 图片开始下载啦
2022-01-24 13:04:16.282127+0800 图片开始下载啦
2022-01-24 13:04:16.282144+0800 图片开始下载啦
2022-01-24 13:04:16.282171+0800 图片开始下载啦
2022-01-24 13:04:16.282190+0800 图片开始下载啦
2022-01-24 13:04:18.287153+0800 第1图片下载好啦
2022-01-24 13:04:18.287153+0800 第2图片下载好啦
2022-01-24 13:04:18.287153+0800 第2图片下载好啦
2022-01-24 13:04:18.287181+0800 第4图片下载好啦
2022-01-24 13:04:18.287183+0800 第3图片下载好啦
2022-01-24 13:04:18.287428+0800 图片开始下载啦
2022-01-24 13:04:18.287439+0800 图片开始下载啦
2022-01-24 13:04:18.287462+0800 图片开始下载啦
2022-01-24 13:04:18.287464+0800 图片开始下载啦
2022-01-24 13:04:18.287488+0800 图片开始下载啦
2022-01-24 13:04:20.287629+0800 第5图片下载好啦
2022-01-24 13:04:20.287629+0800 第5图片下载好啦
2022-01-24 13:04:20.287674+0800 第6图片下载好啦
2022-01-24 13:04:20.287786+0800 图片开始下载啦
2022-01-24 13:04:20.287825+0800 图片开始下载啦
2022-01-24 13:04:20.287845+0800 图片开始下载啦
2022-01-24 13:04:20.287862+0800 第7图片下载好啦
2022-01-24 13:04:20.287880+0800 第8图片下载好啦
2022-01-24 13:04:20.288072+0800 do something
2022-01-24 13:04:20.288075+0800 do something
2022-01-24 13:04:20.288214+0800 图片开始下载啦
2022-01-24 13:04:20.288233+0800 图片开始下载啦
2022-01-24 13:04:22.292002+0800 第10图片下载好啦
2022-01-24 13:04:22.292002+0800 第9图片下载好啦
2022-01-24 13:04:22.292017+0800 第11图片下载好啦
2022-01-24 13:04:22.292023+0800 第12图片下载好啦
2022-01-24 13:04:22.292031+0800 第13图片下载好啦
2022-01-24 13:04:22.292355+0800 图片开始下载啦
2022-01-24 13:04:22.292355+0800 图片开始下载啦
2022-01-24 13:04:22.292380+0800 图片开始下载啦
2022-01-24 13:04:22.292397+0800 图片开始下载啦
2022-01-24 13:04:22.292444+0800 图片开始下载啦
2022-01-24 13:04:24.292628+0800 第15图片下载好啦
2022-01-24 13:04:24.292628+0800 第16图片下载好啦
2022-01-24 13:04:24.292628+0800 第14图片下载好啦
2022-01-24 13:04:24.292902+0800 第17图片下载好啦
2022-01-24 13:04:24.293007+0800 第18图片下载好啦

很明显,图片的下载完成顺序是不对的,这是为什么呢?因为上面的信号量只针对了同时最多只有五个线程下载图片,但是图片的下载索引没有限制同时只能有一个线程修改。

多线程对同一块资源的访问,容易引起数据错乱问题。我们通常只允许同时读,但不允许同时写,也就是说,对于写操作,我们需要线程同步。线程同步要怎么实现?我们一般用锁技术来实现,给需要线程同步的任务加一把锁,使得同一时间只有一个线程能够访问。

GCD中的锁技术有哪些?

4.1 信号量锁

信号量的初始值设置为1,就可以使同一块资源同一时间只能被一个线程访问。 上述例子,我们给它加一个针对索引修改的信号量。

- (void)viewDidLoad {
    [super viewDidLoad];
    _semaphore = dispatch_semaphore_create(5);
    _indexSemaphone = dispatch_semaphore_create(1);
    _count = 0;
    for (int i = 1; i <= 20; i++) {
        [[[NSThread alloc] initWithTarget:self selector:@selector(downloadImage) object:nil] start];
    }
}

- (void)downloadImage {
    dispatch_semaphore_wait(_semaphore, DISPATCH_TIME_FOREVER);
    sleep(2);
    dispatch_semaphore_wait(_indexSemaphone, DISPATCH_TIME_FOREVER);
    _count++;
    NSLog(@"第%d图片下载好啦", _count);
    dispatch_semaphore_signal(_indexSemaphone);

    if (_count ==  8) {
        NSLog(@"do something");
    }
    dispatch_semaphore_signal(_semaphore);
}
// 打印
2022-01-24 16:10:48.777692+0800 Demo-OC[7331:234611] 第1图片下载好啦
2022-01-24 16:10:48.777951+0800 Demo-OC[7331:234613] 第2图片下载好啦
2022-01-24 16:10:48.778164+0800 Demo-OC[7331:234616] 第3图片下载好啦
2022-01-24 16:10:48.778496+0800 Demo-OC[7331:234614] 第4图片下载好啦
2022-01-24 16:10:48.778848+0800 Demo-OC[7331:234620] 第5图片下载好啦
2022-01-24 16:10:50.783141+0800 Demo-OC[7331:234612] 第6图片下载好啦
2022-01-24 16:10:50.783482+0800 Demo-OC[7331:234625] 第7图片下载好啦
2022-01-24 16:10:50.783722+0800 Demo-OC[7331:234618] 第8图片下载好啦
2022-01-24 16:10:50.783948+0800 Demo-OC[7331:234618] do something
2022-01-24 16:10:50.783989+0800 Demo-OC[7331:234619] 第9图片下载好啦
2022-01-24 16:10:50.784225+0800 Demo-OC[7331:234615] 第10图片下载好啦
2022-01-24 16:10:52.786884+0800 Demo-OC[7331:234621] 第11图片下载好啦
2022-01-24 16:10:52.787026+0800 Demo-OC[7331:234623] 第12图片下载好啦
2022-01-24 16:10:52.787147+0800 Demo-OC[7331:234622] 第13图片下载好啦
2022-01-24 16:10:52.787339+0800 Demo-OC[7331:234624] 第14图片下载好啦
2022-01-24 16:10:52.787470+0800 Demo-OC[7331:234617] 第15图片下载好啦
2022-01-24 16:10:54.790658+0800 Demo-OC[7331:234627] 第16图片下载好啦
2022-01-24 16:10:54.790777+0800 Demo-OC[7331:234626] 第17图片下载好啦
2022-01-24 16:10:54.790868+0800 Demo-OC[7331:234628] 第18图片下载好啦
2022-01-24 16:10:54.791026+0800 Demo-OC[7331:234629] 第19图片下载好啦
2022-01-24 16:10:54.791109+0800 Demo-OC[7331:234630] 第20图片下载好啦

4.2 栅栏函数锁

给需要线程同步的任务添加上栅栏,同样可以实现让栅栏内的任务同步执行。 栅栏函数传入的队列一定是要自己创建的并发队列,传入串行或者是全局的并发队列,作用相当于dispatch_async

- (void)viewDidLoad {
    [super viewDidLoad];
    _semaphore = dispatch_semaphore_create(5);
    _concurrentQueue = dispatch_queue_create("concurrent", DISPATCH_QUEUE_CONCURRENT);
    _count = 0;
    for (int i = 1; i <= 20; i++) {
        [[[NSThread alloc] initWithTarget:self selector:@selector(downloadImage) object:nil] start];
    }
 }
 
- (void)downloadImage {
    dispatch_semaphore_wait(_semaphore, DISPATCH_TIME_FOREVER);
    sleep(2);
    dispatch_barrier_async(self.concurrentQueue, ^{
        self.count++;
        NSLog(@"第%d图片下载好啦", self.count);
        if (self.count ==  8) {
            NSLog(@"do something");
        }
    });
    dispatch_semaphore_signal(_semaphore);
}

更多的,栅栏函数一般会用于多读单写的操作,又称读写锁

- (void)viewDidLoad {
    [super viewDidLoad];
    _queue = dispatch_queue_create("concurrent", DISPATCH_QUEUE_CONCURRENT);

    for (int i = 0 ; i < 1000; i ++) {
        [self read];
        [self read];
        [self read];
        [self read];

        [self write];
        [self write];
        [self write];
        [self write];
    }
}
 
- (void)read {
    dispatch_async(_queue, ^{
        NSLog(@"读");
        sleep(1);
    });
}
- (void)write {
    dispatch_barrier_async(_queue, ^{
        NSLog(@"写");
        sleep(1);
    });
}