32

Update 2: I suspect the question gets upvoted because of the possible solution that I describe. Highlighted it for clarity.

Update 1: This question gets a lot of views. If you think the question can be enhanced with the situation in which you encountered the error yourself, please briefly describe your situation in the comments so we can make this Q&A more valuable. And if you have a solution to your version of the problem, please add it as an answer.


I want to update the UI after doing async background work using Task.detached and an async function.

However, I get a build error Reference to captured var 'a' in concurrently-executing code error during build.

I tried some things and turning the variable into a let constant before updating the UI is the only thing that works. Why do I need to make a let constant before being able to update the UI? Are there alternatives?

class ViewModel: ObservableObject {
    
    @Published var something: String?
    
    init() {
        Task.detached(priority: .userInitiated) {
            await self.doVariousStuff()
        }
    }
    
    private func doVariousStuff() async {
        var a = "a"
        let b = await doSomeAsyncStuff()
        a.append(b)
        
        something = a /* Not working,
        Gives
           - runtime warning `Publishing changes from 
           background threads is not allowed; make sure to 
           publish values from the main thread (via operators 
           like receive(on:)) on model updates.`
         or, if `something` is @MainActor:
           - buildtime error `Property 'something' isolated 
           to global actor 'MainActor' can not be mutated 
           from this context`
         */



        await MainActor.run { 
            something = a 
        } /* Not working, 
        Gives buildtime error "Reference to captured var 'a' 
        in concurrently-executing code" error during build
         */


        DispatchQueue.main.async { 
            self.something = a 
        } /* Not working,
        Gives buildtime error "Reference to captured var 'a' 
        in concurrently-executing code" error during build
         */


        /*
         This however, works!
         */
        let c = a
        await MainActor.run {
            something = c
        }

    }
    
    private func doSomeAsyncStuff() async -> String {
        return "b"
    }
} 
Arjan
  • 16,210
  • 5
  • 30
  • 40

3 Answers3

14

In short, something has to be modified from the main thread and only Sendable types can be passed from one actor to another. Let's dig in the details.

something has to be modified from the main thread. This is because @Published properties in an ObservableObject have to be modified from the main thread. The documentation for this is lacking (if anyone finds a link to the official documentation I'll update this answer). But as the subscriber of an ObservableObject is probably a SwiftUI View, it makes sense. Apple could have decided that a View subscribes and receives events on the main thread, but this would hide the fact that it is dangerous to send UI update events from multiple threads.

Only Sendable types can be passed from one actor to another. There are two ways to solve this. First we can make a Sendable. Second we can make sure not to pass a across actor boundaries and have all code run on the same actor (in this case it has to be the Main Actor as it is guaranteed to run on the main thread).

Let's see how to make a sendable and study the case of:

await MainActor.run { 
    something = a 
}

The code in doVariousStuff() function can run from any actor; let's call it Actor A. a belongs to Actor A and it has to be sent to the Main Actor. As a does not conform to Sendable, the compiler does not see any guarantee that a will not be changed while a is read on the Main Actor. This is not allowed in the Swift concurrency model. To give the compiler that guarantee, a has to be Sendable. One way to do that is to make it constant. Which is why this works:

let c = a
await MainActor.run {
    something = c
}

Even if it could be improved to:

await MainActor.run { [a] in
    something = a 
}

Which captures a as a constant. There are other Sendable types, details can be found here https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html#ID649.

The other way to solve this is to make all code run on the same actor. The easiest way to do that is to mark ViewModel with @MainActor as suggested by Asperi. This will guarantee that doVariousStuff() runs from the Main Actor, so it can set something. As a side note, a then belongs to the Main Actor so (even if it is pointless) await MainActor.run { something = a } would work.

Note that actors are not threads. Actor A can run from any thread. It can start on one thread and then continue on another after any await. It could even run partially on the main thread. What is important is that one actor can only ever run from one thread at a time. The only exception to the rule that any actor can run from any thread is for the Main Actor which only runs on the main thread.

davidisdk
  • 3,358
  • 23
  • 12
  • Thanks so much for this explanation, because of it I'm starting to get an idea of how this works :) Am I correct in stating that, making the class @MainActor, is the least effective solution, because that means the async code, which is async on purpose, would be run on the main thread? Does making it a sendable constant also have this type of unwanted side-effects, or does this solution keep the multi-threading advantages? – Arjan Nov 28 '22 at 09:59
  • `@MainActor` will make `doVariousStuff` run on the main thread. If it is doing some heavy calculation then it is not a good idea. Making `a` a constant / sendable is one way to keep `doVariousStuff` running on a background thread. Another is to mark `doVariousStuff` with `nonisolated` (and keep `ViewModel` on `@MainActor`; which is the recommended way if the subscriber of `something` is the UI). – davidisdk Dec 02 '22 at 08:23
11

Make your observable object as main actor, like

@MainActor                                // << here !!
class ViewModel: ObservableObject {

    @Published var something: String?

    init() {
        Task.detached(priority: .userInitiated) {
            await self.doVariousStuff()
        }
    }

    private func doVariousStuff() async {
        var a = "a"
        let b = await doSomeAsyncStuff()
        a.append(b)

        something = a         // << now this works !!
    }

    private func doSomeAsyncStuff() async -> String {
        return "b"
    }
}

Tested with Xcode 13 / iOS 15

Martijn Pieters
  • 1,048,767
  • 296
  • 4,058
  • 3,343
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • 3
    doesn't this just make everything run on the main thread? – ngb Apr 26 '22 at 12:06
  • Yes...? But sorta not completely...? As it's async any emissions (completions, events) should be on the pool, picked up by the message loop in the main thread. I think this should make it operate like any main thread event rather than a full on thread blocker. – ChrisH May 13 '22 at 04:03
  • You must add @MainActor before doVariousStuff() – koliush Jun 22 '22 at 19:44
-3

You can use @State and .task as follows:

struct ContentView: View {
    @State var result = ""

    var body: some View {
        HStack {
            Text(result)
        }
        .task {
            result = await Something.doSomeAsyncStuff()  
        }
    }
}

The task is started when view appears and is cancelled when it disappears. Also if you use .task(id:) it will restart (also cancelling previous task) when the value of id changes.

The async func can go in a few different places, usually somewhere so it can tested independently.

struct Something {
    static func doSomeAsyncStuff() async -> String {
        return "b"
    }
}
malhal
  • 26,330
  • 7
  • 115
  • 133
  • 1
    So, Apple is against using classes completely. That must be why they added `@StateObject` in iOS 15. Got it. Then, MVVM was literally built for two way binding SwiftUI provides. Then, `ObservableObject` is not to be used with SwiftUI, because it's part of Combine... except Apple specifically implemented `@ObservedObject` for this very purpose. Then you basically suggest falling back to procedural programming with static functions. I'm not saying you're completely wrong, but, in my opinion, you go too far with your extremes. – FreeNickname Jan 24 '22 at 12:27
  • Async/await was designed to look like procedural programming to make it simple to write asynchronous code. – malhal Jul 06 '22 at 14:32