如何在主线程与SceneKit线程间安全交换数据?
输入事件由主线程管理,SceneKit渲染代理在专用SceneKit线程中执行。我们需要安全地将数据从主线程发送到渲染代理,但官方文档里未找到可行方法。
核心限制与背景
A - 性能要求:绝对不能用dispatch queues这类可能挂起SceneKit线程的机制——如果主线程修改数据时渲染线程被挂起,会直接降低帧率(FPS)。
B - 官方文档的矛盾点:Apple文档提到,修改部分节点属性的行为会因调用时机不同而变化,比如
SCNPhysicsBody.velocity:在渲染循环方法(SCNSceneRendererDelegate协议中的任意方法)内调用,或从此类方法内调用的其他代码中调用时,读取该属性会返回物理模拟的当前结果,设置该属性会立即应用更改。
在其他任何时间调用时,读取该属性会返回上次设置的值,设置该属性要到下一次渲染循环时才会生效。这说明存在一套内部机制,但测试发现从渲染循环外设置
SCNPhysicsBody.angularVelocity完全无效(值会被莫名重置),而且Apple的实现逻辑和触发时机并不明确。
当前实现方案
因此我最终实现了如下方案:
class MyDataToExchange { ... } var dataToWrite: MyDataToExchange? = MyDataToExchange() var dataToExchange: MyDataToExchange? = nil var dataToRead: MyDataToExchange? = nil // 主线程代码: // 处理事件,在dataToWrite中构建数据 (...) // 准备好向SceneKit发送数据时 dataToExchange = dataToWrite // 这一步应该是"原子操作" // 从这时起SceneKit可以读取数据 dataToWrite = nil // 从这时起主线程不能再操作,必须等待dataToExchange变回nil。期间会忽略/暂存事件,直到满足条件: if dataToExchange == nil { dataToWrite = MyDataToExchange() // 执行后续操作 } // 渲染线程代码: if dataToExchange != nil { dataToRead = dataToExchange dataToExchange = nil // 从这时起可以随意处理dataToRead,主线程也可以开始构建新的待发送数据 }
我认为这种方式是安全的,因为引用复制是原子操作,且同一时间只有一个线程操作MyDataToExchange的实例。但很难相信没有更简便的官方机制,需要自己重新实现。
疑问
- 这种方式是否真的安全?
- 是否有更优/更简便的实现方式?
解答
1. 当前方案的安全性
你的实现本质上是安全的,原因如下:
- Swift中对可选类型的引用赋值是原子操作(64位系统上,指针的读写本身就是原子的),不会出现半写半读的情况;
- 你通过
dataToExchange作为唯一传递桥梁,同一时间只有一个线程会修改它:主线程只在dataToExchange为nil时才写入,渲染线程只在它非nil时才读取并置空,避免了竞态条件; MyDataToExchange的实例一旦被赋值到dataToExchange,主线程就不会再修改它(直接把dataToWrite置空),渲染线程读取后也只会自己持有,不会有两个线程同时修改同一个实例的情况。
不过有个小细节需要注意:如果主线程在等待dataToExchange为nil时直接忽略事件,可能会导致输入丢失,建议改成暂存事件队列,等可以写入时再批量处理。
2. 更优的实现方式
其实你可以利用SceneKit本身的渲染循环机制,结合线程安全的无锁队列来优化,既保证性能又简化代码:
方案一:使用SCNSceneRenderer的perform(_:)方法
SceneKit提供了perform(_:)方法,它会把闭包放到渲染线程的执行队列中,且不会阻塞主线程,同时能保证闭包在渲染循环的安全时机执行:
// 主线程中发送数据 let newData = MyDataToExchange(...) scnView.perform { [weak self] in // 这里的代码在SceneKit渲染线程执行,安全处理数据 self?.processData(newData) }
这个方法的优势是完全利用官方机制,不需要自己管理线程同步,而且不会阻塞主线程或渲染线程——闭包会被加入渲染线程的任务队列,在合适的时机执行,不会影响FPS。
方案二:使用Atomic属性包装器
Swift 5.1+可以用官方的Atomic属性包装器(需要导入Atomics模块),来简化你的原方案,让同步逻辑更严谨:
import Atomics class MyDataToExchange { ... } let dataToExchange = AtomicOptional<MyDataToExchange>(nil) // 主线程 let newData = MyDataToExchange(...) if dataToExchange.load(ordering: .relaxed) == nil { dataToExchange.store(newData, ordering: .relaxed) } // 渲染线程 if let data = dataToExchange.exchange(nil, ordering: .relaxed) { // 处理data }
Atomic的exchange方法会原子性地替换值并返回旧值,比手动赋值更规范,彻底避免潜在的竞态风险。
方案三:无锁环形队列(适合高频数据传递)
如果需要高频传递数据,且不想依赖perform(_:)的队列调度,可以使用无锁的环形队列(基于UnsafeMutablePointer实现),这种结构的读写操作都是原子的,完全不会阻塞线程,性能极高。不过实现起来比前两种方案复杂,适合对性能要求极致的场景。
内容的提问来源于stack exchange,提问作者AirXygène

