Swift Talk # 424

Swift 6 Concurrency (Part 1)

This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We start a new macOS project to explore Swift 6's concurrency features.

00:06 In our workshops, there are a few specific links we always want to share with our students, and today we'd like to build a tiny browser that lets us bookmark and maybe even crawl these sites, as well as enable both a full-text search and a visual way of searching through them. We want to take a snapshot of a webpage and store it so that we can very quickly navigate to the link when people ask a question about the page's topic.

00:44 At least, that's the long-term goal. As a first step, we'll further explore Swift's structured concurrency, actors, and sendable types. We've already used async/await a lot, and we've also used workers and child tasks, but we're still relatively new to the concept of global actors and their execution contexts. And when we switch to the Swift 6 language mode, the compiler can give more errors when we try to move between those contexts, but the compiler cannot guarantee that it's safe to do so.

02:25 Let's begin by setting up a basic skeleton for a Mac app so that we have something to play with, and then we'll look into the concurrency concepts.

Mac App Skeleton

02:32 At the root of our app, we create a navigation split view, with a list of URLs on the left, and a detail view on the right:

struct ContentView: View {
    var body: some View {
        NavigationSplitView(sidebar: {
            List {

            }
        }, detail: {
            Text("Detail view")
        })
    }
}

02:56 We model a webpage as a Page struct, which we conform to Identifiable by giving it an identifier:

struct Page: Identifiable, Hashable {
    var id = UUID()
    var url: URL
}

03:21 We also write a model class to store the pages. To mark this store as @Observable, we have to import the Observation framework:

import Observation

// ...

@Observable
class Store {
    var pages: [Page] = []
}

03:47 We add a store instance to our navigation view, and we loop over its pages in the sidebar:

struct ContentView: View {
    @State var store = Store()

    var body: some View {
        NavigationSplitView(sidebar: {
            List {
                ForEach(store.pages) { page in
                    Text(page.url.absoluteString)
                }
            }
        }, detail: {
            Text("Detail view")
        })
    }
}

04:26 To add a page to the store, we'll need a text field to input a URL string. Just so we don't have to keep typing in a URL, we use our website's URL as a default value of the state property. That way, we only have to press enter to add the page while we build our app. We add the text field to the toolbar, and in an onSubmit action, we send the entered string to the store, and we clear the text field:

struct ContentView: View {
    @State var store = Store()
    @State var currentURLString: String = "https://www.objc.io"

    var body: some View {
        NavigationSplitView(sidebar: {
            List {
                ForEach(store.pages) { page in
                    Text(page.url.absoluteString)
                }
            }
        }, detail: {
            Text("Detail view")
        })
        .toolbar {
            ToolbarItem(placement: .principal) {
                TextField("URL", text: $currentURLString)
                    .onSubmit {
                        if let u = URL(string: currentURLString) {
                            store.submit(u)
                            currentURLString = ""
                        }
                    }
            }
        }
    }
}

06:06 In the store's submit method, we wrap the received URL in a Page, which we then append to the pages array:

@Observable
class Store {
    var pages: [Page] = []

    func submit(_ url: URL) {
        pages.append(Page(url: url))
    }
}

07:01 We can now add pages to the store, but we also have to be able to select a page in the sidebar. We add a state property to store a selected page's ID, and we pass a binding to that property to the List view's selection parameter. Because we loop over Page values, and because Page is an Identifiable type, it's inferred that we want to store a page's ID when we tap on its view in the list:

struct ContentView: View {
    @State var store = Store()
    @State var currentURLString: String = "https://www.objc.io"
    @State var selectedPage: Page.ID?

    var body: some View {
        NavigationSplitView(sidebar: {
            List(selection: $selectedPage) {
                ForEach(store.pages) { page in
                    Text(page.url.absoluteString)
                }
            }
        }, detail: {
            Text("Detail view")
        })
        .toolbar {
            // ...
        }
    }
}

07:57 For the detail view, we return a text view with the selected page ID if there is one. Otherwise, we display a ContentUnavailableView:

struct ContentView: View {
    @State var store = Store()
    @State var currentURLString: String = "https://www.objc.io"
    @State var selectedPage: Page.ID?

    var body: some View {
        NavigationSplitView(sidebar: {
            List(selection: $selectedPage) {
                ForEach(store.pages) { page in
                    Text(page.url.absoluteString)
                }
            }
        }, detail: {
            if let s = selectedPage {
                Text(s.uuidString)
            } else {
                ContentUnavailableView("No page selected", systemImage: "globe")
            }
        })
        .toolbar {
            // ...
        }
    }
}

08:51 We can now submit a URL to add a page to the sidebar, and we can select the page to see its ID in the detail view:

Main Actor

08:59 That'll do for the app's skeleton. Let's move our attention to structured concurrency and actors, specifically what happens when we submit a URL to our store. Right now, everything seems to work fine: when we submit the text field, the closure defined in the onSubmit modifier is executed, and the page is added to the store.

09:29 But what happens when we enable the Swift 6 language mode? We open the build settings, we look for the Swift language version setting, and we set it to Swift 6. We don't get any new compiler errors, which means we aren't doing anything bad so far.

10:01 Let's look at each of our components. The store is @Observable, but that has nothing to do with its execution context; in other words, Store is non-isolated to a particular actor.

10:31 If we look into the View protocol, we'll see that it's attributed with @MainActor. That means the properties and methods of our navigation view, like all views, are isolated to the main actor. Therefore, the closure in the onSubmit modifier is also executed by the main actor, because it inherits that context from the view in which it's defined.

11:39 This works because the Store class is non-isolated, and its submit method can be executed by the main actor. This basically works the same way as if we'd add @MainActor to our model object. That wouldn't change anything, because we're already running on the main actor in the onSubmit closure.

12:39 But it would make our code safer, because now we're not allowed to mutate the store from other actors. We then make it explicit that the model code has to run on the main actor, which means we can't pass the object around to other execution contexts, like a background queue.

13:04 However, the compiler would already stop us from passing the model object around even without it being isolated to the main actor, because it's a class and, unlike with structs, the compiler won't synthesize a Sendable conformance for it.

13:33 We can see this when we try to dispatch to a global queue and print the model object to the console from there:

struct ContentView: View {
    @State var store = Store()
    @State var currentURLString: String = "https://www.objc.io"
    @State var selectedPage: Page.ID?

    var body: some View {
        NavigationSplitView(sidebar: {
            // ...
        })
        .toolbar {
            ToolbarItem(placement: .principal) {
                TextField("URL", text: $currentURLString)
                    .onSubmit {
                        if let u = URL(string: currentURLString) {
                            store.submit(u)
                            currentURLString = ""
                        }
                        DispatchQueue.global().async {
                            print(store)
                        }
                    }
            }
        }
    }
}

13:58 We get a compiler error: "Main actor-isolated property 'store' can not be referenced from a Sendable closure." In other words, the closure we dispatch to the global queue is marked as Sendable because it's sent to a different execution context, and we're not allowed to reference the store property of our main actor-isolated view inside a Sendable closure.

14:26 Removing the @MainActor attribute from Store doesn't make a difference, because as the error states, it's not the model object that's being sent to a different execution context, but the reference to the view's property.

14:43 Let's see if we can create a global, non-isolated variable and pass that around:

let model = Store()

struct ContentView: View {
    @State var store = model
    // ...
}

15:00 Now the compiler complains that it isn't safe to do this, because the Store class is non-Sendable and it may have shared mutable state. It looks like it's not so easy to break the concurrency checks of the Swift 6 compiler.

15:32 We can also try to make a copy of the store and send that to the global background queue instead:

struct ContentView: View {
    @State var store = Store()
    // ...

    var body: some View {
        NavigationSplitView(sidebar: {
            // ...
        })
        .toolbar {
            ToolbarItem(placement: .principal) {
                TextField("URL", text: $currentURLString)
                    .onSubmit {
                        // ...
                        let copy = store
                        DispatchQueue.global().async {
                            print(copy)
                        }
                    }
            }
        }
    }
}

15:48 This gives a different error: "Capture of 'copy' with non-sendable type 'Store' in a @Sendable closure." Before, when we accessed the view's store, the compiler said we couldn't do so because the property is isolated to the main actor. Now that we reference the model object with a different variable — one that's decoupled from the main actor-isolated property — the compiler complains about the Store type not conforming to Sendable.

16:26 This can be fixed by isolating the Store class to the main actor:

@Observable
@MainActor
class Store {
    var pages: [Page] = []

    nonisolated init() {
    }

    func submit(_ url: URL) {
        pages.append(Page(url: url))
    }
}

16:31 Now we can send the model object to the background queue, and we can print it, but we get another error if we try to call submit on it:

struct ContentView: View {
    @State var store = Store()
    // ...

    var body: some View {
        NavigationSplitView(sidebar: {
            // ...
        })
        .toolbar {
            ToolbarItem(placement: .principal) {
                TextField("URL", text: $currentURLString)
                    .onSubmit {
                        // ...
                        let copy = store
                        DispatchQueue.global().async {
                            print(copy)
                            copy.submit(.init(string: "")!)
                        }
                    }
            }
        }
    }
}

17:02 We're first dispatching to a background queue, and then we're calling into the main actor-isolated Store class. That means we're crossing over to a different execution context, so we'd have to call the method with await, unless we can prove to the compiler that we're on the main actor — for example, by dispatching back to the main queue:

struct ContentView: View {
    @State var store = Store()
    // ...

    var body: some View {
        NavigationSplitView(sidebar: {
            // ...
        })
        .toolbar {
            ToolbarItem(placement: .principal) {
                TextField("URL", text: $currentURLString)
                    .onSubmit {
                        // ...
                        let copy = store
                        DispatchQueue.global().async {
                            print(copy)
                            DispatchQueue.main.async {
                                copy.submit(.init(string: "")!)
                            }
                        }
                    }
            }
        }
    }
}

Global Actor

17:42 The main actor is a global actor, but we can also create our own global actor. Let's see what happens when we isolate our store to a global actor. We first define an actor, and we give it the attribute @globalActor. This requires us to define a shared instance on the actor:

@globalActor
actor MyGlobalActor {
    static let shared = MyGlobalActor()
}

18:51 Instead of marking our store with @MainActor, we can now tie the class to our own global actor with @MyGlobalActor:

@Observable
@MyGlobalActor
class Store {
    var pages: [Page] = []

    func submit(_ url: URL) {
        pages.append(Page(url: url))
    }
}

19:02 A few new errors pop up. Where we initialize the store state property, it says "MyGlobalActor-isolated default value in a main actor-isolated context." And where we access the store — in the ForEach and in the onSubmit closure — the compiler doesn't let us do so because these accesses can no longer be done synchronously; they'd have to be called with await.

19:41 To use await, we have to be in an asynchronous context — for example, by creating a Task:

struct ContentView: View {
    @State var store = Store()
    // ...

    var body: some View {
        NavigationSplitView(sidebar: {
            // ...
        })
        .toolbar {
            ToolbarItem(placement: .principal) {
                TextField("URL", text: $currentURLString)
                    .onSubmit {
                        if let u = URL(string: currentURLString) {
                            currentURLString = ""
                            Task {
                                await store.submit(u)
                            }
                        }
                    }
            }
        }
    }
}

20:11 The error around the initialization of the state property can be fixed by adding a nonisolated initializer to Store:

@Observable
@MyGlobalActor
class Store {
    var pages: [Page] = []

    nonisolated init() {
    }

    func submit(_ url: URL) {
        pages.append(Page(url: url))
    }
}

20:36 It's more difficult to make accessing the store's pages in the ForEach view work, because the view requires the access to be synchronous, while the Store type is isolated to a different actor. Here, we can't make a context switch like we do in the onSubmit closure's task.

Next

21:29 This is complicated stuff, but we hope it'll make more sense as we get more used to working with structured concurrency. Our next step in this project would be to take a snapshot of a webpage. We'll probably need to wrap a WKWebView for that, and we'll hit the problem that NSImage is non-sendable.

Resources

  • Sample Code

    Written in Swift 6.0

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

61 Episodes · 21h29min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes