unsafeと未定義動作とFFI
この章でわかること:
unsafeが正確には何を許可するのか(そして何を許可しないのか)- 未定義動作(UB)の正確な意味(なぜ「何が起きてもおかしくない」が 文字どおりなのか)
- UBが「検出されずに成功する」ことの実演と、Miriによる機械的検出
- FFI(C関数呼び出し)の実コストの実測と、境界の設計原則
- なぜ体系に必須か: 本書は最適化の最終手段として何度も
unsafeに 言及してきました(4章、7章、 17章)。その最終手段を安全に扱う知識が なければ、最適化の手段の体系は完成しません。そしてUBの理解は、 6章のコンパイラ最適化の理解のもう1つの側面です
unsafeは何を解除するか
unsafeブロックについて最初に正すべき誤解は、
「検査が全部消えるモード」という理解です。実際に解除されるのは、
代表的には次の5つの操作に対する禁止です。
- 生ポインタ(
*const T/*mut T)を参照外しできます unsafe fn(FFI関数を含む)を呼び出せます- 可変または外部(extern)のstatic変数にアクセスできます
unsafe trait(Send/Syncなど)を実装できますunionのフィールドを読み取れます
(このほか、エディション2024ではexternブロックや一部の属性の
宣言自体にunsafeを書く形が導入されるなど、言語の進化とともに
細目は増えています。正確な一覧は言語リファレンスにあります)
借用検査も型検査も、unsafeブロックの中で通常どおり機能し続けます。
unsafeの意味は、「コンパイラが証明できない安全条件を、私が証明した」と
宣言することであり、「検査をやめる」ことではありません。問題は、その証明を破ったときに何が起きるかです。
未定義動作: 契約違反の正確な意味
未定義動作(undefined behavior、UB)は、プログラムがクラッシュする よりも悪い性質を持ちます。
6章を思い出してください。コンパイラの
最適化は「コードの意味を保つ変形」でした。ではその「意味」は
何を前提に定義されているかというと、UBが存在しないことです。
境界チェックの除去(7章)も、
エイリアス前提の並べ替え(6章)も、「範囲外アクセスは起きない」
「&mutは排他だ」という前提の上に成立しています。
UBを起こすコードは、この前提を破ります。前提が偽になった論理からは
任意の結論が導けるように、最適化後のプログラムの挙動には
いっさいの保証がなくなります。
実際に確かめます。長さ3のVecの
7番目をget_uncheckedで読みます。これは明白なUBです。
fn main() {
let v = vec![10u8, 20, 30];
let i = std::hint::black_box(7usize); // 範囲外の添字
// 検査つきなら panic するが……
let x = unsafe { *v.get_unchecked(i) };
println!("v[{i}] = {x} (?!)");
}
筆者の実行(Playground)では、クラッシュせず、v[7] = 0と表示して
正常終了しました。これがUBの最も厄介な性質です。
誤った値を返したまま実行を続けることがあり、必ずクラッシュするとは限りません。
テストは通り、本番のデータやコンパイラの更新で突然問題が顕在化します。
「動いているからこのunsafeは正しい」という推論が成立しないのです。
Rustで特に起こしやすいUBを列挙しておきます。
- データ競合: 同期なしの並行読み書き(5章)です
- 無効なメモリアクセス: 範囲外、解放済み(use-after-free)、 nullや未整列のポインタの参照外しです
- 参照の規則違反: 生ポインタ経由でも、同じデータへの
&mutの 複数生成や、&越しの書き換えはUBです。参照は「作った瞬間に」 有効・整列・初期化済みを要求します - 未初期化メモリの読み取り: 未初期化領域は
MaybeUninit<T>(「まだ初期化されていないかもしれないT」を表す型)で 扱い、初期化が完了する前にTとして読んではいけません - 不正な値の生成:
boolに2、存在しないenum判別子、 不正なUTF-8のstrなどです。transmuteはこれらの典型的な発生源です
Miri: UBの検出器
「証明した」つもりの安全条件を機械に検査させるツールがあります。 Miriは、コンパイラの中間表現 (MIR、6章)を1ステップずつ解釈実行し、 UBが起きた時点で報告するインタープリタです。先ほどの実験を Miriで実行すると、成功せず、次のように報告されます。
error: Undefined Behavior: `assume` called with `false`
--> src/main.rs:5:23
|
5 | let x = unsafe { *v.get_unchecked(i) };
| ^^^^^^^^^^^^^^^^^^ Undefined Behavior occurred here
使い方はcargo +nightly miri testで、テストをMiri上で
実行するだけです(Playgroundでは画面上部のTools→Miriで試せます)。
参照の排他規則のような微妙な違反も、Stacked Borrowsと呼ばれる
規則モデルで検査します(後継のTree Borrowsモデルも
-Zmiri-tree-borrowsで試せる実験段階にあります)。
限界も知っておきます。Miriは実際に実行したパスしか検査できず
(テストの網羅性がそのまま検査の網羅性です)、FFIの先は追えず、
実行は素のテストより数十〜数百倍遅くなります。それでも、
unsafeを含むコードのテストをMiriでも実行するのは、
現代のRust開発の標準的な規律です。
FFI: 他言語との境界
unsafeの主要な用途がもう1つあります。
FFI(foreign function interface)です。C言語のABI
(application binary interface。呼び出し規約(1章)
やデータ配置など、機械語レベルの取り決めの総称)を介した
他言語との相互運用を指します。コンパイラはCの関数の中身を検証できないため、
呼び出しは常にunsafeです。
// externブロック = 「この署名の関数がリンク先に存在する」という宣言
unsafe extern "C" {
fn labs(x: c_long) -> c_long; // Cライブラリの関数(longはOS依存幅)
}
まず「FFI呼び出しは遅いのか」を実測で確かめます。
Rustの.abs()(インライン化される)と、同じ計算をするCライブラリの
labs(FFI呼び出し)を1億回ずつ実行します。
use std::hint::black_box;
use std::time::Instant;
use std::ffi::c_long;
// Cライブラリの関数を直接宣言する(labs = C言語のlongの絶対値)。
// Cのlongの幅はOS依存(64bit Linux/macOSでは64bit、Windowsでは32bit)
// なので、対応するRust型 c_long を使う
unsafe extern "C" {
fn labs(x: c_long) -> c_long;
}
fn main() {
let n = 100_000_000i64;
// (1) Rustの .abs() : インライン化され、ベクトル化もされうる
let start = Instant::now();
let mut sum = 0i64;
for i in -n / 2..n / 2 {
sum = sum.wrapping_add(black_box(i).abs());
}
println!("Rust abs : {:>9.3?} (sum={sum})", start.elapsed());
// (2) C関数へのFFI呼び出し: 呼び出し境界を毎回越える
let start = Instant::now();
let mut sum = 0i64;
for i in -n / 2..n / 2 {
sum = sum.wrapping_add(unsafe { labs(black_box(i) as c_long) } as i64);
}
println!("C labs : {:>9.3?} (sum={sum})", start.elapsed());
}
筆者の実測(Playground)では約64ミリ秒と約80ミリ秒で、 1億回の呼び出しでも差は2割ほどです。この差にはFFIの 呼び出し境界だけでなく、インライン実装とライブラリ実装の 違いも混ざっているため厳密な1回あたりのコストは出せませんが、 結論には十分です。FFI呼び出しの機械的なコストは、 呼び出し規約に従う普通の関数呼び出しと同程度です。 1回あたりナノ秒級であり、それ自体が問題になることはまれです。
本当のコストは別の場所にあります。
- 最適化境界: 6章のとおり、 インライン化も自動ベクトル化も境界で止まります。 細かい関数を高頻度で呼ぶ設計は最も不利で、 大きな処理を1回の呼び出しで渡すのが境界設計の原則です
- データ表現の変換: Rustの
StringはNUL終端ではないので、 C文字列にはCStringへの変換(確保+コピー)が要ります。 構造体は#[repr(C)](2章)で レイアウトを固定します - 所有権の規約: 誰が確保し誰が解放するかはABIに現れません。 「Cが返したものはCの解放関数に返す」等の規約を守るのはプログラマの責任です
- パニックの遮断: Rustのパニックの巻き戻しをFFI境界の外へ
伝播させてはいけません(
extern "C"関数からの巻き戻しはabortします)。 コールバックをRustで書くときはcatch_unwindで捕捉します
宣言の手書きを避けるツールとして、Cヘッダから宣言を生成する bindgen、逆にRustからCヘッダを生成するcbindgenが定番です。
unsafeの運用規律
最後に、unsafeを書くときの規律をまとめます。
これはRustエコシステムでおおむね合意された規則です。
- 範囲を最小に:
unsafeブロックは違反しうる操作だけを包み、 1ブロック1論点にします - SAFETYコメント: なぜ安全なのかの証明をブロックの直上に
書きます(
// SAFETY: i は直前で len 未満と検査済み)。 clippyのundocumented_unsafe_blocksリントで強制できます - 安全な抽象で包む:
unsafeはモジュールの内側に限定し、 外には安全なAPIだけを見せます。Vec自身が内部でunsafeを多用しているのに 安全に使える、その構造を自分のコードでも作るということです。 不変条件(「len <= capacity」など)をモジュール境界で維持すれば、 検査すべき範囲がそのモジュール内に限定されます - Miriで検査する: unsafeを含むテストはMiriでも実行します
まとめ
unsafeは5つの操作の禁止を解除し、安全条件の証明義務を プログラマが引き受ける宣言です。検査が消えるわけではありません- UBは最適化前提の崩壊であり、クラッシュするとは限りません。実験では 範囲外読みが検出されずに0を返しました。「動いたから正しい」は成立しません
- MiriはUBを実行時に検出します。unsafeを含むコードの標準的な手段です
- FFI呼び出し自体は普通の関数呼び出しと同程度でした。実コストは 最適化境界・表現変換・所有権規約・パニック遮断にあります
- 最小範囲、SAFETYコメント、安全な抽象への限定が運用の三原則です
次章は、コードを変えずに性能を改善する領域である ビルドの制御(PGO、リンカ、コンパイル時間、バイナリサイズ)を扱います。