Signal
主线位置:更新主线上「用户闭包写 signal →
ReactiveContext::mark_dirty→SchedulerMsg」这一段,以及它的前提:上一次run_scope里组件读 signal 时留下的订阅。写锁 drop 的顺序和通知本身在一次点击第 4、5 节已经走过一遍,本页讲订阅关系怎么建立、怎么清掉,读写冲突时报什么,以及那些语法糖。首屏主线上,组件第一次渲染读到的 signal 同样在这里建立订阅(SSR 里通常不会再有写入)。
sequenceDiagram participant RS as run_scope participant RC as 组件的 ReactiveContext participant S as Signal count participant H as 事件回调 participant CH as 调度通道 RS->>RC: reset_and_run_in 清空旧订阅 RC->>S: 组件读 count 订阅 Note over RC,S: 双向记录 S 的集合里有 RC H->>S: count += 1 拿 WriteLock H->>S: WriteLock drop S->>RC: update_subscribers mark_dirty RC->>CH: SchedulerMsg Immediate
源码在 packages/signals/src(约 4300 行,本页读 signal.rs、read.rs、write.rs、impls.rs)和 packages/core/src/reactive_context.rs。dioxus-signals 定义「可读可写、读时订阅」的值;ReactiveContext 在 dioxus-core 里,因为组件的重跑是 core 调度的。
Signal 就是带订阅者集合的 CopyValue
Signal 和 SignalData:
pub struct Signal<T, S: 'static = UnsyncStorage> {
pub(crate) inner: CopyValue<SignalData<T>, S>,
}
/// The data stored for tracking in a signal.
pub struct SignalData<T> {
pub(crate) subscribers: Arc<Mutex<HashSet<ReactiveContext>>>,
pub(crate) value: T,
}
值和它的订阅者集合放在同一个 generational-box 格子里(CopyValue 和格子见 generational-box)。所以 Signal 和 CopyValue 一样大(release 24 字节),一样 Copy,一样跟着创建它的 scope 被回收。订阅者集合是 Arc<Mutex<..>>,即使 S 是 UnsyncStorage:集合要交给 ReactiveContext 另存一份引用(下文),ReactiveContext 本身在 SyncStorage 里,要求 Send + Sync。
use_signal 就是 use_hook(|| Signal::new_with_caller(f(), caller)):第一次渲染建一个,之后每次渲染返回 hook 里存的同一个句柄。直接在组件里调 Signal::new 不会出错,但每次渲染都在当前 scope 的 Owner 下多占两格,直到组件卸载才释放;Signal::new 的文档注释专门用 ❌ / ✅ 示例警告了这一点。
读:订阅栈顶的 ReactiveContext
读的入口是 Readable trait 的两个方法,Signal 的实现(try_read_unchecked、try_peek_unchecked)只差一个 if:
fn try_read_unchecked(&self) -> BorrowResult<ReadableRef<'static, Self>>
where
T: 'static,
{
let inner = self.inner.try_read_unchecked()?;
if let Some(reactive_context) = ReactiveContext::current() {
tracing::trace!("Subscribing to the reactive context {}", reactive_context);
reactive_context.subscribe(inner.subscribers.clone());
}
Ok(S::map(inner, |v| &v.value))
}
fn try_peek_unchecked(&self) -> BorrowResult<ReadableRef<'static, Self>>
where
T: 'static,
{
self.inner
.try_read_unchecked()
.map(|inner| S::map(inner, |v| &v.value))
}
用户平时调的 read()、peek()、with、cloned 都在扩展 trait ReadableExt 里,最后都落到这两个方法;read 是 self.try_read().unwrap()。返回的 ReadableRef 就是 generational-box 的读借用守卫,守卫活着,格子的 RefCell 就处于读借用状态。
「订阅谁」由 ReactiveContext::current 决定:线程局部栈 CURRENT 的栈顶。栈由 run_in 压入、弹出,组件、memo、effect 各自在自己的 context 里运行用户代码。事件回调不在任何 context 里运行,所以在 onclick 里 read() 不订阅任何东西,和 peek() 效果一样;spawn 和 use_future 的 async 块也不在任何 context 里,.await 前后都一样;use_resource 例外,它把每次 poll 都包在自己的 context 里(见异步)。
ReactiveContext
ReactiveContext 本身也是一个 Copy 句柄:
pub struct ReactiveContext {
scope: ScopeId,
inner: GenerationalBox<Inner, SyncStorage>,
}
Inner 里有三样东西:update: Box<dyn FnMut() + Send + Sync>(被标脏时调用的回调)、subscribers: HashSet<PointerHash<dyn SubscriberList + Send + Sync>>(它订阅了哪些 signal 的订阅者集合)、self_(自己的句柄,Drop 时用来把自己从那些集合里删掉)。debug 下再加创建位置和所属 scope,trace 里的 ReactiveContext(for scope: ScopeId(3, "root"))、ReactiveContext created at src/bin/click.rs:10:3 就是它的 Display。
订阅是双向记录的。subscribe 一边 subscriptions.add(*self)(把自己放进 signal 的集合),一边把这个集合的 Arc 记进自己的 Inner.subscribers。集合按 Arc 指针哈希(PointerHash),context 按格子 id 哈希,同一对只记一次。反向这一份是为了能退订:clear_subscribers 把自己记下的每个集合取出来,从里面 remove(self)。reset_and_run_in 就是「先 clear_subscribers,再 run_in」,run_scope 每次调组件函数都用它(见 Scope 与 hooks)。所以组件的订阅只反映最近一次渲染读到了什么。
signal 的集合在 Signal::subscribers() 里能拿到(Readable 的第三个方法),Subscribers::visit 可以遍历。sig1-exp/src/bin/sig_subscribe.rs:组件按 flag 决定读不读 count,每一步之后打印两个 signal 的订阅者:
fn app() -> Element {
let count = use_signal(|| 0);
let flag = use_signal(|| true);
HANDLES.set(Some((count, flag)));
let shown = if flag() { count() } else { -1 };
println!(" [app 运行] flag={} count={} shown={shown}", flag.peek(), count.peek());
rsx! { "{shown}" }
}
fn subs<T: 'static>(label: &str, s: &Signal<T>) {
let mut v = vec![];
s.subscribers().visit(|rc| v.push(rc.to_string()));
println!(" {label} 的订阅者: {v:?}");
}
main 用 dom.in_runtime(..) 从外面写 signal,再给 wait_for_work 50 毫秒:返回就 render_immediate,超时说明没有脏 scope。debug 输出:
[app 运行] flag=true count=0 shown=0
--- count += 1
[app 运行] flag=true count=1 shown=1
count 的订阅者: ["ReactiveContext(for scope: ScopeId(3, \"root\"))"]
flag 的订阅者: ["ReactiveContext(for scope: ScopeId(3, \"root\"))"]
--- flag = false
[app 运行] flag=false count=1 shown=-1
count 的订阅者: []
flag 的订阅者: ["ReactiveContext(for scope: ScopeId(3, \"root\"))"]
--- count += 1(flag 为 false)
(没有脏 scope,组件没重跑)
count 的订阅者: []
flag 的订阅者: ["ReactiveContext(for scope: ScopeId(3, \"root\"))"]
--- flag = true
[app 运行] flag=true count=2 shown=2
count 的订阅者: ["ReactiveContext(for scope: ScopeId(3, \"root\"))"]
flag 的订阅者: ["ReactiveContext(for scope: ScopeId(3, \"root\"))"]
flag 变成 false 的那一轮,组件没读 count,count 的集合随即变空,之后写 count 不再触发重跑。flag 回到 true,组件又读到 count,订阅恢复。打印里的 flag.peek()、count.peek() 不订阅,不影响结果。
ReactiveContext 有四种来源,区别只在 update 回调做什么:
| 谁的 | 怎么建 | update 做什么 | 详见 |
|---|---|---|---|
| 组件 | new_for_scope,建 scope 时 | 往调度通道发 SchedulerMsg::Immediate(id) | 调度 |
Memo | new_with_callback | 置 dirty 标志,往自己的通道发 () | Memo 与 effect |
use_effect | new_with_origin | 通道里还没有消息就发一个 (),唤醒 hook 里 spawn 的任务 | Memo 与 effect |
use_resource | new_with_origin | 同上,任务重新跑 future | 异步 |
Inner 存在 context 所属 scope 的 Owner<SyncStorage> 里,scope 删掉它就被回收。signal 的集合里可能还留着它的句柄,但 mark_dirty 对一个已回收的 context try_write 失败、返回 false,update_subscribers 的 retain 顺手把它删掉:死掉的订阅者在下一次写入时才被清理。
写:WriteLock 释放时通知
写的入口是 Writable 的 try_write_unchecked,扩展 trait WritableExt 提供 write、with_mut、set。Signal 的实现(try_write_unchecked)返回一个 WriteLock,里面带着 SignalSubscriberDrop;它被 drop 时调 update_subscribers,把集合整个 take 出来,对每个 context mark_dirty,再 extend 回去。这一段代码、字段的 drop 顺序(先还借用、再通知)和「先 take 再放回」的原因,一次点击第 4 节已经逐行讲过,这里不重复。
要记住的只有三点:
- 写本身不通知,通知在
WriteLockdrop 时。拿着write()的守卫做很多事,订阅者要等守卫离开作用域才被标脏。 - 通知只是
mark_dirty,组件那个 context 只往通道里塞一条消息,不会在写的地方同步重跑任何组件。 - 写不检查新值和旧值是否相等。
count.set(0)写一个和原来相同的值,订阅者照样被标脏、组件照样重跑。要「值不变就不通知」,用 memo(下一页)。
Signal 还有一个已标记 #[deprecated] 的 write_silent:直接 self.inner.write_unchecked(),不带 SignalSubscriberDrop,写完不通知任何人。它的文档注释用很长的篇幅解释为什么改用 peek:write_silent 是在写的一侧全局关掉通知,别的文件里读这个 signal 的组件会静默地不更新;peek 是在读的一侧、只对这一次读关掉订阅。
借用冲突
signal 的值在 RefCell 里(UnsyncStorage),读守卫和写守卫不能同时存在。先看编译期:write 的签名是 fn write(&mut self) -> WritableRef<'_, Self>,read 是 fn read(&self) -> ReadableRef<'_, Self>,返回的守卫都借着句柄本身。所以对同一个变量先 write() 再 read(),rustc 直接拒绝。sig_borrow 最初的写法:
fn read_while_write() -> Element {
let mut count = use_signal(|| 0);
let _w = count.write();
let _r = count.read();
rsx! {}
}
fn write_while_read() -> Element {
let mut count = use_signal(|| 0);
let _r = count.read();
let _w = count.write();
rsx! {}
}
fn read_in_with_mut() -> Element {
let mut count = use_signal(|| 0);
count.with_mut(|v| *v = count() + 1);
rsx! {}
}
rustc 1.99.0 的报错(节选):
error[E0502]: cannot borrow `count` as immutable because it is also borrowed as mutable
--> src/bin/sig_borrow.rs:9:12
|
8 | let _w = count.write();
| ----- mutable borrow occurs here
9 | let _r = count.read();
| ^^^^^ immutable borrow occurs here
10 | rsx! {}
11 | }
| - mutable borrow might be used here, when `_w` is dropped and runs the destructor for type `WriteLock<'_, i32, UnsyncStorage, dioxus::prelude::SignalSubscriberDrop<i32, UnsyncStorage>>`
error[E0502]: cannot borrow `count` as mutable because it is also borrowed as immutable
--> src/bin/sig_borrow.rs:16:12
|
15 | let _r = count.read();
| ----- immutable borrow occurs here
16 | let _w = count.write();
| ^^^^^^^^^^^^^ mutable borrow occurs here
17 | rsx! {}
18 | }
| - immutable borrow might be used here, when `_r` is dropped and runs the destructor for type `dioxus::dioxus_core::internal::generational_box::GenerationalRef<std::cell::Ref<'_, i32>>`
error[E0502]: cannot borrow `count` as mutable because it is also borrowed as immutable
--> src/bin/sig_borrow.rs:22:3
|
22 | count.with_mut(|v| *v = count() + 1);
| ^^^^^^--------^---^^^^^^-----^^^^^^^
| | | | |
| | | | first borrow occurs due to use of `count` in closure
| | | immutable borrow occurs here
| | immutable borrow later used by call
| mutable borrow occurs here
...
这层检查是附带的:句柄是 Copy,复制一份(或者两个闭包各自捕获一份)就绕过去了,冲突留到运行期。把每个函数里的一个 count 换成它的复制 other:
fn read_while_write() -> Element {
let mut count = use_signal(|| 0);
let other = count; // Copy 出第二个句柄
let _w = count.write();
let _r = other.read();
rsx! {}
}
fn write_while_read() -> Element {
let count = use_signal(|| 0);
let mut other = count;
let _r = count.read();
let _w = other.write();
rsx! {}
}
fn read_in_with_mut() -> Element {
let mut count = use_signal(|| 0);
let other = count;
count.with_mut(|v| *v = other() + 1);
rsx! {}
}
fn across_await() -> Element {
let mut count = use_signal(|| 0);
use_hook(|| {
spawn(async move {
let r = count.read();
println!("task: holding read, value = {}", *r);
tokio::time::sleep(Duration::from_millis(100)).await;
println!("task: done, value = {}", *r);
})
});
rsx! { button { onclick: move |_| count += 1, "+" } }
}
前三个在组件函数里冲突;across_await 的任务拿着读守卫跨过 .await,主程序先让任务跑到 sleep,再派发一次点击。每个函数单独跑一个进程,debug 输出:
=== rw
thread 'main' (1187842) panicked at src/bin/sig_borrow.rs:10:18:
called `Result::unwrap()` on an `Err` value: AlreadyBorrowedMut(AlreadyBorrowedMutError { borrowed_mut_at: Location { file: "src/bin/sig_borrow.rs", line: 9, column: 18 } })
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
rendered:
=== wr
thread 'main' (1187869) panicked at src/bin/sig_borrow.rs:18:18:
called `Result::unwrap()` on an `Err` value: AlreadyBorrowed(AlreadyBorrowedError { borrowed_at: [Location { file: "src/bin/sig_borrow.rs", line: 17, column: 18 }] })
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
rendered:
=== with_mut
thread 'main' (1187921) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/dioxus-signals-0.7.10/src/read.rs:259:38:
called `Result::unwrap()` on an `Err` value: AlreadyBorrowedMut(AlreadyBorrowedMutError { borrowed_mut_at: Location { file: "src/bin/sig_borrow.rs", line: 25, column: 9 } })
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
rendered:
=== await
task: holding read, value = 0
click while task holds the read guard
thread 'main' (1188014) panicked at src/bin/sig_borrow.rs:39:37:
called `Result::unwrap()` on an `Err` value: AlreadyBorrowed(AlreadyBorrowedError { borrowed_at: [Location { file: "src/bin/sig_borrow.rs", line: 33, column: 21 }] })
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
release 下错误里没有位置:
=== rw --release
called `Result::unwrap()` on an `Err` value: AlreadyBorrowedMut(AlreadyBorrowedMutError)
=== wr --release
called `Result::unwrap()` on an `Err` value: AlreadyBorrowed(AlreadyBorrowedError)
=== with_mut --release
called `Result::unwrap()` on an `Err` value: AlreadyBorrowedMut(AlreadyBorrowedMutError)
=== await --release
called `Result::unwrap()` on an `Err` value: AlreadyBorrowed(AlreadyBorrowedError)
(每段只留错误那一行,panic 位置和 debug 相同。)读这些输出:
- 「读时已有写」是
AlreadyBorrowedMut,带的是另一方(写守卫)的位置;「写时已有读」是AlreadyBorrowed,带所有还活着的读守卫的位置。panic 位置本身是出错的这一次调用。 - 组件函数里的 panic 被
run_scope的catch_unwind接住,变成错误交给错误边界,rendered:后面是空的;事件回调里的 panic(await那个)没人接,原生进程直接退出,wasm 里同样中止。 count += 1报在sig_borrow.rs:39:37,就是+=那里:AddAssign、with_mut、write一路都有#[track_caller]。other()报在read.rs:259,原因见下一节。- 跨
.await拿着读守卫,编译器不会拦:任务不要求Send,守卫可以活过.await;冲突发生在别处来写的那一刻,panic 在写的一方。读出需要的值、让守卫在.await之前结束(let v = count();或*count.read()拷出来),就不会有这个问题。
SyncSignal(use_signal_sync)的同样冲突不 panic,而是死锁,见 generational-box 的实验。
问: 既然 write(&mut self) 能在编译期抓到一部分冲突,为什么 Signal 还要做成 Copy?不 Copy,冲突不就全在编译期了吗?
答: 不 Copy 就回到了「闭包要么借用、要么拿走」的老问题:onclick: move |_| count += 1 会把 count 移进闭包,同一个组件里别的闭包和 rsx! 就用不了它。Copy 句柄的全部意义就是让每个闭包各拿一份,代价是借用检查挪到运行期。&mut self 只是让最常见的写法(同一个变量上先 write 后 read)提前报错,并不承诺全部抓到。
语法糖:signal()、+=、"{signal}"
signal()。Signal 实现了 Deref<Target = dyn Fn() -> T>(要求 T: Clone),count() 是先 deref 成一个闭包再调用。闭包从哪来?ReadableExt::deref_impl:
// First we create a closure that captures something with the Same in memory layout as Self (MaybeUninit<Self>).
let uninit_callable = MaybeUninit::<Self>::uninit();
// Then move that value into the closure. We assume that the closure now has a in memory layout of Self.
let uninit_closure = move || Self::read(unsafe { &*uninit_callable.as_ptr() }).clone();
// Check that the size of the closure is the same as the size of Self in case the compiler changed the layout of the closure.
let size_of_closure = std::mem::size_of_val(&uninit_closure);
assert_eq!(size_of_closure, std::mem::size_of::<Self>());
// ...
unsafe {
std::mem::transmute(self)
},
这是注释里引用的 dtolnay「callable types」技巧:造一个只捕获「和 Self 同样布局的东西」的闭包,它的内存布局就是 Self,于是可以把 &Self 直接 transmute 成 &闭包,再当成 &dyn Fn() -> T 返回。调用时闭包把自己的捕获(其实就是这个 signal)当作 &Self 去 read(),再 clone()。所以 count() 等于 count.read().clone():会订阅,会在冲突时 panic。panic 位置落在 read.rs:259,是因为闭包的 call 不可能带 #[track_caller],位置只能停在这个闭包里。Memo、ReadSignal 等用同一个 deref_impl。
+= 等运算符。write_impls! 宏给 Signal、Memo 等生成 Add、AddAssign、Sub、SubAssign、Mul、MulAssign、Div、DivAssign,要求 T: Copy。AddAssign 是 self.with_mut(|v| *v = *v + rhs):直接在写守卫里读旧值,不经过 read(),所以 count += 1 不订阅;count + 1(Add)走 with,会订阅。
"{count}"。rsx! 的格式化字符串最终调 Display。fmt_impls! 生成的 Display / Debug 是 self.with(|v| Display::fmt(v, f)),with 走 read():在组件里写 "{count}" 就订阅了 count。上面 sig_subscribe 的组件只读了 flag() 和 count(),rsx! 里的 "{shown}" 是普通 i32,订阅只来自前两者。把 signal 直接放进属性或子节点(div { {count} })则走 IntoAttributeValue / IntoDynNode,同样是 read 之后 clone。
渲染时写 signal
组件函数也在自己的 ReactiveContext 里运行,渲染时写 signal 和在事件里写没有区别:写锁释放时,订阅者被标脏。问题在于订阅者可能就是组件自己。sig1-exp/src/bin/sig_write_render.rs:
fn read_then_write() -> Element {
let mut count = use_signal(|| 0);
let v = count(); // 先读:订阅
println!(" [read_then_write 运行] count = {v}");
count += 1; // 再写:通知刚订阅的自己
rsx! { "{v}" }
}
fn write_then_read() -> Element {
let mut count = use_signal(|| 0);
count += 1; // 先写:这一轮还没有订阅者
let v = count(); // 再读:订阅
println!(" [write_then_read 运行] count = {v}");
rsx! { "{v}" }
}
main 对每个组件 rebuild_in_place(),然后最多四轮「wait_for_work(50 毫秒超时)→ render_immediate」,装了打印 WARN 的 tracing_subscriber。read_then_write 的 debug 输出:
=== read_then_write
[read_then_write 运行] count = 0
第 1 轮:wait_for_work 返回(有脏 scope)
[read_then_write 运行] count = 1
第 2 轮:wait_for_work 返回(有脏 scope)
[read_then_write 运行] count = 2
第 3 轮:wait_for_work 返回(有脏 scope)
[read_then_write 运行] count = 3
第 4 轮:wait_for_work 返回(有脏 scope)
[read_then_write 运行] count = 4
每次渲染都把自己标脏,wait_for_work 永远立即返回,只是被实验的四轮上限截断了。在浏览器里就是主循环一直在重渲染。没有任何警告:debug 和 release 输出完全相同。run_scope 结束时会把 scope 从 dirty_scopes 里删掉,但那条 SchedulerMsg::Immediate 还在通道里,下一次 wait_for_work 的 process_events 又把它放回去。
write_then_read 会怎样,留作本节的动脑筋。
上面的 write_then_read,同样 rebuild_in_place() 之后最多跑四轮 wait_for_work。[write_then_read 运行] 一共打印几行,打印的 count 是几?(M2 Max、macOS 27、rustc 1.99.0,debug 和 release。)
0.6 的 debug 构建会对渲染时写 signal 发警告;0.7.10 里没有,这是有意删掉的。
0.6 的 SignalSubscriberDrop::drop 在 debug 下除了打 trace,还调两个警告(0.5.7 里同样的两项检查直接写在 drop 里):signal_write_in_component_body(文字是「Write on signal at {} happened while a component was running. Writing to signals during a render can cause infinite rerenders when you read the same signal in the component. Consider writing to the signal in an effect, future, or event handler if possible.」)和 signal_read_and_write_in_reactive_scope(同一个 reactive context 既读又写)。PR #4024「Remove signal write warnings」把它们连同 memo.rs、use_reactive.rs 里为它们开的豁免一起删了,说明是:「These warnings have been too noisy in the past and we don't have a good way of improving reliability (detecting infinite loops) in the general case」。use_reactive 和 memo 的惰性重算本身就要在渲染时写 signal,只能一处处加 allow(之前的 #3647 就是为 memo 加豁免)。现在 warnings.rs 里只剩 copy_value_hoisted 一个警告。仓库里第一个包含 #4024 的 tag 是 v0.7.0-alpha.0,正式版 0.7.0 的发布说明里列着它。
动脑筋答案
sig_write_render 里 write_then_read 的原样输出(debug 和 release 相同):
=== write_then_read
[write_then_read 运行] count = 1
第 1 轮:50ms 内没返回,停了
- 只跑一次,打印的是 1:
count += 1在读之前,值先从 0 变成 1。 - 写的时候组件还没订阅
count:run_scope用reset_and_run_in调组件函数,开头clear_subscribers已经把组件从count的集合里删掉(第一次渲染时集合本来就是空的)。update_subscribers遍历一个空集合,没有人被标脏。 - 之后的
count()才订阅。下一次有别的原因让组件重跑时,同样是先清空、再写(没人收到)、再读,所以永远不会自己触发自己。但每次重跑count都会加一,这依然不是该在渲染里做的事。
小结
Signal<T>是CopyValue<SignalData<T>>:值和订阅者集合Arc<Mutex<HashSet<ReactiveContext>>>在同一个 generational-box 格子里,Copy,跟着创建它的 scope 回收。read订阅线程局部栈CURRENT的栈顶 context,peek不订阅;事件回调不在任何 context 里,读了也不订阅。ReactiveContext是SyncStorage里的句柄,update回调决定被标脏时做什么(组件发Immediate、memo 置脏、effect / resource 唤醒任务);订阅双向记录,reset_and_run_in每次先退订,组件只订阅最近一次渲染读到的 signal。- 通知发生在
WriteLockdrop 时,写入不比较新旧值;死掉的 context 在下一次写入时从集合里清掉。 - 同一个变量先
write再read编译期报 E0502;复制出的句柄冲突时运行期 panicAlreadyBorrowedMut/AlreadyBorrowed,debug 带对方的位置。读守卫跨.await编译器不拦。 signal()是Deref<Target = dyn Fn() -> T>的 transmute 技巧,等于read().clone();+=不订阅,"{signal}"订阅。- 渲染时先读后写会无限重跑,0.7 起没有任何警告;先写后读不会自我触发。