Migrating to Swift 6 Tutorial

The migration path to Swift 6 is now a lot smoother, with lots more guideposts. Work through this tutorial to find out how much easier it’s become. By Audrey Tam.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 2 of 3 of this article. Click here to view the first page.

Migrating TheMet to Swift 6.2

Open the project’s Build Settings. Instead of using the incremental approach, which doesn’t raise any issues, enable complete concurrency checking:

Xcode Build Settings: Enable complete checking

Xcode Build Settings: Enable complete checking

Xcode Build Settings: Enable complete checking

Press Command-B to build the project. The build succeeds, but there are warnings:

Warnings raised by complete checking

Warnings raised by complete checking

Warnings raised by complete checking

<code>
Sending `self.store` risks causing data races...
Capture of `self` with non-sendable type `TheMetStore` in a `@Sendable` closure...
</code>

To understand these warnings, you need to know about crossing isolation boundaries and Sendable types.

Crossing Isolation Boundaries

Isolation domains protect their mutable objects, but your app needs to be able to pass data between domains — some values need to cross isolation boundaries, but this is permitted only where there is no potential for concurrent access to shared mutable state.

Values can cross boundaries directly, via asynchronous function calls. When you call an asynchronous function with a different isolation domain, the parameters and return value need to move into that domain. Values can also cross boundaries indirectly when captured by closures. Closures introduce many potential opportunities for concurrent accesses. They can be created in one domain and then executed in another. They can even be executed in multiple, different domains.

The warning arises because of the await in this line, which occurs twice in ContentView:

try await store.fetchObjects(for: query)

You’re sending the MainActor object self.store to a different isolation domain, and Swift 6 sees this as a data race risk. store is of type TheMetStore, and it’s there to call its fetchObjects(for:) method. The purpose of TheMetStore is to provide ContentView with data to display, so it makes sense to include it in the MainActor isolation domain. Then, you won’t be sending store across to another isolation domain.

Instead of explicitly marking TheMetStore as @MainActor, you’ll set Default Actor Isolation to MainActor … but first, look at the second set of warnings in TheMetStore.

Sendable Types

Capture of self … in a @Sendable closure

Capture of self ... in a @Sendable closure

Capture of self … in a @Sendable closure

<code>
Capture of `self` with non-sendable type `TheMetStore` in a `@Sendable` closure...
</code>

A Sendable type is one that conforms to the Sendable protocol. Sendable types are naturally thread safe, and you can send them between different isolation domains with no risk of data races. All value types are Sendable because their values are copied.

A reference type can be made Sendable only if it contains no mutable state, and all its immutable properties are also Sendable. And, the compiler can only validate the implementation of final classes.

Functions can also be @Sendable. Global function declarations, closures, getters and setters are all @Sendable by default.

This warning is about the closure in fetchObjects(for:):

await MainActor.run {
  objects.append(object)
}

Here, self is TheMetStore — a class with mutable objects, so it isn’t Sendable.

You’ll soon set the default actor to MainActor, and then fetchObjects will run on the main thread, so objects.append(object) will run on the main thread. It won’t need to be in an await MainActor.run closure, so you can delete or comment out those lines:

func fetchObjects(for queryTerm: String) async throws {
  print("fetchObjects running on: \(Thread.currentThread)")
  if let objectIDs = try await service.getObjectIDs(from: queryTerm) {
    print("fetchObjects resuming on: \(Thread.currentThread)")
    for (index, objectID) in objectIDs.objectIDs.enumerated()
    where index < maxIndex {
      if let object = try await service.getObject(from: objectID) {
//          await MainActor.run {
          objects.append(object)
//          }
      }
    }
  }
}

Now, go ahead and change Default Actor Isolation to MainActor in Build Settings:

Xcode Build Settings: Default Actor Isolation — MainActor

Xcode Build Settings: Default Actor Isolation — MainActor

Xcode Build Settings: Default Actor Isolation — MainActor

Build again, and the warnings go away but now, Circular reference errors appear, as well as warnings about conformance to protocol crossing into main actor-isolated code:

<code>
Conformance of 'Object' to protocol 'Encodable' crosses into main actor-isolated code and can cause data races; this is an error in the Swift 6 language mode

Main actor-isolated instance method 'encode(to:)' cannot satisfy nonisolated requirement (TheMet.Object.encode)

Isolate this conformance to the main actor with '@MainActor'  [Apply]
Turn data races into runtime errors with '@preconcurrency'    [Apply]
</code>

Protocol conformance warning

Protocol conformance warning

Protocol conformance warning

Protocols

From swift.org documentation: A protocol conformance can implicitly affect isolation. However, the protocol’s effect on isolation depends on how the conformance is applied.

The question mark next to the warning links to the swift.org article. Object and ObjectIDs are now in the MainActor isolation domain, but Codable is non-isolated. The fix is to isolate both protocol conformances to MainActor.

struct Object: @MainActor Codable, Hashable {
  let objectID: Int
  let title: String
  let creditLine: String
  let objectURL: String
  let isPublicDomain: Bool
  let primaryImageSmall: String
}

struct ObjectIDs: @MainActor Codable {
  let total: Int
  let objectIDs: [Int]
}

Build and run and watch the console:

Store and Service methods running on main thread

Store and Service methods running on main thread

Store and Service methods running on main thread

Yes indeed, everything is now running on the main thread, except for the URLSession method. TheMet is now in phase two of the Swift 6.2 development process. What about phase 3? You need a mock slow process — coming right up!

Adding Concurrency to TheMet

Suppose you want the app to apply some process to each Object as it arrives, and this process is very slow. Open SlowProcess.swift and add this code to it:

struct SlowProcess {
  func doSomething() async throws {
    print("doSomething starting on: \(Thread.currentThread)")
    try! await Task.sleep(for: Duration(attoseconds: 10000000000))
    print("doSomething resuming on: \(Thread.currentThread)")
  }
}

The method is a mock slow process — it sleeps for a long time, and the print statements show which thread it's running on, before and after Task.sleep(for:). A real slow process method might do all its work on the same thread so, if it starts on the main thread, all its work is done on the main thread.

In TheMetService, call doSomething() in getObject(from:), just before return object:

print("getObject running on: \(Thread.currentThread)")
try! await SlowProcess().doSomething()
print("getObject resuming after slow process on: \(Thread.currentThread)")

Again, you print which thread(s) getObject(from:) is running on, before and after the call to doSomething().

Now, build and run and watch the console.

Slow process running on main thread

Slow process running on main thread

Slow process running on main thread

SlowProcess is in the MainActor isolation domain by default and, sure enough, that's where doSomething is running! You don't want it slowing down the UI response, so here's how you move it off the main thread.

  1. Mark SlowProcess as nonisolated.
  2. Mark doSomething as @concurrent.
nonisolated struct SlowProcess {  // 1
  @concurrent func doSomething() async throws {  // 2
    print("doSomething starting on: \(Thread.currentThread)")
    try! await Task.sleep(for: Duration(attoseconds: 10000000000))
    print("doSomething resuming on: \(Thread.currentThread)")
  }
}
  1. Marking SlowProcess as nonisolated uncouples it from MainActor, so its methods don't automatically run on the main thread. 
  2. Marking doSomething as @concurrent ensures it always runs on a background thread.

Build and run again.

Slow process running on background threads

Slow process running on background threads

Slow process running on background threads

Great, you've successfully moved doSomething to background threads, and there's no risk of it slowing down the main thread!

Contributors

Audrey Tam

Author, Tech Editor, Editor&Final Pass Editor

Over 300 content creators. Join our team.