如何用Rust编写Node扩展监听Mac时区变更且不阻塞主线程?
非阻塞监听Mac系统时区变更的Node.js Rust扩展实现
问题背景
我想用Rust结合core_foundation_sys和neon库实现一个Node.js扩展,暴露addListener API,当Mac系统时区修改时触发回调。最初的代码能成功收到时区变更通知,但会阻塞Node.js主线程;尝试将监听逻辑移到子线程后,又无法收到任何通知。
初始阻塞代码
extern "C" fn notification_callback( _center: notification_center::CFNotificationCenterRef, _observer: *mut c_void, _name: notification_center::CFNotificationName, _object: *const c_void, _user_info: CFDictionaryRef, ) { println!("timezone_change"); } fn init_notification() { unsafe { let center = notification_center::CFNotificationCenterGetLocalCenter(); let observer = &Observer as *const _ as *mut c_void; let name = CFStringCreateWithCString( kCFAllocatorDefault, b"kCFTimeZoneSystemTimeZoneDidChangeNotification\0".as_ptr() as *const i8, kCFStringEncodingUTF8, ); let object = ptr::null(); let suspension_behavior: isize = CFNotificationSuspensionBehaviorDeliverImmediately as isize; notification_center::CFNotificationCenterAddObserver( center, observer, notification_callback, name, object, suspension_behavior, ); // 手动启动Run Loop导致主线程阻塞 CFRunLoopRun(); } } fn add_date_listener(mut cx: FunctionContext) -> JsResult<JsUndefined> { let cb_name_js = &cx.argument::<JsString>(0)?; let cb_name = cb_name_js.value(&mut cx); if cb_name == "timezone_change" { init_notification(); TimeZoneChangeEvent::new(cx) } else { Ok(cx.undefined()) } } // 模块初始化代码省略...
子线程尝试(无效)
fn init_notification() { thread::spawn(move || { unsafe { let center = notification_center::CFNotificationCenterGetLocalCenter(); let observer = &Observer as *const _ as *mut c_void; let name = CFStringCreateWithCString( kCFAllocatorDefault, b"kCFTimeZoneSystemTimeZoneDidChangeNotification\0".as_ptr() as *const i8, kCFStringEncodingUTF8, ); let object = ptr::null(); let suspension_behavior: isize = CFNotificationSuspensionBehaviorDeliverImmediately as isize; notification_center::CFNotificationCenterAddObserver( center, observer, notification_callback, name, object, suspension_behavior, ); CFRunLoopRun(); } }); }
Node.js使用示例
const addon = require('dist/x.node'); addon.date.addListener('timezone_change', () => { console.log('timezone_change'); })
解决方案
核心问题分析
- 主线程阻塞原因:手动调用
CFRunLoopRun()会让主线程进入无限循环,而Node.js在Mac平台本身已经基于CFRunLoop维护自己的事件循环,重复调用会阻塞Node.js的正常执行。 - 子线程无效原因:
CFNotificationCenterGetLocalCenter()返回的是当前线程专属的本地通知中心,系统时区变更通知只会发送到主线程的通知中心,子线程的观察者无法接收;且子线程的Run Loop未与系统通知源正确绑定。
修正后的完整代码
use neon::prelude::*; use std::sync::{Arc, Mutex}; use core_foundation_sys::{ notification_center as nc, string::CFStringCreateWithCString, base::{kCFAllocatorDefault, kCFStringEncodingUTF8}, }; use lazy_static::lazy_static; // 全局状态:保存JavaScript回调和跨线程通信Channel struct ListenerState { callback: Option<Root<JsFunction>>, channel: Channel, } lazy_static! { static ref GLOBAL_STATE: Arc<Mutex<ListenerState>> = Arc::new(Mutex::new(ListenerState { callback: None, channel: Channel::new().unwrap(), })); } // Core Foundation通知回调 extern "C" fn timezone_notification_callback( _center: nc::CFNotificationCenterRef, _observer: *mut std::os::raw::c_void, _name: nc::CFNotificationName, _object: *const std::os::raw::c_void, _user_info: core_foundation_sys::dictionary::CFDictionaryRef, ) { let state = GLOBAL_STATE.lock().unwrap(); // 通过Channel将事件传递到JavaScript线程 state.channel.send(move |mut cx| { if let Some(callback) = &state.callback { let callback = callback.clone(&mut cx); // 调用JavaScript回调函数 callback.call(&mut cx, cx.global(), vec![])?; } Ok(()) }).unwrap(); } fn init_timezone_listener() { unsafe { // 获取主线程的本地通知中心 let center = nc::CFNotificationCenterGetLocalCenter(); let observer = std::ptr::null_mut(); // 创建时区变更通知的CFString名称 let notification_name = CFStringCreateWithCString( kCFAllocatorDefault, b"kCFTimeZoneSystemTimeZoneDidChangeNotification\0".as_ptr() as *const i8, kCFStringEncodingUTF8, ); // 添加通知观察者 nc::CFNotificationCenterAddObserver( center, observer, Some(timezone_notification_callback), notification_name, std::ptr::null(), nc::CFNotificationSuspensionBehaviorDeliverImmediately as isize, ); // 无需手动启动Run Loop,Node.js主线程已在运行 } } // Node.js扩展的addListener API fn add_timezone_listener(mut cx: FunctionContext) -> JsResult<JsUndefined> { let event_name = cx.argument::<JsString>(0)?.value(&mut cx); let callback = cx.argument::<JsFunction>(1)?; if event_name == "timezone_change" { let mut state = GLOBAL_STATE.lock().unwrap(); // 保存JavaScript回调(Root防止被GC回收) state.callback = Some(callback.root(&mut cx)); // 初始化监听(仅执行一次) init_timezone_listener(); } Ok(cx.undefined()) } // 模块初始化 fn create_date_module(cx: &mut ModuleContext) -> NeonResult<()> { let date = cx.empty_object(); let add_listener_fn = JsFunction::new(cx, add_timezone_listener)?; date.set(cx, "addListener", add_listener_fn)?; cx.export_value("date", date)?; Ok(()) } #[neon::main] fn main(mut cx: ModuleContext) -> NeonResult<()> { create_date_module(&mut cx)?; Ok(()) }
关键修改点
- 移除
CFRunLoopRun():依赖Node.js主线程已有的CFRunLoop,避免阻塞主线程。 - 使用Neon的
Channel:安全地将Core Foundation回调中的事件从Rust线程(主线程)传递到JavaScript线程,确保回调在Node.js的事件循环中执行。 - 全局状态管理:用
Arc<Mutex>保存JavaScript回调的Root实例,防止回调被垃圾回收,同时保证多线程安全。 - 主线程添加观察者:在Node.js主线程调用
init_timezone_listener(),确保观察者绑定到主线程的本地通知中心,能接收系统发送的时区变更通知。
内容的提问来源于stack exchange,提问作者刘北京
相关产品推荐
相关产品推荐

