Επεξήγηση ταυτότητας: Πώς να δημιουργήσετε μια εφαρμογή πολλαπλών νημάτων iOS

Το Concurrency στο iOS είναι ένα τεράστιο θέμα. Έτσι, σε αυτό το άρθρο θέλω να μεγεθύνω ένα υπο-θέμα σχετικά με τις ουρές και το πλαίσιο Grand Central Dispatch (GCD).

Συγκεκριμένα, θα ήθελα να διερευνήσω τις διαφορές μεταξύ σειριακών και ταυτόχρονων ουρών, καθώς και τις διαφορές μεταξύ σύγχρονης και ασύγχρονης εκτέλεσης.

Εάν δεν έχετε χρησιμοποιήσει ποτέ το GCD στο παρελθόν, αυτό το άρθρο είναι ένα εξαιρετικό μέρος για να ξεκινήσετε. Εάν έχετε κάποια εμπειρία με το GCD, αλλά εξακολουθείτε να είστε περίεργοι για τα θέματα που αναφέρονται παραπάνω, νομίζω ότι θα το βρείτε ακόμα χρήσιμο. Και ελπίζω να πάρει ένα ή δύο νέα πράγματα στην πορεία.

Δημιούργησα μια εφαρμογή SwiftUI για να δείξω οπτικά τις έννοιες σε αυτό το άρθρο. Η εφαρμογή διαθέτει επίσης ένα διασκεδαστικό σύντομο κουίζ που σας προτείνω να δοκιμάσετε πριν και μετά την ανάγνωση αυτού του άρθρου. Κατεβάστε τον πηγαίο κώδικα εδώ ή λάβετε τη δημόσια έκδοση beta εδώ.

Θα ξεκινήσω με μια εισαγωγή στο GCD, ακολουθούμενη από μια λεπτομερή εξήγηση για συγχρονισμό, ασύγχρονο, σειριακό και ταυτόχρονο. Στη συνέχεια, θα καλύψω κάποιες παγίδες όταν εργάζομαι με ταυτόχρονη χρήση. Τέλος, θα τελειώσω με μια περίληψη και μερικές γενικές συμβουλές.

Εισαγωγή

Ας ξεκινήσουμε με μια σύντομη εισαγωγή στο GCD και αποστολή ουρών. Μη διστάσετε να μεταβείτε στην ενότητα Συγχρονισμός εναντίον Async εάν είστε ήδη εξοικειωμένοι με το θέμα.

Ταυτότητα και αποστολή Grand Central

Το Concurrency σάς επιτρέπει να εκμεταλλευτείτε το γεγονός ότι η συσκευή σας διαθέτει πολλούς πυρήνες CPU. Για να χρησιμοποιήσετε αυτούς τους πυρήνες, θα πρέπει να χρησιμοποιήσετε πολλά νήματα. Ωστόσο, τα νήματα είναι ένα εργαλείο χαμηλού επιπέδου και η διαχείριση των νημάτων με μη αυτόματο τρόπο με αποτελεσματικό τρόπο είναι εξαιρετικά δύσκολη.

Το Grand Central Dispatch δημιουργήθηκε από την Apple πριν από 10 χρόνια ως αφαίρεση για να βοηθήσει τους προγραμματιστές να γράψουν κώδικα πολλαπλών νημάτων χωρίς να δημιουργήσουν και να διαχειριστούν τα νήματα με μη αυτόματο τρόπο.

Με το GCD, η Apple υιοθέτησε μια ασύγχρονη προσέγγιση σχεδιασμούστο πρόβλημα. Αντί να δημιουργείτε απευθείας νήματα, χρησιμοποιείτε το GCD για τον προγραμματισμό εργασιών και το σύστημα θα εκτελέσει αυτές τις εργασίες για εσάς κάνοντας την καλύτερη δυνατή χρήση των πόρων του. Το GCD θα χειριστεί τη δημιουργία των απαιτούμενων νημάτων και θα προγραμματίσει τις εργασίες σας σε αυτά τα θέματα, μετατοπίζοντας το βάρος της διαχείρισης του νήματος από τον προγραμματιστή στο σύστημα.

Ένα μεγάλο πλεονέκτημα του GCD είναι ότι δεν χρειάζεται να ανησυχείτε για πόρους υλικού καθώς γράφετε τον ταυτόχρονο κωδικό σας. Το GCD διαχειρίζεται ένα νήμα για εσάς και θα κλιμακωθεί από το Apple Watch ενός πυρήνα μέχρι το MacBook Pro πολλών πυρήνων.

Ουρές αποστολής

Αυτά είναι τα κύρια δομικά στοιχεία του GCD που σας επιτρέπουν να εκτελείτε αυθαίρετα μπλοκ κώδικα χρησιμοποιώντας ένα σύνολο παραμέτρων που ορίζετε. Τα καθήκοντα στις ουρές αποστολής ξεκινούν πάντα με τρόπο από πρώτο σε πρώτο (FIFO). Σημειώστε ότι είπα ότι ξεκίνησα , επειδή ο χρόνος ολοκλήρωσης των εργασιών σας εξαρτάται από διάφορους παράγοντες και δεν είναι εγγυημένο ότι θα είναι FIFO (περισσότερα σε αυτό αργότερα.)

Σε γενικές γραμμές, υπάρχουν τρία είδη ουρών στη διάθεσή σας:

  • Η κύρια ουρά αποστολής (σειριακή, προκαθορισμένη)
  • Καθολικές ουρές (ταυτόχρονες, προκαθορισμένες)
  • Ιδιωτικές ουρές (μπορεί να είναι σειριακές ή ταυτόχρονες, τις δημιουργείτε)

Κάθε εφαρμογή διαθέτει μια βασική ουρά, η οποία είναι μια σειριακή ουρά που εκτελεί εργασίες στο κύριο νήμα. Αυτή η ουρά είναι υπεύθυνη για τη σχεδίαση της διεπαφής χρήστη της εφαρμογής σας και την απόκριση στις αλληλεπιδράσεις των χρηστών (άγγιγμα, κύλιση, μετακίνηση κ.λπ.) Εάν αποκλείσετε αυτήν την ουρά για πολύ καιρό, η εφαρμογή σας iOS θα φαίνεται να παγώνει και η εφαρμογή macOS θα εμφανίζει την περίφημη παραλία μπάλα / περιστρεφόμενο τροχό.

Όταν εκτελείτε μια μακροχρόνια εργασία (κλήση δικτύου, υπολογιστικά εντατική εργασία, κ.λπ.), αποφεύγουμε να παγώσουμε το περιβάλλον εργασίας χρήστη εκτελώντας αυτήν την εργασία σε ουρά παρασκηνίου. Στη συνέχεια, ενημερώνουμε το περιβάλλον εργασίας χρήστη με τα αποτελέσματα στην κύρια ουρά:

URLSession.shared.dataTask(with: url) { data, response, error in if let data = data { DispatchQueue.main.async { // UI work self.label.text = String(data: data, encoding: .utf8) } } }

Κατά κανόνα, όλες οι εργασίες διεπαφής χρήστη πρέπει να εκτελούνται στην ουρά Main. Μπορείτε να ενεργοποιήσετε την επιλογή Main Thread Checker στο Xcode για να λαμβάνετε προειδοποιήσεις όποτε εκτελείται η εργασία διεπαφής χρήστη σε ένα νήμα φόντου.

ο κύριος έλεγχος νημάτων βρίσκεται στον επεξεργαστή σχήματος

Εκτός από την κύρια ουρά, κάθε εφαρμογή διαθέτει πολλές προκαθορισμένες ταυτόχρονες ουρές που έχουν διαφορετικά επίπεδα ποιότητας υπηρεσίας (μια αφηρημένη έννοια προτεραιότητας στο GCD.)

Για παράδειγμα, εδώ είναι ο κώδικας για την υποβολή της εργασίας ασύγχρονα στην αλληλεπιδραστική (υψηλότερης προτεραιότητας) ουρά QoS χρήστη:

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

Εναλλακτικά, μπορείτε να καλέσετε την καθολική ουρά προεπιλεγμένης προτεραιότητας , χωρίς να ορίσετε ένα QoS όπως αυτό:

DispatchQueue.global().async { print("Generic global queue") }

Επιπλέον, μπορείτε να δημιουργήσετε τις δικές σας ιδιωτικές ουρές χρησιμοποιώντας την ακόλουθη σύνταξη:

let serial = DispatchQueue(label: "com.besher.serial-queue") serial.async { print("Private serial queue") }

Κατά τη δημιουργία ιδιωτικών ουρών, βοηθά στη χρήση μιας περιγραφικής ετικέτας (όπως αντίστροφη σημείωση DNS), καθώς αυτό θα σας βοηθήσει κατά τον εντοπισμό σφαλμάτων στο πρόγραμμα περιήγησης Xcode, lldb και Instruments:

Από προεπιλογή, οι ιδιωτικές ουρές είναι σειριακές (θα εξηγήσω τι σημαίνει αυτό σύντομα, υπόσχεση!) Εάν θέλετε να δημιουργήσετε μια ιδιωτική ταυτόχρονη ουρά, μπορείτε να το κάνετε μέσω της προαιρετικής παραμέτρου χαρακτηριστικών :

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

Υπάρχει επίσης μια προαιρετική παράμετρος QoS. Οι ιδιωτικές ουρές που δημιουργείτε θα προσγειωθούν τελικά σε μία από τις παγκόσμιες ταυτόχρονες ουρές βάσει των δεδομένων παραμέτρων τους.

Τι υπάρχει σε μια εργασία;

Ανέφερα την αποστολή εργασιών σε ουρές. Οι εργασίες μπορούν να αναφέρονται σε οποιοδήποτε μπλοκ κώδικα που υποβάλλετε σε μια ουρά χρησιμοποιώντας το syncή τις asyncσυναρτήσεις. Μπορούν να υποβληθούν με τη μορφή ανώνυμου κλεισίματος:

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

Ή μέσα σε ένα αντικείμενο εργασίας αποστολής που εκτελείται αργότερα:

let item = DispatchWorkItem(qos: .utility) { print("Work item to be executed 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:

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.

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.

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!

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.

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.

Think of sync as synonymous with ‘blocking’.

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.

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.

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.

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

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.

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.

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

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.

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:

let imageProcessingQueue = DispatchQueue(label: "com.besher.image-processing") func processImageAsync(data: Data) -> UIImage? { guard let image = UIImage(data: data) else { return nil } imageProcessingQueue.async { let processedImage = upscaleAndFilter(image: image) return processedImage } }

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.

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.

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.

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.

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!

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.

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.

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.

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.

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).

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:

let concurrent = DispatchQueue(label: "com.besher.concurrent", attributes: .concurrent) concurrent.sync { for _ in 0..<5 { print("?") } } concurrent.async { for _ in 0..<5 { print("?") } }

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?

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.

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.

Which one to use?

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

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.

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.

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.

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.

It’s kind of like having cars stuck in frontof 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)

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 queueis blue, background is white.)

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.

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:

// 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.

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!

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!

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.

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.

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.

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 relationto other queues, so you still get a performance benefit when you offload your work to a queue, even if it isn’t concurrent.

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.

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.

Μπορείτε να έχετε πρόσβαση στο απολυμαντικό νήμα στον επεξεργαστή σχήματος

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.

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.

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.

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.

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
  • 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

Μου αρέσει η μεταφορά από το Swift Concurrency Manifesto να έχω ένα «νησί σειριοποίησης σε μια θάλασσα ταυτόχρονης». Αυτό το συναίσθημα κοινοποιήθηκε επίσης σε αυτό το tweet από τον Matt Diephouse:

Το μυστικό για τη σύνταξη ταυτόχρονου κώδικα είναι να κάνετε το μεγαλύτερο μέρος του σειριακό. Περιορίστε την ταυτόχρονη σύνδεση σε ένα μικρό, εξωτερικό στρώμα. (Σειριακός πυρήνας, ταυτόχρονο κέλυφος.)

π.χ. αντί να χρησιμοποιήσετε ένα κλείδωμα για τη διαχείριση 5 ιδιοτήτων, δημιουργήστε έναν νέο τύπο που τις περιτυλίγει και χρησιμοποιήστε μια μεμονωμένη ιδιότητα μέσα στο κλείδωμα.

- Matt Diephouse (@mdiep) 18 Δεκεμβρίου 2019

Όταν εφαρμόζετε τη συνάφεια με αυτήν τη φιλοσοφία κατά νου, νομίζω ότι θα σας βοηθήσει να επιτύχετε ταυτόχρονο κώδικα που μπορεί να αιτιολογηθεί χωρίς να χαθείτε σε ένα χάος των επιστροφών.

Εάν έχετε οποιεσδήποτε ερωτήσεις ή σχόλια, μη διστάσετε να επικοινωνήσετε μαζί μου στο Twitter

Μπέσερ Αλ Μαλέ

Φωτογραφία εξωφύλλου του Onur K στο Unsplash

Κάντε λήψη της συνοδευτικής εφαρμογής εδώ:

almaleh / Dispatcher Companion app στο άρθρο μου σχετικά με το ταυτόχρονο. Συμβάλλετε στην ανάπτυξη almaleh / Dispatcher δημιουργώντας έναν λογαριασμό στο GitHub. almaleh GitHub

Δείτε μερικά από τα άλλα άρθρα μου:

Fireworks - Ένας επεξεργαστής οπτικών σωματιδίων για το Swift Δημιουργία κώδικα Swift εν κινήσει για macOS και iOS καθώς σχεδιάζετε και επαναλαμβάνετε τα εφέ σωματιδίων σας Besher Al Maleh Flawless iOS Δεν χρειάζεστε (πάντα) [αδύναμος εαυτός] Σε αυτό το άρθρο, Θα μιλήσω για αδύναμο εαυτό μέσα στο κλείσιμο Swift για να αποφύγετε τη διατήρηση κύκλων και να εξερευνήσετε περιπτώσεις όπου μπορεί ή δεν είναι απαραίτητο να συλλάβετε τον εαυτό σας ασθενώς. Besher Al Maleh Flawless iOS

Περαιτέρω ανάγνωση:

Εισαγωγή Εξηγεί τον τρόπο εφαρμογής ταυτόχρονων διαδρομών κώδικα σε μια εφαρμογή. Ταυτόχρονος προγραμματισμός: APIs και προκλήσεις · το objc.io objc.io δημοσιεύει βιβλία για προηγμένες τεχνικές και πρακτικές για ανάπτυξη iOS και OS X Florian Kugler API χαμηλής στάθμης Concurrency · το objc.io objc.io δημοσιεύει βιβλία για προηγμένες τεχνικές και πρακτικές για iOS και Ανάπτυξη OS X Daniel Eggert

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

Παράλληλες και σειριακές ουρές στο GCD Παλεύω να κατανοήσω πλήρως τις ταυτόχρονες και σειριακές ουρές στο GCD. Έχω κάποια ζητήματα και ελπίζω ότι κάποιος μπορεί να μου απαντήσει με σαφήνεια και στο σημείο. Διαβάζω ότι δημιουργούνται σειριακές ουρές ... Bogdan Alexandru Stack Overflow

Βίντεο WWDC:

Χρήση Εκσυγχρονισμός Grand Central Dispatch - WWDC 2017 - Βίντεο - Η Apple MacOS Developer 10.13 και iOS 11 έχουν εφευρεθεί εκ νέου το πώς Grand Central Dispatch και η Συνεργασία Darwin πυρήνα, επιτρέποντας τις εφαρμογές σας να τρέχουν ... Η Apple Developer οικοδόμηση Κατανοητή και αποτελεσματική Εφαρμογές με ΠΔΠ - WWDC 2015 - Βίντεο - Το Apple Developer watchOS και το iOS Multitasking θέτουν αυξημένες απαιτήσεις για την αποτελεσματικότητα και την απόκριση της εφαρμογής σας Με εξειδικευμένη καθοδήγηση από το ... Apple Developer