DEV Community

Jameson
Jameson

Posted on • Updated on

Modern Async Primitives on iOS, Android, and the Web

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.

A comparison of synchronous and asynchronous program execution across two timelines
(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.

Along a single timeline, a task must be suspended in order to work on something new
(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.

Java NIO's Selector API allows efficient reading from multiple channels using a single thread
(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
Enter fullscreen mode Exit fullscreen mode

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 and metadata objects above might look a lot like JavaScript Promises, Rx Singles, or Java Futures, 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()
}
Enter fullscreen mode Exit fullscreen mode

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 Tasks. 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) }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 an AsyncSquence 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) }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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]
}
Enter fullscreen mode Exit fullscreen mode

You could iterate over this value like so:

for await credential in $user.credentials {
  // use credential
}
Enter fullscreen mode Exit fullscreen mode

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?
}
Enter fullscreen mode Exit fullscreen mode

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()
  }
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

This code will print:

An inside job!
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The code above uses an explicit yield() operator to show where the code yields execution. As a technical note, collect { } itself does not suspend, without yield(), even if the Flow would otherwise.

In practice, you find Flows 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 Flows.

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())()
Enter fullscreen mode Exit fullscreen mode

The code above works similarly to the unstructured concurrency primitives we saw with Swift's Task and Kotlin's async/Job. 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);
})();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)