Home > database >  Make tasks in Swift concurrency run serially
Make tasks in Swift concurrency run serially

Time:07-22

I've a document based application that uses a struct for its main data/model. As the model is a property of (a subclass of) NSDocument it needs to be accessed from the main thread. So far all good.

But some operations on the data can take quite a long time and I want to provide the user with a progress bar. And this is where to problems start. Especially when the user starts two operations from the GUI in quick succession.

If I run the operation on the model synchronously (or in a 'normal' Task {}) I get the correct serial behaviour, but the Main thread is blocked, hence I can't show a progress bar. (Option A)

If I run the operation on the model in a Task.detached {} closure I can update the progress bar, but depending on the run time of the operations on the model, the second action of the user might complete before the first operation, resulting in invalid/unexpected state of the model. This is due to the await statements needed in the detached task (I think). (Option B).

So I want a) to free up the main thread to update the GUI and b) make sure each task runs to full completion before another (queued) task starts. This would be quite possible using a background serial dispatch queue, but I'm trying to switch to the new Swift concurrency system, which is also used to perform any preparations before the model is accessed.

I tried using a global actor, as that seems to be some sort of serial background queue, but it also needs await statements. Although the likelihood of unexpected state in the model is reduced, it's still possible.

I've written some small code to demonstrate the problem:

The model:

struct Model {
    var doneA = false
    var doneB = false

    mutating func updateA() {
        Thread.sleep(forTimeInterval: 5)
        doneA = true
    }

    mutating func updateB() {
        Thread.sleep(forTimeInterval: 1)
        doneB = true
    }
}

And the document (leaving out standard NSDocument overrides):

@globalActor
struct ModelActor {
    actor ActorType { }

    static let shared: ActorType = ActorType()
}

class Document: NSDocument {
    var model = Model() {
        didSet {
            Swift.print(model)
        }
    }

    func update(model: Model) {
        self.model = model
    }

    @ModelActor
    func updateModel(with operation: (Model) -> Model) async {
        var model = await self.model
        model = operation(model)
        await update(model: model)
    }

    @IBAction func operationA(_ sender: Any?) {
        //Option A
//        Task {
//            Swift.print("Performing some A work...")
//            self.model.updateA()
//        }

        //Option B
//        Task.detached {
//            Swift.print("Performing some A work...")
//            var model = await self.model
//            model.updateA()
//            await self.update(model: model)
//        }

        //Option C
        Task.detached {
            Swift.print("Performing some A work...")
            await self.updateModel { model in
                var model = model
                model.updateA()
                return model
            }
        }
    }

    @IBAction func operationB(_ sender: Any?) {
        //Option A
//        Task {
//            Swift.print("Performing some B work...")
//            self.model.updateB()
//        }

        //Option B
//        Task.detached {
//            Swift.print("Performing some B work...")
//            var model = await self.model
//            model.updateB()
//            await self.update(model: model)
//        }

        //Option C
        Task.detached {
            Swift.print("Performing some B work...")
            await self.updateModel { model in
                var model = model
                model.updateB()
                return model
            }
        }
    }
}

Clicking 'Operation A' and then 'Operation B' should result in a model with two true's. But it doesn't always.

Is there a way to make sure that operation A completes before I get to operation B and have the Main thread available for GUI updates?

CodePudding user response:

Please review / test the following before using it.

Concept

  • UIState needs to be a MainActor
  • ServiceA and ServiceB are separate actors
  • Wait on previous running tasks, in case IBAction is pressed multiple times while running

Code

@MainActor
class Document: NSDocument {
    
    let state = UIState()
    let serviceA = ServiceA()
    let serviceB = ServiceB()

    @IBAction func operationA(_ sender: Any?) {
        Task {
            if case .inProgress(let task) = state.statusA {
                await task.value
            }
            let task = Task {
                await serviceA.updateA()
            }
            state.statusA = .inProgress(task)
            await task.value
            state.statusA = .notRunning
        }
    }
    
    @IBAction func operationB(_ sender: Any?) {
        Task {
            if case .inProgress(let task) = state.statusB {
                await task.value
            }
            let task = Task {
                await self.serviceB.updateB()
            }
            state.statusB = .inProgress(task)
            await task.value
            state.statusB = .notRunning
        }
    }
}

@MainActor
class UIState {
    var statusA = Status.notRunning
    var statusB = Status.notRunning
}

enum Status {
    case notRunning
    case inProgress(Task<Never, Never>)
}

actor ServiceA {
    func updateA() {
        for _ in 1 ..< 8_000_000 {}
    }
}

actor ServiceB {
    func updateB() {
        for _ in 1 ..< 4_000_000 {}
    }
}

CodePudding user response:

Obviously if your tasks do not have any await or other suspension points, you would just use an actor, and not make the method async, and it automatically will perform them sequentially.

But, if you really are trying to a series of asynchronous tasks serially, you just have each task await the prior one. E.g.,

actor Foo {
    private var previousTask: Task<(), Error>?

    func add(block: @Sendable @escaping () async throws -> Void) {
        previousTask = Task { [previousTask] in
            let _ = await previousTask?.result

            return try await block()
        }
    }
}

There are two subtle aspects to the above:

  1. I use the capture list of [previousTask] to make sure to get a copy of the prior task.

  2. I perform await previousTask?.value inside the new task, not before it.

    If you await prior to creating the new task, you have race, where if you launch three tasks, both the second and the third will await the first task, i.e. the third task is not awaiting the second one.

And, perhaps needless to say, because this is within an actor, it avoids the need for detached task, while keeping the main thread free.

enter image description here

  • Related