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 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?
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.
- At the
main
part, for loop is running insideTask
- In each loop, the shared
lock
is locked before runningdoSomething
. - Thus, if the
lock
couldn’t get unlocked,doSomething
won’t be able to run after the second loop(i == 1
). - Inside
doSomething
, an unstructured task printing current thread is created and dispatched - 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.
Entered 0
: It entered the first loop(i == 0
)Ready to do 0
: It entereddoSomething
which had been called from the first loopReturned 0
:doSomething
was returnedEntered 1
: It entered the second loop(i == 1)
. Since thelock
was locked, it would be paused indefinitely until thelock
is unlocked at the point right before the next job’s called.- Since Swift concurrency uses serial queueing, there’s no chance that the second task, unlock the
lock
, is executed until the first job, observing thelock
, is done -> deadlock happens
With DispatchQueue
, it can be expressed like this:
So what can we do to solve this problem?
- We can change
NSLock
toNSRecursiveLock
to make alock
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.