std::thread::spawn で見る Rust の unsafe の責務と型システム
概要
Rustの標準ライブラリ内にstd::thread::spawnという関数があります。これは渡されたクロージャを実行するスレッドを新たに作成する関数で、並行プログラミング解説の文脈では最も基本的な例としてよく取り上げられると思います。そして同じように、このspawn
関数に渡すクロージャのライフタイムに関する制限についても述べられることが多いです。クロージャの中から、その外で定義された変数を参照する場合はクロージャにmove
キーワードをつけて所有権を移さないといけないというあれのことです。本記事では、この所有権を移さないといけない(そうしないと型検査に通らない)ということが、型システムのどの部分から要請されているのか、それは破れそう(?)(型システムがエラーにしないといけないプログラムを実行させてしまうという意味)か、だとするとそれはどのような手段によってか、といったことを探索的に調べながら、最終的にRustのunsafeの仕組みが負う役割と、型システムの健全性との関係について考えをまとめる記事です。
読む順番
本記事は、自分がstd::thread::spawn
に関してふと思った疑問を解決していくうちにunsafe
と型システムの関係を理解するに至った過程をなぞるようにして構成しているので、探索的でボトムアップな構成になっています。もし先に一般化された事実を頭に入れてからその具体例を見ていきたい場合、最後の「unsafeと型システムの健全性の関係」のセクションを先に読んでも大丈夫です。
std::thread::spawn
のおさらいとその注意点(としてよく語られるもの)
まずはstd::thread::spawn
の使い方を簡単におさらいします。
use std::thread::spawn;
fn main() {
let handle = spawn(|| {
println!("Hello from the spawned thread");
});
handle.join().unwrap()
}
このstd::thread::spawn
は、引数に渡されたクロージャを実行するスレッドを作成します。なんてことはない普通の例です。
続いて、この関数を使うときの注意点を述べるために使われがちな例を見てみます。
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(|| {
println!("Here's a vector: {v:?}");
});
handle.join().unwrap();
}
このプログラムは、以下のような型エラーがでて、コンパイルすることができません。
error[E0373]: closure may outlive the current function, but it borrows `v`, which is owned by the current function
--> src/a.rs:6:32
|
6 | let handle = thread::spawn(|| {
| ^^ may outlive borrowed value `v`
7 | println!("Here's a vector: {v:?}");
| - `v` is borrowed here
|
note: function requires argument type to outlive `'static`
--> src/a.rs:6:18
|
6 | let handle = thread::spawn(|| {
| __________________^
7 | | println!("Here's a vector: {v:?}");
8 | | });
| |______^
help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
|
6 | let handle = thread::spawn(move || {
|
このエラーメッセージをできるだけ忠実に言い換えると、spawn
関数に渡しているクロージャは、現在の関数(今回の場合はmain
関数)より長生きするかもしれないのにも拘わらず、main
関数によって所有されている変数v
を借用しているというものです。クロージャがmain
関数より長生きするかもしれないとは、つまりクロージャが実行されている途中にmain
関数のスコープで定義された変数v
がdropされてしまう可能性があるということで、万が一これが起きると既にdropされた変数にクロージャからアクセスすることになります。Rustの型システムとしてはそのようなuse after freeが起きる可能性のあるプログラムは受け付けられないのでエラーになるというわけです。だからそれを解決するためにクロージャにmove
キーワードを付けて、v
の所有権をクロージャに移す、つまりクロージャが実行している間はv
がdropされないことを保証してくださいとエラーメッセージは言っています。
注意点を守らないと型が通らない理由
前節で、std::thread::spawn
を使った2つ目のプログラムが型エラーになる理由として、「Rustの型システムは(メモリ安全まで保証の対象に入れているため)use after freeが起きる可能性を許容できないから」と書きました。これは間違っていないのですが、ここではより具体的に、型チェックのどの部分にひっかかってエラーになってしまうのかということを考えます。
と言っても答えは単純明快です。std::thread::spawn
の関数の型を見れば答えが書いてあります。
pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
ここで、spawn
に渡すクロージャf
の型F
に対して、次のような制約が定義されています。
F: FnOnce() -> T + Send + 'static,
はい.......。'static
ライフタイムが要求されています。この'static
ライフタイムはトレイト境界の文脈でのものなので、公式ドキュメントのRust By Example 15.4.8 Staticより、その意味するところは次になります。
Fは、非静的な参照を持っていない
つまりF
はその型の構成要素に一切参照をもっていないか、持っていたとしても'static
とみなせるものだけであるということです。さらに引用先に続く説明が述べるように、これは、F
はspawn
側が好きなだけ保持していていいということを意味します。これなら確かに、'static
をF
のトレイト境界に追加することで、前述したuser after freeが起きないことを保証できそうです。前述の例で言えば、変数v
はクロージャの中では参照として持たれ、またv
はmain関数で定義された非静的なライフタイムを持つので、spawn
関数の定義の中でF
に要求されるトレイト境界を満たしておらず、型チェックに通ることができません。これは文字列を受け取る関数に整数を渡すコードが型エラーになるのと同じような話ですね。そしてmove
を付ければ所有権を得られる、つまり参照がなくなり、トレイト境界における'static
の定義である、「型の構成要素に非静的な参照を含まない」を満たすことができるため、型チェックに通るようになります。
注意点を守らず型を通せるような例は作れるか
しかしここで一つ疑問がわきます。std::thread::spawn
を使ったスレッド作成においてuse after freeが起きないことを型システムがチェックしてくれるのは、その定義において'static
ライフタイムをF
に要請したからでした。では、そう定義しなかった場合はどうなるのでしょうか。それともそう定義できないのでしょうか。
本節では、std::thread::spawn
の実装を眺めながら、次のことを目指します。
-
std::thread::spawn
とほぼ同等の機能を提供しながら、F
のトレイト境界に'static
を持たないdangerous_spawn
関数を定義する - 結果として、use after freeが起きる可能性のあるコードを型チェックに通す
早速std::thread::spawn
のソースコードを見てみます。
pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
F: FnOnce() -> T,
F: Send + 'static,
T: Send + 'static,
{
Builder::new().spawn(f).expect("failed to spawn thread")
}
ここでは、std::thread::Builer
のspawn
メソッドを呼んでいるだけでした。このコードも見てみます。
pub fn spawn<F, T>(self, f: F) -> io::Result<JoinHandle<T>>
where
F: FnOnce() -> T,
F: Send + 'static,
T: Send + 'static,
{
unsafe { self.spawn_unchecked(f) }
}
std::thread::Builer
のspawn
メソッドでもF
に'static
の制約があるので、std::thread::spawn
の定義から'static
制約だけをしれっと除いた次の関数は型エラーになります。Builer
のspawn
が'static
を要求しているので、それをラップする側でも'static
以上を要求しなくてはならないからです。
pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
F: FnOnce() -> T,
F: Send,
T: Send + 'static,
{
Builder::new().spawn(f).expect("failed to spawn thread")
}
error[E0310]: the parameter type `F` may not live long enough
--> src/main.rs:16:5
|
16 | Builder::new().spawn(f).expect("failed to spawn thread")
| ^^^^^^^^^^^^^^^^^^^^^^^
| |
| the parameter type `F` must be valid for the static lifetime...
| ...so that the type `F` will meet its required lifetime bounds
|
help: consider adding an explicit lifetime bound
|
12 | F: FnOnce() -> T + 'static,
| +++++++++
先ほどのstd::thread::Builder
のspawn
メソッドのコードに戻ると、こちらもただspawn_unchecked
メソッドをラップしているだけでした。これもコードを見ることにします(これで最後です)。
pub unsafe fn spawn_unchecked<F, T>(self, f: F) -> io::Result<JoinHandle<T>>
where
F: FnOnce() -> T,
F: Send,
T: Send,
{
Ok(JoinHandle(unsafe { self.spawn_unchecked_(f, None) }?))
}
このシグネチャについてポイントは2つあります。
-
F
のトレイト境界から'static
が消えている -
unsafe
キーワードが付与されている
まず前者に注目して、この関数をラップすることで冒頭で述べたdangerous_spawn
関数の定義を試みます。
use std::thread::Builder;
use std::{io, thread::JoinHandle};
fn main() {
let v = vec![1, 2, 3];
let handle = dangerous_spawn(|| {
let len = v.len();
let sum = v.iter().sum::<usize>();
println!("average = {}", sum / len);
});
drop(v);
handle.join().unwrap()
}
fn dangerous_spawn<F, T>(f: F) -> JoinHandle<T>
where
F: FnOnce() -> T,
F: Send,
T: Send,
{
dangerous_builder_spawn(Builder::new(), f).expect("heoge")
}
fn dangerous_builder_spawn<F, T>(builder: Builder, f: F) -> io::Result<JoinHandle<T>>
where
F: FnOnce() -> T,
F: Send,
T: Send,
{
unsafe { builder.spawn_unchecked(f) }
}
なんと、あっさりできてしまいました。このプログラムは型チェックに通り、実行もできます。drop(v)
がある(できる)ということはv
の所有権はmain関数のスコープに紐づいています。つまりdangerous_spawn
に渡したクロージャはv
の所有権を持っておらず、参照として持っています。そしてv
のライフタイムは静的ではありません。このプログラムでは、スレッド内からv
を参照するときにそれがdropされていないことを保証できないので、use after freeの可能性があります。Rustの型システムはメモリ安全性までもその保証の射程内にいるので、型チェックの通ったプログラムにはuse after freeの可能性もないはずです。ではなぜこのプログラムは型チェックを通過したのでしょうか?
まず端的にこの疑問に回答すると、前述した「型チェックの通ったプログラムにはuse after freeの可能性もない」というのは、「unsafe
な部分を正しく利用した場合」という条件付きで正しい言明だからです。dangerous_spawn
は後述するように、unsafe
の部分を正しく利用していません。
そしてこのunsafe
が何を意味するのか、そしてRustにおけるunsafe
とは何かについては、次節以降で述べます。
本ケースにおいてunsafeが意味していること
先に引用したstd::thread::Builer
のunchecked_spawn
関数の定義には、unsafe
キーワードがありました。そしてこの関数のドキュメントには、ErrorsやPanicsといった見知ったセクションに加えて、Safetyという見慣れないセクションがあります。この部分を次に引用します。
§Safety
The caller has to ensure that the spawned thread does not outlive any references in the supplied thread closure and its return type. This can be guaranteed in two ways:
- ensure that join is called before any referenced data is dropped
- use only types with 'static lifetime bounds, i.e., those with no or only 'static references (both thread::Builder::spawn and thread::spawn enforce this property statically)
前述のように、Rustの型システムが健全性を持つ(型チェックに通ったプログラムはいかなる場合も未定義動作を起こさない)というのはunsafe
の部分を正しく扱った場合に限ります。このunsafe
関数はRustのunsafe
の部分の一つです。であれば、どうすればunsafe
部分を正しく扱ったということになるのかというのは、利用者としては当然知りたい情報になりますよね。このSafetyセクションにはその十分条件が記載されています。unchecked_spawn
の作成者によれば、箇条書きされている2つのうちのどちらかを守ればunsafe
部分を正しく扱ったことになるといっています。ここで2つ目を見ると、'static
ライフライムを使うことと書かれています。本記事で例に挙げたstd::thread::spawn
、そしてこれがラップしているstd::thread::Builer
のspawn
メソッドではトレイト境界に'static
ライフタイムを追加することによってこれを実現しています。
一方で、本記事で実験的に作成したdangerous_spawn
関数は上記の2つの条件を満たしていないため、unchecked_spawn
というunsafe
関数を正しく使っていません。そのため、型チェックに通るにも拘わらず未定義動作(use after free)を起こす可能性のあるコードを書くことができてしまいました。
unsafeと型システムの健全性の関係
前節までに述べたものは、Rustのunsafe
と型システムの関係性から導かれる一つの例です。そこで、種明かし的にはなりますが、この関係性をより一般的な形でまとめたいと思います。
unsafeとはなにか
型システムの健全性を損なう可能性のある操作につけなければならないマーカーのようなものです。この安全ではない操作とは例えば以下のようなものがあります。
- 生のポインタが指す値を得る
-
unsafe
な関数を呼ぶ(FFIも含まれます) - 静的可変変数にアクセスしたり変更を加える
これらは、The Book、Rust By Example、The Rustonomiconなどにもそれぞれ記載があります。
そしてコンパイラは、このような安全でない操作には必ずunsafe
キーワードを付与するようにチェックします。そうではない場合、「unsafeを正しく扱った(もしくはそもそも使っていない)、型チェックにも通ったコードが未定義動作を引き起こす」コードが存在することになりRustの型システムに対する主張と矛盾するからです。
unsafeのモチベーション
私は特にここがわかっていません。次の調べ物のネタにします。
現時点で筆者がなんとなく思っているのは、概ね以下のような動機です。
- パフォーマンス改善など、のっぴきならない理由で
unsafe
を使いたいという需要がわりとある - OSなど結構低レイヤーな部分の処理を書くときに必要になる
- 「型が通ったら(メモリ操作上のあれこれも含めて)未定義動作を起こさない」というのはかなり強い主張で、この主張と実用的な問題解決の場での活躍を両立させるためには割と本質的に必要になる
型システムの健全性を維持するためにunsafeはどのように運用されるべきか
未定義動作を起こさないためには、unsafe
のコードを正しく扱うことが必要(であり、十分)です。
unsafe
のコードを書く側は、それを利用するものに向けて、そのコードを使うための条件を正しく理解し、明示する必要があります。一方でunsafe
のコードを使う側は、実装者によって明示された安全のための条件を満たすことを確認したうえで利用する必要があります。unsafe
コードのドキュメントに安全のための条件が明示されていない場合は自分でソースを読んで自力で条件を同定する必要がありますが、そもそもそのようなコードは利用しない方がいいのかもしれません。
また、作成者によって記された条件が正しいもの(それを守れば未定義動作が起こらないのか)なのかはコンパイラは検証できません。しかし実装者の書いた条件が間違っているというのは言うなればプログラムのバグに相当するものなので、Rust特有の問題というよりはあまねくプログラミング言語に共通するものという枠組みで捉えることも可能です。またこの問題を形式手法や動的解析である程度解決しようという試みも存在し、例えば前者に対応するRustBelt、後者に対応するMiriなどがあるそうです。
あとがき
出発点の疑問がstd::thread::spawn
のシグネチャを見ればすぐに解決するものだったので比較的小さなまとめになると思っていましたが、想定より内容がかなり肥大化してしまいました。また、外部に公開する文章としてまとめるにあたり"The Book"、"Rust By Example"、"The Rustonomicon"(全部丁寧に書かれた公式ドキュメント!すごい!)を改めて読み返すことになり、非常に理解が進みました。はじまりは自明に感じるようなことでも外部の目に晒すに耐える(と自分が妥協できる)ところまで作り上げるとものすごい主体的で楽しい学びが得られる実感があり、自分の学びをアウトプットするモチベーションになりました。
また、好奇心が満たされたので記事を書いてよかったですが、思っていた数倍も時間がかかってしまったこともあり、最初にこれだけ時間がかかるとわかっていたら二の足を踏んだ可能性があります。その意味で、これからも見切り発車でいろいろやってみるぞの気持ちです。
Discussion