我很好奇是否有人会很容易地发现我的错误。我遇到一个问题,我的应用程序在调用睡眠期间挂起。我最好的猜测是,这是使用 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 移动到本地状态存储,那么就没有问题。所以我有点不确定地得出结论,这个问题与两者的结合有关。请有人指出我做错了什么吗?
有人可以指出我做错了什么吗?
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
之间的大量跳跃。