在并发执行的代码中使用捕获变量的引用

34

更新2:我怀疑问题被点赞是因为我描述了可能的解决方案。为了更清晰,我将其加粗。

更新1:这个问题有很多浏览量。如果你认为自己遇到的错误情况可以增强这个问题,请在评论中简要描述你的情况,这样我们就可以使这个问答更有价值。如果你有解决问题的方案,请将其作为答案添加。


我想在使用Task.detachedasync函数执行异步后台工作后更新UI。

然而,在构建期间,我遇到了Reference to captured var 'a' in concurrently-executing code错误的构建错误。

我尝试了一些方法,只有在更新UI之前将变量转换为let常量才能正常工作。为什么我需要在能够更新UI之前将其变成一个let常量?还有其他替代方法吗?

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"
    }
} 

1
是的,我的代码有些不同,但将“var”变成“let”似乎解决了问题。 - ConfusionTowers
3个回答

17
简而言之,something 必须从主线程进行修改,并且只有可发送的类型可以从一个 actor 传递到另一个 actor。让我们深入了解细节。 something 必须从主线程进行修改。这是因为在 ObservableObject 中的 @Published 属性必须从主线程进行修改。文档对此缺乏说明(如果有人找到官方文档的链接,我会更新此答案)。但是由于 ObservableObject 的订阅者可能是 SwiftUI 的 View,这是有道理的。苹果公司本可以决定将 View 订阅并接收事件放在主线程上,但这样会隐藏从多个线程发送 UI 更新事件是危险的事实。
只有可发送的类型可以从一个 actor 传递到另一个 actor。有两种方法可以解决这个问题。首先,我们可以使 a 可发送。其次,我们可以确保不跨 actor 边界传递 a,并且所有代码都在同一个 actor 上运行(在这种情况下,它必须是 Main Actor,因为它保证在主线程上运行)。
让我们看看如何使 a 可发送,并研究以下案例:
await MainActor.run { 
    something = a 
}

在函数doVariousStuff()中的代码可以从任何Actor运行,让我们称其为Actor A。a属于Actor A并且必须发送到Main Actor。由于a不符合Sendable,编译器无法保证在Main Actor上读取aa不会被更改,这在Swift并发模型中是不允许的。为了给编译器提供这个保证,a必须是Sendable。一种方法是将其设置为常量。这就是为什么以下操作有效:
let c = a
await MainActor.run {
    something = c
}

即使它可以被改进为:
await MainActor.run { [a] in
    something = a 
}

这里使用 a 作为常量进行捕获。还有其他的 Sendable 类型,详细信息可以在此处找到:https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html#ID649

另一种解决方法是让所有代码在同一个 actor 上运行。最简单的方法是按照 Asperi 建议的方式标记 ViewModel@MainActor。这将保证 doVariousStuff() 从 Main Actor 中运行,因此它可以设置 something。需要注意的是,a 然后属于 Main Actor,因此(即使是没有意义的)await MainActor.run { something = a } 也可以工作。

请注意,actor 不是线程。Actor A 可以从任何线程运行。它可以在一个线程上启动,然后在任何 await 后继续在另一个线程上运行。它甚至可以部分地在主线程上运行。重要的是,一个 actor 只能同时从一个线程运行。唯一违反该规则的例外是 Main Actor,它只在主线程上运行。


非常感谢您的解释,因为它让我开始了解这是如何工作的 :) 我是否正确地陈述,将类@MainActor设置为最不有效的解决方案,因为这意味着异步代码(故意异步)将在主线程上运行? 将其设置为可发送的常量是否也具有此类不良副作用,或者此解决方案是否保持多线程优势? - Arjan
@MainActor 将使 doVariousStuff 在主线程上运行。如果它正在进行一些重计算,则不是一个好主意。将 a 设置为常量/可发送的是保持 doVariousStuff 在后台线程上运行的一种方法。另一种方法是使用 nonisolated 标记 doVariousStuff(并将 ViewModel 保留在 @MainActor 上;如果 something 的订阅者是 UI,则这是推荐的方式)。 - davidisdk

12
让您的可观察对象成为主角,例如:
@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"
    }
}

使用Xcode 13 / iOS 15 进行测试


3
这难道不是让所有东西都在主线程上运行吗? - ngb
是的...?但有点不完全...?由于它是异步的,任何发射(完成、事件)都应该在池中,由主线程中的消息循环接收。我认为这应该使它像任何主线程事件一样运行,而不是完全阻塞线程。 - ChrisH
在执行 doVariousStuff() 之前,您必须添加 @MainActor。 - koliush

-3
您可以按照以下方式使用@State.task
struct ContentView: View {
    @State var result = ""

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

任务在视图出现时启动,并在其消失时取消。此外,如果您使用 .task(id:),则当 id 的值更改时,它将重新启动(同时取消之前的任务)。

异步函数可以放在几个不同的位置,通常是在某个地方进行独立测试。

struct Something {
    static func doSomeAsyncStuff() async -> String {
        return "b"
    }
}

1
所以,苹果公司完全反对使用类。这就是为什么他们在iOS 15中添加了@StateObject。搞定了。然后,MVVM就是为双向绑定提供的SwiftUI专门构建的。然后,ObservableObject不应与SwiftUI一起使用,因为它是Combine的一部分...除非苹果公司专门为此实现了@ObservedObject。然后,您基本上建议回到具有静态函数的过程式编程。我并不是说你完全错了,但在我看来,你在极端方面走得太远了。 - FreeNickname
Async/await 的设计目的是让异步编程看起来像过程式编程,以便更容易编写异步代码。 - malhal

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接