AlreadyBorrowed / AlreadyBorrowedMut
主线位置:更新主线里「用户闭包写 signal」那一步。
Signal::write要从格子的RefCell里借出可写引用,这时还有一个读守卫没释放。守卫、错误类型和编译期能拦住的那部分见 Signal 的「借用冲突」,本页讲两种编译器拦不住、实际代码里最常见的写法。
flowchart TB
rd["read() 拿到读守卫"] --> live{"守卫还活着?"}
live -->|"循环体 / .await 期间"| wr["另一个句柄 write()"]
wr --> uns{"存储类型"}
uns -->|UnsyncStorage| p1["panic:AlreadyBorrowed"]
uns -->|SyncStorage| p2["同线程死锁"]
live -->|已释放| ok["正常写入"]
本页实验在 M2 Max、macOS 27、rustc 1.99.0 上运行,依赖 dioxus = "=0.7.10",原生 VirtualDom,点击用 Runtime::handle_event 派发。
现象
两个组件,pit1-exp/src/bin/already_borrowed.rs:
// A helper that takes the signal by value (Signal is Copy)
fn add_tag(mut tags: Signal<Vec<String>>, tag: String) {
tags.write().push(tag);
}
fn loop_then_write() -> Element {
let tags = use_signal(|| vec!["rust".to_string(), "web".to_string()]);
rsx! {
button {
onclick: move |_| {
for t in tags.read().iter() {
if t == "rust" {
add_tag(tags, "wasm".to_string()); // writes while the loop's read guard is alive
}
}
},
"add"
}
"{tags:?}"
}
}
fn guard_across_await() -> Element {
let mut tags = use_signal(|| vec!["rust".to_string()]);
rsx! {
button {
onclick: move |_| async move {
let list = tags.read(); // guard lives until the end of the block
tokio::time::sleep(Duration::from_millis(50)).await; // pretend to save `list` to a server
println!("saved {} tags", list.len());
},
"save"
}
button { onclick: move |_| tags.push("web".to_string()), "add" }
"{tags:?}"
}
}
第一个点一次「add」。第二个先点「save」,让它的任务跑到 sleep,再点「add」。debug:
=== loop
before: <button>add</button>["rust", "web"]
thread 'main' (2021036) panicked at src/bin/already_borrowed.rs:8:8:
called `Result::unwrap()` on an `Err` value: AlreadyBorrowed(AlreadyBorrowedError { borrowed_at: [Location { file: "src/bin/already_borrowed.rs", line: 16, column: 23 }] })
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
=== await
click save
click add while save is waiting
thread 'main' (2021057) panicked at src/bin/already_borrowed.rs:39:37:
called `Result::unwrap()` on an `Err` value: AlreadyBorrowed(AlreadyBorrowedError { borrowed_at: [Location { file: "src/bin/already_borrowed.rs", line: 33, column: 25 }] })
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
release,panic 位置相同,错误里没有 borrowed_at:
called `Result::unwrap()` on an `Err` value: AlreadyBorrowed(AlreadyBorrowedError)
- 循环:
for t in tags.read().iter()的读守卫活到循环结束。add_tag(tags, ..)收的是Signal,Signal是Copy,传进去的是一个新句柄,借用检查器看不出两者是同一个值,编译通过。panic 位置是add_tag里的write()(第 8 行),borrowed_at指回循环头的read()(第 16 行)。 - 跨
.await:list在sleep期间一直借着值。panic 的是另一个回调(第 39 行的tags.push),不是拿着守卫的那个;borrowed_at指向第 33 行。两个回调可能在不同的文件里,只看 panic 位置会找错地方。 - 两个都是事件回调里的 panic,原生进程直接退出(
exit 101),没有错误边界接着。
同样的冲突放在 use_signal_sync(SyncSignal)上不会 panic,而是同线程死锁,页面卡住,见 generational-box 的「UnsyncStorage 与 SyncStorage」。
出处
read() 是 Readable::read,write() 是 Writable::write,两者都是 try_*().unwrap()。UnsyncStorage 里格子是一个 RefCell,写时 get_split_mut 的 try_borrow_mut 失败,返回 BorrowMutError::AlreadyBorrowed;反过来写着的时候读,是 BorrowError::AlreadyBorrowedMut。debug 下格子记着每个活着的守卫是在哪借的,这就是 borrowed_at;release 不记,错误只剩类型名。
write(&mut self) / read(&self) 的签名让「同一个变量先读后写」在编译期报 E0502,但拦不住复制出来的句柄:传参、闭包捕获、use_context 各拿一份,都是新的 Copy。tags.push(..) 这类便捷方法(WritableVecExt::push)内部是 self.with_mut(|v| v.push(value)),with_mut 同样要拿写守卫,同样会冲突,panic 位置能指到调用行是因为一路都带 #[track_caller]。
修法
规则只有一条:写之前,读守卫必须已经结束。读出需要的东西(布尔值、长度、一份克隆),让守卫在语句末尾 drop,再写。src/bin/already_borrowed_fix.rs:
fn loop_then_write() -> Element {
let mut tags = use_signal(|| vec!["rust".to_string(), "web".to_string()]);
rsx! {
button {
onclick: move |_| {
// decide while reading, write after the guard is gone
let has_rust = tags.read().iter().any(|t| t == "rust");
if has_rust {
tags.push("wasm".to_string());
}
},
"add"
}
"{tags:?}"
}
}
fn guard_across_await() -> Element {
let mut tags = use_signal(|| vec!["rust".to_string()]);
rsx! {
button {
onclick: move |_| async move {
let list = tags.cloned(); // an owned copy, no guard
tokio::time::sleep(Duration::from_millis(50)).await;
println!("saved {} tags", list.len());
},
"save"
}
button { onclick: move |_| tags.push("web".to_string()), "add" }
"{tags:?}"
}
}
同样的点击顺序:
loop: <button>add</button>["rust", "web", "wasm"]
saved 1 tags
await: <button>save</button><button>add</button>["rust", "web"]
cloned()等于read().clone(),守卫在这一句就结束。save保存的是点击那一刻的快照(1 个标签),之后的修改不影响它,这通常也正是想要的语义。值很大、不想克隆时,把它放进Rc再存进 signal,克隆的就只是指针。- 要「边遍历边改」,就在一个写守卫里完成:
tags.write()拿到&mut Vec,retain、iter_mut都在里面做。不要在读守卫里调用任何可能写同一个 signal 的函数。 peek()也是借用,只是不订阅;拿着peek()的守卫去写,一样 panic。
怎么确认没中招
跨 .await 的那种可以交给 clippy。await_holding_refcell_ref 只认 std::cell::Ref 本身,不认 dioxus 的守卫,直接跑什么也不报;但 await_holding_invalid_type 可以配置要拦的类型。读守卫的类型是 GenerationalRef,写守卫是 WriteLock。在 crate 根目录放一个 clippy.toml:
await-holding-invalid-types = [
{ path = "dioxus::dioxus_core::internal::generational_box::GenerationalRef", reason = "signal read guard held across .await" },
{ path = "dioxus::signals::WriteLock", reason = "signal write guard held across .await" },
]
cargo clippy 对上面两个文件(already_borrowed_fix.rs 里另外放了一个拿着 tags.write() 跨 .await 的函数,专门给 clippy 看):
warning: holding a disallowed type across an await point `dioxus::dioxus_core::internal::generational_box::GenerationalRef`
--> src/bin/already_borrowed.rs:33:13
|
33 | let list = tags.read(); // guard lives until the end of the block
| ^^^^
|
= note: signal read guard held across .await
= help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.99.0/index.html#await_holding_invalid_type
= note: `#[warn(clippy::await_holding_invalid_type)]` on by default
warning: holding a disallowed type across an await point `dioxus::signals::WriteLock`
--> src/bin/already_borrowed_fix.rs:42:7
|
42 | let mut w = tags.write();
| ^^^^^
|
= note: signal write guard held across .await
= help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.99.0/index.html#await_holding_invalid_type
= note: `#[warn(clippy::await_holding_invalid_type)]` on by default
- 这条 lint 只看
.await前后存活的局部变量的类型本身。写守卫要写WriteLock,写GenerationalRefMut不报(它包在WriteLock里面);改过fix版用的tags.cloned()没有守卫,不报。 - 循环里那种没有
.await,clippy 管不到。搜收Signal<..>参数并且会写它的辅助函数,看调用点外面有没有.read()/.peek()/for .. in x.read()。 - 已经出错时,debug 构建 panic 里的
borrowed_at列出所有活着的读守卫(AlreadyBorrowed),或那个写守卫(AlreadyBorrowedMut)的位置:先去那里看守卫为什么还没释放。
小结
- 读守卫活着时写同一个值:
UnsyncStoragepanic(AlreadyBorrowed/AlreadyBorrowedMut),SyncStorage死锁。 - 编译器只拦同一个变量上的冲突;
Signal是Copy,传给函数、被另一个闭包捕获就绕过去了。 - 跨
.await拿着守卫,panic 的是别的回调;看borrowed_at找真正拿着守卫的地方(只有 debug 有)。 - 修法:先拷出值或结论、让守卫结束再写;遍历加修改放进一个
write()里。clippy.toml配await-holding-invalid-types能在编译期抓跨.await的守卫。