Rustで並行処理の第一歩を踏み出そう!
はじめに:なぜマルチスレッド?
コンピュータの性能向上は、コア数の増加によってもたらされることが多くなりました。複数の処理を同時に実行する並行処理は、アプリケーションの応答性を高めたり、重い計算をバックグラウンドで実行したりするのに役立ちます。
Rustでは、標準ライブラリの std::thread
モジュールを使って、OSレベルのスレッドを比較的簡単に扱うことができます。今回は、この std::thread
を使ったスレッドの基本的な作成方法と、スレッド間でデータを安全に共有するための同期メカニズムについて学びます。
スレッドを生成する:std::thread::spawn
新しいスレッドを生成するには、std::thread::spawn
関数を使用します。この関数は、引数としてクロージャを受け取ります。このクロージャ内に、新しいスレッドで実行したい処理を記述します。
use std::thread;
use std::time::Duration;
fn main() { // 新しいスレッドを生成 let handle = thread::spawn(|| { for i in 1..=5 { println!(" 別スレッド: カウント {}", i); thread::sleep(Duration::from_millis(500)); // 少し待機 } }); // メインスレッドの処理 for i in 1..=3 { println!(" メインスレッド: カウント {}", i); thread::sleep(Duration::from_millis(300)); // 少し待機 } // spawnされたスレッドが終了するのを待つ // これがないと、メインスレッドが先に終了し、 // spawnされたスレッドの処理が途中で打ち切られる可能性がある handle.join().unwrap(); println!(" すべてのスレッドが終了しました!");
}
thread::spawn
は JoinHandle
という型の値を返します。このハンドルの join()
メソッドを呼び出すことで、対応するスレッドが終了するまで現在のスレッド(この例ではメインスレッド)の実行をブロック(待機)させることができます。
move
クロージャの必要性
多くの場合、スレッドに渡すクロージャ内で、メインスレッドで定義された変数の値を使いたいことがあります。しかし、Rustの所有権システムのため、そのままでは変数を共有できません。
このような場合は、クロージャの前に move
キーワードを付けます。これにより、クロージャが使用する変数の所有権がクロージャ自身に移動(move)され、スレッドセーフな形で値を扱えるようになります。
use std::thread;
fn main() { let v = vec![1, 2, 3]; // vの所有権をクロージャに移動させる let handle = thread::spawn(move || { println!(" 別スレッドでベクタを参照: {:?}", v); }); // ここではもうvは使えない(所有権が移動したため) // drop(v); // これはコンパイルエラーになる handle.join().unwrap();
}
スレッド間のデータ共有と同期
複数のスレッドから同じデータにアクセスしようとすると、データ競合(Data Race)と呼ばれる問題が発生する可能性があります。これは、複数のスレッドが同時に同じメモリ位置にアクセスし、少なくとも1つが書き込みを行おうとする状況で、予測不能な結果を引き起こします。
Rustの所有権・借用システムは、コンパイル時に多くのデータ競合を防いでくれますが、複数のスレッド間で可変な状態を安全に共有するには、特別な仕組みが必要です。ここでは代表的な2つの型を紹介します。
1. Mutex (ミューテックス)
std::sync::Mutex<T>
は、「相互排他(Mutual Exclusion)」を実現するための仕組みです。Mutexは、あるデータ T
へのアクセスを、一度に一つのスレッドだけに許可します。
データにアクセスしたいスレッドは、まずMutexの lock()
メソッドを呼び出して「ロック」を取得しようとします。ロックが成功すると、MutexGuard
というスマートポインタが返され、これを通じてデータにアクセスできます。他のスレッドが既にロックを取得している場合、lock()
を呼び出したスレッドはロックが解放されるまで待機します。
MutexGuard
はスコープを抜けると自動的にロックを解放するため、手動でアンロックする必要はありません(RAIIパターン)。
use std::sync::Mutex;
use std::thread;
fn main() { // Mutexで保護されたカウンターを作成 let counter = Mutex::new(0); let mut handles = vec![]; for _ in 0..10 { // 各スレッドでカウンターをインクリメント let handle = thread::spawn(|| { // ロックを取得。取得できるまで待機する let mut num = counter.lock().unwrap(); // MutexGuardを通じてデータにアクセス *num += 1; // MutexGuardがスコープを抜けるときに自動でロック解除される }); handles.push(handle); } // 全てのスレッドが終了するのを待つ for handle in handles { handle.join().unwrap(); } // 最終的なカウンターの値を出力 // メインスレッドもロックを取得する必要がある println!(" 結果: {}", *counter.lock().unwrap()); // => 結果: 10
}
複数のMutexを使用する場合、スレッドがお互いのロックを待ち合ってしまい、処理が進まなくなる「デッドロック」が発生する可能性があります。Mutexのロック順序を一貫させるなどの注意が必要です。
2. Arc (アトミック参照カウント)
Mutex
はデータへのアクセスを排他的にしますが、Mutex
自体の所有権は一つのスレッドしか持てません。複数のスレッドで同じ Mutex
を共有したい場合、どうすればよいでしょうか?
ここで登場するのが std::sync::Arc<T>
(Atomically Reference Counted) です。Arc<T>
は、Rc<T>
(参照カウント) のスレッドセーフ版です。これを使うことで、ある値 T
の所有権を複数のスレッド間で安全に共有できます。
Arc
は、値への参照がいくつ存在するかをアトミックに(不可分操作として)カウントします。最後の参照がなくなったときに、値は破棄されます。
Mutex
と Arc
を組み合わせる (Arc<Mutex<T>>
) ことで、複数のスレッドからアクセス可能な可変データを安全に扱うことができます。
use std::sync::{Arc, Mutex};
use std::thread;
fn main() { // Arcを使ってMutexの所有権を共有できるようにする let counter = Arc::new(Mutex::new(0)); let mut handles = vec![]; for _ in 0..10 { // counterの参照をクローンする (参照カウントが増える) let counter_clone = Arc::clone(&counter); let handle = thread::spawn(move || { // クローンされたArcを通じてMutexにアクセスし、ロックを取得 let mut num = counter_clone.lock().unwrap(); *num += 1; }); handles.push(handle); } // 全てのスレッドが終了するのを待つ for handle in handles { handle.join().unwrap(); } // 最終的なカウンターの値を出力 println!(" 結果: {}", *counter.lock().unwrap()); // => 結果: 10
}
この例では、Arc::clone
を使って Arc<Mutex<i32>>
への参照を増やし、それを move
クロージャで各スレッドに渡しています。これにより、すべてのスレッドが同じ Mutex
を安全に共有できています。
まとめ
今回は、Rustの標準ライブラリを使ってマルチスレッドプログラミングの基本を学びました。
-
std::thread::spawn
で新しいスレッドを生成できる。 -
JoinHandle::join
でスレッドの終了を待機できる。 - スレッド間でデータを共有するには、所有権とライフタイムに注意が必要 (
move
クロージャ)。 -
std::sync::Mutex
でデータへの排他的アクセスを実現できる。 -
std::sync::Arc
で所有権を複数のスレッド間で安全に共有できる。 -
Arc<Mutex<T>>
は、スレッドセーフな可変状態共有の一般的なパターン。
std::thread
はOSスレッドを直接扱うため強力ですが、より高度な並行処理パターン(タスク間の通信など)には、次のステップで学ぶチャネル (Channel) や、さらに進んだ非同期プログラミング (async/await) が役立ちます。
マルチスレッドは複雑な問題を伴うこともありますが、Rustの安全性機能が多くの落とし穴から守ってくれます。恐れずに挑戦してみましょう!