Skip to content

Instantly share code, notes, and snippets.

@gdavis
Last active November 20, 2023 01:51
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save gdavis/91bb9c83e12f753285089682a7c9b47e to your computer and use it in GitHub Desktop.
Save gdavis/91bb9c83e12f753285089682a7c9b47e to your computer and use it in GitHub Desktop.
Notes about usage of Swift Concurrency

Swift Concurrency

Video: Meet async/await in Swift

With Swift concurrency, functions, initializers, read-only properties, and for-loops can all be marked async. Property getters can also throw.

func fetchThumbnails() await throws -> [UIImage] {
}

extension UIImage {
  var thumbnail: UIImage {
    get async throws {
      let size = CGSize(width: 50, height: 50)
      return await byPreparingThumbnail(ofSize: size)
    }
  }
}

for await id in imageIDs {
  let thumbnail = await fetchThumbnail(withID: id)
}

async enables a function to be suspended while the task is performed. await marks where an async function may be suspended. The thread that suspends for an await call is not blocked, and can perform other work.

Tests support async/await.

func testSomething() async throws {
  let result = try await object.someAsyncThing()
  XCTAssertEqual(result, expectedResult)
}

Async Alternatives and Continuations

Continuations provide a bridge to older completion handler based code into new async based methods.

func persistentPosts() async throws -> [Post] {
  typealias PostsContinuation = CheckedContinuation<[Post], Error>
  return try await withCheckedContinuation { (continuation: PostsContinuation) in 
    // perform call-back based request
    self.getPosts { posts, error in 
      if let error = error {
        continuation.resume(throwing: error)
      }
      else {
        continuation.resume(returning: posts)
      }
    }
  }
}

Resumes must only be called once for each path. Discarding the continuation without resuming is not allowed.

Continuations can be stored as properties and called outside of the defining scope to support behaviors such as working with delegate callbacks.

Structured Tasks

The following async let and group tasks demonstrate structured concurrency where tasks can be executed within other tasks, and also provide the ability to cancel tasks while propogating that to other tasks in the structure.

async let

Allows a concurrent creation and setting of a variable. The code following the declaration of the async let variable will be allowed to execute until the value of the async let variable needs to be read.

async let remoteData = MyDataLoader.load(from: url)

// ... values can execute until it is used:
let count = remoteData.count

Video: Explore structured concurrency in Swift

Group Tasks

To allow multiple concurrent tasks to execute together as a group, nest them within a group task. Each sub-task is invoked by calling group.async { ... } within the group.

var thumbnails = [String: UIImage]()
try await withThrowingTaskGroup(of: Void.self) { group in 
  for id in ids {
    group.async {
      thumbnails[id] = try await fetchThumbnail(withID: id)
    }
  }
}

The above code has an issue though: concurrent access to the thumbnails property. That structure is not thread-safe, so we need to fix the issue using by returning values to the task group, and then update the final storage with a new for try await loop.

var thumbnails = [String: UIImage]()

try await withThrowingTaskGroup(of: (String, UIImage).self) { group in 
  for id in ids {
    group.async {
      return (id, try await fetchThumbnail(withID: id))
    }
  }
  // outside of the async group sub-tasks, coalesce the results into the final thumbnails dictionary:
  for try await (id, thumbnail) in group {
   thumbnails[id] = thumbnail
  }
}

return thumbnails

Video: Meet async sequence

Task Cancellation

Check if a task has been cancelled during the scope of an async method:

if Task.isCancelled { break }

or

try Task.checkCancellation()

This is useful when grouping tasks, or checking to see if a task within multiple calls to throwing async methods has failed, and we no longer want to continue additional work.

Groups can also be cancelled:

group.cancelAll()

Unstructured Concurrency

func collectionView(_ view: UICollectionView, willDisplayCell cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
  Task { 
    let thumbnails = await fetchThumbnails(forItem: item)
    displayThumbnails(thumbnails, in: cell)
  }
}

This task will execute the blocking fetchThumbnails method on the thread it was called on. In this case, the main thread since it was from a collection view delegate method. However, the calling scope is not blocked and is allowed to continue execution. The task will be executed later when it is efficient to do so.

The lifetime of the Task is not limited to the calling scope. It will live on until the await is fulfilled. Can be manually cancelled or awaited. Below is an example of cancelling the task to fetch thumbnails for a collection view cell after it is scrolled out of view:

var thumbnailTasks = [IndexPath: Task<Void, Never>]()
func collectionView(_ view: UICollectionView, willDisplayCell cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
  thumbnailTasks[indexPath] = Task { 
    defer { thumbnailTasks[indexPath] = nil } // clear task from storage once completed
    let thumbnails = await fetchThumbnails(forItem: item)
    displayThumbnails(thumbnails, in: cell)
  }
}

func collectionView(_ view: UICollectionView, willEndDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
  thumbnailTasks[indexPath].cancel()
  thumbnailTasks[indexPath] = nil
}

Detached Tasks

Unstructured tasks that have unscoped lifetime, manually cancelled and awaited, and do not inherit their originating context, e.g. the thread which it was invoked. These run independently, and can be configured with custom priority and other traits.

Task.detatched(priority: .background) {
  writeThumbnailsToCache(thumbnails)
}

Detached tasks can also make use of structured tasks within them, gaining the advantages of structured concurrency.

Task.detatched(priority: .background) {
  withTaskGroup(of: Void.self) { group 
    group.async { writeThumbnailsToCache(thumbnails) }
    group.async { log(thumbnails) }
    group.async { ... } 
  }
}

If the background detatched group is cancelled, all sub-tasks will be canceled as well. These sub tasks will also inherit the priority of the parent task.

Tasks Summary

image

Protecting Mutable State with Actors

Video: Protect mutable state with Swift actors

Actors provide synchronization for shared mutable state. Actors isolate their state from the rest of the program. The actor will ensure mutally-exclusive access to its state.

actor Counter {
  var value = 0
  
  func increment() -> Int {
    value = value + 1
    return value
  }
}

Actors are similar to structs, classes, and enums, but are their own fundamental reference type. They can conform to protocols and be extended with extensions. Their defining characteristic is that they provide synchronization and isolation of its state.

Calls within an actor are perform synchronously and run uninterrupted.

extension Counter {
  func resetSlowly(toValue value: Int) {
    value = 0 // access is synchronous
    for _ in 0..<value {
      increment() // call is synchronous
    }
  }
}

Calls from outside are asynchronous, and can be awaited.

let counter = Counter()

await counter.increment()

Within the actor, we may use await calls that may suspend the method. Be sure to note that the state of the actor can change by the time the awaited call completes. Do not assume that that the state is the same, and make sure to check for mutations that may have happened during suspension. These assumptions should be checked after resuming from an await.

Actor Isolation

nonisolated keyword. Methods decorated with these keyword are defined as being "outside" of the actors controlled state. These methods cannot access mutable state on the actor, since it can be accessed outside of the controlled state. The following example shows how to conform to Hashable by using a nonisolated implementation of the hash(into:) method that uses an immutable id property. Using a mutable property in the method would lead to a compiler error.

actor User {
  let id: String
}

extension User: Hashable {
  nonisolated func hash(into hasher: inout Hasher) {
    hasher.combine(id)
  }
}

In the case where we have non-isolated closures that call back to mutate the actor, we need to ensure that we re-enter the actor in a safe way using await.

extension LibraryAccount {
  func read() -> Int {  }
  
  func readLater() {
    Task.detatched {
      await self.read()
    }
  }
}

Sendable Types

Sendable types are objects that are safe to share concurrently. It is a protocol and can be conformed to.

Classes for example are typically not safe because other objects can mutate their state at the same time and cause a race condition. Classes can only be sendable when their state is immutable, or interally performs synchronization. Value types are sendable because they can be modified independently since they are copied with each instance. Actor types are always sendable.

Functions can be sendable, but not always, and may be defined as such with the @Sendable keyword. This keyword is used to indicate where concurrent execution can occur, and prevents data races when accessing captured variables.

This is important for closures when capturing variables. Sendable functions cannot capture mutable variables, and can only capture other Sendable variables. Sendable functions cannot be both synchronous and actor-isolated.

Detatched tasks required sendable closures, and is defined like so:

static func detatched(operation: @Sendable () async -> Success) -> Task<Success, Never>

Main Actor

An actor that executes solely on the main thread. We can decorate functions, classes and actors with @MainActor to indicate its work is performed on the main thread. It implies that all methods and properties are run on the main thread.

@MainActor func updateUI(title: String) {
  
}

// if called from outside of the main thread, it must be awaited
await updateUI(title: "Sweet")
@MainActor class MyViewController: UIViewController {
  func onPress() {  } // implicitly called on main thread
  
  // methods can opt out of the main thread using `nonisolated`
  nonisolated func fetchLatestAndDisplay() async {  }
}

Aync Sequence

Sequences can be iterated asychronously. This is done with an iterator that uses a async method to return results to a loop. The loop can use the await keyword to handle each element as they are provided.

for await quake in quakes {  }

do { 
  for try awake quake in quakes {  } 
} catch { 
  
}

These can also be placed within tasks to prevent the calling thread from suspending, or if they run indefinitely:

Task {
  for await quake in quakes {  }
}

They can also be cancelled:

let iteration = Task {
  for await quake in quakes {  }
}

// …later
iteraction.cancel()

FileHandle objects can now provide bytes in an async fashion:

public var bytes: AsyncBytes
for try await line in FileHandle.standardInput.bytes.lines {
}

You can also asynchronously access bytes or lines from a URL:

public var resourceBytes: AsyncBytes
public var lines: AsyncLineSequence<AsyncBytes>
let url = URL(fileURLWithPath: "/path/to/file.txt")
for try await line in url.lines {  }

Read bytes from a URLSession: image

Video: Use async/await with URLSession

You can wait for notifications to be delivered:

let notification = await NotificationCenter.default.notifications(named: .NSPersistentStoreChange).first {
  $0.userInfo[NSStoreUUIDKey] == storeUUID
}

AsyncStream

AsyncStream can be used to adapt existing callback patterns to provide a stream of values compatible with swift concurrency.

let quakes = AsyncStream(Quake.self) { continuation in
  let monitor = QuakeMonitor()
  
  monitor.quakeHandler = { quake in 
    continuation.yield(quake)
  }
  
  continuation.onTermination = { _ in 
    monitor.stopMonitoring()
  }
  
  monitor.startMonitoring()
}

The AsyncStream can then be iterated over using for await as values are provided.

Use AsyncThrowingStream to produce a stream of values that can also throw.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment