Let's Talk About Asynchronicity
When you began your programming journey, you started with synchronous, serialized code. One operation follows another, and so on, along a single timeline.
Soon after, you likely discovered the ability to execute code along multiple timelines. With this capability, your code might still be synchronous, even if it splits its tasks across two timelines. But, your concurrent code could also be asynchronous, meaning that your main timeline continues along while some results are deferred until later.
(Thanks @AlisdairBroshar for the image.)
Arguably, a more interesting version of asynchronicity exists when multiple tasks are scheduled along a single timeline. This is intuitively easy to understand: you go about your day on a single timeline, and you probably juggle many unrelated tasks as you do so. You have to suspend one of your tasks to focus on the next one, and then the next one, etc. But unfortunately, those suspended tasks don't make any progress while you're not working on them.
(Image credit to Sonic Wang @ DoorDash Engineering)
In this space, we also have the somewhat related term blocking. Java's NIO library is one well-known non-blocking tool used for managing multiple tasks on a single Java thread. When listening to sockets, most of the time a thread is just blocked, doing nothing until it receives some data. So, it's efficient to use a single thread for monitoring many sockets, to increase the likelihood of the thread having some actual work to do. The Selector
API does this but is notoriously challenging to program well. Instead, developers use frameworks like Netty which abstract some of NIO's complexity and layer on some best practices.
(Thanks GeeksForGeeks.org for the image.)
In the current generation of programming languages, there has been a renewed effort to simplify asynchronicity through structured concurrency. Language facilities like Swift's async
/await
or Kotlin's Coroutines allow for a unit of work to be suspended and resumed within the context of one or more timelines. Below, we're going to explore how some of these tools work.
Swift: async/await & AsyncSequence
Swift's structured concurrency support was announced at Apple's WWDC '21 conference. Kavon @ Apple gives a great intro to the topic here. Swift's async
/await
support allows you to yield a flow of execution in your program while awaiting the result of a task. One example given in Kavon's presentation is to download some data and then await the result:
// Note `async let` here! Two tasks run concurrently.
async let (data, _) = URLSession.shared.data(for: imageReq)
async let (metadata, _) = URLSession.shared.data(for: metadataReq)
// Do other tasks here, while tasks execute
guard
let size = parseSize(from: try await metadata),
let image = try await UIImage(data: data)?.byPreparingThumbnail(ofSize: size)
else {
throw ThumbnailFailedError()
}
return image
The beauty of this code is that all of the async work will be terminated if this block goes out of scope. This simplifies your mental model considerably and avoids entire classes of bugs.
Remark: the
data
andmetadata
objects above might look a lot like JavaScriptPromise
s, RxSingle
s, or JavaFuture
s, if you've seen any of those before. (We'll talk about Promises, later.)
Swift >=5.5 also supports a new means of unstructured concurrency, where you are responsible for governing the task lifecycle yourself, instead of letting Swift handle it implicitly. For example, you can launch a Task to await the execution of some async function:
Task {
await someAsyncFunction()
}
When using the Task
construct, you need to be careful to cancel()
the Task at the appropriate time when it goes out of scope.
Beyond these single-result-oriented constructs, Swift >=5.5 also supports async operations over collections. AsyncSequence
, also announced at WWDC21 builds on earlier streamed value solutions like RxSwift's Observable
or Combine's PassthroughSubject
. But unlike those constructs, AsyncSequence
can yield execution between iterations.
Let's see in action. First, let's take some ordinary for loops and wrap them in Task
s. As noted above, the Task
object will provide an execution context on the main actor, in which async work could occur.
Task { @MainActor in
for f in [1, 2, 3] { print(f) }
}
Task { @MainActor in
for s in [10, 20, 30] { print(s) }
}
As you would expect, since there isn't any use of async
anywhere, these just run sequentially. The output is:
1
2
3
10
20
30
Now, instead, let's try changing the array to an AsyncSequence
, and running it again. Importantly, everything will still be on the main actor.
Note for the code below:
.publisher
transforms the array into a Combine publisher, and.values
creates anAsyncSquence
from that Publisher.
Task { @MainActor in
for await f in [1, 2, 3].publisher.values { print(f) }
}
Task { @MainActor in
for await s in [10, 20, 30].publisher.values { print(s) }
}
This time, the loops yield execution after each value is emitted. The two tasks end up interleaving their output:
1
10
2
20
3
30
The two tasks collaborate on a single thread of execution, almost as if they were operating concurrently. Pretty neat, right?
One convenient place an AsyncSequence
shows up is in this short-hand to read a network request's data, line-by-line. This can be achieved by async-iterating over the lines
property on URL
:
let rickMortyApi = "https://rickandmortyapi.com/api"
if let data = URL(string: rickMortyApi) {
for try await line in data.lines {
print(line)
}
}
Another place you might encounter AsyncSequence
is in SwiftUI's property wrappers. For example, let's suppose you have a field @Published var credentials: [Credential]
in some @ObservableObject
:
class User: ObservableObject {
@Published var id: String
@Published var credentials: [Credential]
}
You could iterate over this value like so:
for await credential in $user.credentials {
// use credential
}
Side note, if you Migrate from the Observable Object protocol to the Observable macro, a lot of your
$
binding calls will probably go away, rendering this kind of iteration unnecessary.
This new functionality is not without some open questions, though. In his excellent blog from April 2022, Using new Swift Async Algorithms package to close the gap on Combine, John O'Reilley notes:
As developers have started adopting the new Swift Concurrency functionality introduced in Swift 5.5, a key area of interest has been around how this works with the Combine framework and how much of existing Combine-based functionality can be replaced with async/await, AsyncSequence etc. based code.
In that blog, John makes a note of the AsyncExtensions library, which has several cool Features like merge()
, zip()
, etc., that you'd expect from other stream/collections API surfaces.
You might also prefer to look at Apple's own evolution library for AsyncSequence, which offers a mostly-similar feature-set, called swift-async-algorithms. This library is more likely to be a conservative staging ground for forthcoming Swift language APIs.
Kotlin: Coroutines, Flow
Many ecosystems have adopted the async
/await
style of structured concurrency, but Kotlin took a slightly different approach. Coroutines is an older technology: the first usage of the term dates back to the late fifties and early sixties. The Kotlin implementation provides several improvements over prior implementations, however, such as native language integration, readability, structured concurrency, and error handling.
While many languages use async
to denote an asynchronous function, Kotlin instead marks functions with suspend
. Similar to what we saw with AsyncSequence
, suspend
introduces a suspension point where execution may be yielded.
suspend fun getNetworkData(): Result<Response, Error> {
// ... well, how bout it?
}
Just like Swift has support for structured concurrency, Kotlin can nest coroutines, too, s.t. canceling a top-level coroutine will also cancel its child coroutines. Let's consider an example. Below we use the launch
coroutine builder to initiate a parent coroutine, and another launch
coroutine builder to create a child coroutine within it.
coroutineScope.launch { // When I am canceled,
launch { // I will cancel, too.
getNetworkData()
}
}
In Kotlin, the closest thing to Swift's Task
is GlobalScope.launch
, which can be used to initiate a coroutine that is not bound to its parent's scope. Use of GlobalScope
is generally not recommended, since it will require you to carefully manage the lifecycle of the returned Job
object, calling cancel()
when appropriate.
Consider the bit of code below as an example, wherein a coroutine is launched within another coroutine:
var inside: Job? = null
GlobalScope.launch {
inside = GlobalScope.launch {
delay(500)
print("An inside job!")
}
}.apply {
delay(100)
cancel()
}
inside!!.join()
This code will print:
An inside job!
If you remove the GlobalScope
designation, then the code prints nothing, since inside
is automatically canceled.
Kotlin also has a construct for asynchronous collections/streams. Kotlin's version of AsyncSequence
is called a Flow
. Just as Swift's AsyncSequence
builds upon prior experience with RxSwift and Combine, Kotlin's Flow
APIs build upon earlier stream/collection APIs in the JVM ecosystem: Java's RxJava, Java8 Streams, Project Reactor, and Scala's Akka.
Let's see Flow
in action:
runBlocking {
launch {
flowOf(1, 2, 3).collect {
yield()
println(it)
}
}
launch {
flowOf(10, 20, 30).collect {
yield()
println(it)
}
}
}
The code above is single-threaded. If we weren't using Flow
, we might expected the values to be emitted serially: 1,2,3,10,20,30. But, since each coroutine yields execution upon receiving a value, the outputs interleave:
1
10
2
20
3
30
The code above uses an explicit
yield()
operator to show where the code yields execution. As a technical note,collect { }
itself does not suspend, withoutyield()
, even if theFlow
would otherwise.
In practice, you find Flow
s used heavily between the View and View Model layers of most modern apps using MVVM/MVI design patterns. Views commonly collect state updates from View Models using Flow
s.
JavaScript: aysnc/await, Promises, AsyncIterator/AsyncGenerator
async
/await
support has existed in Node.js and web browsers since 2017. Famously, await
can be used to wait for a Promise
to resolve or reject:
async function fetchData() {
const fetchPromise = fetch("https://rickandmortyapi.com/api")
console.log("Awaiting your data...")
const data = await (await fetchPromise).json()
console.log("The data has arrived: ", data);
}
(async () => await fetchData())()
The code above works similarly to the unstructured concurrency primitives we saw with Swift's Task
and Kotlin's GlobalScope.launch
. The fetch
function initiates some asynchronous work, bundles it up into a Promise
object, and yields execution so the lines below it can proceed. Finally, we await the result of the Promise, and extract JSON data received from the network.
Unlike Swift and Kotlin, JavaScript does not have any native support for structured concurrency. In fact, it doesn't even have native support for canceling unstructured tasks. There could be various reasons for this: JavaScript was one of the first mainstream languages to adopt async
/await
and its interpreters have traditionally been single-threaded. Nevertheless, this oversight has compelled the ecosystem to innovate its own unique solutions. As a well-known example, Axios provides a CancelToken
to facilitate task cancellation within that library.
However, JavaScript does have async collection primitives analogous to those provided by Swift and Kotlin: AsyncIterator
and AsyncGenerator
.
Much like we saw with Swift's AsyncSequence
, adding the await
keyword into the generic for
loop will create a suspension point wherein the JavaScript interpreter can yield its execution to other tasks. (See for await...of
documentation from Mozilla.)
async function* asyncGenerator(multiplier: number = 1) {
yield 1 * multiplier;
yield 2 * multiplier;
yield 3 * multiplier;
}
(async () => {
for await (let i of asyncGenerator(0)) console.log(i);
})();
(async () => {
for await (let i of asyncGenerator(10)) console.log(i);
})();
This code is single-threaded, but we again see two tasks using cooperative multitasking to yield execution to one another. The result is familiar:
1
10
2
20
3
30
Wrapping Up
Well, there you have it: a broad overview of structured concurrency and asynchronous collections programming in Swift, Kotlin, and JavaScript.
To help illustrate, here's a table comparing some of the machinery we discussed, across each of the three languages.
Swift | Kotlin | JavaScript | |
---|---|---|---|
Launch async work (structured) | async let |
launch { } |
N/A |
Launch async work (unstructured) | Task { } |
GlobalScope.launch { } |
Promise() |
Cancel async work (unstructured) | Task.cancel() |
Job.cancel() |
N/A |
Await completion of work | await |
(Implicit) | await |
Mark function as asynchronous | async |
suspend |
async |
Async collection | AsyncSequence |
Flow |
AsyncIterator / AsyncGenerator
|
Async iteration syntax | for await x in seq |
flow.collect { } |
for await (let x of gen) |
Top comments (0)