Porting Thread & Delegate Code to Swift 6 Using Tasks

Hello,

I have a lot of apps and I am currently trying to port them over to Swift 6. I thought that this process should be relatively simple but I have to admit that I have a lot of trouble to understand how the Concurrency system works.

Let's start with some code that shows how I am currently working when it comes to asynchronous work in my apps:

  • I have a Model that is marked with @Observable.
  • Inside this model, a Controller is hosted.
  • The Controller has its own ControllerDelegate.
  • The Model has a search function. Inside this function a lot of IO stuff is executed. This can take a lot of time. Because of this fact, I am doing this in a separate Thread.

I all is put together, it looks a little bit like this:

@main
struct OldExampleApp : App {
    
    @State private var model = Model()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(self.model)
        }
    }
}

struct ContentView: View {
    @Environment(Model.self) private var model
    
    var body: some View {
        if self.model.isSearching {
            ProgressView()
        }
        else {
            Button("Start") {
                self.model.search()
            }
        }
    }
}

protocol ControllerDelegate : AnyObject {
    func controllerDidStart()
    func controllerDidEnd()
}

class Controller {
    weak var delegate: ControllerDelegate?
    
    func search() {
        let thread = Thread {
            DispatchQueue.main.async {
                self.delegate?.controllerDidStart()
            }
            
            // Do some very complex stuff here. Let's use sleep to simulate this.
            Thread.sleep(forTimeInterval: 2.0)
            
            DispatchQueue.main.async {
                self.delegate?.controllerDidEnd()
            }
        }
        
        thread.start()
    }
}

@Observable
class Model {
    private(set) var isSearching = false
    
    var controller = Controller()
    
    init() {
        self.controller.delegate = self
    }
    
    func search() {
        self.controller.search()
    }
}

extension Model : ControllerDelegate {
    func controllerDidStart() {
        self.isSearching = true
    }
    
    func controllerDidEnd() {
        self.isSearching = false
    }
}

This works perfectly fine and by that I mean:

  • The task is run in the background.
  • The main thread is not blocked. The main window can be dragged around, no beach ball cursor etc.

Now comes the Swift 6 part:

  • I want to merge the Model and Controller into one class (Model).
  • I still want the Model to be Observable.
  • I want to run arbitrary code in the Model. This means that the code is not necessarily a prime candidate for await like getting data from a web server etc.
  • The main thread should not be blocked, so the main window should still be movable while the app calculates data in the background.

I have this example:

struct ContentView: View {
    
    @Environment(Model.self) private var model
    
    var body: some View {
        if self.model.controller.isSearching
        {
            ProgressView()
        }
        else
        {
            Button("Search") {
                Task {
                    await self.model.controller.heavyWork()
                }
            }
        }
    }
}

@Observable
final class Model : Sendable
{
    @MainActor var controller = AsyncController()
    
    init()
    {
        
    }
}

@Observable
@MainActor
class AsyncController
{
    private(set) var isSearching = false
    
    public func heavyWork() async
    {
        self.isSearching = true
        
        Swift.print(Date.now)
        let i = self.slowFibonacci(34)
        Swift.print(i)
        Swift.print(Date.now)
        
        self.isSearching = false
    }
    
    func slowFibonacci(_ n: Int) -> Int
    {
        if n <= 1 {
            return n
        }
        
        let x = slowFibonacci(n - 1)
        let y = slowFibonacci(n - 2)
        
        return x + y
    }
}

I come from a C# background and my expectation is that when I use a Task with await, the main thread is not blocked and the Code that is called inside the Task runs in the background. It seems like the function is run in the background, but the UI is not updated. Because I set the isSearching flag to true, I would expect that the app would display the ProgressView - but it does not.

I changed the code to this:

public func heavyWork() async
    {
        self.isSearching = true
        
        Swift.print(Date.now)
        let i = await self.slowFibonacci(20)
        Swift.print(i)
        Swift.print(Date.now)
        
        self.isSearching = false
    }
    
    func slowFibonacci(_ n: Int) async -> Int
    {
        let task = Task { () -> Int in
            if n <= 1 {
                return n
            }
            
            let x = await slowFibonacci(n - 1)
            let y = await slowFibonacci(n - 2)
            
            return x + y
        }
        
        return await task.value
    }

This seems to work - but is this correct? I have this pattern implemented in one of my apps and there the main thread is blocked when the code is run.

So I think it all comes down to this:

  • Is it possible, to run a arbitrary code block (without an await in it) in a Task, that can be awaited so the main thread is not blocked?
  • The class (or actor?) that contains the function that is called via await should be Observable.
  • Or should I simply keep my Swift 5 code and move on? :D

Regards, Sascha

Answered by DTS Engineer in 798016022
my expectation is that when I use a Task with await, the main thread is not blocked

I think this is your fundamental misconception. I actually just explained this over on another thread. Have a read of that and then get back to me here if you’re still stuck.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

my expectation is that when I use a Task with await, the main thread is not blocked

I think this is your fundamental misconception. I actually just explained this over on another thread. Have a read of that and then get back to me here if you’re still stuck.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Hello and thank you for your reply.

The problem is that I simply have not found a working example for what I am actually trying to do.

In one of my tests I also tried to simulate a long running operation with

await Task.sleep(...)

and it worked. But as soon as I want to run some other code (that by default is not await able because it is not async), I don't know what to do.

That's why I thought that putting the code into an async function so I could use await would be the correct move.

I have created another small demo. In it, a 512 MB file is written to a rather old (and slow) external hard disk:

import Observation
import AppKit

@Observable
final class Model : Sendable
{
    @MainActor var controller = AsyncController()
    
    init()
    {
        
    }
}

@Observable // Too good to not use.
@MainActor  // Required for Observable to work? Can't get it to work without it.
class AsyncController
{
    private(set) var isWorking = false
    
    public func heavyWork() async throws {
        self.isWorking = true
        
        do {
            try await self.save()
        }
        catch {
            Swift.print(error.localizedDescription)
        }
        
        self.isWorking = false
    }
    
    func save() async throws {
        let task = Task {
            let data = Data(count: 512 * 1024 * 1024)
            
            let url = URL(fileURLWithPath: "/Volumes/LaCie/out.bin")
            try data.write(to: url)
        }
        
        return try await task.value
    }
}

In this example, the Observable property works fine. As soon as I click the button, the ProgressView is shown. However, the GUI is unresponsive (we are using the Main Actor, so this is to be expected I guess, but I created a new Task so that's what I don't understand).

I also don't see why this should be done in a separate Task, I am already using a Task when clicking the button:

func save() throws {
  let data = Data(count: 512 * 1024 * 1024)
        
  let url = URL(fileURLWithPath: "/Volumes/LaCie/out.bin")
  try data.write(to: url)
}

So my next best guess was to simply remove the @MainActor from the AsyncController to get this thing off the main thread:

@Observable
class AsyncController
{
}

But now the compiler complains:

Button("Write") {
  Task {
    try await self.model.controller.heavyWork() // Error: Sending 'self.model.controller' risks causing data races
  }
}

Regards, Sascha

I have created another small demo. In it, a 512 MB file is written to a rather old (and slow) external hard disk:

This is a valid demo, but your use of file I/O complicates the issue. Lemme start by answering as if your long-running task was entirely CPU bound. After that I’ll come back to file I/O.

On the CPU side of things, I’ve been seeing this question a lot recently so I wrote it up properly. See Task Isolation Inheritance and SwiftUI.

The situation with file I/O is tricky. The issue is that all of our file I/O primitives, all the way down into the kernel, are synchronous. So, if the I/O ends up blocking, it blocks within the kernel. That’s not a good match for Swift concurrency.

Note This is in constrast to network I/O, where the underlying primitives are non-blocking. That’s why, for example, URLSession has great support for Swift concurrency.

If you’re doing small amounts of file I/O then it’s reasonable to do that in a Swift async function. That is, treat it like you would CPU bound work. OTOH, if you’re doing lots of file I/O, that’s not appropriate. You can easily end up tying up all the Swift concurrency pool’s worker threads. There are reasonable paths forward in that case, but the best option depends on your specific situation.

So, if file I/O is a big deal for you, write back with some more details about the sort of file I/O you’re doing and we can take things from there.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Porting Thread &amp; Delegate Code to Swift 6 Using Tasks
 
 
Q