Zenn
🔍

std::thread::spawn で見る Rust の unsafe の責務と型システム

2025/03/22に公開

概要

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とみなせるものだけであるということです。さらに引用先に続く説明が述べるように、これは、Fspawn側が好きなだけ保持していていいということを意味します。これなら確かに、'staticFのトレイト境界に追加することで、前述した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::Builerspawnメソッドを呼んでいるだけでした。このコードも見てみます。

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::BuilerspawnメソッドでもF'staticの制約があるので、std::thread::spawnの定義から'static制約だけをしれっと除いた次の関数は型エラーになります。Builerspawn'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::Builderspawnメソッドのコードに戻ると、こちらもただ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::Builerunchecked_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::Builerspawnメソッドではトレイト境界に'staticライフタイムを追加することによってこれを実現しています。

一方で、本記事で実験的に作成したdangerous_spawn関数は上記の2つの条件を満たしていないため、unchecked_spawnというunsafe関数を正しく使っていません。そのため、型チェックに通るにも拘わらず未定義動作(use after free)を起こす可能性のあるコードを書くことができてしまいました。

unsafeと型システムの健全性の関係

前節までに述べたものは、Rustのunsafeと型システムの関係性から導かれる一つの例です。そこで、種明かし的にはなりますが、この関係性をより一般的な形でまとめたいと思います。

unsafeとはなにか

型システムの健全性を損なう可能性のある操作につけなければならないマーカーのようなものです。この安全ではない操作とは例えば以下のようなものがあります。

  • 生のポインタが指す値を得る
  • unsafeな関数を呼ぶ(FFIも含まれます)
  • 静的可変変数にアクセスしたり変更を加える
    これらは、The BookRust By ExampleThe 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

ログインするとコメントできます