Chapters

Hide chapters

Modern Concurrency in Swift

First Edition · iOS 15 · Swift 5.5 · Xcode 13

Section I: Modern Concurrency in Swift

Section 1: 11 chapters
Show chapters Hide chapters

9. Global Actors
Written by Marin Todorov

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

In the previous chapter, you got to meet Swift’s actor type, which provides code with safe, concurrent access to its internal state. This makes concurrent computation more reliable and turns data-race crashes into a thing of the past.

You worked through adding actor-powered safety to an app called EmojiArt, an online catalog for digital art. Once you fleshed out a useful actor called ImageLoader, you injected it into the SwiftUI environment and used it from various views in the app to load and display images.

Additionally, you used MainActor, which you can conveniently access from anywhere, by calling MainActor.run(...). That’s pretty handy given how often you need to make quick changes that drive the UI:

actor 2 actor 1 MainActor code code code code code UI code UI code UI code

When you think about it, this is super-duper convenient: Because your app runs on a single main thread, you can’t create a second or a third MainActor. So it does make sense that there’s a default, shared instance of that actor that you can safely use from anywhere.

Some examples of app-wide, single-instance shared state are:

  • The app’s database layer, which is usually a singleton type that manages the state of a file on disk.
  • Image or data caches are also often single-instance types.
  • The authentication status of the user is valid app-wide, whether they have logged in or not.

Luckily, Swift allows you to create your own global actors, just like MainActor, for exactly the kinds of situations where you need a single, shared actor that’s accessible from anywhere.

Getting to meet GlobalActor

In Swift, you can annotate an actor with the @globalActor attribute, which makes it automatically conform to the GlobalActor protocol:

@globalActor actor MyActor {
  ...
}

GlobalActor has a single requirement: Your actor must have a static property called shared that exposes an actor instance that you make globally accessible.

This is very handy because you don’t need to inject the actor from one type to another, or into the SwiftUI environment.

Global actors, however, are more than just a stand-in for singleton types.

Just as you annotated methods with @MainActor to allow their code to change the app’s UI, you can use the @-prefixed annotation to automatically execute methods on your own, custom global actor:

@MyActor func say(_ text: String) {
  ... automatically runs on MyActor ...
}

To automatically execute a method on your own global actor, annotate it with the name of your actor prefixed with an @ sign, like so: @MyActor, @DatabaseActor, @ImageLoader and so on.

You might already imagine how this can be a fantastic proposition for working with singleton-like concepts such as databases or persistent caches.

Views Views Views Views Views Models Utilities DatabaseActor Server

To avoid concurrency problems due to different threads writing data at the same time, you just need to annotate all the relevant methods and make them run on your global actor.

In fact, you can annotate a complete class with a global actor and that will add that actor’s semantics to all its methods and properties (as long as they aren’t nonisolated):

@MyActor class MyClass {
  ...
}

Lastly, by using the @ annotation, you can group methods or entire types that can safely share mutable state in their own synchronized silo:

MainView Server App Model requestFile() sendFile() setup() saveToDatabase() getFromDatabase() formattingDates() showProgress() loadDatabaseItems() DatabaseActor

In this chapter, you’ll add a persistent cache layer to the EmojiArt project that you worked on in the last chapter, as shown in the diagram above.

You’ll get plenty of opportunities to learn about global actors in detail while having fun with juggling on-disk and in-memory caches.

Continuing with the EmojiArt project

In this section, you’ll keep working on the last chapter’s project: EmojiArt, your online store for verified, digital emoji art:

Creating a global actor

In this section, you’ll enhance EmojiArt with a new global actor that will persist downloaded images on disk.

import UIKit

@globalActor actor ImageDatabase {
  static let shared = ImageDatabase()

}
let imageLoader = ImageLoader()

private let storage = DiskStorage()
private var storedImagesIndex = Set<String>()

Creating a safe silo

Above, you introduced two dependencies to your code: ImageLoader and DiskStorage.

AwideFacefati abidayed UhiweDaluloge .exohu(_:) UcecuKosexana .fjiwo(_:yumCaj:) WefbMsoliga .liun(loce:) EmuwiXoqekuye .edade(_:) SojdVyixuyu .jcida(_:none:)

@ImageDatabase class DiskStorage {
Call to global actor 'ImageDatabase'-isolated initializer 'init()' in a synchronous actor-isolated context

Initializing the database actor

First, switch back to ImageDatabase.swift. Then, replace:

private let storage = DiskStorage()
private var storage: DiskStorage!
func setUp() async throws {
  storage = await DiskStorage()
  for fileURL in try await storage.persistedFiles() {
    storedImagesIndex.insert(fileURL.lastPathComponent)
  }
}

Writing files to disk

The new cache will need to write images to disk. When you fetch an image, you’ll export it to PNG format and save it. To do that, add the following method anywhere inside ImageDatabase:

func store(image: UIImage, forKey key: String) async throws {
  guard let data = image.pngData() else {
    throw "Could not save image \(key)"
  }
  let fileName = DiskStorage.fileName(for: key)
  try await storage.write(data, name: fileName)
  storedImagesIndex.insert(fileName)
}
nonisolated static func fileName(for path: String) -> String {

Fetching images from disk (or elsewhere)

Next, you’ll add a helper method to fetch an image from the database. If the file is already stored on disk, you’ll fetch it from there. Otherwise, you’ll use ImageLoader to make a request to the server. This is how the completed flow will look:

Wilufg ovuno dvuy kogofh Depesj naka nley luwy Teqegw winjxaz awabu Nujuaxd Odqet Ol ew payfal ub cobuml? Eb oc giyrif ih wasl? Kob ic ciqmipjdidkb liybgeq lyuz clu kejqun? qzcas ug amjiw ga tiq leq jar ne wu

func image(_ key: String) async throws -> UIImage {
  let keys = await imageLoader.cache.keys
  if keys.contains(key) {
    print("Cached in-memory")
    return try await imageLoader.image(key)
  }

}
do {
  // 1
  let fileName = DiskStorage.fileName(for: key)
  if !storedImagesIndex.contains(fileName) {
    throw "Image not persisted"
  }

  // 2
  let data = try await storage.read(name: fileName)
  guard let image = UIImage(data: data) else {
    throw "Invalid image data"
  }

  print("Cached on disk")
  // 3
  await imageLoader.add(image, forKey: key)
  return image
} catch {
  // 4
}
let image = try await imageLoader.image(key)
try await store(image: image, forKey: key)
return image

Purging the cache

To easily test the caching logic, you’ll add one more method to ImageDatabase. clear() will delete all the asset files on disk and empty the index. Add the following anywhere in ImageDatabase:

func clear() async {
  for name in storedImagesIndex {
    try? await storage.remove(name: name)
  }
  storedImagesIndex.removeAll()
}

Wiring up the persistence layer

As noted earlier, before you do anything with the new database type, you need to set it up safely by calling ImageDatabase‘s setUp method. You can do that anywhere in your code, but for this example, you’ll pair it up with the rest of your app setup.

try await ImageDatabase.shared.setUp()
ImageDatabase.shared.image(file.url)
.task {
  guard let image = try? await 
    ImageDatabase.shared.image(file.url) else {
    overlay = "camera.metering.unknown"
    return
  }
  updateImage(image)
}
ImageDatabase.shared.image(file.url)
Download: http://localhost:8080/gallery/image?26
Cached in-memory
Cached in-memory
Download: http://localhost:8080/gallery/image?2
Cached in-memory
Download: http://localhost:8080/gallery/image?9
Download: http://localhost:8080/gallery/image?22
...
Cached in-memory
Cached in-memory
Cached in-memory
Cached in-memory
Cached in-memory
Cached in-memory
Cached in-memory
Cached on disk
Cached on disk
Download: http://localhost:8080/gallery/image?10
Cached on disk
Cached on disk

Adding a cache hit counter

In this section, you’ll add code to activate the bottom bar in the feed screen to help you debug your caching mechanism. This is how the toolbar will look when you finish:

ztuonh xxe yexn puzye mzoeqv nbo jineqk gemco ajzidd riipif fzuh taqx adxivt soicek tket jeditf

@MainActor private(set) var inMemoryAccess: AsyncStream<Int>?

private var inMemoryAccessContinuation: AsyncStream<Int>.Continuation?
private var inMemoryAccessCounter = 0 {
  didSet { inMemoryAccessContinuation?.yield(inMemoryAccessCounter) }
}
func setUp() async {
  let accessStream = AsyncStream<Int> { continuation in
    inMemoryAccessContinuation = continuation
  }
  await MainActor.run { inMemoryAccess = accessStream }
}
inMemoryAccessCounter += 1
OyekuZiajaq SeuvIjguc ivXigebtUwqufq cfahayuk o fimuo uxParakbAfpugtKiawsec += 4 itKazeqmOhkughWowyepaudeat.reeqb()

deinit {
  inMemoryAccessContinuation?.finish()
}

Displaying the counter

You’ll get around to updating your view code in a moment, but don’t forget that the image loader will not set itself up automatically. You’ll now add the call to ImageLoader.setUp(), just like you did for ImageDatabase.

await imageLoader.setUp()
.task {
  guard let memoryAccessSequence = 
    ImageDatabase.shared.imageLoader.inMemoryAccess else {
    return
  }
  for await count in memoryAccessSequence {
    inMemoryAccessCount = count
  }
}

Purging the in-memory cache

To complete the last exercise for this chapter, you’ll wire up the button that clears the memory cache.

func clearInMemoryAssets() async {
  await imageLoader.clear()
}
Task {
  await ImageDatabase.shared.clearInMemoryAssets()
  try await model.loadImages()
}

Challenges

Challenge: Updating the number of disk fetches

In this challenge, you’ll finish the debugging toolbar by connecting the second counter, which displays on-disk cache hits.

Key points

  • Global actors protect the global mutable state within your app.
  • Use @globalActor to annotate an actor as global and make it conform to the GlobalActor protocol.
  • Use a global actor’s serial executor to form concurrency-safe silos out of code that needs to work with the same mutable state.
  • Use a mix of actors and global actors, along with async/await and asynchronous sequences, to make your concurrent code safe.
Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now