使用 SwiftData 和 @Observable 时如何避免死锁/挂起?

问题描述 投票:0回答:1

我很好奇是否有人会很容易地发现我的错误。我遇到一个问题,我的应用程序在调用睡眠期间挂起。我最好的猜测是,这是使用 SwiftData 和 @Observable 引起的死锁。我已将代码简化为最小的 SwiftUI 应用程序,该应用程序在点击“开始”按钮后几秒钟始终挂起(Xcode 15.4)。我可能做了一些愚蠢的错误,但我无法发现它。

这是代码:

import SwiftUI
import SwiftData
import os

private let logger = Logger(subsystem: "TestApp", category: "General")


@Observable
class AppState {
    var queue: [Item] = []
}

@Model
final class Item {
    var name: String
    
    init(name: String) {
        self.name = name
    }
}

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query private var items: [Item]
    @State private var state = AppState()
//    @State private var queue = [Item]()
    
    @State private var testsRunning = false
    @State private var remoteTask: Task<(), Never>?
    @State private var syncTask: Task<(), Never>?

    
    var body: some View {
        VStack {
            Button ("Begin") {
                Task { await runTests() }
                testsRunning = true
            }.disabled(testsRunning)
            Text("Remote Queue: \(state.queue.count)")
            List (items) {
                Text($0.name)
            }
        }
    }
}

extension ContentView {
    @MainActor func runTests() async {
        for item in items {
            modelContext.delete(item)
        }
        state.queue.removeAll()
        
        startRemoteWork()
        startSync()
    }
    
    @MainActor func startRemoteWork() {
        // Adds non-inserted SwiftData items in an array to simulate data in cloud
        remoteTask = Task.detached {
            while true {
                await sleep(duration: .random(in: 0.2...0.5))
                let newItem = Item(name: "Item \(items.count + state.queue.count + 1)")
                state.queue.append(newItem)
                logger.info("\(Date.now): \(newItem.name) added to remote queue")
            }
        }
    }
    
    @MainActor func syncQueuedItems() async {
        // removes items from remote queue and inserts them into local SwiftData context.
        while !state.queue.isEmpty
        {
            let item = state.queue.removeFirst()
            
            modelContext.insert(item)
            let delay = Double.random(in: 0.01...0.05)
            logger.info("    \(Date.now): syncing \(item.name) (will take \(delay) seconds)...")
            await sleep(duration: delay)    // simulating work
            logger.info("    \(Date.now): Done")
        }
    }
    
    @MainActor func startSync() {
        syncTask = Task.detached {
            logger.info("  \(Date.now): Sync Task Started")
            while true {
                await syncQueuedItems()
                logger.info("  \(Date.now): Sync Task sleeping for 3 seconds till next sync")
                await sleep(duration: 3)
            }
        }
    }
    
    func sleep(duration: Double) async {
        do {
            try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))
        } catch { fatalError("Sleep failed") }
    }
}

#Preview {
    ContentView()
        .modelContainer(for: Item.self, inMemory: true)
}

我知道,如果我将代码更改为不使用 SwiftData(将 Item 替换为保存在本地状态存储中的普通结构),那么就没有问题。另外,如果我将队列数组从 AppState @Observable 移动到本地状态存储,那么就没有问题。所以我有点不确定地得出结论,这个问题与两者的结合有关。请有人指出我做错了什么吗?

swiftui swiftdata swift-concurrency
1个回答
0
投票

有人可以指出我做错了什么吗?

Task.detached
采用
@Sendable
闭包,它无法捕获非
Sendable
的事物。但是,您使用的闭包正在捕获
[Item]
ContentView
AppState
,它们都是非
Sendable
。如果你开启完整的并发检查,你的代码中将会出现很多警告。

您应该将整个

ContentView
隔离到
MainActor
,而不是将其各个方法标记为
@MainActor

然后,使用

.task(id:)
修饰符而不是
Task.detached
来启动任务。如果您想在非主线程上运行某些内容,请将其放入异步
nonisolated func
或与另一个参与者隔离的函数中。

SwiftData 模型不是

Sendable
- 因此“一个
Task.detached
创建
Item
并将它们添加到队列中,另一个
Task.detached
将它们从队列中取出”的想法是行不通的。相反,您应该使用一些
Sendable
,例如具有所有
let
属性的简单结构,其中包含创建
Item
所需的所有内容。您应该在将
Item
放入上下文之前创建
insert

这是这些转换后的代码:

@MainActor
struct ContentView: View {
    private let logger = Logger(subsystem: "TestApp", category: "General")
    @Environment(\.modelContext) private var modelContext
    @Query private var items: [Item]
    @State private var state = AppState()
    
    @State private var testsRunning = false
    
    // when you want to cancel these tasks, just set these to false
    @State private var remoteTask = false
    @State private var syncTask = false

    
    var body: some View {
        VStack {
            Button ("Begin") {
                testsRunning = true
                runTests()
            }.disabled(testsRunning)
            Text("Remote Queue: \(state.queue.count)")
            List (items) {
                Text($0.name)
            }
        }
        .task(id: remoteTask) {
            if remoteTask {
                await startRemoteWork()
            }
        }
        .task(id: syncTask) {
            if syncTask {
                await startSync()
            }
        }
    }
}

extension ContentView {
    func runTests() {
        for item in items {
            modelContext.delete(item)
        }
        state.queue.removeAll()
        
        remoteTask = true
        syncTask = true
    }
    
    func startRemoteWork() async {
        while !Task.isCancelled {
            let newItemName = await fetchItemName()
            state.queue.append(newItemName)
            logger.info("\(Date.now): \(newItemName) added to remote queue")
        }
    }
    
    nonisolated func fetchItemName() async -> String {
        await sleep(duration: .random(in: 0.2...0.5))
        return UUID().uuidString
    }
    
    func syncQueuedItems(with itemsActor: ItemsActor) async {
        let queue = state.queue
        state.queue = []
        await itemsActor.insertItems(withNames: queue)
    }
    
    func startSync() async {
        logger.info("  \(Date.now): Sync Task Started")
        let itemsActor = ItemsActor(modelContainer: modelContext.container)
        while !Task.isCancelled {
            await syncQueuedItems(with: itemsActor)
            logger.info("  \(Date.now): Sync Task sleeping for 3 seconds till next sync")
            await sleep(duration: 3)
        }
    }
}

@ModelActor
actor ItemsActor {
    private let logger = Logger(subsystem: "ItemsActor", category: "General")
    func insertItems(withNames names: [String]) async {
        for name in names {
            let delay = Double.random(in: 0.01...0.05)
            logger.info("    \(Date.now): syncing \(name)")
            modelContext.insert(Item(name: name))
            await sleep(duration: delay)
            logger.info("    \(Date.now): Done")
            if Task.isCancelled {
                break
            }
        }
    }
}

func sleep(duration: Double) async {
    do {
        try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))
    } catch { fatalError("Sleep failed") }
}
  • 我在
    AppState
    中创建了队列,存储表示项目名称的字符串数组。这是
    Sendable
    ,所以可以发送给模型actor进行插入
  • fetchItemName
    是非隔离的,所以当你
    await
    它时它不会在主线程上运行。
  • insertItems(withNames:)
    ItemsActor
    隔离,因此它也不会在主线程上运行。
  • syncQueuedItems
    中的逻辑与您的代码略有不同。我只是简单地取出队列中的所有内容并插入所有内容。也可以按照你的方式来做,但这会涉及主角和
    ItemsActor
    之间的大量跳跃。
© www.soinside.com 2019 - 2024. All rights reserved.