Rustコトハジメ

プログラミング言語Rustと競プロに関する情報をお届けします。

Arc<Mutex<T>>という形はデザインパターン

RustはGCのない言語なので、GCがあった時にふつうに書けていたコードが書けなくなります。その典型例はリスト・ツリー・グラフといった再帰的な構造です。

これに対してドキュメントや記事を読むと、以下のような型が出てきます。Haskellモナドスタックの再来と怯える人もいるかも知れません。

enum List {
    Cons(Rc<RefCell<i32>>, Rc<List>),
    Nil,
}
pub struct NodeRef<T>(Rc<RefCell<Node<T>>>);
type Link<T> = Option<Rc<RefCell<Node<T>>>>; 
type WeakLink<T> = Option<Weak<RefCell<Node<T>>>>;

大抵のドキュメントは、木構造を実装してみようというテーマで、上のような型がなぜ必要になるのか?を説明してから、そのマルチスレッド版であるArcMutexの説明がなされるのですが、私は「逆順で説明した方がわかりやすい」と感じました。この記事は、そのシリーズの第一弾ということになります。

私は、この参照カウント系<内部不変系<T>>という型は、Rustが、プログラマがそう設計することを期待したデザインパターンのようなものであると考えていて、マルチスレッド版からシングルスレッド版に向かって説明しても、本質は説明出来ると考えています。

今回はまず、なぜマルチスレッドで可変性を扱うのに、Arc<Mutex<T>>という型が必要になるのか小さな実験を拡張していくことで説明します。

実験1: 参照をスレッドに渡すことは出来ない

自分と別に1つのスレッドを立てて、その中で参照をとるというコードを考えます。

use std::thread;

fn main()
{
    let v = vec![1,2,3];
    let hdl = thread::spawn(|| {
        println!("{:?}", v);
    });
    hdl.join().unwrap();
}

このコードは失敗します。

error[E0373]: closure may outlive the current function, but it borrows `v`, which is owned by the current function
 --> prog.rs:6:29
  |
6 |     let hdl = thread::spawn(|| {
  |                             ^^ may outlive borrowed value `v`
7 |         println!("{:?}", v);
  |                          - `v` is borrowed here
help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword

これが何を言ってるかというと、コンパイラには、vがスコープを抜けてドロップをしたあとにもスレッドが動作し続けてるように見えているということです。人間の目にはこれがあり得ないことはわかるのですが、コンパイラは非常に保守的に動作するため、この結果となります。

コンパイラのアドバイスどおり、このクロージャをmoveクロージャにするとコンパイルは成功します。

実験2: 2スレッド立ち上げてみる

調子に乗ってスレッドを2つ(合計3つ)立ち上げてみましょう。

use std::thread;

fn main()
{
    let v = vec![1,2,3];
    let hdl0 = thread::spawn(move || {
        println!("{:?}", v);
    });
    let hdl1 = thread::spawn(move || {
        println!("{:?}", v);
    });
    
    hdl0.join().unwrap();
    hdl1.join().unwrap();
}

これはコンパイル出来ません。

error[E0382]: capture of moved value: `v`
  --> prog.rs:10:26
   |
6  |     let hdl0 = thread::spawn(move || {
   |                              ------- value moved (into closure) here
...
10 |         println!("{:?}", v);
   |                          ^ value captured here after move
   |
   = note: move occurs because `v` has type `std::vec::Vec<i32>`, which does not implement the `Copy` trait

何を言ってるかというと、vははじめのスレッドの中にmoveされてしまっているため、2つ目のスレッドではキャプチャ出来ませんといっています。noteで言ってるのは、「moveの動作がコピーになってれば問題ないよ」ということで、Vecではなく整数のような型であれば許すということです。

実験3: Arcを使って共有参照しか出来ない所有権を複製する

この問題を解決するためにArcがあります。ArcはAtomically Reference Countedの略で、スレッドセーフなリファレンスカウントを実装したスマートポインタです。RustではArc::cloneを使って所有権のクローンを作ります。つまり、以下のコードでは、共有参照しか出来ない所有権が3つ作られて、そのうち2つが生成されたスレッドにムーブされたことになります。リファレンスカウントという言葉から想像出来るように、中身はこれらの所有権がすべてスコープを抜けてドロップされた時に最後の一人が片付けをします。

これは、「ムーブしか出来ないから所有権をコピーしてそいつをムーブすることにした」というまるで逆ギレのようなやり方ですが、これはRustによって仕組まれたデザインパターンのようなものです。別途紹介するシングルスレッド版のRcでも同様の設計がなされています。ちなみに、clone()メソッドを使っても同じようにクローンすることが出来ますが、Arc::cloneRc::cloneという形で呼ぶのが推奨されていて、こうすることによって、中身を丸々コピーしているのではなく、所有権をコピーしていて軽量なのだという意図が伝わりやすくなります。

use std::thread;
use std::sync::Arc;

fn main()
{
    let v = Arc::new(vec![1,2,3]);
    
    let v0 = Arc::clone(&v);
    let hdl0 = thread::spawn(move || {        
        println!("{:?}", v0);
    });
    
    let v1 = Arc::clone(&v);
    let hdl1 = thread::spawn(move || {
        println!("{:?}", v1);
    });
    
    hdl0.join().unwrap();
    hdl1.join().unwrap();
}

このコードは通ります。リファレンスカウントについて、最終的には以下のようになります。

f:id:akiradeveloper529:20190107214201j:plain

実験4: 調子にノッて変更してみる

さきほどから、Arcは共有参照しかとれないといっているので当然動かないのだろうと想像は出来ると思いますが、v0v1から可変参照をとるようなコードを書いてみます。

use std::thread;
use std::sync::Arc;

fn main()
{
    let v = Arc::new(vec![1,2,3]);
    
    let v0 = Arc::clone(&v);
    let hdl0 = thread::spawn(move || {        
        v0.push(4);
    });
    
    let v1 = Arc::clone(&v);
    let hdl1 = thread::spawn(move || {
        v1.push(5);
    });
    
    hdl0.join().unwrap();
    hdl1.join().unwrap();
}

これは想像どおり、コンパイラに拒絶されます。

error[E0596]: cannot borrow immutable borrowed content as mutable
  --> prog.rs:10:9
   |
10 |         v0.push(4);
   |         ^^ cannot borrow as mutable

error[E0596]: cannot borrow immutable borrowed content as mutable
  --> prog.rs:15:9
   |
15 |         v1.push(5);
   |         ^^ cannot borrow as mutable

実験5: 共有参照でも変更するためにMutexを使う

最後の実験になります。実験4の失敗は、Arcは、包んでるものに対して共有参照しか返せないのが原因でした。では「共有参照でも変更出来るようにすりゃいいじゃん」という再度逆ギレによってMutexを導入します。

use std::thread;
use std::sync::{Arc, Mutex};

fn main()
{
    let v = Arc::new(Mutex::new(vec![1,2,3]));
    
    let v0 = Arc::clone(&v);
    let hdl0 = thread::spawn(move || {        
        v0.lock().unwrap().push(4);
    });
    
    let v1 = Arc::clone(&v);
    let hdl1 = thread::spawn(move || {
        v1.lock().unwrap().push(5);
    });
    
    hdl0.join().unwrap();
    hdl1.join().unwrap();
    
    println!("{:?}", v);
}
Mutex { data: [1, 2, 3, 5, 4] }

Mutexがなぜ共有参照しかなくても更新が出来るかというと実行時にロックをとらないと値に触ることが出来ないため、実質的に1つのスレッドからしか書き込めないからです。このように、コンパイラによる静的なボローチェッカーを回避し、それを実行時に委ねる仕組みを内部可変性(Internal Mutability)といいます。これはその名のとおり、ボローチェッカーからは共有参照に見えるけど、実行時のロジックで安全性を確保する形で可変性を実現するという仕組みです。このシングルスレッド版が冒頭で触れたRefCellとなります。

なぜ、Arc<RefCell<T>>ではだめなのかは、

std::sync::Arc - Rust

に委ねることにします。端的にいうと、RefCellSyncではないからです。