【Rust】非同期処理でクロージャをうまく使う方法はあるんだろうか…

2020-07-12

Rustで作ったプログラムをブラウザ上でも動かせるようにしたかったので調べてみた。 RustのコードをWasmにコンパイルしてブラウザで動かせるような環境を作ってくれるツールがあって、それに沿って進めればある程度は動かせるんだけど、 画像読み込み時のコールバックで処理しようとしたらハマった。

wasm-packの導入

以前Rustのコードをブラウザで動かすリポジトリでEmscriptenを使用していたのを見たことがあった。 それを参考にしようとしていたんだけど、他にすごくいいチュートリアルがあった: Introduction - Rust and WebAssembly

上の本ではwasm-packというものを使っていて、 wasm-pack-templateというwasmへのコンパイルや ブラウザ上で動かすJavaScriptとのやりとりが用意された雛形プロジェクトに追加していってライフゲームを作成している。

ポイントとしてはRustではライフゲームの計算だけを行っていて、キャンバスへの描画はJavaScriptで行っている。

RustでDOM、キャンバス操作

描画処理もRust側から行うにはDOM操作をする必要がある。 wasm-bindgenにweb-sysというcrateがあって、これを使うとRust上でDOM操作ができる。

web-sys: A Simple Paint Program - The wasm-bindgen Guide のキャンバスとコンテキストを取得して描画するサンプルが参考になる。

画像読み込み

キャンバスに描画する画像は Image を生成して src にパスをセットすれば読み込める。 で読み込みが完了したときのコールバック onload でなにか処理させたい場合にどうするか。

WebAssemblyで遊んでみるその2〜web_sysでブロック崩し〜 - 虎の穴開発室ブログ の「イベントハンドラの設定」を参考に:

use wasm_bindgen::JsCast;
use web_sys::HtmlImageElement;

#[wasm_bindgen]
pub struct App {
}

#[wasm_bindgen]
impl App {
pub fn load_image(&mut self, path: &str) {
let image = HtmlImageElement::new().unwrap();
{
let path = String::from(path);
let closure = Closure::wrap(Box::new(move |_event: web_sys::Event| {
web_sys::console::log_1(&format!("Image loaded: {}, {:?}", &path, &_event).into());
}) as Box<dyn FnMut(web_sys::Event)>);
image.set_onload(Some(closure.as_ref().unchecked_ref()));
closure.forget();
}
image.set_src(&path);
}
}
  • Rustのクロージャを web_sys::Closure にして、それから js_sys::Function を取り出して設定する
  • 本当なら生成したクロージャがスコープから抜けた時点で解放されてしまうとコールバック時に不定になってしまってマズいので 保持しておく必要があるんだけど、面倒なので forget でリークさせてしまって生き続けるようにしている
  • (後でわかったことだが onmousemove と違って onload は複数回呼び出されないので FnMut を使用する必要はなかった、後述)

クロージャに値を持ち込めない件

上記のコードは動くけど、実際には onload のコールバック内で読み込みに成功した画像をメンバ変数に格納して、後々使えるようにしたい。 しかし self のミュータブル参照をクロージャに move することはできない。 なのでメンバ変数を Rc<RefCell> にして使おうとしても、

use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use wasm_bindgen::JsCast;
use web_sys::{HtmlImageElement};

#[wasm_bindgen]
pub struct App {
images: Rc<RefCell<HashMap<String, HtmlImageElement>>>, // <= Rc<RefCell> で
}

#[wasm_bindgen]
impl App {
pub fn load_image(&mut self, path: &str) {
let image = HtmlImageElement::new().unwrap();
let image = Rc::new(RefCell::new(image)); // <= Rc<RefCell>で保持
{
let path = String::from(path);
let images = self.images.clone(); // メンバ変数 images のRc参照+1
let image_dup = image.clone(); // Rc参照+1
let closure = Closure::wrap(Box::new(move |_event: web_sys::Event| {
web_sys::console::log_1(&format!("Image loaded: {}", &path).into());
// クロージャ内でimageを使用したいが、持ち込めない
let image = Rc::try_unwrap(image_dup).unwrap().into_inner();
// ロードに成功したimageを格納したいが、できない
images.borrow_mut().insert(path, image);
}) as Box<dyn FnMut(web_sys::Event)>);
let cb = closure.as_ref().unchecked_ref();
image.borrow_mut().set_onload(Some(cb));
closure.forget();
}
image.borrow_mut().set_src(&path);
}
}

エラーが出る:

$ wasm-pack build
[INFO]: 🎯 Checking for the Wasm target...
[INFO]: 🌀 Compiling to Wasm...
Compiling wasm-game-of-life v0.1.0 (/Users/admin/tmp/rust-webasm/wasm-game-of-life)
error[E0277]: expected a `std::ops::FnMut<(web_sys::features::gen_Event::Event,)>` closure, found `[closure@src/lib.rs:64:50: 68:14 path:std::string::String, image:std::rc::Rc<std::cell::RefCell<web_sys::features::gen_HtmlImageElement::HtmlImageElement>>, images:std::rc::Rc<std::cell::RefCell<std::collections::HashMap<std::string::String, web_sys::features::gen_HtmlImageElement::HtmlImageElement>>>]`
--> src/lib.rs:64:41
|
64 | let closure = Closure::wrap(Box::new(move |_event: web_sys::Event| {
| _________________________________________^
65 | | web_sys::console::log_1(&format!("Image loaded: {}", &path).into());
66 | | let image = Rc::try_unwrap(image_dup).unwrap().into_inner();
67 | | images.borrow_mut().insert(path, image);
68 | | }) as Box<dyn FnMut(web_sys::Event)>);
| |______________^ expected an `FnMut<(web_sys::features::gen_Event::Event,)>` closure, found `[closure@src/lib.rs:64:50: 68:14 path:std::string::String, image:std::rc::Rc<std::cell::RefCell<web_sys::features::gen_HtmlImageElement::HtmlImageElement>>, images:std::rc::Rc<std::cell::RefCell<std::collections::HashMap<std::string::String, web_sys::features::gen_HtmlImageElement::HtmlImageElement>>>]`
|
= help: the trait `std::ops::FnMut<(web_sys::features::gen_Event::Event,)>` is not implemented for `[closure@src/lib.rs:64:50: 68:14 path:std::string::String, image:std::rc::Rc<std::cell::RefCell<web_sys::features::gen_HtmlImageElement::HtmlImageElement>>, images:std::rc::Rc<std::cell::RefCell<std::collections::HashMap<std::string::String, web_sys::features::gen_HtmlImageElement::HtmlImageElement>>>]`
= note: required for the cast to the object type `dyn std::ops::FnMut(web_sys::features::gen_Event::Event)`

error: aborting due to previous error; 1 warning emitted

For more information about this error, try `rustc --explain E0277`.
error: could not compile `wasm-game-of-life`.

To learn more, run the command again with --verbose.
Error: Compiling your crate to WebAssembly failed
Caused by: failed to execute `cargo build`: exited with exit code: 101
full command: "cargo" "build" "--lib" "--release" "--target" "wasm32-unknown-unknown"
  • エラーメッセージによると、クロージャが FnMut トレイトを実装しないのでエラー、とのこと
  • クロージャには FnOnce, FnMut, Fn の3種類あって、キャプチャした変数を move するクロージャは FnMut にならないらしい:

FnOnce を使用する

wasm-bindgenのドキュメント を見ていたら FnOnce を使用する例が出ていた。 Imageの onload も1度しか呼び出されないので FnMut じゃなくて FnOnce でよかった。 それを使用すると:

#[wasm_bindgen]
impl App {
pub fn load_image(&mut self, path: &str) {
let image = HtmlImageElement::new().unwrap();
let image = Rc::new(RefCell::new(image)); // <= Rc<RefCell>で保持
{
let path = String::from(path);
let images = self.images.clone();
let image_dup = image.clone();
let closure = Closure::once_into_js(move |_event: web_sys::Event| { // <= FnOnceを受け取れる
web_sys::console::log_1(&format!("Image loaded: {}", &path).into());
let image = Rc::try_unwrap(image_dup).unwrap().into_inner(); // Rc<RefCell> でラップしたimageを取り出す
images.borrow_mut().insert(path, image);
});
let cb = closure.as_ref().unchecked_ref();
image.borrow_mut().set_onload(Some(cb));
}
image.borrow_mut().set_src(&path);
}
}
  • Rc<RefCell>image をラップしておいて、他に参照している箇所はないはずなのでクロージャ内で Rc::try_unwrap()RefCell::into_inner() で取り出すことができる

上記で一応は動く。

さらに onerror も処理を追加するとしてそちらでも image を参照したいということはないと思うけど、仮にそういう場合があるとすると問題がある。 Closure::once_into_js の説明に書いてあるが、クロージャを呼び出さないと削除されないので、 image.set_onerror(None) などとしても image への参照が残ってしまうため try_unwrap() に失敗することになる。

また同様に読み込みエラーが発生すると onload が呼び出されないためリークしてしまう。

番外編:JavaScriptに任せる

Rust だけで解決するのは難しいんじゃないかと考えて、別の手段として画像の読み込みはJavaScriptに任せるという方法を思いついた。 Rustだと所有権の管理が難しいのでJavaScriptで読み込ませて、 onloadonerror で特定のメソッドを呼び出すようにすれば解決できる:

// Rust
#[wasm_bindgen]
pub struct App {
images: HashMap<String, HtmlImageElement>, //Rc<RefCell<>>,
load_image_fn: js_sys::Function,
}

#[wasm_bindgen]
impl App {
pub fn new(canvas_id: &str, load_image_fn: js_sys::Function) -> Self {
Self {
images: HashMap::new(),
load_image_fn,
}
}

pub fn load_image(&mut self, path: &str) {
let this = JsValue::NULL;
// JavaScript のコールバックで読み込んでもらう
self.load_image_fn.call1(&this, &JsValue::from(path))
.expect("cannot call load_image_fn");
}

// 画像読み込みに成功したら呼び出してもらう
pub fn onload_succeeded(&mut self, path: String, image: HtmlImageElement) {
self.images.insert(path, image);
}
}
// JS
import { App } from "wasm-game-of-life";

const app = App.new(
'game-of-life-canvas',
// load_image_fn
(path) => {
const image = new Image()
image.onload = (_event) => app.onload_succeeded(path, image) // <= 読み込めたらRustに渡す
image.onerror = (_event) => console.error(`Load image failed: $path`)
image.src = path
},
)
app.load_image('image.png')
  • メンバ変数を Rc<RefCell> で持つ必要がないのがよい
  • onload でJavaScriptからRustの所定のメソッド(onload_succeeded)を引数付きで呼び出す必要があるので、自由度はない

これ、wasmバインディングでJavaScriptが使えるからいいけど、同じような非同期処理をRustだけで解決しようとしたらどうしたらいいんだろうか。 クロージャや所有権やライフタイムが難しいし Rc<RefCell> まみれになるし…。

参考