并发说明:如何构建多线程iOS应用

Concurrency in iOS is a massive topic. So in this article I want to zoom in on a sub-topic concerning queues and the Grand Central Dispatch (GCD) framework.

iOS中的并发性是一个巨大的话题。 因此,在本文中,我想放大有关队列和中央中央调度(GCD)框架的子主题。

In particular, I wish to explore the differences between serial and concurrent queues, as well as the differences between synchronous and asynchronous execution.

特别是,我希望探讨串行队列和并发队列之间的差异,以及同步和异步执行之间的差异。

If you've never used GCD before, this article is a great place to start. If you have some experience with GCD, but are still curious about the topics mentioned above, I think you will still find it useful. And I hope you will pick up one or two new things along the way.

如果您以前从未使用过GCD,那么本文是一个不错的起点。 如果您对GCD有一定的经验,但是仍然对上述主题感到好奇,那么我认为您仍然会发现它很有用。 我希望您在此过程中能学到一两个新事物。

I created a SwiftUI companion app to visually demonstrate the concepts in this article. The app also has a fun short quiz that I encourage you to try before and after reading this article. Download the source code here, or get the public beta here.

我创建了一个SwiftUI配套应用,以直观地演示本文中的概念。 该应用程序还提供了一个有趣的简短测验,我鼓励您在阅读本文之前和之后进行尝试。 在此处下载源代码 ,或在此处获取公开Beta

I will begin with an introduction to GCD, followed by a detailed explanation on sync, async, serial and concurrent. Afterwards, I will cover some pitfalls when working with concurrency. Finally, I will end with a summary and some general advice.

我将先介绍GCD,然后再详细介绍同步,异步,串行和并发。 之后,在并发工作时,我将介绍一些陷阱。 最后,我将以总结和一些一般性建议作为结尾。

介绍 (Introduction)

Let’s start with a brief intro to GCD and dispatch queues. Feel free to skip to the Sync vs Async section if you are already familiar with the topic.

让我们从GCD和调度队列的简要介绍开始。 如果您已经熟悉该主题,请随时跳至“ 同步与异步”部分。

并发和中央调度 (Concurrency and Grand Central Dispatch)

Concurrency lets you take advantage of the fact that your device has multiple CPU cores. To make use of these cores, you will need to use multiple threads. However, threads are a low-level tool, and managing threads manually in an efficient manner is extremely difficult.

并发使您可以利用设备具有多个CPU内核的优势。 要使用这些核心,您将需要使用多个线程。 但是,线程是低级工具,以高效方式手动管理线程非常困难。

Grand Central Dispatch was created by Apple over 10 years ago as an abstraction to help developers write multi-threaded code without manually creating and managing the threads themselves.

Apple于10年前创建了Grand Central Dispatch,它是一种抽象,可帮助开发人员编写多线程代码,而无需自己手动创建和管理线程。

With GCD, Apple took an asynchronous design approach to the problem. Instead of creating threads directly, you use GCD to schedule work tasks, and the system will perform these tasks for you by making the best use of its resources. GCD will handle creating the requisite threads and will schedule your tasks on those threads, shifting the burden of thread management from the developer to the system.

通过GCD,Apple采用了异步设计方法 解决问题。 您可以使用GCD安排工作任务,而不必直接创建线程,系统将通过充分利用其资源来为您执行这些任务。 GCD将处理创建必要的线程,并将您的任务安排在这些线程上,从而将线程管理的负担从开发人员转移到系统上。

A big advantage of GCD is that you don’t have to worry about hardware resources as you write your concurrent code. GCD manages a thread pool for you, and it will scale from a single-core Apple Watch all the way up to a many-core MacBook Pro.

GCD的一大优点是,您在编写并发代码时不必担心硬件资源。 GCD为您管理线程池,它将从单核Apple Watch一直扩展到多核MacBook Pro。

调度队列 (Dispatch Queues)

These are the main building blocks of GCD which let you execute arbitrary blocks of code using a set of parameters that you define. The tasks in dispatch queues are always started in a first-in, first-out (FIFO) fashion. Note that I said started, because the completion time of your tasks depends on several factors, and is not guaranteed to be FIFO (more on that later.)

这些是GCD的主要构建块,可让您使用定义的一组参数执行任意代码块。 调度队列中的任务始终以先进先出(FIFO)的方式启动。 请注意,我说的是start ,因为您的任务的完成时间取决于几个因素,并且不能保证是FIFO(稍后会详细介绍)。

Broadly speaking, there are three kinds of queues available to you:

从广义上讲,您可以使用三种队列:

  • The Main dispatch queue (serial, pre-defined)

    主调度队列(串行,预定义)
  • Global queues (concurrent, pre-defined)

    全局队列(并发,预定义)
  • Private queues (can be serial or concurrent, you create them)

    专用队列(可以创建串行队列,也可以并发队列)

Every app comes with a Main queue, which is a serial queue that executes tasks on the main thread. This queue is responsible for drawing your application’s UI and responding to user interactions (touch, scroll, pan, etc.) If you block this queue for too long, your iOS app will appear to freeze, and your macOS app will display the infamous beach ball/spinning wheel.

每个应用程序都带有一个主队列,它是一个在主线程上执行任务的串行队列。 此队列负责绘制应用程序的UI并响应用户交互(触摸,滚动,平移等)。如果您阻塞此队列的时间过长,iOS应用程序将似乎死机,而macOS应用程序将显示臭名昭著的海滩球/纺车。

When performing a long-running task (network call, computationally intensive work, etc), we avoid freezing the UI by performing this work on a background queue. Then we update the UI with the results on the main queue:

在执行长时间运行的任务(网络调用,计算密集型工作等)时,我们通过在后台队列上执行此工作来避免冻结UI。 然后,我们使用主队列上的结果更新UI:

As a rule of thumb, all UI work must be executed on the Main queue. You can turn on the Main Thread Checker option in Xcode to receive warnings whenever UI work gets executed on a background thread.

根据经验,所有UI工作都必须在Main队列上执行。 您可以在Xcode中打开“主线程检查器”选项,以在UI工作在后台线程上执行时接收警告。

In addition to the main queue, every app comes with several pre-defined concurrent queues that have varying levels of Quality of Service (an abstract notion of priority in GCD.)

除了主队列之外,每个应用程序还带有几个预定义的并发队列,这些队列具有不同级别的服务质量 (GCD中优先级的抽象概念)。

For example, here’s the code to submit work asynchronously to the user interactive (highest priority) QoS queue:

例如,下面是将工作异步提交到用户交互式 (最高优先级)QoS队列的代码:

DispatchQueue.global(qos: .userInteractive).async {
    print("We're on a global concurrent queue!") 
}

Alternatively, you can call the default priority global queue by not specifying a QoS like this:

或者,您可以通过不指定以下QoS来调用默认优先级全局队列:

Additionally, you can create your own private queues using the following syntax:

此外,您可以使用以下语法创建自己的专用队列:

When creating private queues, it helps to use a descriptive label (such as reverse DNS notation), as this will aid you while debugging in Xcode’s navigator, lldb, and Instruments:

创建专用队列时,使用描述性标签(例如反向DNS表示法)会有所帮助,因为在Xcode的导航器,lldb和Instruments中进行调试时,这将为您提供帮助:

By default, private queues are serial (I’ll explain what this means shortly, promise!) If you want to create a private concurrent queue, you can do so via the optional attributes parameter:

默认情况下,私有队列是串行的 (稍后,我会解释,这是什么意思,诺言!)如果要创建私有并发队列,可以通过可选的attributes参数来实现:

let concurrent = DispatchQueue(label: "com.besher.serial-queue", attributes: .concurrent)
concurrent.sync {
    print("Private concurrent queue")
}

There is an optional QoS parameter as well. The private queues that you create will ultimately land in one of the global concurrent queues based on their given parameters.

也有一个可选的QoS参数。 您创建的专用队列最终将根据其给定参数进入全局并发队列之一。

任务是什么? (What’s in a task?)

I mentioned dispatching tasks to queues. Tasks can refer to any block of code that you submit to a queue using the sync or async functions. They can be submitted in the form of an anonymous closure:

我提到了将任务调度到队列。 任务可以引用您使用syncasync函数提交到队列的任何代码块。 它们可以以匿名闭包的形式提交:

DispatchQueue.global().async {
    print("Anonymous closure")
}

Or inside a dispatch work item that gets performed later:

或在稍后执行的调度工作项中:

Regardless of whether you dispatch synchronously or asynchronously, and whether you choose a serial or concurrent queue, all of the code inside a single task will execute line by line. Concurrency is only relevant when evaluating multiple tasks.

无论您是以同步方式还是异步方式调度,还是选择串行队列还是并发队列,单个任务中的所有代码都将逐行执行。 并发仅在评估多个任务时才有意义。

For example, if you have 3 loops inside the same task, these loops will always execute in order:

例如,如果在同一任务中有3个循环,则这些循环将始终按顺序执行:

DispatchQueue.global().async {
    for i in 0..<10 {
        print(i)
    }

    for _ in 0..<10 {
        print("🔵")
    }

    for _ in 0..<10 {
        print("💔")
    }
}

This code always prints out ten digits from 0 to 9, followed by ten blue circles, followed by ten broken hearts, regardless of how you dispatch that closure.

此代码始终打印出从0到9的十个数字,然后打印十个蓝色圆圈,再打印十个破碎的心,无论您如何调度该闭包。

Individual tasks can also have their own QoS level as well (by default they use their queue’s priority.) This distinction between queue QoS and task QoS leads to some interesting behaviour that we will discuss in the priority inversion section.

各个任务也可以具有自己的QoS级别(默认情况下,它们使用队列的优先级。)队列QoS和任务QoS之间的区别导致了一些有趣的行为,我们将在优先级倒置一节中进行讨论。

By now you might be wondering what serial and concurrent are all about. You might also be wondering about the differences between sync and async when submitting your tasks. This brings us to the crux of this article, so let’s dive in!

现在,您可能想知道串行并发到底是什么。 您可能还想知道提交任务时syncasync之间的区别。 这将我们带到了本文的关键,因此让我们开始吧!

同步与异步 (Sync vs Async)

When you dispatch a task to a queue, you can choose to do so synchronously or asynchronously using the sync and async dispatch functions. Sync and async primarily affect the source of the submitted task, that is the queue where it is being submitted from.

将任务分派到队列时,您可以选择使用syncasync分派函数来同步或异步进行。 同步和异步主要影响已提交任务的来源 ,即从中提交任务的队列。

When your code reaches a sync statement, it will block the current queue until that task completes. Once the task returns/completes, control is returned to the caller, and the code that follows the sync task will continue.

当您的代码到达sync语句时,它将阻塞当前队列,直到该任务完成。 任务返回/完成后,控制权将返回给调用者, sync任务之后的代码将继续。

Think of sync as synonymous with ‘blocking’.

sync视为“阻止”的同义词。

An async statement, on the other hand, will execute asynchronously with respect to the current queue, and immediately returns control back to the caller without waiting for the contents of the async closure to execute. There is no guarantee as to when exactly the code inside that async closure will execute.

另一方面, async语句将相对于当前队列异步执行,并立即将控制权返回给调用方,而无需等待async闭包的内容执行。 无法保证异步闭包中的确切代码何时执行。

当前队列? (Current queue?)

It may not be obvious what the source, or current, queue is, because it’s not always explicitly defined in the code.

源队列或当前队列是什么,可能并不明显,因为它并非始终在代码中明确定义。

For example, if you call your sync statement inside viewDidLoad, your current queue will be the Main dispatch queue. If you call the same function inside a URLSession completion handler, your current queue will be a background queue.

例如,如果您在viewDidLoad内部调用sync语句,则当前队列将是Main调度队列。 如果您在URLSession完成处理程序中调用相同的函数,则当前队列将是后台队列。

Going back to sync vs async, let’s take this example:

回到同步与异步,让我们举个例子:

DispatchQueue.global().sync {
    print("Inside")
}
print("Outside")
// Console output:
// Inside
// Outside

The above code will block the current queue, enter the closure and execute its code on the global queue by printing “Inside”, before proceeding to print “Outside”. This order is guaranteed.

上面的代码将阻塞当前队列,进入闭包并通过打印“ Inside”在全局队列上执行其代码,然后再打印“ Outside”。 此订单得到保证。

Let’s see what happens if we try async instead:

让我们看看如果尝试async ,会发生什么:

DispatchQueue.global().async {
    print("Inside")
}
print("Outside")
// Potential console output (based on QoS): 
// Outside
// Inside

Our code now submits the closure to the global queue, then immediately proceeds to run the next line. It will likely print “Outside” before “Inside”, but this order isn’t guaranteed. It depends on the QoS of the source and destination queues, as well as other factors that the system controls.

现在,我们的代码将闭包提交到全局队列,然后立即继续运行下一行。 它可能会在“内部”之前打印“外部”,但是不能保证此顺序。 它取决于源队列和目标队列的QoS,以及系统控制的其他因素。

Threads are an implementation detail in GCD — we do not have direct control over them and can only deal with them using queue abstractions. Nevertheless, I think it can be useful to ‘peek under the covers’ at thread behaviour to understand some challenges we might encounter with GCD.

线程是GCD中的一个实现细节-我们没有对其的直接控制,只能使用队列抽象来处理它们。 尽管如此,我认为“窥探线程”行为对于了解我们在使用GCD时可能会遇到的一些挑战很有用。

For instance, when you submit a task using sync, GCD optimizes performance by executing that task on the current thread (the caller.)

例如,当您使用sync提交任务时, GCD通过在当前线程 (调用方) 上执行该任务来优化性能

There is one exception however, which is when you submit a sync task to the main queue —  doing so will always run the task on the main thread and not the caller. This behaviour can have some ramifications that we will explore in the priority inversion section.

但是,有一个例外,那就是当您将同步任务提交到主队列时,这样做总是在主线程而不是调用者上运行任务。 这种行为可能会有一些后果,我们将在优先级反转部分中进行探讨。

使用哪一个? (Which one to use?)

When submitting work to a queue, Apple recommends using asynchronous execution over synchronous execution. However, there are situations where sync might be the better choice, such as when dealing with race conditions, or when performing a very small task. I will cover these situations shortly.

将工作提交到队列时, Apple建议使用异步执行而不是同步执行 。 但是,在某些情况下,例如处理竞争条件或执行非常小的任务时, sync可能是更好的选择。 我将在短期内介绍这些情况。

One large consequence of performing work asynchronously inside a function is that the function can no longer directly return its values (if they depend on the async work that’s being done). It must instead use a closure/completion handler parameter to deliver the results.

在函数内部异步执行工作的一个重要结果是该函数不再可以直接返回其值(如果它们依赖于正在完成的异步工作)。 相反,它必须使用闭包/完成处理程序参数来传递结果。

To demonstrate this concept, let’s take a small function that accepts image data, performs some expensive computation to process the image, then returns the result:

为了演示这个概念,让我们采用一个小函数,该函数接受图像数据,执行一些昂贵的计算来处理图像,然后返回结果:

func processImage(data: Data) -> UIImage? {
    guard let image = UIImage(data: data) else { return nil }
    // calling an expensive function
    let processedImage = upscaleAndFilter(image: image)
    return processedImage 
}

In this example, the function upscaleAndFilter(image:) might take several seconds, so we want to offload it into a separate queue to avoid freezing the UI. Let’s create a dedicated queue for image processing, and then dispatch the expensive function asynchronously:

在此示例中,函数upscaleAndFilter(image:)可能需要花费几秒钟,因此我们希望将其卸载到单独的队列中,以避免冻结UI。 让我们为图像处理创建一个专用队列,然后异步分派昂贵的函数:

There are two issues with this code. First, the return statement is inside the async closure, so it is no longer returning a value to the processImageAsync(data:) function, and currently serves no purpose.

此代码有两个问题。 首先,return语句位于异步闭包内部,因此它不再将值返回到processImageAsync(data:)函数,并且目前没有任何作用。

But the bigger issue is that our processImageAsync(data:) function is no longer returning any value, because the function reaches the end of its body before it enters the async closure.

但是更大的问题是我们的processImageAsync(data:)函数不再返回任何值,因为该函数在进入async闭包之前已到达其主体的末尾。

To fix this error, we will adjust the function so that it no longer directly returns a value. Instead, it will have a new completion handler parameter that we can call once our asynchronous function has completed its work:

要解决此错误,我们将调整函数,使其不再直接返回值。 相反,它将具有一个新的完成处理程序参数,一旦异步函数完成其工作,便可以调用该参数:

let imageProcessingQueue = DispatchQueue(label: "com.besher.image-processing")

func processImageAsync(data: Data, completion: @escaping (UIImage?) -> Void) {
    guard let image = UIImage(data: data) else {
        completion(nil)
        return
    }

    imageProcessingQueue.async {
        let processedImage =  self.upscaleAndFilter(image: image)
        completion(processedImage)
    }
}

As evident in this example, the change to make the function asynchronous has propagated to its caller, who now has to pass in a closure and handle the results asynchronously as well. By introducing an asynchronous task, you can potentially end up modifying a chain of several functions.

从该示例可以明显看出,使函数异步的更改已传播到其调用方,该调用方现在必须传递闭包并异步处理结果。 通过引入异步任务,您可能最终可能会修改一系列功能。

Concurrency and asynchronous execution add complexity to your project as we just observed. This indirection also makes debugging more difficult. That’s why it really pays off to think about concurrency early in your design — it’s not something you want to tack on at the end of your design cycle.

正如我们刚刚观察到的,并发和异步执行增加了项目的复杂性。 这种间接方式也使调试更加困难。 这就是为什么在设计初期就考虑并发确实会有所收获的原因-在设计周期的最后,您并不想这么做。

Synchronous execution, by contrast, does not increase complexity. Rather, it allows you to continue using return statements as you did before. A function containing a sync task will not return until the code inside that task has completed. Therefore it does not require a completion handler.

相反,同步执行不会增加复杂性。 相反,它允许您像以前一样继续使用return语句。 包含sync任务的函数在该任务中的代码完成之前不会返回。 因此,它不需要完成处理程序。

If you are submitting a tiny task (for example, updating a value), consider doing it synchronously. Not only does that help you keep your code simple, it will also perform better — Async is believed to incur an overhead that outweighs the benefit of doing the work asynchronously for tiny tasks that take under 1ms to complete.

如果要提交微小任务(例如,更新值),请考虑同步执行。 这不仅可以帮助您使代码保持简单,而且还可以提高性能-异步被认为会带来开销 ,而异步处理对于花费不到1毫秒才能完成的微小任务的好处不胜枚举。

If you are submitting a large task, however, like the image processing we performed above, then consider doing it asynchronously to avoid blocking the caller for too long.

但是,如果您要提交大型任务,例如上面我们执行的图像处理,那么请考虑异步执行,以避免阻塞调用者太长时间。

在同一队列上调度 (Dispatching on the same queue)

While it is safe to dispatch a task asynchronously from a queue into itself (for example, you can use .asyncAfter on the current queue), you can not dispatch a task synchronously from a queue into the same queue. Doing so will result in a deadlock that immediately crashes the app!

虽然可以安全地从队列中将任务异步地调度到自身中(例如,可以在当前队列上使用.asyncAfter ),但是不能将任务从队列中同步地调度到同一队列中。 这样做会导致死锁,立即使应用程序崩溃!

This issue can manifest itself when performing a chain of synchronous calls that lead back to the original queue. That is, you sync a task onto another queue, and when the task completes, it syncs the results back into the original queue, leading to a deadlock. Use async to avoid such crashes.

在执行导致返回原始队列的同步调用链时,此问题可能会显现出来。 也就是说,您将一个任务sync到另一个队列上,并且当任务完成时,它将结果同步回到原始队列中,从而导致死锁。 使用async以避免此类崩溃。

阻塞主队列 (Blocking the main queue)

Dispatching tasks synchronously from the main queue will block that queue, thereby freezing the UI, until the task is completed. Thus it’s better to avoid dispatching work synchronously from the main queue unless you’re performing really light work.

主队列同步分派任务将阻塞该队列,从而冻结UI,直到任务完成。 因此,最好避免从主队列同步分派工作,除非您执行的工作量很轻。

串行与并发 (Serial vs Concurrent)

Serial and concurrent affect the destination —  the queue in which your work has been submitted to run. This is in contrast to sync and async, which affected the source.

串行并发会影响目标 ,即您提交工作所在的队列。 这与同步异步相反,后者影响了

A serial queue will not execute its work on more than one thread at a time, regardless of how many tasks you dispatch on that queue. Consequently, the tasks are guaranteed to not only start, but also terminate, in first-in, first-out order.

无论您在该队列上分派多少任务,串行队列一次都不会在一个以上线程上执行其工作。 因此,保证任务不仅以先进先出的顺序开始而且终止。

Moreover, when you block a serial queue (using a sync call, semaphore, or some other tool), all work on that queue will halt until the block is over.

此外,当您阻止串行队列(使用sync调用,信号量或其他工具)时,该队列上的所有工作都将停止,直到阻止结束为止。

A concurrent queue can spawn multiple threads, and the system decides how many threads are created. Tasks always start in FIFO order, but the queue does not wait for tasks to finish before starting the next task, therefore tasks on concurrent queues can finish in any order.

并发队列可以产生多个线程,然后系统决定创建多少个线程。 任务始终以FIFO顺序开始 ,但是队列在开始下一个任务之前不会等待任务完成,因此并发队列上的任务可以以任何顺序完成。

When you perform a blocking command on a concurrent queue, it will not block the other threads on this queue. Additionally, when a concurrent queue gets blocked, it runs the risk of thread explosion. I will cover this in more detail later on.

当您在并发队列上执行阻止命令时,它不会阻止该队列上的其他线程。 此外,当并发队列被阻塞时,存在冒线程爆炸的危险。 稍后我将详细介绍。

The main queue in your app is serial. All the global pre-defined queues are concurrent. Any private dispatch queue you create is serial by default, but can be set to be concurrent using an optional attribute as discussed earlier.

您应用中的主队列是串行队列。 所有全局预定义队列都是并发的。 默认情况下,您创建的任何私有调度队列都是串行的,但是可以使用前面讨论的可选属性将其设置为并发。

It’s important to note here that the concept of serial vs concurrent is only relevant when discussing a specific queue. All queues are concurrent relative to each other.

重要的是要注意, 串行并发的概念仅在讨论特定队列时才有意义。 所有队列相对于彼此并发。

That is, if you dispatch work asynchronously from the main queue to a private serial queue, that work will be completed concurrently with respect to the main queue. And if you create two different serial queues, and then perform blocking work on one of them, the other queue is unaffected.

也就是说,如果您将工作从主队列异步分派到专用串行队列,则该工作将相对于主队列同时完成。 而且,如果创建两个不同的串行队列,然后对其中一个执行阻塞工作,则另一个队列不会受到影响。

To demonstrate the concurrency of multiple serial queues, let’s take this example:

为了演示多个串行队列的并发性,让我们举个例子:

let serial1 = DispatchQueue(label: "com.besher.serial1")
let serial2 = DispatchQueue(label: "com.besher.serial2")

serial1.async {
    for _ in 0..<5 { print("🔵") }
}

serial2.async {
    for _ in 0..<5 { print("🔴") }
}

Both queues here are serial, but the results are jumbled up because they execute concurrently in relation to each other. The fact that they’re each serial (or concurrent) has no effect on this result. Their QoS level determines who will generally finish first (order not guaranteed).

这里的两个队列都是串行的,但是由于它们相对于彼此并发执行,结果变得混乱。 它们都是串行的(或并发的)这一事实对这个结果没有影响。 他们的QoS级别决定了通常由谁先完成(顺序无法保证)。

If we want to ensure that the first loop finishes first before starting the second loop, we can submit the first task synchronously from the caller:

如果要确保在开始第二个循环之前首先完成第一个循环,则可以从调用方同步提交第一个任务:

let serial1 = DispatchQueue(label: "com.besher.serial1")
let serial2 = DispatchQueue(label: "com.besher.serial2")

serial1.sync { // <---- we changed this to 'sync'
    for _ in 0..<5 { print("🔵") }
}
// we don't get here until first loop terminates
serial2.async {
    for _ in 0..<5 { print("🔴") }
}

This is not necessarily desirable, because we are now blocking the caller while the first loop is executing.

这不一定是理想的,因为我们现在在执行第一个循环时阻止了调用者。

To avoid blocking the caller, we can submit both tasks asynchronously, but to the same serial queue:

为了避免阻塞调用者,我们可以异步提交两个任务,但可以将它们提交到同一串行队列中:

let serial = DispatchQueue(label: "com.besher.serial")

serial.async {
    for _ in 0..<5 { print("🔵") }
}

serial.async {
    for _ in 0..<5 { print("🔴") }
}

Now our tasks execute concurrently with respect to the caller, while also keeping their order intact.

现在,我们的任务针对调用者并发执行,同时还保持其顺序不变。

Note that if we make our single queue concurrent via the optional parameter, we go back to the jumbled results, as expected:

请注意,如果通过可选参数使单个队列并发,则可以像预期的那样返回混杂的结果:

let concurrent = DispatchQueue(label: "com.besher.concurrent", attributes: .concurrent)

concurrent.async {
    for _ in 0..<5 { print("🔵") }
}

concurrent.async {
    for _ in 0..<5 { print("🔴") }
}

Sometimes you might confuse synchronous execution with serial execution (at least I did), but they are very different things. For example, try changing the first dispatch on line 3 from our previous example to a sync call:

有时您可能会将同步执行与串行执行混淆(至少我做到了),但是它们是完全不同的。 例如,尝试将第3行的第一个调度从前面的示例更改为sync调用:

Suddenly, our results are back in perfect order. But this is a concurrent queue, so how could that happen? Did the sync statement somehow turn it into a serial queue?

突然,我们的结果恢复了完美的顺序。 但这是一个并发队列,那么怎么可能呢? sync语句是否以某种方式变成了串行队列?

The answer is no!

答案是否定的!

This is a bit sneaky. What happened is that we did not reach the async call until the first task had completed its execution. The queue is still very much concurrent, but inside this zoomed-in section of the code. it appears as if it were serial. This is because we are blocking the caller, and not proceeding to the next task, until the first one is finished.

这有点偷偷摸摸。 发生的事情是,直到第一个任务完成执行后,我们才到达async调用。 队列仍然非常并发,但是在代码的此放大部分内。 它看起来好像是串行的。 这是因为我们阻止了调用者,直到第一个任务完成才继续执行下一个任务。

If another queue somewhere else in your app tried submitting work to this same queue while it was still executing the sync statement, that work will run concurrently with whatever we got running here, because it’s still a concurrent queue.

如果您的应用程序中其他地方的另一个队列在仍在执行sync语句的同时尝试将工作提交到同一队列,则该工作与我们在此处运行的任何内容同时运行,因为它仍然是并发队列。

使用哪一个? (Which one to use?)

Serial queues take advantage of CPU optimizations and caching, and help reduce context switching.

串行队列利用了CPU优化和缓存的优势,并有助于减少上下文切换。

Apple recommends starting with one serial queue per subsystem in your app —  for example one for networking, one for file compression, etc. If the need arises, you can later expand to a hierarchy of queues per subsystem using the setTarget method or the optional target parameter when building queues.

Apple建议在您的应用程序中每个子系统从一个串行队列开始-例如,一个用于网络,一个用于文件压缩等。如果需要,您以后可以使用setTarget方法可选目标扩展到每个子系统的队列层次结构。建立队列时的参数

If you run into a performance bottleneck, measure your app’s performance then see if a concurrent queue helps. If you do not see a measurable benefit, it’s better to stick to serial queues.

如果遇到性能瓶颈,请评估应用程序的性能,然后查看并发队列是否有帮助。 如果看不到可衡量的好处,则最好坚持使用串行队列。

陷阱 (Pitfalls)

优先级倒置和服务质量 (Priority Inversion and Quality of Service)

Priority inversion is when a high priority task is prevented from running by a lower priority task, effectively inverting their relative priorities.

优先级反转是指高优先级任务被低优先级任务阻止运行,从而有效地反转其相对优先级。

This situation often occurs when a high QoS queue shares a resources with a low QoS queue, and the low QoS queue gets a lock on that resource.

当高QoS队列与低QoS队列共享资源并且低QoS队列锁定该资源时,通常会发生这种情况。

But I wish to cover a different scenario that is more relevant to our discussion —  it’s when you submit tasks to a low QoS serial queue, then submit a high QoS task to that same queue. This scenario also results in priority inversion, because the high QoS task has to wait on the lower QoS tasks to finish.

但我希望涵盖与我们的讨论更相关的另一种情况-即您将任务提交到低QoS串行队列,然后将高QoS任务提交到同一队列。 这种情况还会导致优先级倒置,因为高QoS任务必须等待低QoS任务才能完成。

GCD resolves priority inversion by temporarily raising the QoS of the queue that contains the low priority tasks that are ‘ahead’ of, or blocking, your high priority task.

GCD通过暂时提高队列的QoS来解决优先级倒置问题,该队列包含高优先级任务“超前”或阻塞的低优先级任务。

It’s kind of like having cars stuck in front of an ambulance. Suddenly they’re allowed to cross the red light just so that the ambulance can move (in reality the cars move to the side, but imagine a narrow (serial) street or something, you get the point :-P)

这有点像有救护车 前面卡住。 突然,他们被允许越过红灯,以便救护车可以移动(实际上,汽车向侧面移动,但是想象一条狭窄的(串行)街道或其他什么地方,您明白了:-P)

To illustrate the inversion problem, let’s start with this code:

为了说明反转问题,让我们从以下代码开始:

enum Color: String {
    case blue = "🔵"
    case white = "⚪️"
}

func output(color: Color, times: Int) {
    for _ in 1...times {
        print(color.rawValue)
    }
}

let starterQueue = DispatchQueue(label: "com.besher.starter", qos: .userInteractive)
let utilityQueue = DispatchQueue(label: "com.besher.utility", qos: .utility)
let backgroundQueue = DispatchQueue(label: "com.besher.background", qos: .background)
let count = 10

starterQueue.async {

    backgroundQueue.async {
        output(color: .white, times: count)
    }

    backgroundQueue.async {
        output(color: .white, times: count)
    }

    utilityQueue.async {
        output(color: .blue, times: count)
    }

    utilityQueue.async {
        output(color: .blue, times: count)
    }

    // next statement goes here
}

We create a starter queue (where we submit the tasks from), as well as two queues with different QoS. Then we dispatch tasks to each of these two queues, each task printing out an equal number of circles of a specific colour (utility queue is blue, background is white.)

我们创建一个启动程序队列( 从中提交任务),以及两个具有不同QoS的队列。 然后,我们将任务分派到这两个队列中的每个队列中,每个任务打印出相等数量的特定颜色的圆圈( 实用程序队列 是蓝色, 背景是白色。)

Because these tasks are submitted asynchronously, every time you run the app, you’re going to see slightly different results. However, as you would expect, the queue with the lower QoS (background) almost always finishes last. In fact, the last 10–15 circles are usually all white.

由于这些任务是异步提交的,因此每次运行该应用程序时,您都会看到略有不同的结果。 但是,正如您所期望的,具有较低QoS(后台)的队列几乎总是最后完成。 实际上,最后10到15个圆圈通常都是白色的。

But watch what happens when we submit a sync task to the background queue after the last async statement. You don’t even need to print anything inside the sync statement, just adding this line is enough:

但是请注意,当我们在最后一条异步语句之后将同步任务提交到后台队列时会发生什么。 您甚至不需要在sync语句中打印任何内容,只需添加以下行即可:

// add this after the last async statement, 
// still inside starterQueue.async
backgroundQueue.sync {}

The results in the console have flipped! Now, the higher priority queue (utility) always finishes last, and the last 10–15 circles are blue.

控制台中的结果已翻转! 现在,优先级较高的队列(实用程序)始终排在最后,最后10至15个圆圈为蓝色。

To understand why that happens, we need to revisit the fact that synchronous work is executed on the caller thread (unless you’re submitting to the main queue.)

为了理解为什么会发生这种情况,我们需要重新审视在调用者线程上执行同步工作的事实(除非您要提交到主队列。)

In our example above, the caller (starterQueue) has the top QoS (userInteractive.) Therefore, that seemingly innocuous sync task is not only blocking the starter queue, but it’s also running on the starter’s high QoS thread. The task therefore runs with high QoS, but there are two other tasks ahead of it on the same background queue that have background QoS. Priority inversion detected!

在上面的示例中,调用方(starterQueue)具有最高的QoS(userInteractive)。因此,看似无害的sync任务不仅阻塞了启动程序队列,而且还运行在启动程序的高QoS线程上。 因此,该任务以高QoS运行,但是在同一个具有后台 QoS的后台队列中,它前面还有其他两个任务。 检测到优先级反转!

As expected, GCD resolves this inversion by raising the QoS of the entire queue to temporarily match the high QoS task. Consequently, all the tasks on the background queue end up running at user interactive QoS, which is higher than the utility QoS. And that’s why the utility tasks finish last!

不出所料,GCD通过提高整个队列的QoS来临时匹配高QoS任务,从而解决了这种反转问题。 因此,后台队列上的所有任务最终都以用户交互 QoS运行,该交互交互式 QoS高于实用程序 QoS。 这就是实用程序任务最后完成的原因!

Side-note: If you remove the starter queue from that example and submit from the main queue instead, you will get similar results, as the main queue also has user interactive QoS.

旁注:如果从该示例中删除启动程序队列,然后从主队列提交,则将得到类似的结果,因为主队列还具有用户交互式 QoS。

To avoid priority inversion in this example, we need to avoid blocking the starter queue with the sync statement. Using async would solve that problem.

为了避免在此示例中发生优先级倒置,我们需要避免使用sync语句阻塞启动程序队列。 使用async将解决该问题。

Although it’s not always ideal, you can minimize priority inversions by sticking to the default QoS when creating private queues or dispatching to the global concurrent queue.

尽管这并不总是理想的,但是在创建专用队列或分派到全局并发队列时,可以通过坚持默认QoS来最大程度地减少优先级倒置。

螺纹爆炸 (Thread explosion)

When you use a concurrent queue, you run the risk of thread explosion if you’re not careful. This can happen when you try to submit tasks to a concurrent queue that is currently blocked (for example with a semaphore, sync, or some other way.) Your tasks will run, but the system will likely end up spinning up new threads to accommodate these new tasks, and threads aren’t cheap.

使用并发队列时,如果不小心,就有冒线程爆炸的危险。 当您尝试将任务提交到当前被阻塞的并发队列(例如,使用信号灯,同步或其他方式)时,可能会发生这种情况。您的任务运行,但系统最终可能会分解出新的线程来适应这些新任务和线程并不便宜。

This is likely why Apple suggests starting with a serial queue per subsystem in your app, as each serial queue can only use one thread. Remember that serial queues are concurrent in relation to other queues, so you still get a performance benefit when you offload your work to a queue, even if it isn’t concurrent.

这可能是Apple建议在您的应用程序中为每个子系统从一个串行队列开始的原因,因为每个串行队列只能使用一个线程。 请记住,串行队列是并发的 其他队列,因此即使将工作卸载到队列中,也可以在性能上获得好处。

比赛条件 (Race conditions)

Swift Arrays, Dictionaries, Structs, and other value types are not thread-safe by default. For example, when you have multiple threads trying to access and modify the same array, you will start running into trouble.

Swift数组,字典,结构和其他值类型在默认情况下不是线程安全的。 例如,当您有多个线程试图访问和修改同一阵列时,您将开始遇到麻烦。

There are different solutions to the readers-writers problem, such as using locks or semaphores. But the relevant solution I wish to discuss here is the use of an isolation queue.

有多种解决读者-作者问题的方法 ,例如使用锁或信号灯。 但是我想在这里讨论的相关解决方案是使用隔离队列

Let’s say we have an array of integers, and we want to submit asynchronous work that references this array. As long as our work only reads the array and does not modify it, we are safe. But as soon as we try to modify the array in one of our asynchronous tasks, we will introduce instability in our app.

假设我们有一个整数数组,并且想要提交引用此数组的异步工作。 只要我们的工作仅读取数组而不修改它,我们就很安全。 但是,一旦我们尝试在异步任务之一中修改数组,就会在应用程序中引入不稳定性。

It’s a tricky problem because your app can run 10 times without issues, and then it crashes on the 11th time. One very handy tool for this situation is the Thread Sanitizer in Xcode. Enabling this option will help you identify potential race conditions in your app.

这是一个棘手的问题,因为您的应用程序可以运行10次而不会出现问题,然后在第11次崩溃。 一种适用于这种情况的便捷工具是Xcode中的Thread Sanitizer。 启用此选项将帮助您确定应用程序中潜在的比赛条件。

To demonstrate the problem, let’s take this (admittedly contrived) example:

为了演示这个问题,让我们举一个(公认的)例子:

class ViewController: UIViewController {
    
    let concurrent = DispatchQueue(label: "com.besher.concurrent", attributes: .concurrent)
    var array = [1,2,3,4,5]

    override func viewDidLoad() {
        for _ in 0...1 {
            race()
        }
    }

    func race() {

        concurrent.async {
            for i in self.array { // read access
                print(i)
            }
        }

        concurrent.async {
            for i in 0..<10 {
                self.array.append(i) // write access
            }
        }
    }
}

One of the async tasks is modifying the array by appending values. If you try running this on your simulator, you might not crash. But run it enough times (or increase the loop frequency on line 7), and you will eventually crash. If you enable the thread sanitizer, you will get a warning every time you run the app.

async任务之一是通过附加值来修改数组。 如果尝试在模拟器上运行它,则可能不会崩溃。 但是运行它足够的时间(或增加第7行的循环频率),最终将导致崩溃。 如果启用线程清理程序,则每次运行该应用程序时都会收到警告。

To deal with this race condition, we are going to add an isolation queue that uses the barrier flag. This flag allows any outstanding tasks on the queue to finish, but blocks any further tasks from executing until the barrier task is completed.

为了处理这种竞争情况,我们将添加一个使用barrier标志的隔离队列。 该标志允许队列中所有未完成的任务完成,但阻止其他任何任务执行,直到屏障任务完成。

Think of the barrier like a janitor cleaning a public restroom (shared resource.) There are multiple (concurrent) stalls inside the restroom that people can use.

将障碍想象为看门人打扫公共洗手间(共享资源)。洗手间内有多个(并发)摊位可供人们使用。

Upon arrival, the janitor places a cleaning sign (barrier) blocking any newcomers from entering until the cleaning is done, but the janitor does not start cleaning until all the people inside have finished their business. Once they all leave, the janitor proceeds to clean the public restroom in isolation.

到达后,看门人放置一个清洁标志(障碍物),以阻止任何新来者进入,直到清洁完成为止,但直到所有内部人员完成业务后,看门人才开始清洁。 他们全部离开后,看门人将继续单独清洁公共厕所。

When finally done, the janitor removes the sign (barrier) so that the people who are queued up outside can finally enter.

最终完成后,管理员将删除标志(障碍物),以便在外面排队的人可以最终进入。

Here’s what that looks like in code:

这是代码中的样子:

class ViewController: UIViewController {
    let concurrent = DispatchQueue(label: "com.besher.concurrent", attributes: .concurrent)
    let isolation = DispatchQueue(label: "com.besher.isolation", attributes: .concurrent)
    private var _array = [1,2,3,4,5]
    
    var threadSafeArray: [Int] {
        get {
            return isolation.sync {
                _array
            }
        }
        set {
            isolation.async(flags: .barrier) {
                self._array = newValue
            }
        }
    }
    
    override func viewDidLoad() {
        for _ in 0...15 {
            race()
        }
    }
    
    func race() {
        concurrent.async {
            for i in self.threadSafeArray {
                print(i)
            }
        }
        
        concurrent.async {
            for i in 0..<10 {
                self.threadSafeArray.append(i)
            }
        }
    }
}

We have added a new isolation queue, and restricted access to the private array using a getter and setter that will place a barrier when modifying the array.

我们添加了新的隔离队列,并使用getter和setter限制了对私有阵列的访问,这将在修改阵列时设置障碍。

The getter needs to be sync in order to directly return a value. The setter can be async, as we don’t need to block the caller while the write is taking place.

getter需要sync才能直接返回值。 设置器可以是async ,因为在写入过程中我们不需要阻止调用方。

We could have used a serial queue without a barrier to solve the race condition, but then we would lose the advantage of having concurrent read access to the array. Perhaps that makes sense in your case, you get to decide.

我们本来可以使用没有障碍的串行队列来解决竞争条件,但是那样我们将失去对数组进行并发读取访问的优势。 也许这对您来说有意义,您可以决定。

结论 (Conclusion)

Thank you so much for reading this far! I hope you learned something new from this article. I will leave you with a summary and some general advice:

非常感谢您阅读本文! 希望您从本文中学到了新的东西。 我将为您提供摘要和一些一般建议:

摘要 (Summary)

  • Queues always start their tasks in FIFO order

    队列始终以FIFO顺序开始其任务

  • Queues are always concurrent relative to other queues

    队列相对于其他队列始终是并发的

  • Sync vs Async concerns the source

    同步异步关系到源头

  • Serial vs Concurrent concerns the destination

    串行并发关注目标

  • Sync is synonymous with ‘blocking’

    同步是“阻止”的同义词
  • Async immediately returns control to caller

    异步立即将控制权返回给调用方
  • Serial uses a single thread, and guarantees order of execution

    串行使用单个线程,并保证执行顺序
  • Concurrent uses multiple-threads, and risks thread explosion

    并发使用多线程,并有发生线程爆炸的风险
  • Think about concurrency early in your design cycle

    在设计周期的早期考虑并发
  • Synchronous code is easier to reason about and debug

    同步代码更易于推理和调试
  • Avoid relying on global concurrent queues if possible

    尽可能避免依赖全局并发队列
  • Consider starting with a serial queue per subsystem

    考虑从每个子系统的串行队列开始
  • Switch to concurrent queue only if you see a measurable performance benefit

    切换到并发队列只有当你看到一个可衡量的业绩受益

I like the metaphor from the Swift Concurrency Manifesto of having an ‘island of serialization in a sea of concurrency’. This sentiment was also shared in this tweet by Matt Diephouse:

我喜欢Swift并发宣言中的隐喻,即“在并发海洋中拥有序列化的孤岛”。 Matt Diephouse在此推文中也分享了这种观点:

When you apply concurrency with that philosophy in mind, I think it will help you achieve concurrent code that can be reasoned about without getting lost in a mess of callbacks.

当您考虑到并发性时,我认为这将帮助您实现可以被推理的并发代码,而不会在混乱的回调中迷失方向。

If you have any questions or comments, feel free to reach out to me on Twitter

如果您有任何疑问或意见,请随时在Twitter上与我联系

Besher Al Maleh

Besher Al Maleh

Cover photo by Onur K on Unsplash

封面图片Onur K on Unsplash

在此处下载配套应用: (Download the companion app here: )

进一步阅读: (Further reading:)

http://khanlou.com/2016/04/the-GCD-handbook/

http://khanlou.com/2016/04/the-GCD-handbook/

WWDC视频: (WWDC Videos:)

翻译自: https://www.freecodecamp.org/news/ios-concurrency/

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值