Rust in Everything
基于 dioxus 0.7.10

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>[&#34;rust&#34;, &#34;web&#34;]

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>[&#34;rust&#34;, &#34;web&#34;, &#34;wasm&#34;]
saved 1 tags
await: <button>save</button><button>add</button>[&#34;rust&#34;, &#34;web&#34;]
  • 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)的位置:先去那里看守卫为什么还没释放。

小结

  • 读守卫活着时写同一个值:UnsyncStorage panic(AlreadyBorrowed / AlreadyBorrowedMut),SyncStorage 死锁。
  • 编译器只拦同一个变量上的冲突;Signal 是 Copy,传给函数、被另一个闭包捕获就绕过去了。
  • 跨 .await 拿着守卫,panic 的是别的回调;看 borrowed_at 找真正拿着守卫的地方(只有 debug 有)。
  • 修法:先拷出值或结论、让守卫结束再写;遍历加修改放进一个 write() 里。clippy.toml 配 await-holding-invalid-types 能在编译期抓跨 .await 的守卫。

💬 关联讨论

发起讨论
加载中...