JavaとRust

JavaとRust、並行処理――夜中に呼び出されるバグの種類が変わる

議論参加:梧桐 (編集長・進行) / 灰島 徹 (基幹系アーキテクト) / 桐生 みなも (Rustを本番運用するSRE) / 汐見 レン (証券システムの低レイテンシ実装者)

前回まで、所有権設計の語彙メモリとリソースを見てきた。今回は並行処理を扱う。

この領域には、Rustについての最も強い宣伝文句がある。「コンパイルが通れば並行バグはない」。これは誤りである。 そして誤りであることを示すのが、この回の主な仕事になる。

Rustが消すのはデータ競合であって、インシデントではない。

Javaの仮想スレッドが解いたのは待ちのコストであって、スレッド安全性ではない。

両者は同じ問題を解く競合ではない。変わるのは、夜中に呼び出されるバグの種類である。

掲載するコードと数値は、すべて実際にコンパイル・実行して確認した(macOS / Apple Silicon、JDK 25.0.4.1、rustc 1.96.0)。

この回で出てくるRustの書き方

本文に入る前に、今回のコードで使う記法をまとめておく。第3回までに出たもの(let / let mut&&mutVec<T>Stringuse_ 始まりの変数名)は省き、並行処理で新しく出てくるものだけを挙げる。

書き方意味Javaで言えば
thread::spawn(...)新しいOSスレッドを起こし、渡した処理を走らせるnew Thread(...).start()
|| { ... }引数なしのクロージャ(その場で書く無名の関数)() -> { ... } のラムダ
move || { ... }クロージャが、外から使う値の所有権ごと受け取る対応する概念がない(後述)
.join()そのスレッドが終わるのを待つThread.join()
Mutex<T>中身とロックを一体にした箱守りたいオブジェクト+それ用のロック
.lock()ロックを取る。他が持っていれば取れるまで待つsynchronized (x) { に入る
Arc<T>一つの値を複数のスレッドで共有するための包み参照そのもの(Javaでは意識しない)
.unwrap()失敗しうる結果から中身を取り出す。失敗していればその場で異常終了する例外を捕まえずに落とすのに近い
"a".into()借りている文字列 &str を、所有する String に変換する型変換
format!("key{i}")変数を埋め込んで文字列を作る"key" + i
{:?}デバッグ用の表示形式Arrays.toString() に近い出力

Arcatomically reference counted(原子的に参照カウントする)の略である。第1回で出た Rc の、スレッドをまたげる版だと思えばよい。なぜ二種類あるのかは、後の節で扱う。

まず、Javaで静かに壊してみる

同期していないHashMapを、4スレッドから同時に書き換える。それぞれ1万件、合計4万件を入れる。

Map<String,Integer> counts = new HashMap<>();      // 同期していない
ExecutorService pool = Executors.newFixedThreadPool(4);
for (int i = 0; i < 4; i++) {
    final int n = i;
    pool.submit(() -> { for (int k=0;k<10000;k++) counts.put("k"+n+"_"+k, k); });
}
pool.shutdown(); pool.awaitTermination(10, TimeUnit.SECONDS);
System.out.println("期待40000件 → 実際: " + counts.size());

3回実行した結果である。

期待40000件 → 実際: 19805
期待40000件 → 実際: 20334
期待40000件 → 実際: 15132

半分近くが消え、しかも毎回違う。 例外は一つも出ていない。テストが通ることもあれば通らないこともあり、負荷をかけた本番でだけ壊れる、という典型的な形である。

これはJavaが悪いのではなく、書き手が同期を書かなかっただけである。同期する形に直せば、当然きちんと動く。

Map<String,Integer> counts = Collections.synchronizedMap(new HashMap<>());
同期した場合 → 40000
同期した場合 → 40000

Javaに手段がないのではない。 問題は、書かなくてもコンパイルが通ってしまうことである。

まったく同じことをRustで書いてみる。 4スレッド、それぞれ1万件、同期していないマップへ。

use std::collections::HashMap;
use std::thread;

fn main() {
    let mut counts: HashMap<String, i32> = HashMap::new();
    let mut handles = vec![];

    for n in 0..4 {
        handles.push(thread::spawn(move || {
            for k in 0..10000 { counts.insert(format!("k{n}_{k}"), k); }
        }));
    }
    for h in handles { h.join().unwrap(); }
    println!("期待40000件 → 実際: {}", counts.len());
}
error[E0382]: borrow of moved value: `counts`
   |
 5 |     let mut counts: HashMap<String, i32> = HashMap::new();
   |         ---------- move occurs because `counts` has type `HashMap<String, i32>`,
   |                    which does not implement the `Copy` trait
 8 |     for n in 0..4 {
   |     ------------- inside of this loop
 9 |         handles.push(thread::spawn(move || {
   |                                    ------- value moved into closure here,
   |                                            in previous iteration of loop
14 |     println!("期待40000件 → 実際: {}", counts.len());
   |                                        ^^^^^^ value borrowed here after move

そもそも2本目のスレッドが作れない。 1本目に counts を渡した時点で持ち主が移っており、ループの2周目で渡すものが残っていない。Javaでは4本のスレッドが同じマップを掴んで走り出し、実行してみるまで壊れたことが分からなかった。Rustでは、その状況を書くことすらできない

読み方を分解しておく。thread::spawn(...) が新しいスレッドを起こす部分で、Javaの new Thread(...).start() にあたる。渡している move || { ... } が、そのスレッドで走らせる処理――Javaのラムダに相当するもの――である。

問題は先頭の move である。これは「このクロージャの中で使う外の変数を、借りるのではなく所有権ごと持っていく」という指定になる。 マップの持ち主が1本目のスレッドへ移るので、2本目にも main にも、もう渡せるものが無い。

第1回でやった所有権の規則が、そのまま並行処理の安全性になっている。マップを別スレッドへ渡したなら、こちらはもう持ち主ではない。 4つのスレッドが同じデータを同時に書き換える状況が、そもそも作れない。

なぜ move を書かされるのか

「渡してしまうから駄目なら、move を消して借りるだけにすればいい」と考えるのが自然だろう。やってみる。

handles.push(thread::spawn(|| {                    // move を書かない
    for k in 0..10000 { counts.insert(format!("k{n}_{k}"), k); }
}));

結果は、エラーが1つ減るどころか4つに増える

error[E0373]: closure may outlive the current function, but it borrows `counts`
error[E0499]: cannot borrow `counts` as mutable more than once at a time
error[E0373]: closure may outlive the current function, but it borrows `n`
error[E0502]: cannot borrow `counts` as immutable because it is also borrowed as mutable
error: aborting due to 4 previous errors

一つずつ性格が違う。順に見ていく。

E0373――スレッドは、起こした関数より長生きしうる。

error[E0373]: closure may outlive the current function, but it borrows `counts`,
              which is owned by the current function
   |
   = note: function requires argument type to outlive `'static`
help: to force the closure to take ownership of `counts` (and any other
      referenced variables), use the `move` keyword

main が先に終われば、借りていた counts は既に片付いている。Javaならこの状況でもオブジェクトはGCに守られて生き残るが、Rustには変数のスコープで片付ける規則しかない。だから「借りたまま別スレッドへ渡す」は原則として禁じられる。同じ理由で、ループ変数の n についても同じエラーが出ている(3つ目)。

E0499――そして、ここが今回の主題そのものである。

error[E0499]: cannot borrow `counts` as mutable more than once at a time
   |
 9 |     handles.push(thread::spawn(|| {
   |                                ^^ `counts` was mutably borrowed here
   |                                   in the previous iteration of the loop
10 |         for k in 0..10000 { counts.insert(format!("k{n}_{k}"), k); }
   |                             ------ borrows occur due to use of `counts` in closure

「書き換えるための借用は、同時に一つしか作れない」――第1回で見た、あの規則である。insert は中身を書き換えるので &mut counts が要る。1本目のスレッドがそれを持っているあいだに、2本目が同じものを要求した。だから止まった。

つまり、Javaで4万件が2万件に減った現象と、このエラーは同じことを指している。 4つのスレッドが同じマップを同時に書き換えようとしている、という一点である。Javaはそれを実行時に、しかも静かに壊れる形で表した。Rustはコンパイル時に、借用の規則違反として表した。並行処理のための特別な検査があるのではなく、第1回の所有権と借用の規則がそのまま効いている。

4つ目の E0502 は、その帰結である。スレッドに &mut を渡したままなので、最後の counts.len()(読むための借用)も作れない。

move は、これらをまとめて解消する宣言にあたる。借りるのをやめて所有権ごと渡してしまえば、寿命の問題も、借用が重なる問題も消える。ただし今度は「渡したら手元に残らない」という最初のエラーに戻る。この行き止まりを抜けるために ArcMutex が要る、という順序になっている。

なお、Javaのラムダには move に対応する概念がない。Javaでは実質的にすべてが「参照を借りたまま渡す」形であり、その安全性はGCが引き受けているからである。

正しく共有する形

とはいえ、複数スレッドで一つのマップを共有したい場面は当然ある。Javaで Collections.synchronizedMap に替えたのと同じことを、Rustでもやる。

let counts = Arc::new(Mutex::new(HashMap::new()));   // 共有したいマップを二重に包む
let mut handles = vec![];

for n in 0..4 {
    let c = Arc::clone(&counts);                     // 包みだけを複製して渡す
    handles.push(thread::spawn(move || {
        for k in 0..10000 { c.lock().unwrap().insert(format!("k{n}_{k}"), k); }
    }));
}
for h in handles { h.join().unwrap(); }              // 4本とも終わるのを待つ
println!("期待40000件 → 実際: {}", counts.lock().unwrap().len());
期待40000件 → 実際: 40000
期待40000件 → 実際: 40000
期待40000件 → 実際: 40000

Javaで同期を書いた場合と、同じ結果になる。 違いは結果ではなく、同期を書き忘れる道が用意されていないことである。

包みが二重になっているのが、最初は読みにくいところである。内側から順に意味がある。

役割
HashMap実際に共有したい中身
Mutex<...>中身をロック無しでは触れないようにする
Arc<...>その箱を複数スレッドから指せるようにする

そして先ほど詰まった「2本目に渡すものが無い」問題は、Arc が解いている。Arc::clone(&counts) は、中のマップを複製しているのではない。複製されるのは包みだけで、指している先は同じ一つのマップである。だから4本ぶん配れる。Javaで参照をもう一つの変数に代入するのに近いが、Rustでは所有権があるため「指す人が増えた」ことを明示的に記録する必要がある。Arc はその人数を数えていて、最後の一人が居なくなったときに中身を片付ける。

なお counts.clone() と書いても同じものが得られるが、慣習として Arc::clone(&counts) と書く。「中身をコピーしたのではなく、共有者を一人増やしただけだ」と読み手に示すためである。

ロックと中身が、型で束ねられている

注目すべきは c.lock().unwrap().insert(...) という形である。ロックを取る操作を経由しないと、中のマップに手が届かない。 ロックを飛ばして c.insert(...) と書くと、そんなメソッドは無いとコンパイラに言われる。Javaの synchronized は、ロックと保護対象の対応が規約でしかなく、うっかりロックの外で触っても書ける。Rustは型がその二つを束ねている。

.lock() が返すのは中身そのものではなく、ロックを持っている間だけ中身に触れる証明書である(ガード と呼ばれる)。これがいつ捨てられるかが、そのままロックの解放時刻になる。synchronized ブロックの } に相当するものが、Rustでは見えない形で存在している、ということである。

上のコードでは c.lock().unwrap().insert(...) を一つの文として書いているので、その文が終わった時点でガードは捨てられ、ロックは解放される。実際に確かめると、こうなる。

d.lock().unwrap().push(1);          // 名前を付けずに使い捨てる
println!("同じ文が終わった後にロックできるか -> {}", d.try_lock().is_ok());
同じ文が終わった後にロックできるか -> true

一方、ガードに名前を付けて変数に取ると、その変数が生きている間ずっとロックを持ち続ける

let _g = m.lock().unwrap();         // 名前を付けて保持
println!("直後に再ロックできるか -> {}", m.try_lock().is_ok());
直後に再ロックできるか -> false

この違いは後のデッドロックの節で効いてくるので、覚えておいてほしい。

.unwrap() は何をしているのか

ここまで何度も出てきた .unwrap() にも触れておく。.lock()失敗しうる操作として定義されていて、成功か失敗かを包んだ値を返す。.unwrap() は「成功しているはずだから中身を出せ、失敗していたらその場で落とせ」という指定である。

つまり lock().unwrap() は、エラー処理を書かずに済ませている書き方にあたる。短い例では読みやすさのために使うが、本番のコードでそのまま置くと、後の「壊れた状態を、隠すか、止めるか」の節で見るとおり、プロセスが落ちる原因になりうる。

Arc で包めば何でも共有できる、わけではない

中身がスレッド間で共有しても安全な型でなければ、やはり弾かれる。

let a = Arc::new(RefCell::new(0));   // Arcで包んだが中身はRefCell
let b = Arc::clone(&a);
thread::spawn(move || { *b.borrow_mut() += 1; });
error[E0277]: `RefCell<i32>` cannot be shared between threads safely
  = help: the trait `Sync` is not implemented for `RefCell<i32>`

第1回で見た RefCell――借用の検査を実行時に回す入れ物――は、単独のスレッドで使う前提で作られている。中で借用の状態を数えているのだが、その数え方が同時アクセスを想定していない。

ここで出てくる Sync は、その型を複数のスレッドから同時に見てよいかを表す印である。これと対になる Send という印もあり、こちらはその型を別のスレッドへ渡してよいかを表す。両方ともメソッドを持たない印だけの trait で、条件を満たす型にコンパイラが自動的に付ける。

違いは、エラーメッセージの動詞にそのまま出る。

let r = Rc::new(Mutex::new(0));                 // Arc ではなく Rc
thread::spawn(move || { let _ = r.lock(); });
error[E0277]: `Rc<Mutex<i32>>` cannot be sent between threads safely
    = help: the trait `Send` is not implemented for `Rc<Mutex<i32>>`

Rc渡せないSend でない)。第1回で見たとおり Rc は参照カウントを普通の足し算で増減させるので、複数スレッドから同時に増減されると数を取りこぼす。Arc はここを不可分な操作にした版で、そのぶん少し遅い。Rustが RcArc を分けているのは、単一スレッドで使う場合にその追加コストを払わせないためである。Javaにはこの区別がなく、参照カウント自体が存在しない。

一方 RefCell共有できないSync でない)。渡すこと自体は問題ないが、二つのスレッドから同時に見られると壊れる。

意味満たさない代表例
Send別のスレッドへ渡してよいRc<T>
Sync複数のスレッドから同時に見てよいRefCell<T>

共有してよい型かどうかを、コンパイラが型のレベルで区別しているわけである。Javaでは、あるクラスがスレッドセーフかどうかはドキュメントに書いてあるだけで、間違って使ってもコンパイルは通る。

灰島徹(基幹系アーキテクト)

「このロックは何を守っているのか」がコードから読めない、というのは実務でよくあります。答えはWikiの奥か、当時いた人の頭の中にしかない。型に書いてあるというのは、5年後のレビューでは効きます。

だが、Rustでもデッドロックは書ける

ここが今回いちばん重要な節である。

二つのロックを、二つのスレッドが逆の順序で取る。古典的なデッドロックである。

let a = Arc::new(Mutex::new(0));      // ロックA
let b = Arc::new(Mutex::new(0));      // ロックB

let (a1, b1) = (Arc::clone(&a), Arc::clone(&b));   // スレッド1へ渡す分
let (a2, b2) = (Arc::clone(&a), Arc::clone(&b));   // スレッド2へ渡す分

thread::spawn(move || {
    let _x = a1.lock().unwrap();      // A を取る
    thread::sleep(Duration::from_millis(50));
    let _y = b1.lock().unwrap();      // B を待つ
    println!("スレッド1完了");
});
thread::spawn(move || {
    let _y = b2.lock().unwrap();      // B を取る
    thread::sleep(Duration::from_millis(50));
    let _x = a2.lock().unwrap();      // A を待つ → デッドロック
    println!("スレッド2完了");
});
2スレッドを起動した。3秒待つ…
3秒経過。どちらも完了メッセージを出していない=デッドロック

コンパイルは何の文句も言わず通り、実行すると止まった。 完了メッセージはどちらも出ていない。

ここで、先ほどのガードの話が効いてくる。let _x = a1.lock().unwrap();名前を付けて受けているので、_x が生きているあいだ――つまりクロージャの } まで――ロックAは握られたままになる。_ で始めているのは第3回で見た「使わないが持っていることに意味がある値」の書き方で、ここではまさに持ち続けること自体が目的である。

スレッド1がAを持ったままBを待ち、スレッド2がBを持ったままAを待つ。どちらも相手が離すまで動けない。

Arc::clone を四つ作っているのも、move の帰結である。クロージャは所有権ごと持っていくので、二つのスレッドに同じ a を渡すことはできない。共有者を人数分だけ用意してから配る、という手順になる。

ロックの寿命については、コンパイラが口を出す

デッドロックそのものは検出されないが、その手前の「ロックをいつ離すか」については、コンパイラが一つ罠を塞いでいる。

ガードを捨てるつもりで let _ = ... と書くと、こうなる。

let _ = m.lock().unwrap();          // 名前を付けずに捨てる
error: non-binding let on a synchronization lock
  |
  = note: `#[deny(let_underscore_lock)]` (part of `#[deny(let_underscore)]`) on by default
help: consider binding to an unused variable to avoid immediately dropping the value
help: consider immediately dropping the value

_x_ は一文字違いだが、意味が正反対である。_x持ち続ける_その場で捨てる。ロックを取ったつもりで実は取れていない、という事故になりやすいので、コンパイラがエラーとして止めている(警告ではなくエラーであることに注意したい)。

ロックの寿命については機械が見ているが、ロックを取る順序については何も見ていない。 この節の線引きは、そこにある。

議論で確定した線引きを、表にしておく。

Rustがコンパイル時に防ぐRustが防がない
データ競合(同じデータへの同時書き換え)
スレッド境界を越えた Rc の持ち出し
デッドロック×
ライブロック×
論理的な競合状態(順序の想定違い)×
分散環境の整合性×
Rc 循環によるメモリリーク×
panicによる停止×

桐生みなも(Rustを本番運用するSRE)

夜中に呼ばれなくなるわけではありません。呼ばれる理由が変わるだけです。データ競合で呼ばれることは減りましたが、デッドロックと論理的な競合は普通に残っています。そこを期待して入れると、期待の外し方が大きい。

仮想スレッドとasyncは、同じ問題を解いていない

もう一つ、混同されやすいところを分ける。

Javaの仮想スレッドはJDK 21で正式機能になった(JEP 444)。1万個のタスクをそれぞれ100ミリ秒ブロックさせてみる。

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++) {
        executor.submit(() -> {
            Thread.sleep(Duration.ofMillis(100));   // 普通にブロックして書ける
            return null;
        });
    }
}
1万タスク×100ms待ちを完走 → 所要 138 ms

1万個の待ちが実質100ミリ秒強で終わっている。 そして書き方は、普通にブロックする同期コードのままである。コールバックも async も出てこない。

これが仮想スレッドの本質である。解いたのは「待ちのコスト」であって、スレッド安全性ではない。 前節でHashMapが壊れた問題は、仮想スレッドを使っても一切解決しない。

「待ち」ではなく「計算」をさせると、この性格がはっきり出る。同じ計算量のタスクを100個、仮想スレッドとOSスレッド固定数で流してみる。

CPUを使う100タスク(仮想スレッド): 62 ms
同じものをOSスレッド固定数で   : 49 ms

待たないタスクでは、仮想スレッドに優位はない。 当然で、CPUは増えないからである。仮想スレッドが効くのは、待っている間に別の仕事へ切り替えられる場面――つまりI/Oが絡む場面に限られる。

汐見レン(証券システムの低レイテンシ実装者)

仮想スレッドは書き味の革命ですが、裏返すと「これはスレッドセーフか?」と考えるきっかけが減ります。同期コードのように見えるものが、実は並行に走っている。問いを立てるタイミングが後ろにずれる、という副作用はあると思います。

Rust側は async/await を使う。ただしこちらには、Javaから来た人がぶつかる壁がある。

関数の色

async を付けた関数は、普通の関数から直接は呼べない。

async fn fetch() -> i32 { 42 }

fn sync_caller() -> i32 {
    fetch()          // ← 同期関数からそのまま呼べるか?
}
error[E0308]: mismatched types
  |
4 |     fetch()
  |     ^^^^^^^ expected `i32`, found future

fetch()i32 を返すと書いてあるのに、expected i32, found future――「整数のはずが future が来た」と言われている。

async を付けた関数が返すのは、値ではない。「これから走る仕事」そのものである。これを future と呼ぶ。Javaの Future と名前は同じだが、性格が違う。Javaの Future は「もう走り始めている処理の受け取り口」だが、Rustの future は渡すまで一切走らない。実行基盤(ランタイム)に預けて初めて動き出す。

そして、この future から値を取り出す .await は、async な文脈の中でしか書けない。結果として、async な関数を呼びたければ呼び出す側も async になり、その呼び出し元も async になり……と連鎖する。俗に「関数の色」と呼ばれる問題である。ある関数が「同期の色」なのか「非同期の色」なのかが型に現れ、混ぜられない。

Javaの仮想スレッドには、この分断がない。前節のコードが Thread.sleep を普通に書けていたとおり、待つ処理と待たない処理が同じ書き方のまま同居できる。Rustが二つの世界を型で分けたのに対し、Javaは一つに畳んだ、という違いである。

もう一つ、Rustには標準の実行基盤が無い。どのランタイムを使うかは利用者が選ぶ。Javaで言えば、スレッドプールの実装を自分で選んで持ち込むようなもので、ライブラリごとに前提とするランタイムが違うと組み合わせられないことがある。

さらに、第2回で見た dyn による動的ディスパッチが、async では使えない。

trait Fetcher {
    async fn fetch(&self) -> i32;      // トレイト内のasync fn(1.75で安定)
}
fn take(_f: &dyn Fetcher) {}           // ← 動的ディスパッチにできるか?
error[E0038]: the trait `Fetcher` is not dyn compatible

インターフェースと多態性を中心に設計してきたJava経験者には、ここが大きな摩擦になる。Rustの2026年ロードマップ「Just add async」は、この動的ディスパッチ、trait実装での Send 境界、async再帰、スコープ付き借用、非同期のリソース解放を未整備の項目として自ら列挙している

両方とも、まだ途中である

公平を期すために、Java側の現在地も書いておく。

機能状態
仮想スレッド(JEP 444)JDK 21で正式機能
synchronized のピニング解消(JEP 491)JDK 24。原文は「ほぼすべての場合を解消する」であり、全廃ではない
Scoped Values(JEP 506)JDK 25で正式機能
構造化並行性(JEP 533)JDK 27でも第7プレビュー。正式化JEPはJDK 28のターゲットにも入っていない

Rustは非同期の言語機能に穴を残し、Javaは構造化並行性を7回プレビューし続けている。どちらも「まだ答えが出ていない」領域を抱えている、というのが2026年9月時点の正直な姿である。

壊れた状態を、隠すか、止めるか

議論で出た論点のうち、最も設計哲学が分かれたのがここだった。

ロックを持ったまま異常終了したら、その共有データはどうなるか。同じ状況を両方で作ってみる。

Javaの場合。synchronized ブロックの中で例外を投げる。

Thread t = new Thread(() -> {
    synchronized (data) {
        data.add(4);
        throw new RuntimeException("書き換えの途中で異常終了");
    }
});
t.start(); t.join();
synchronized (data) {                      // 例外の後、ロックは解放されている
    System.out.println("  普通に読めた: " + data);
}
  スレッドが落ちた: 書き換えの途中で異常終了
別スレッドがpanicした後、ロックを取ると:
  普通に読めた: [1, 2, 3, 4]

ロックは言語仕様どおり解放され、何事もなかったように読める。 ただし中身は、書き換えの途中で止まった状態そのものである。

Rustの場合。

let _ = thread::spawn(move || {
    let mut v = d.lock().unwrap();
    v.push(4);
    panic!("書き換えの途中で異常終了");   // 中途半端な状態で落ちる
}).join();

match data.lock() {
    Ok(v)  => println!("  普通に読めた: {:?}", v),
    Err(e) => println!("  拒否された(PoisonError)。中身は取り出せる: {:?}", e.into_inner()),
}
  拒否された(PoisonError)。中身は取り出せる: [1, 2, 3, 4]

Rustは拒否した。 ロックを持っていたスレッドがpanicしたため、そのMutexは「毒された」状態になり、次に lock() した人にはエラーが返る。中身を取り出す手段(into_inner)は用意されているが、「壊れているかもしれない」と知ったうえで明示的に取りに行く形になる。

コードのほうも読み解いておく。match data.lock() { Ok(v) => ..., Err(e) => ... } は、lock() が返す成功/失敗を両方とも書かせる分岐である。第2回で見た網羅性検査が効いていて、Err の腕を書き忘れればコンパイルが通らない。

そしてここが、先ほど触れた .unwrap() の話に繋がる。もしこの場面で data.lock().unwrap() と書いていたら、どうなるか。

thread 'main' panicked at:
called `Result::unwrap()` on an `Err` value: PoisonError { .. }

呼んだ側も落ちる。 毒されたMutexに unwrap() で触ると、失敗が連鎖する。本稿でここまで使ってきた lock().unwrap() という短い書き方は、裏を返せば「他のスレッドが壊したら自分も落ちる」という選択を既定にしている、ということである。

どちらが正しいという話ではない。

壊れた状態を隠して先へ進むか、顕在化させて止めるか。 Javaは継続性を、Rustは整合性を既定にした。

決済処理のように途中状態で進むと困る領域ではRustの既定が合い、一部が壊れても全体を止めたくないシステムではJavaの既定が合う。

型が語ることと、規約に残ること

第2回から続く主題がここにも出る。

Arc<Mutex<T>> という型は、次に読む人へ「これは共有される状態で、触るにはロックが要る」と伝える。Javaの synchronized は、その情報をコードの外に置く。

ただし、型が並行の意図を全部語るわけではまったくない。議論で釘が刺された点である。ロックの粒度――一つの大きなロックで囲むか、複数の細かいロックに分けるか――は型に現れない。この判断は、Javaと同じく規約とレビューの領域に残る。型が語れるのは、データ競合という特定のクラスについてだけである。

この回で断定していないこと

  • 性能の比較はしていない。 仮想スレッドの「1万タスクを138ミリ秒」は、待つだけのタスクを本稿の環境で流した一例であり、Rustの非同期実行基盤と比べた数値ではない。
  • Javaの4万件が半分に減った数値は、同期を書かなかった場合に「壊れること」を示す例であって、壊れ方の程度を一般化するものではない。実行のたびに変わる。
  • どちらの並行モデルが優れているかは論じていない。Rustの async は言語機能に穴を残し、Javaの構造化並行性は7回目のプレビューにある。どちらも途上である。
  • ロック粒度の設計や、Rustのコンパイラエラーの難解さが実務でどれだけの摩擦になるかは、定量的には確認していない。議論では「難解なエラーが unsafeunwrap での迂回を誘発しうる」という懸念が出たが、本稿では検証していない。
  • 構造化並行性がJavaで正式化される時期、Rustの非同期動的ディスパッチが安定する時期は、どちらも公表されていない

梧桐(編集長・進行)

今回の持ち帰りは、線引きそのものです。Rustが消すのはデータ競合であって、インシデントではない。仮想スレッドが解いたのは待ちのコストであって、安全性ではない。 どちらも「何を解いた道具なのか」を取り違えなければ、正しく使えます。次回からは、それが実際にどの現場で使われているのかを、一次情報で確認していきます。

出典を読むときのポイント

キーワード:#データ競合#仮想スレッド#Send

出典

コメント