RealityKit并行加载模型实体时使用信号量导致死锁的原因及同步实现方案问询
首先得给你把死锁的根因掰明白,再说说如果非要转同步代码该怎么弄——不过先提前敲个黑板:真的不推荐在主线程搞这种阻塞操作,你后来想到的非阻塞写法才是正确方向,但既然你好奇死锁的来龙去脉,咱们一步步拆解:
为什么用信号量会触发死锁?
你写的didClickStart是在**主线程(MainActor上下文)**里执行的对吧?问题就出在两个核心矛盾上:
- 当你调用
sema.wait()时,直接把主线程彻底堵死了——主线程停在这一步,啥也干不了,包括处理任何需要在MainActor上执行的任务。 - 你创建的普通
Task会自动继承当前的MainActor上下文,也就是说,这个Task里的代码(哪怕是await loadEntitiesInParallel完成后的sema.signal()),最终也需要在MainActor上执行。
这就形成了闭环死锁:主线程被wait()卡死,没法处理Task里的sema.signal();而sema.signal()没执行,wait()就永远不会放行。哪怕你换成Task.detached,如果loadEntitiesInParallel里有任何需要切回MainActor的逻辑(比如RealityKit部分API隐含依赖MainActor),还是会因为主线程被堵死而触发死锁。
你后来用viewDidLoad复现的极简案例也是同一个道理:主线程被wait()卡死,Task里的代码哪怕是空的await loadSomething(),只要后续的signal()依赖MainActor(默认Task会继承上下文),就根本跑不起来。
如果非要把async代码转成阻塞同步代码,怎么改才能跑?
核心原则只有一个:绝对不能在MainActor(主线程)上执行阻塞等待,必须把阻塞逻辑挪到后台线程,同时让async代码的上下文不依赖被阻塞的主线程。
给你写个能正常运行的版本:
func didClickStart() { scene.presentLoadingScreen() var models: [String:Entity]! // 把阻塞等待的逻辑扔到后台队列,别堵主线程 DispatchQueue.global(qos: .userInitiated).sync { let sema = DispatchSemaphore(value: 0) // 用detached Task,不继承MainActor上下文,避免依赖被阻塞的主线程 Task.detached { models = await self.loadEntitiesInParallel(fns: self.entities, tr: self.tr) sema.signal() } sema.wait() } // 拿到结果后切回主线程更新UI DispatchQueue.main.async { self.scene.presentGame(models: models) } }
不过还是要再强调一遍:这种写法完全违背了Swift Concurrency的设计初衷,不仅会占用后台线程资源,还彻底浪费了异步代码的灵活性——比如你没法给加载屏幕加动效,用户体验会很差。你后来想到的非阻塞写法才是最优解,既不卡主线程,还能做加载动画这类交互优化,香多了。
最后补一句:Swift的async/await是为了让异步代码写起来像同步,但本质还是异步逻辑,强行用信号量把它转成阻塞同步本身就是反模式,很容易踩死锁的坑,能不用就别用。
内容来源于stack exchange

