[译] Parallel programming with Swift: Basics

用Swift进行并行编程:基础部分

原文 《Parallel programming with Swift: Basics》
作者 | Jan Olbrich
翻译 | JACK
编辑 | JACK

大约一年前,我的团队开始了一个新项目。这次我们想利用从以前项目中学到的所有知识。其中一个决定便是我们要让所有的模型API异步化。这可以帮助我们在变更模型实现的同时,不会影响App的其余部分。如果我们的App能够处理异步调用,那么与后端、缓存、数据库之间进行通信都不成问题。而且,它还可以在我们进行协同开发时带来好处。

作为开发人员,我们必须了解并发性和并行性。否则,在编程时可能会给我们带来困扰和麻烦。那么让我们一起学习如何进行并发编程。

同步 VS 异步

那么同步和异步之间的真正区别是什么?想象一下,我们有一个任务队列。如果是同步,我们需要在下一个任务开始前处理完上一个任务。它的行为方式与FIFO 队列(先进先出)相同。

1_CQ86Ga7UXIpI6mNa_-dVow.jpeg

翻译成代码就是:每一条语句都会按顺序执行。

func method1() {
  statement1()
  statement2()
  statement3()
  statement4()
}
复制代码

所以同步意味着一次只能处理1个任务。

相比之下,异步可以同时处理多个任务。例如,它会处理 item1,暂停 item2,然后继续并完成 item1。

1_3NJytE_PeEZF4sQAQ2J2RQ.png

下面的代码展示了一个回调的过程,在调用callback1()之前,会先执行statement2

func method2() {
  statement1 {
    callback1()
  }
  statement2
}
复制代码

并发 VS 并行(Concurrency vs Parallelism)

并发性和并行性通常是可以互换使用的(甚至连维基百科在某些地方都进行了错误的使用)。这很容易混淆并导致问题产生。但如果区别清楚,这些问题很容易避免。让我们用一个例子来解释它:

试想一下,我们在位置 A 有一堆箱子(线程),我们想将它们运送到位置 B(执行线程)。为此,我们可以使用工人(CPU)。在同步环境下,我们只能使用 1 个工人来完成此操作。他一次携带 1 个箱子,从 A 运到 B 。

1_CXF9pmApDCBMjVqbvsiluA.png

但是如果我们可以同时使用多个工人。他们每个人都会拿一个箱子,这会大大提高我们的生产力,不是吗?由于我们使用多个工人,它会增加与我们拥有的工人数量相同的因素。只要至少有 2 个工人同时搬运箱子,他们就是并行的。

Parallelism is about executing work at the same time.(并行是两个线程互不抢占 CPU 资源,同时执行任务。)

1_sNx_YZnCgXKngrYjNac-ow.png

考虑一下,如果我们只有1个工人并且在任务中可能需要使用更多工人,会发生什么?我们应该考虑在处理时可能会有多个节点(CPU 运行时间划分成若干个时间段,再将时间段分配给各个线程执行),这就是并发的意义所在。这可以看作是将 A 到 B 之间分为多个步骤。工人可以将一个箱子从 A 搬运到整个距离的中点,然后再回到 A 去抓下一个箱子。使用多名工人时,我们可以让他们都将箱子搬运到不同的距离。通过这种方式,我们异步处理这些箱子。当我们有多个工人,我们可以并行处理这些箱子。

1_D_sAPRRyyYnq7EXsZHVdHQ.png

那么,并行和并发的区别就显而易见了。并行是"同时"执行任务,并发是强调在一段时间内,处理多个事务的能力,无需非得是同时。。并发可以是并行,也可以不是。大多数计算机和手机都是并行工作的(取决于内核数量),但我们所安装的应用程序是并发运行的。(在操作系统中,并发是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行,但任一个时刻点上只有一个程序在处理机上运行。

1_jGBryeDrAeYMLDF-C9_rzg.png

并发机制

每个操作系统都提供不同的工具来使用并发。在 iOS 中,我们依赖于进程和线程,还有调度队列(Dispatch Queues)。

进程

进程是应用程序的实例。它包含执行应用程序所需的一切,包括堆、栈和所有其他资源。

尽管 iOS 是一个多任务操作系统,但它不支持一个App拥有多个进程。因此,你的App只有一个进程(一个App就是一个进程)。macOS下,你可以使用Process 类生成新的子进程。子进程独立于父进程,但包含父进程在创建子进程时拥有的所有信息。如果你是在macOS下工作,你可以像下方的代码创建和执行一个进程:

let task = Process()
task.launchPath = "/bin/sh" //executable you want to run
task.arguments = arguments //here is the information you want to pass

task.terminationHandler = {
  // do here something in case the process terminates
}

task.launch()
复制代码

线程

线程可以理解为是一种轻量级的进程。与进程相比,线程与其所在进程共享内存,这可能会导致问题。例如有两个线程同时更改资源(例如变量)。这会导致当我们再次访问资源时,我们将得到不合理的结果。线程是 iOS(或任何 POSIX 兼容系统)上的有限资源。iOS中一个进程被限制为只能同时创建64个线程,某些情况下可能会超出这个限制。你可以通过以下方式创建和执行线程:

class CustomThread: Thread {
  override func main() {
    do_something
  }
}

let customThread = CustomThread()
customThread.start()
复制代码

调度队列

由于我们只有一个进程,并且线程被限制为最多64个,因此必须有其他方式来并发执行代码。Apple的解决方案是调度队列。您可以将任务添加到调度队列中,并期望它在某个时间点被执行。苹果为我们提供了不同类型的调度队列。一种是串行队列(SerialQueue),在这种队列中,所有任务都将按照添加到队列中的顺序依次进行处理。另一个是并发队列(ConcurrentQueue),顾名思义,任务可以在这个队列中并发执行。

从上述来看,这并不算是真正意义上的并发,对吧?尤其是在使用串行队列时,没有带给我们任何明显的好处。并发队列也并没有让并发这件事变得更容易。我们确实有多线程,那么使用调度队列的意义何在?

考虑一下如果我们有多个队列会发生什么。我们可以在一个线程上运行一个队列,然后每当我们安排一个任务时,就将其添加到其中一个队列中。额外的,我们还可以根据优先级和当前工作负载合理分配要进入队列的任务,从而优化我们的系统资源。

1_i-dH4UH99Ykisz3kQZgrQQ.png

对于上述的实现,苹果称之为Grand Central Dispatch(简称 GCD)。那么,在iOS中是如何实现的呢?

DispatchQueue.main.async {
    // execute async on main thread
}
复制代码

调度队列最大的优势在于,它改变了并发编程的思维模型。我们可以摆脱在线程中思考问题的束缚,取而代之是将其视为推送到不同队列的Block,这要变得容易很多。

操作队列

Cocoa对GCD的高级抽象就是操作队列。你创建的操作不是一组离散的工作单元,这些任务将被推送到一个操作队列中,然后按正确的顺序执行。这里有不同类型的队列:在主线程上执行的主队列(main queue)和不在主线程上执行的自定义队列(custom queues)。

let operationQueue: OperationQueue = OperationQueue()
operationQueue.addOperations([operation1], waitUntilFinished: false)
复制代码

创建 Operation 对象可以通过两种方式完成。使用Block或子类化Operation进行创建。如果使用子类化,不要忘记最后调用的finish(),否则操作永远不会停止。

class CustomOperation: Operation {
    override func main() {
        guard isCancelled == false else {
            finish()
            return
        }
        
        // Do something
        
        finish()
    }
    
    func finish() {
        willChangeValue(forKey: "isFinished")
        willChangeValue(forKey: "isExecuting")
        
        ...
        
        didChangeValue(forKey: "isExecuting")
        didChangeValue(forKey: "isFinished")
    }
}
复制代码

有一个非常好的建议,就是在Operation对象之间使用依赖,如果 operation2 依赖于 Operation1,那么只有在 Operation1 执行完成之后才会执行 operation2。

operation2.addDependency(operation1) //execute operation1 before operation2
复制代码

Run Loops

Run Loop 类似于队列。系统执行队列中的所有任务,然后从头开始。例如屏幕重绘,就是由 Run Loop 完成的。需要注意的是,Run Loops 并不是真正创建并发的方法。相反,它们绑定到单个线程。尽管如此,你可以在 Run Loop 中异步执行你的代码,这会减轻你考虑并发性的负担。不是每个线程都有一个 Run Loop,相反,它会在第一次被请求时创建。

在使用 Run Loops 时,你需要考虑它们有不同的模式(Mode)。例如,在设备上滚动时,主线程的 Run Loop 会变更 mode 并延迟所有传入的事件。一旦您的设备停止滚动,Run Loop 将返回其默认状态并处理所有事件。记住,Run Loop 总是需要一个输入源(Input Source),否则,它会立即退出。

控制并发的方法

我们看到操作系统提供了不同的途径来实现并发。但就像上文提到的,并发会产生一些问题。最容易产生,最难识别的问题就是多个并发任务访问同一资源。如果没有合理的机制来处理这些访问,就会导致资源竞争。那么,最常见的解决方案就是对资源的访问进行加锁。这样,其他线程就不能在锁定时访问资源,从而解决了资源竞争。

优先级反转

要理解不同的锁定机制,我们先要理解线程的优先级。不难猜到,高优先级的线程的执行要先于低优先级的线程。当低优先级的线程锁定资源时,高优先级的线程想要访问资源就必须等待,就相当于提升了较低优先级线程的优先级,这称为优先级反转。它可能导致高优先级的线程卡死,永远不会被执行。所以你肯定想避免这种情况。

想象一下,有两个高优先级线程(1 和 2)和一个低优先级线程(3)。如果 线程3 锁住了资源,线程1 想要访问,那么 线程1 不得不等待。由于 线程2 具有更高的优先级,因此会优先执行完其上的所有任务。由于3的优先级较低,线程3 卡住不会被执行,而 线程1 因无法拿到资源将被无限期地阻塞。

1_xqK9qAYhu2LuCxGqTTbvuA.png

优先级继承

优先级反转的解决方案是优先级继承。也就是说,只要 线程1 由于锁而被阻塞,线程1 就会放弃他的优先级,转让给 线程3,即 线程3 继承了 线程1 的优先级。因此 线程3 和 线程2 具有高优先级并且都被执行(取决于操作系统)。当 线程3 释放锁之后,高优先级将转移回 线程1,线程1 继续执行。

1_dJj-050OlozTQN_YY8vn4A.png

原子性

这里所说的原子性与数据库事务中提到的原子性相同。假如你想在一次操作中,一次性写入一个值。如果程序运行在32位操作系统下,类型是int64_t,并且没有原子性,此时可能会出现非常奇怪的事情。为什么?让我们看下会发生什么:

int64_t x = 0

Thread1:
x = 0xFFFF

Thread2:
x = 0xEEDD
复制代码

进行非原子性操作可能会导致,线程1开始写入x,但由于我们在32位操作系统上工作,我们不得不把0xFFFF拆分成两个0xFF分两次进行写入。

与此同时,当 线程2 决定把值写入 x 时,可能就会按下面的顺序进行操作:

Thread1: part1
Thread2: part1
Thread2: part2
Thread1: part2
复制代码

那么,最后我们将得到:

x == 0xEEFF
复制代码

这并不是我们想要的结果。因为x既不是0xFFFF,也不是0xEEDD。

如果使用原子性操作,那么就只会产生一个事务,事务中的操作,要么不做,要么全做。进而产生下面的结果:

Thread1: part1
Thread1: part2
Thread2: part1
Thread2: part2
复制代码

所以,x 最终的值为 线程2 中的赋值结果。Swift本身并没有实现原子性。在Swift evolution上有一个提议。但目前,你必须自己去实现它。

锁是一种防止多个线程同时访问相同资源的简单方法。线程首先会检查是否可以进入受保护的部分。如果它可以进入,它将锁定受保护的部分并继续。一旦退出,它就会解锁。如果在进入时,线程遭遇了锁定的部分,它将等待。这通常是通过睡眠和周期性唤醒来完成的,以检查资源是否仍然被锁定。

在 iOS 中,我们可以使用NSLock。但注意,释放锁时,所在线程必须与锁定时的线程相同。

let lock = NSLock()
lock.lock()
//do something
lock.unlock()
复制代码

除此之外,还有很多其他类型的锁可供我们使用。例如递归锁,使用递归锁,一个线程可以对一个资源多次加锁,一次锁定就要对应一次释放。在锁完全释放前,其他线程是被排除在外的。

另一种类型是读写锁。它适用于大型应用中,存在多个线程大量频繁读取资源,却很少写入时。只要没有线程写入资源,所有线程都可以访问它。一旦一个线程想要执行写入操作,它就会加锁资源。在释放锁之前,其他线程无法读取。

在进程级别,还有分布式锁。不同的是,如果进程被阻塞,它只向进程报告,进程可以决定如何处理这种情况。

自旋锁(Spinlock)

自旋锁由多个操作组成,会长期占用CPU资源,调用者线程不会睡眠,会一直处于忙等,过程中无上下文切换(自旋锁利用寄存器来存储线程状态)。这些操作和变化会消耗大量计算时间。如果能在很短时间内获得锁,并且不会长时间占用资源,则可以使用自旋锁。自旋锁基本思想是让等待线程轮询锁,使其处于忙等状态。这需要比进入休眠的线程消耗更多的资源。也正是自旋锁的特点造就了它在小规模操作中速度更快,效率更高。

这在理论上听起来不错,但 iOS 总是不同的。iOS 有一个叫做 DispatchQoS 的概念。在QoS机制下,系统会优先考虑那些具有更高服务等级(优先级)的任务,可能会发生根本不执行低优先级线程的情况。在这样的线程上设置自旋锁并且让更高优先级的线程尝试访问它,将导致高优先级线程使低优先级线程饿死,进而导致高优先级线程无法释放所需的资源并阻塞自身。因此,自旋锁在 iOS 上是被禁止的。

互斥锁(Mutex)

互斥锁可以跨线程,甚至可以跨进程,使用过程中有上下文的切换。遗憾的是,你不得不实现自己的 Mutex,因为 Swift 没有。你可以使用 C 的 pthread_mutex 来完成。

var m = pthread_mutex_t()
pthread_mutex_lock(&m)
// do something
pthread_mutex_unlock(&m)
复制代码

信号量(Semaphore)

信号量是一种用于支持线程同步中的互斥性的数据结构。它由一个计数器、一个 FIFO 队列以及方法 wait() 和 signal() 组成。

每次线程想要访问受保护的资源时,它都会在信号量上调用 wait() 方法。计数器-1,只要不小于0,就允许线程继续。否则,信号量会将线程存储在其队列中。每当线程退出受保护的部分时,它都会调用 signal() 来通知信号量。信号量会首先检查在队列中是否有线程在等待。如果有,它将唤醒线程,。如果没有,计数器+1。在 iOS 中,我们可以使用DispatchSemaphore来实现。

let s = DispatchSemaphore(value: 1)
_ = s.wait(timeout: DispatchTime.distantFuture)
// do something
s.signal()
复制代码

人们可能认为二进制信号量(binary semaphore – 计数器值为1的信号量)与互斥锁相同,但互斥锁是一种锁机制,而信号量是一种信号机制。这么说可能不是很明确,那么两者的区别在哪里?

锁机制是关于保护和管理对共享资源的访问。因此它可以防止多个线程同时访问一个资源。信号系统更像是在说 “嘿,我完成了!下一个继续!”。例如,如果你在手机(信号量)上听音乐(线程A)并且有来电(xianche线程B),则手机将获取共享资源(耳机)。完成后,手机会通过信号通知mp3播放器继续播放。互斥锁必须由同一线程加锁和解锁,信号量可以由一个线程释放资源,另一个线程得到资源。

所以我们得到了什么结论?假设有一个低优先级线程 (1)正在访问资源,另外有一个高优先级线程 (2),它刚刚在信号量上调用了 wait()。(2) 正在睡觉,等待信号量唤醒它。现在我们有一个线程 (3),它的优先级高于 (1)。这个线程与 QoS 结合会阻止 (1) 向信号量发信号,从而使其他两个线程都处于饥饿状态。所以 iOS 中的信号量没有优先级继承。

同步(Synchronized)

在 OC 中,可以使用 @synchronized 创建互斥锁。由于 Swift 没有,我们必须深入研究一下。你会发现,@synchronized 只是调用 objc_sync_enter。

let lock = self

objc_sync_enter(lock)
closure()
objc_sync_exit(lock)
复制代码

由于我在互联网上多次看到这个问题,所以也回答一下。据我所知,这不是私有方法,因此使用它不会在上架时被拒。

并发队列的调度

由于 Swift 中没有 Mutex,并且 @synchronized 也被移除了,因此 DispatchQueue 顺理成为 Swift 开发者优先使用的标准。在同步中使用时,DispatchQueue 具有与互斥锁相同的行为,因为所有操作都在同一个队列中排队,确保了唯一性和排他性。
缺点是它进行分配和上下文切换时消耗了大量时间。如果你的应用程序不需要任何高计算能力,这无关紧要,但如果你丢帧了,那你可能需要考虑其他不同的解决方案(例如互斥锁)。

调度屏障

使用Dispatch Barrier,我们可以为需要放在一起执行的操作创建一个受保护的区域。我们还可以控制异步代码的执行顺序。这样做听起来很奇怪,但想象一下你有一个耗时的任务要做,它可以分成几个部分,这些部分需要按序执行,同时也可以再次拆分为更小的部分。同一部分中,这些拆分后较小的部分可以异步运行。那么使用 Dispatch Barrier 就是用来在较小部分执行时,同步较大的部分,。

跳床(Trampoline)

Trampoline 并不是系统提供的真正机制。相反,它是一种可用于确保让方法在正确的线程上调用的方式。思路很简单,在方法一开始就检查是否在正确的线程上,否则就在正确的线程上调用自己并返回。有时你需要使用上面的介绍的锁机制,来实现一个等待过程。

func executeOnMain() {
  if !Thread.isMainThread {
    DispatchQueue.main.async(execute: {() -> Void in
      executeOnMain()
    })
    return
  }
  
  // do something
}
复制代码

记住,不要太频繁地使用它。这样做确实可以确保你处在正确的线程中,但与此同时,它页会使和你进行协同开发的人员感到困惑。他们可能不明白为什么你到处都在改变线程。某些时候,它会让你的代码混乱,并分散你的逻辑。

结束语

哇,这是一个相当沉重的帖子。进行并发编程有很多选择,这篇文章只是触及皮毛。同时,有很多机制可以这样做,需要考虑很多情况。每当我谈论线程时,我可能会惹恼工作中的每个人,但它们很重要,慢慢地我的同事开始同意。就在今天,我修复了一个Bug,一些操作异步访问了数组,并我们了解到 Swift 不支持原子操作。你猜怎么着?它以崩溃告终。如果我们所有人都对并发有更多的了解,这可能不会发生,但说实话,我一开始也没有想到。

了解你所能使用的工具是我能给你的最好的建议。通过上面的帖子,我希望你找到了并发的起点。祝你好运!

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享