Concurrency Patterns in Swift with Async/Await

In this post, we will explore the use of async & await patterns of handling asynchronous operations in Swift for those coming from a JavaScript background.

Concurrency Patterns in Swift with Async/Await
Photo by Hennie Stander / Unsplash

async & await are key concurrency tools for most JavaScript developers and have been in common use for many years.  In the Swift language, on the other hand, async and await are relatively new, appearing in Swift 5.5, and are generally not the most common idiom for dealing with concurrency.  While they may not be the most common approach, there are definitely scenarios in which they can be used to craft a cleaner solution.  In this post, we will explore the use of async & await patterns of handling asynchronous operations in Swift for those coming from a JavaScript background.

Concurrency and Callbacks

Concurrency, broadly speaking, is the ability for a language to manage multiple streams of computation at the same time.  Note that concurrency is distinct from parallelism, which is the ability to improve performance by actively performing two tasks at the same time (e.g., on multiple processors or cores).  

In both JavaScript and in Swift, simple concurrency can be achieved through the use of functions that accept callbacks, and the two have comparable syntax:

// JavaScript:
readFile(path, r => { console.log(result) })
performExpensiveComputation()

// Swift:
readFile(path: path, completion: { result in print(result) })
performExpensiveComputation()
Callback pattern in JavaScript and Swift

In the two example above, we make use concurrency to do two things at once: read a file, performing some operation with the result, and perform some other expensive computation.  In this example, we don't know and don't care which operation completes first–only that both streams of computation are performed.

Why Async/Await?

For simple concurrency, callbacks work just fine.  To understand why we need async and await, we need to consider some a complicated example.   Let's consider a completely fictional (but plausible) chain of asynchronous computation to read a file, parse the contents, fetch & process some metadata, and post the result to a server:

fetchFile(url: url) { contents in 
  parseFile(contents: contents) { parsed in
    fetchMetadata(parsedData: parsed) { metadata in 
      processMetadata(metadata: metadata) { processed in 
        postProcessedDataToServer(data: processed) { result in
          logResult(result)
        }
      }
    }
  }
}
An example of "callback hell"

Yikes!  We've created "callback hell".  This is where async and await come into play: they replace callbacks with a linear syntax for perfoming concurrent computations.  Let's refactor our code to make use of async and await. First, the finished computation using await:

let fileContents = await fetchFile(url: url)
let parsed = await parseFile(contents: fileContents)
let metadata = await fetchMetadata(parsedData: parsed)
let processed = await processMetadata(metadata: metadata)
let result = await postProcessedDataToServer(data: processed)
logResult(result)
"Callback hell" resolved with async/await

Behind the scenes, we've also updated the signature for the functions we're using.  We'll get into the exact implementations later, but here's how the declarations change:

// with callbacks:
func fetchFile(url: URL, callback: (Data) -> Void) { ... }

// with async/await:
func fetchFile(url: URL) async -> Data { ... }
A function definition with a callback, and with async

Async/Await Patterns in Swift & JavaScript

Let's take a look at some common async/await patterns you may be familiar with in the JavaScript world, and see how they are implemented in Swift.

Basic async Function Implementation

First, let's take a look at the most basic usage.  In order to use await, you'll need to write async functions.  In both Swift and JavaScript, the simplest possible way to implement an async function is to await the result of another async function!  

First, a simple example in JavaScript:

async function loadData(url) {
  return await fetch(url)
}

In Swift, we can do the same thing using existing async Foundation function on URLSession to read data from a URL in async fashion:

func loadData(url: String) async throws -> Data {
  let request = URLRequest(url: URL(string: url)!)
  let (data, _) = try await URLSession.shared.data(for: request)
  return data
}

Composing Async Functions from Callbacks

What about creating an async function when no existing async function exists?  For example, when adapting a function using a callback?

In JavaScript, we can create an async function by returning a Promise.  In this case, we wrap an existing apiCall function in a promise to make it async:

async function asyncApiCall() {
  return new Promise((resolve, reject) => {
    apiCall((result, error) => {
      if (result) {
      	resolve(result)
      } else {
     	reject(error)
      }
    })
  })
}

Similarly, in Swift, we can construct an async call using a Continuation.  The full details of continuations in Swift are a much larger topic, but in context of composing an async function, they can be used in a way similar to the JavaScript Promise in the example above:

func fetch(url: String) async throws -> Data? {
  let request = URLRequest(url: URL(string: url)!)
        
  return try await withCheckedThrowingContinuation { continuation in
    URLSession.shared.dataTask(with: request) { data, _, err in
      if(err == nil) {
        continuation.resume(returning: data)
      } else {
        continuation.resume(throwing: err!)
      }
    }
  }
}

Here we use continuation.resume in the same way as the JavaScript Promise functions resolve and reject: we can resume with either a return value, or an error.

Combining Multiple Async Calls

Combining async calls works a a bit differently in JavaScript and Swift.

As we alluded to earlier, async calls in JavaScript are just promises under the hood.  Because of this, if we call an async function without await, we can get a Promise object, and thus handle multiple async calls using  Promise.all():

async function fetchData() { ... }

func fetchMultiple(urls) {
  let promises = urls.map(url => {
    return fetchData(url) // note -- we're not using await! 
  })
  
  return Promises.all(promises)
}

In Swift, we can achieve something similar with a TaskGroup:

// assume we have the following:
func fetchData() async -> Data { ... }

func fetchMultiple(urls: [String]) async -> [Data] {
  return await withTaskGroup(of: Data.self) { group in
    urls.forEach { url in group.addTask { await self.fetch(url: url) } }
    
    var data: [Data] = []
    for await i in group { data.append(i) }        
    return data
  }
}

In this example, we add the individual calls to a TaskGroup (using await while makign the call!) and then assemble the results manually.  

Conclusion

async/await are powerful tools for dealing with asynchronous code and relatively new to the Swift ecosystem.  While async & await are not the only approach to building concurrency in Swift, they are especially useful when representing a linear flow of logic which relies on complex asynchronous tasks, and helping to unwind the "callback hell" that can occur when building this logic with callbacks.