Swift Concurrency Deep Dive [4] — Task

Enebin
6 min readNov 4, 2022

--

Photo by Bozhin Karaivanov on Unsplash

This post is written for a deeper understanding of Swift concurrency, that is, async/await.

I’ve collected information from reliable sources like Apple Developer’s Document, Swift-evolution repository or Swift Language Guide as much as possible, but may contain incorrect information. In that case, please let me know in the comments.

I highly recommend you to read my previous series

Task

Concept of task

💡 from proposal document

A task is the basic unit of concurrency in the system. Every asynchronous function is executing in a task. In other words, a task is to asynchronous functions, what a thread is to synchronous functions.

Task is a basic unit of asynchronous job in Swift concurrency, as defined in the proposal document, and all asynchronous functions must be executed in a task.

Creating a task

From Explore structured concurrency in Swift — WWDC21

💡 from Language Guide

In addition to the structured approaches to concurrency described in the previous sections, Swift also supports unstructured concurrency. Unlike tasks that are part of a task group, an unstructured task doesn’t have a parent task. … To create an unstructured task that runs on the current actor, call the Task.init(...) initializer.

Tasks are created and managed by the provided API, Task.init and Task.detached. With Task, we can create an unstructured concurrency, as the Swift language guide said.

It might be awkward to say that we can create an unstructured concurrency not a structured one because we’ve said Swift concurrency is implementation of structured programming. Well, then what does “create an unstructured concurrency” actually mean?

Unstructured concurrency?

From Explore structured concurrency in Swift — WWDC21 again but GIF this time🎬

To make long story short, there's no other meaning. Task is just designed to do so. To understand this, you should first know why we need Task.

Task is the only way to create unstructured concurrency in Swift and you can’t turn the rest of your codes into suspended state and wait for the end of the Task. That’s the reason why we can't await for Task itself.

Imagine that you should dispatch a Swift concurrency task in structured way where there’re no any parent tasks. It’s impossible.

Therefore, due to its unstructured feature, it became possible to enter the context of Swift concurrency from the outside. In this case, it behaves like an entry point for the Swift concurrency.

In fact, besides that, you can use Task when you need to get out from the “current actor” to execute a task. We’ll check it later in this post.

await for Task

Sometimes, you can see the code using return in Task and await to get return of the Task like the above code. I said we can’t use await for Task. Then what the heck is this?

If you run the code, however, you’ll notice that doesn’t mean Task will await for the result at the point it’s declared. Instead, the variable result, where await is set, will wait for the Task’s return. Important thing is where the await is, not the Task’s made.

Logs for the example is like:

CURRENT_SEC - 317:  doSomethingAsyncCURRENT_SEC - 317:  runningCURRENT_SEC - 317:  doAnotherAsyncCURRENT_SEC - 319:  Done:  100

As you can see doSomethingAsync and doAnotherAsync were started almost simultaneously. However, since the result was waiting for the result of doAnotherAsync, the last log was printed 2 seconds after it started.

If you want to make child task in structured way, you should use TaskGroup or async let.

Detached Task

💡 From Language guide

To create an unstructured task that’s not part of the current actor, known more specifically as a detached task, call the Task.detached(priority:operation:) class method.

I’ve mentioned that we can use a Task when need to get out of the current actor. For that, we can make a detached task, which can be made with the Task.detached(priority:operation:).

In most cases, there is no need to use this, referring to the documentation. Also, Apple recommends avoiding direct use of detached task as possible.

Actor will be covered in detail shortly after. It’s enough for now to know that it’s just a way to run tasks in different threads.

Review the deadlock problem

I’ve discussed the deadlock problem which can occur in Swift concurrency context at the end of this post before. Now we’re ready enough to investigate why did deadlock happen.

Added some print to visualize what’s happening inside. Now let’s look into how the code runs.

  1. At the main part, for loop is running inside Task
  2. In each loop, the shared lock is locked before running doSomething.
  3. Thus, if the lock couldn’t get unlocked, doSomething won’t be able to run after the second loop(i == 1).
  4. Inside doSomething, an unstructured task printing current thread is created and dispatched
  5. Since the task was created unstructured, the rest part of the function won’t wait for its return
  • To make the function wait for the task, there should be a parent task it belongs to, which doesn’t exist in this case.

Executing the above code, following logs are printed,

Entered:  0 
Ready to do: 0
Returned: 0
Entered: 1

Let’s take a look at it step by step.

  1. Entered 0: It entered the first loop(i == 0)
  2. Ready to do 0: It entered doSomething which had been called from the first loop
  3. Returned 0: doSomething was returned
  4. Entered 1: It entered the second loop(i == 1). Since the lock was locked, it would be paused indefinitely until the lock is unlocked at the point right before the next job’s called.
  5. Since Swift concurrency uses serial queueing, there’s no chance that the second task, unlock the lock, is executed until the first job, observing the lock, is done -> deadlock happens

With DispatchQueue, it can be expressed like this:

So what can we do to solve this problem?

  1. We can change NSLock to NSRecursiveLock to make a lock ignore locked-already status.
  • However, in this case, the task is executed in parallel, not waiting for the previous task, and in conclusion there is no difference from not using a lock. It is not we intended.

2. We can use Task.detached instead of Task.

  • It’s mechanism of this solution that unlock the lock in another thread to proceed with the stopped work.
  • In this case, it can be said that the intended purpose has been achieved because the previous task is waiting for the next task.
  • However, detached tasks can cause unexpected errors due to their out-of-actor characteristics as the document has warned.

3. Actually, we don’t have to use any locks

  • In this case, we can write a non-deadlock code without any locks, also sticking to the core principle, forward progress.

I briefly mentioned the actor. Actor is also one of the most important concepts of Swift concurrency and helps us to manage unintended data race. We’ll look into it in the next post.

--

--

Enebin
Enebin

Making your dreams a reality |  Mainly Interested in iOS | https://github.com/enebin

Responses (1)