2026/10/22 2:45:54

Flutter鸿蒙适配:原生读写锁与原子信标破解跨线程孤岛

Flutter鸿蒙适配:原生读写锁与原子信标破解跨线程孤岛 上个月我在调一个跑在鸿蒙平板上的 Flutter 采集模块时遇到了一类以前在 Android 上很少见的现象程序在真机上跑几十秒就随机崩溃log 指向的却是一个我已经用 Dart 层同步语义保护过的方法。后来排查了很久才意识到问题根本不是 Dart 层有没有加锁而是 Dart 的锁管不到原生线程——底层一个 C 采集线程和 Flutter 引擎的 UI 线程在同一时刻触碰了同一块共享缓冲区。这套问题的根子就是标题里那个词超线程孤岛。多语言内核环境下Dart isolate、原生 C/C 线程、还有系统侧轻量任务各自为政互相看不见对方的状态共享数据就成了无人看管的雷区。我最后用native_synchronization这个三方库引入跨边沿原生读写锁和原子信标才把这层并发边界彻底理顺。这篇就把整个思路、落地过程、以及踩过的坑完整写出来给同样在 Flutter 上做鸿蒙 ohos 适配的人一个参考。1. 问题现场Flutter 跑在鸿蒙上底层并发为什么成了孤岛1.1 一个典型的崩溃现场先说具体的。我那个模块负责采集传感器数据C 侧有一个后台线程持续往一块共享内存里写数据Flutter UI 侧每个 vsync 周期读一次拿到最新数据后驱动图表渲染。Dart 层代码逻辑很简单// 错误示范Dart 层自以为加了锁就安全 final _lock SynchronousFuture(0); void onRenderFrame() { _lock.then((_) { _readSensorBuffer(); setState(() {}); }); }在 Android 上这套代码跑得好好的但换到鸿蒙 ohos 平台上出现两种典型异常偶发SIGSEGV崩溃栈在 native 层指向共享缓冲区的 memcpy。更隐蔽的是数据撕裂读到的缓冲区内容一半是新、一半是旧结构体字段对不上UI 直接渲染出鬼影。为什么同一份 Flutter 代码换个平台就崩因为Dart 层的锁从来锁不住原生线程。1.2 孤岛是怎么形成的三类线程模型交汇鸿蒙这种多语言内核环境下一个 Flutter 应用进程里其实活跃着好几套并发体系Dart isolateFlutter 引擎自己管理的并发单元内存隔离、事件循环驱动。原生业务线程通过 FFI 调起来的 C/C 线程由系统内核直接调度。系统侧轻量任务例如挂到 TaskPool 上的旁路逻辑独立于 Dart 世界。这三套线程各有各的调度策略互相之间没有天然的协作协议。Dart isolate 之间靠消息传递但原生线程并不在这个协议内。于是共享内存被两边同时访问时Dart 侧没有任何同步原语能约束原生线程原生线程也不了解 Dart isolate 的事件循环节奏。这就是孤岛的本质并发单元物理上在同一个进程逻辑上却隔着看不见的墙。1.3 为什么线程安全的最后一公里必须交给原生层我之前有个误解Flutter 的compute、Isolate.run这些机制能解决并发问题。后来想明白了它们解决的是Dart 计算任务的分发解决不了Dart 与原生共享内存的并发写读。只要存在下面任意一条就必须引入原生层同步原语C 线程直接写 Dart 层持有的Pointer指向的内存。Native 回调通过 FFI 往 Dart 侧上报数据同时 Dart 侧也在读同一块缓冲。引擎线程池里的任务与用户自建线程同时触碰同一个底层对象。native_synchronization干的事情就是把锁、信号量、原子变量这些原本只活在 C/C 里的原语暴露给 Dart让跨边沿的并发访问有法可依。2. 工具定位native_synchronization 能解决什么、不能解决什么2.1 这个库提供的三类核心能力我在项目里用到的主要是三块能力对应原语解决场景原生读写锁std::shared_mutex封装多读单写、UI 高频读 采集线程低频写原子信标std::atomicbool 条件变量原生线程写完数据后通知 Dart 侧原生互斥锁std::mutex封装临界区很小、写频率很高的场景它本质上做了一件事把 Dart 的 FFI 边界做成一道大门门里是完整可用的原生同步原语。Dart 侧不直接操作线程而是拿一个句柄方法调用映射到原生锁的 lock/unlock。2.2 和 Dart 自带同步机制的本质区别Dart 官方没有提供跨线程共享内存锁这是语言设计决定的。isolate 模型中状态传递靠消息复制理论上不需要锁。但一旦接上 FFI这条假设就崩了——原生线程不是 isolate它不走消息通道。有人可能问那我用synchronized关键字行不行 不行Dart 根本没有synchronized很多从 Java 转过来的开发者第一周都会踩这个坑。Flutter 里常见的SynchronousFuture也只是事件循环层面的一种调度约束不是线程锁。native_synchronization的价值就是补上这个缺口把真正能约束原生线程的锁拉到 Dart 层。2.3 什么时候真的需要它不是所有项目都需要引入这套东西。我的判断标准很直接需要跨 FFI 共享同一块内存并且两边都有写操作的才需要。纯 Dart 层的数据交互用 isolate 消息就够了别引入原生锁。只有一边写、另一边只读且不在乎读到旧值的优先考虑无锁 版本号设计。早期我犯过一个错把所有 filter 方法里的共享变量统统加上了读写锁结果 UI 线程每次渲染都要锁一次虽然 lock/unlock 开销不大但同步屏障带来的调度抖动在低端设备上非常明显。后来只在极少数真正跨边沿的缓冲区上保留锁性能平稳了许多。3. 核心机制一跨边沿原生读写锁的设计与鸿蒙适配3.1 为什么是读写锁而不是互斥锁采集场景里读和写的频率差了一个数量级UI 每 16ms 读一次采集线程每 200ms 才写一次。如果全部用互斥锁读操作也要排队等于让高频操作迁就低频操作。读写锁的思路是读与读不互斥写与读写互斥。这样多个渲染阶段并发读完全不受影响只有写线程进入时才拦截其他读者。具体数据后面第六节会放对比这里先给结论读多写少场景下shared_mutex比普通互斥锁吞吐高出一倍以上代价是写操作偶尔要等待更多读者退出。3.2 C 层实现从 shared_mutex 到 FFI 导出native_synchronization在鸿蒙平台落地时底层用的是 C17 标准库的std::shared_mutex。鸿蒙的 LLVM libc 实现支持这个头文件所以不用额外引第三方库CMake 里开启 C17 即可。核心封装大致长这样// readwrite_lock.h #pragma once #include shared_mutex class NativeRWLock { public: void ReadLock() { m_mutex.lock_shared(); } void ReadUnlock() { m_mutex.unlock_shared(); } void WriteLock() { m_mutex.lock(); } void WriteUnlock() { m_mutex.unlock(); } private: mutable std::shared_mutex m_mutex; };注意mutable关键字因为lock_shared()在语义上是读锁锁对象自身状态却可能变化。然后通过 extern C 导出接口// bindings.cc extern C { void* nss_rwlock_create() { return new NativeRWLock(); } void nss_rwlock_destroy(void* handle) { auto* lock static_castNativeRWLock*(handle); delete lock; } void nss_rwlock_read_lock(void* handle) { static_castNativeRWLock*(handle)-ReadLock(); } void nss_rwlock_read_unlock(void* handle) { static_castNativeRWLock*(handle)-ReadUnlock(); } void nss_rwlock_write_lock(void* handle) { static_castNativeRWLock*(handle)-WriteLock(); } void nss_rwlock_write_unlock(void* handle) { static_castNativeRWLock*(handle)-WriteUnlock(); } }这里有点需要注意Dart 侧拿到的void*句柄本质上只是把 C 对象的地址当成整数传递类型安全完全靠约定。所以封装层要保证传进去的指针永远来自nss_rwlock_create绝不自己 new 或伪造。3.3 锁的跨边沿语义与内存序读写锁本身解决了互斥但只靠它还不够。Dart 侧写完数据、释放锁之后另一个原生线程拿到锁、读到的数据是否一定是最新的这里涉及内存可见性。现代 CPU 和编译器都可能重排指令如果锁的实现里没有正确的内存屏障写线程的数据可能还停留在 CPU cache 里。std::shared_mutex内部已经处理了 acquire/release 语义加锁是 acquire 操作解锁是 release 操作。也就是说写线程先写数据再解锁release保证数据发布出去。读线程加锁acquire之后才读数据保证能看到写线程发布的内容。这层语义在单核环境下无所谓但在多核尤其是大小核架构上非常关键。鸿蒙设备普遍是 ARM 大小核跨核访问共享内存时 cache coherence 延迟明显我实测在锁粒度合理的前提下因为 cache miss 带来的额外延迟可以控制在 20% 以内远比数据撕裂崩溃划算。3.4 鸿蒙 ohos 平台 FFI 加载的差异点到这里不得不提一个与 Android 差异很大的细节动态库加载路径。在 Android 上DynamicLibrary.open(libxxx.so)直接找 app 目录即可鸿蒙 ohos 的 FFI 加载机制不完全一样尤其是经过插件封装时动态库往往被打包在插件目录而不是应用根目录。我的做法是在 Dart 侧做一层回退DynamicLibrary _loadNative() { try { return DynamicLibrary.open(libnative_synchronization.so); } catch (_) { // 部分 ohos 包装场景下需要从插件目录加载 return DynamicLibrary.open( packages/native_synchronization/libnative_synchronization.so, ); } }这条路径在不同版本的 ohos SDK 上有差异建议在接入时先打印DynamicLibrary.executable()或ProcessInfo拿到实际路径避免在真机上运行时才发现找不到符号。4. 核心机制二原子信标如何打通 Native 到 Dart 的通知回路4.1 从轮询到信标一个反直觉的设计读写锁解决了数据竞争但还有一个问题原生采集线程写完数据后怎么让 Dart 侧第一时间知道最粗暴的方案是 Dart 侧起一个后台 isolate 高频轮询缓冲区的版本号。这个方案简单但有两个坏处空转浪费 CPU平板上耗电明显。轮询间隔不好设间隔大数据到达不及时间隔小大量无谓访问。原子信标的设计思路完全不同原生线程写完数据后不是等 Dart 来问而是主动打一个信号。4.2 原子变量 条件变量的配合以及 release/acquire 的取舍信标核心实现分两层一个std::atomicbool作为状态标志一个std::condition_variable负责阻塞等待。// atomic_signal.h #pragma once #include atomic #include condition_variable #include mutex class AtomicSignal { public: void Set() { m_flag.store(true, std::memory_order_release); m_cv.notify_one(); } void Reset() { m_flag.store(false, std::memory_order_relaxed); } bool Wait(int64_t timeout_ms) { std::unique_lockstd::mutex lock(m_mutex); return m_cv.wait_for(lock, std::chrono::milliseconds(timeout_ms), [this]() { return m_flag.load(std::memory_order_acquire); }); } bool Peek() { return m_flag.load(std::memory_order_acquire); } private: std::atomicbool m_flag{false}; std::mutex m_mutex; std::condition_variable m_cv; };这里有两个设计要点值得展开说第一为什么 flag 要用 atomiccondition_variable 还要配一个普通 mutex。因为wait_for内部需要锁来保护谓词求值防止丢失唤醒。atomic flag 单独用不够裸的 while 循环轮询浪费 CPU配合 CV 才能做到有信号才醒、没信号睡死。第二内存序的选择。Set()里用memory_order_release目的是保证 ①写共享缓冲区的全部普通内存操作 ②设置 flagtrue 这两者在时序上不被重排即数据先写完信标后点亮。Wait()里用memory_order_acquire保证看到 flagtrue 时写线程在 release 之前发布的所有数据操作都是可见的。很多人在这里有个误区觉得memory_order_seq_cst默认值最安全。其实在这个场景下 release/acquire 足够了且 seq_cst 会引入不必要的同步开销。如果是高频信号这个差距会被放大。4.3 两种通知模式后台 isolate 等待与 Port 回调有了信标原语剩下的问题是Dart 侧去哪等我试过两种模式各有适用场景。模式一专用后台 isolate 阻塞等待Futurevoid monitorLoop(SendPort port, NativeAtomicSignal signal) async { while (true) { final ok signal.waitFor(50); if (!ok) continue; final data readSharedBuffer(); port.send(data); } }这个模式适合数据量大、需要及时处理中间结果的场景。后台 isolate 阻塞在waitFor上不占 UI 资源数据到达后立刻读缓冲并投递。模式二原生线程直接投递 Port 消息不单独开 isolate原生线程在Set()之后通过 Dart 侧提前注册的 SendPort 直接投递一条消息final receivePort ReceivePort(); receivePort.listen((msg) { // 收到通知再读取共享缓存 loadAndRender(); }); nativeChannel.registerNotifyPort(receivePort.sendPort);C 侧void NotifyDart(Dart_Port port) { // 通过 Dart C API 向指定端口投递空消息 dart_post_c_object(port, ...); }两种模式的选择标准采集频率低、UI 恰好有固定帧节奏的用模式二更省资源采集频率高、每次数据都要单独消费的模式一更稳不依赖回调抢占时机。5. 实战接线在鸿蒙 Flutter 工程里完整接入并验证5.1 工程组织与 CMake 的鸿蒙适配native_synchronization以插件形式接入我建议不要把所有 C 文件堆在应用工程里而是单独抽成一个 plugin 目录方便复用。CMakeLists 里需要显式处理 ohos toolchaincmake_minimum_required(VERSION 3.22) project(native_synchronization) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) if(DEFINED OHOS_SDK) set(CMAKE_TOOLCHAIN_FILE ${OHOS_SDK}/native/build/cmake/ohos.toolchain.cmake) endif() add_library(native_synchronization SHARED src/readwrite_lock.cc src/atomic_signal.cc src/bindings.cc ) target_include_directories(native_synchronization PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include )有个容易忽略的点鸿蒙设备的 ABI 不止一种。我一般把arm64-v8a和x86_64都编出来模拟器用 x86_64真机用 arm64避免本地模拟器跑通了、上真机链接失败。5.2 Dart 侧封装句柄、类型映射与 FinalizerFFI 侧类型映射鸿蒙和 Android 大体一致但要特别注意指针宽度。void*在 Dart 里对应PointerVoid不要用int去接否则在 arm64 上可能触发截断。我封装的 Dart 侧大概是这个形态typedef _CreateNative PointerVoid Function(); typedef _ReadLockNative Void Function(PointerVoid); class NativeRWLock { NativeRWLock._(this._ptr); final PointerVoid _ptr; factory NativeRWLock.create() { final ptr _nssRwlockCreate(); final lock NativeRWLock._(ptr); // 用 Finalizer 兜底避免 Dart 对象被 GC 后原生锁泄漏 _finalizer.attach(lock, ptr); return lock; } void readLock() _nssReadLock(_ptr); void readUnlock() _nssReadUnlock(_ptr); void writeLock() _nssWriteLock(_ptr); void writeUnlock() _nssWriteUnlock(_ptr); }Finalizer 这部分是血泪教训。早期版本没注册 FinalizerDart 侧的NativeRWLock对象被 GC 后C 里的NativeRWLock实例永远没人 delete每创建一次泄漏一次。加了 Finalizer 之后Dart GC 回收对象时自动销毁原生对象这才把内存问题解决。5.3 一个完整的采集示例把锁和信标串起来核心逻辑大概是这样的class SensorBufferController { final lock NativeRWLock.create(); final signal NativeAtomicSignal.create(); final bufferPtr allocateSensorsBuffer(capacity: 4096); // 原生采集线程回调从 C 侧触发 void onNativeWriteComplete() { // 写线程已经写完 buffer且 Set() 已经 release lock.writeLock(); try { updateMetaInfo(); } finally { lock.writeUnlock(); } signal.set(); } // UI 帧回调 void onFrameUpdate() { lock.readLock(); try { final data readSensorsFromBuffer(bufferPtr); _latestData data; } finally { lock.readUnlock(); } setState(() {}); } }流程上保证一条不变式写线程先拿写锁、写数据、释放写锁、最后 Set 信标读线程先等信标或帧时机、拿读锁、读数据。用锁保证互斥用信标保证时序。这套不变式守住了前文提到的撕裂问题就彻底消失了。6. 实测数据与踩坑复盘三个最容易忽视的细节6.1 压测结果读写锁的优势在什么条件下成立我在自己搭的模拟项目上做了组对照测试线程模型是 4 个读线程 1 个写线程共享结构体 64 字节跑 100 万次混合操作。数据仅供参考不同内核调度策略差异很大。方案总耗时最大写阻塞延迟说明std::mutex5.2s约 1.3ms读写完全串行std::shared_mutex2.3s约 2.1ms读并发度高写偶尔排长队无锁 版本号轮询2.8s—无阻塞但存在读到中间态风险看到没有读写锁总耗时低但最大写阻塞延迟反而比互斥锁高。原因是写锁需要等当前全部读者退出极端场景下最后一个读者刚进去写线程就得等完整个读临界区。所以如果你的业务对写延迟极度敏感读写锁不一定更优。我在采集场景里写频率低这点延迟完全可接受。6.2 坑一异步 FFI 回调里的对象生命周期Flutter 的 FFI 有同步和异步两种调用方式。同步调用好办异步回调模式下Dart 层传进去的PointerVoid句柄在回调触发时可能已经被 Dart GC 处理掉了。我遇到过一次锁对象在 Dart 侧失去引用Finalizer 销毁了原生锁而仍然有一个原生采集线程正阻塞在lock.lock()上然后它拿到的是一个已释放的指针接着操作就崩了。解决方案是约定业务需要保证所有使用锁的线程都停止后Dart 侧才释放最后一个引用。实际工程里我加了一个活跃计数采集线程退出前会waitUntilZero()确定没有线程在临界区里才允许 Dart 侧置空引用。6.3 坑二线程优先级反转在实时场景的表现鸿蒙的多语言内核里不同来源的线程有不同的调度优先级。高优先级 UI 渲染线程等锁持锁的是低优先级采集线程这类优先级反转在低配机型上一旦发生就会表现为几毫秒到几十毫秒的卡顿。我处理这个问题分两步一是给采集线程适当抬高优先级或调整调度策略二是把持锁临界区压到最小减少持锁时间。原子信标的wait_for超时参数也因此不能设太长我测试中 50ms 是一个均衡值既能及时响应又不会因为极端情况下的锁等待把整个系统拖僵。6.4 坑三释放顺序与析构竞争最后一个坑比较隐蔽进程退出或插件被销毁时如果先置空 Dart 侧句柄、没有先关停原生采集线程原生线程可能还在等待一个已经进入 Finalizer 队列的锁对象。我的做法是显式管理生命周期不依赖 Finalizer 兜底Futurevoid dispose() async { await nativeBridge.shutdownCollector(); // 先停线程 _lock.dispose(); // 再销毁锁 _signal.dispose(); // 最后销毁信标 }顺序不能反先让所有消费者退出再销毁同步原语否则必然出现析构竞争。最后再分享一个判断如果你也准备在 Flutter 鸿蒙适配里引入这套方案先别急着写代码。用一张纸列清楚哪些数据是真正跨边沿共享的哪些线程会访问它读多还是写多用锁到底是要防数据竞争还是要解决通知时序把这三个问题想清楚再动手接库整个过程会顺畅很多。