はじめに
並列処理を安全かつ効率的に扱うことは、Rustの主な目標のひとつです。
並列処理といえば難しく不具合の起こりやすい危険なものというイメージがありますが、Rust はどのようにして安全な並列処理を提供しているのでしょうか。
(なお、ここでの安全とはデータ競合が発生しないことを指しています)
概要
Rust がどうやって安全な並列処理を提供しているのか、先に概要をまとめてみます。
- Rust はスレッドをつかってコードを並列で実行します
- 所有権の制約によりスレッド間でのデータの共有が行われないことが保証されるためデータ競合が起こり得ず安全です
- スレッド同士のデータの共有をチャンネルというメッセージの送受信器を経由して行う場合、スレッド内のデータはそのスレッドからしか変更されることがないため安全です
- スレッド間で可変なオブジェクトを共有する場合、データ競合が発生しない仕組みを利用していることをコンパイル時にチェックするので安全です
Rust の並列処理
Rust はスレッドをつかって並列処理を提供します。
新しいスレッドを実行するには、新しいスレッドで実行したい処理をクロージャで実装して std::thread::spawn
に渡します。
use std::thread;
fn main() {
thread::spawn(|| {
println!("hello from new thread");
});
println!("hello from main thread!");
}
並列処理と所有権
所有権とは「ある値はあるひとつの変数に所有され、この変数からしか読み込みも書き込みも行われない」というルールのことです。
これにより、値はただひとつの変数に束縛され、変数は宣言されたスコープに束縛されるため、結果的に値はただひとつのスコープに束縛されます。
並列処理に所有権のルールを適用すると、スレッドで利用されるデータはスレッドのクロージャのスコープ内に束縛され、他のスレッドと値を共有することはなくなります。
クロージャの中から外の変数を利用したい場合は move
キーワードを利用します。
クロージャの前に move
キーワードをつけると、クロージャ外の変数をクロージャ内で利用した場合に所有権が強制的に移動され、所有権の制約をうまく守ることができます。
use std::thread;
fn main() {
let x = vec![100, 200];
thread::spawn(move || {
println!("x is {:?}", x);
});
// x の所有権はクロージャ内へ移動しているのでメインスレッドからはもう利用できない
// println!("x is {:?}", x);
}
こうして所有権によってスレッド間でデータが共有されないように制限されるために安全に並列処理を実装することができます。
チャンネルを使ったスレッド間のデータ共有
move を使ってスレッド開始時にデータを与える方法は先ほど見ましたが、実行中にデータの送受信を行いたい場合はチャンネルというメッセージ送受信器を利用します。
スレッドへのデータの送受信はチャンネルを経由して行われるため、スレッド外から直接変更されるということがなく安全にデータを取り扱うことができます。
use std::thread;
use std::sync::mpsc;
fn main() {
// チャンネルの作成. sender がメッセージの送信機で receiver が受信機
// メインスレッド -> サブスレッドのチャンネル
let (sender1, receiver1) = mpsc::channel();
// メインスレッド <- サブスレッドのチャンネル
let (sender2, receiver2) = mpsc::channel();
// サブスレッドの開始
thread::spawn(move || {
// メインスレッドからメッセージの受信. 受信するまで処理を待つ
let val = receiver1.recv().unwrap();
println!("send from main thread. {}", val);
// メインスレッドへメッセージの送信
sender2.send("hi".to_string()).unwrap();
});
// サブスレッドへメッセージの送信
sender1.send("hello".to_string()).unwrap();
// サブスレッドからメッセージの受信
let val = receiver2.recv().unwrap();
println!("send from sub thread. {}", val);
}
データの共有と変更
Rust では所有権とチャンネルによってスレッドの安全性が保証されるということをここまでみてきましたが、オブジェクトを共有する方法も提供されています。
まずはカウンター用の変数をスレッド間で共有して加算していくサンプルを見てみます。
use std::sync::{Mutex, Arc};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}
counter
がカウンター用の変数で、Arc
がデータ共有用の型、Mutex
が共有データ編集のための型です。
Arc
は任意の値を内包する型で、Arc::clone
で内包した値への参照を作成してスレッド間でデータを共有できるようにします。
Arc は Atomically Reference Counted の略でその名の通りリファレンスカウント形式で管理された値への参照を作成し、参照がなくなれば値を解放するというはたらきをします。
Mutex
も任意の値を内包する型で、lock
で内包した値を編集するためのロックを取得してスレッド間で共有したデータの更新を行なえるようにします。
コンパイラはスレッド間で同じオブジェクトを参照するためにArc
の利用を、またその中身が可変であればさらにMutex
に内包されていることを要求します。1
すなわち、コンパイルに通る時点で安全なコードとなっています。
とはいえMutex を利用する以上、デッドロックが発生する可能性があったり、ロックを持ったままスレッドが死ぬことで Mutex が不正な状態になり正常にロックができなくなる可能性があったりと気をつけるべき問題が新たに出てくるので、利用範囲をできるだけ狭めて使っていくのがいいかと思います。2
おわりに
Rust はスレッド間でそもそもオブジェクトを共有しない、もしくは共有する場合は安全な仕組みの利用をコンパイル時にチェックするという方針を採ることで並列処理の安全性を提供していることがわかりました。
このパターンは例えば Go などの Rust 以外の言語でも(コンパイラのサポートはないとはいえ)活用できるものだと思うので、いずれの言語でも並列処理を実装する可能性のあるひとは知っておいて損はないのではないかと思います。