世界を動かす技術を、日本語で。

スレッドセーフがなければメモリ安全は実現できない

概要

  • Memory safety の定義や意味についての議論
  • Go言語 のデータレースによるメモリ安全性の問題指摘
  • Java など他言語との安全性比較
  • Undefined Behavior (未定義動作)の重要性強調
  • 言語の 安全保証 の本質的な違いの解説

メモリ安全性とスレッド安全性の区別の曖昧さ

  • Memory safety は一般的に「use-after-free」や「out-of-boundsアクセス」を防ぐ言語の特徴とされる
  • Thread safety は並行処理におけるバグ(データレース等)を防ぐ性質
  • これらを明確に区別することは実際にはあまり有用ではないという主張
  • 真に求められる性質は Undefined Behavior(未定義動作) の排除

Go言語におけるデータレースとメモリ安全性

  • Goは memory safe と呼ばれることが多いが、データレースによってメモリ安全性が破れる例を紹介
  • インターフェース型の値を複数スレッドで同時に操作することで、 不正なメモリアクセスやクラッシュ が発生
    • インターフェース値(vtableとデータポインタ)の更新が非アトミックであることが原因
  • Goのスライスやマップでも同様の問題が起こり得る
  • Goはデータレース検出ツールを標準で提供するが、 型システムや静的保証がないためテスト依存 となる

他言語との比較:Java等

  • Java はデータレースがあっても未定義動作には至らないよう設計されている
    • 産業用として初の 正式な並行メモリモデル を導入
    • 予期しない値(null等)は返るが、不正なポインタアクセスやクラッシュはしない
  • 多くの言語(Java, C#, OCaml, JavaScript, WebAssembly)は「全ての並行プログラムが合理的に動作する」ことを重視
  • Rust や新しい Swift は、型システムでデータレース自体を排除するアプローチ

Goの安全性の位置付けと問題点

  • Goは上記どちらのアプローチも採用していない
    • 問題あるデータレースが存在しうるため、厳密には memory safeとは言えない
    • データレースがなければメモリアクセスは安全
  • Goの公式ドキュメントもこの点を明確に説明していない
    • 「ほとんどのレースは限られた結果しか生まない」と説明しつつ、全てではないことを隠している
    • JavaやJavaScriptと同等とする主張は誤解を招く

本質的な安全性とは何か

  • 本当に重要なのは プログラムが言語仕様を破れないこと
    • つまり Undefined Behavior が発生しないこと
  • メモリ安全性、スレッド安全性、型安全性などに細分化することにあまり意味はない
    • なぜUBが起きたかではなく、「UBが起きるかどうか」が本質
  • GoはCよりは安全だが、 保証できる安全性には明確な限界
  • 安全性はスペクトラム であり、Goはその中間に位置

総括

  • Go は並行プログラミング向けに設計されたが、 データレースによる未定義動作のリスク が存在
  • JavaやRust、Swift は安全性確保に大きな努力を払っている
  • Goの安全性の限界 を理解し、適切な選択と認識が重要
  • 安全性保証 を証明する上で、Goの現状は十分とは言えない
  • 言語選択時には、どのような 未定義動作やリスク が残るかを正しく把握する必要

Hackerたちの意見

たまにこの話題が出るけど、Rustのサウンドネスホールの問題に似てるね。正直言って、これは本当に問題で、うっかりやってしまうこともあるから、Rustのサウンドネスホール(?)とは違って、理解しづらいし、誰かのプライベートキーを当てるくらい自然に遭遇することはないと思う。とはいえ、Goを何年も使ってきたけど、このバグを引き起こすための正確な条件に出くわしたことはないな。UberはGoのコードのバグについてたくさん話してるけど、この記事はGo開発者が実際に直面している問題を理解するのに役立つよ。特に、下の表は各問題の発生頻度をまとめていて面白い。 https://www.uber.com/en-US/blog/data-race-patterns-in-go/ これに関して特定のカテゴリはないみたいで、ほとんどの場合、同じスライスへの同時アクセスが原因だから、破損した読み取りを示す必要があるんだ。じゃあ、どうして実際にはもっと起こらないのかな?うーん、正直わからない。多分、みんなこの特定の落とし穴を避けるくらい神経質なんじゃないかな、アメリカ人と延長コード/パワーストリップについてのテクノロジーコネクションの理論みたいにね。並行して使われることがわかっている変数を再代入するのは明らかに問題だし、言語にはアトミック、チャネル、ミューテックスロックがあるから、ほとんどの人は並行コンテキストでそれをやらないと思う(少なくとも故意にはね)。レースディテクターは確実に見つけるけど、パフォーマンスの影響を受けるけど、破損した読み取りの問題は修正できると思う。多分やった方がいいけど、Goのコードが本番で動いてるからって、そんなに心配はしてないよ。大きな問題にはなってないしね。

Goでデータレースを解決するのに数ヶ月かかったよ。レースディテクターは何も見つけられなかったし、誰も何が起こっているのかわからなかった。最終的にはループカウンターがオーバーフローして、同じことを何億回も計算することになった(でもいつも同じ!)。だから、目に見える影響は、リクエストがランダムに3分かかることがあったり、100msのはずがそうなったりしたんだ。結局、プロダクションでperfを使って、間接的にデータレースを理解することになった。プラットフォーム開発者として変なことをデバッグする経験があったから、チームを手伝うために呼ばれたんだ。これのおかげで、Goのレースにたくさん遭遇したけど、偏った見方をすると、Rustがどこにでもあった方がいいと思ってる。でも、これじゃ自分の仕事がなくなっちゃうかな? ;)

Uberの記事で「GoプログラムはJavaマイクロサービスに比べて8倍の同時実行性を持っている」と言ってるけど、どういう意味なんだろう?同時実行性を数えられる名詞のように使ってるけど。

Rustのメンテナは、さまざまなサウンドネスホールを修正が必要なバグとして認識していることも注目に値すると思う。ただ、例えば https://github.com/rust-lang/rust/issues/25860 (あなたが言ってるのはこれだと思うけど)みたいに、修正するにはコンパイラの特定の部分を大幅にリファクタリングする必要があるから、時間がかかってるんだ。

わお、これはGoの大きな落とし穴だね!でも、Goはゴルーチン間でメモリを直接共有するんじゃなくて、通信プリミティブを使うことを強調してるから、そこは公平に見ないとね。[1] https://go.dev/blog/codelab-share

実際のGolangプログラムは、常にメモリを共有している。なぜなら、「通信による共有」パターンは、広範な論理的問題、つまり「安全な」レース条件や「安全な」デッドロックを引き起こすから。

ゴルーチン間でチャンネルを使ってデータを送信しても、Goでは安全にそれを行うのがすごく難しいんだ。送信可能な型や所有権、読み取り専用の参照などの概念がないからね。例えば、次のプログラムは安全なのか、それともレース状態なのか?

func processData(lines

もちろん、これはデータレースだよ。なぜなら、buf.Bytes()が基になるメモリを返して、その後Resetで同じバックメモリを再利用できるから、「processData」と「main」が同時に同じデータに書き込んでいることになるんだ。Rustでは、同じデータへの2つの可変参照があるからコンパイルエラーになるよ。チャンネルを通じて所有権を渡すか、コピーを送る必要がある。Goでは混乱するよね。bytes.Buffer.ReadBytes("\n")を使うとコピーが返ってくるから、それを送れる。bytes.Buffer.String()も同じ。でも、bytes.Buffer.Bytes()を使うと、チャンネルを通じて安全に渡せないものが返ってくるんだ。もしそのbytes.Bufferを再利用しないなら別だけど。Rustのチャンネルはこの問題を解決するけど、Goにはそういうのがないから、ミューテックスよりも遅くて、初心者のゴファーには正しく使うのがもっと難しい新しい道具を与えているだけなんだ。

悲しいことに、スレッドを持つほとんどの言語は、グローバル変数と制限のない共有メモリアクセスがデフォルトになっている。これがデータの破損やレースの大部分の原因なんだ。プロセスは一般的にスレッドよりも良い同時実行モデルだけど、多くのユースケースには重すぎるのが残念。もし、各スレッドに必要なデータをメッセージパッシングでデフォルトで渡すようにしたら(常にコピーするか、不要なコピーを省くために所有権を追跡するか)、こういった問題のほとんどは解決できると思う。それまでの間、幸いにも私たちは選択肢があって、プラットフォームが提供していてもグローバル変数や共有メモリを使わない自由がある。

メッセージパッシングは、適切に同期されたアクセスでメモリを直接共有するよりも、論理的なエラー(レース条件やデッドロックなど)を引き起こしやすい。これは万能薬じゃないよ。

悲しいことに、スレッドを持つほとんどの言語はグローバル変数と制限のない共有メモリアクセスがデフォルトなんだ。これがデータ破損やレースの大部分の原因だよ。プロセスは一般的にスレッドよりも良い並行モデルだし、現代の言語は型システムでスレッド安全を表現するオプションがある。例えばRustがやってることみたいに、スレッドを使うのは夢のようだよ(特にthread::scopeを使って構造化された並行性を利用できるときは)。人々はRustの元々の目標が「メモリ安全なシステム言語を作ろう」じゃなくて、「スレッド安全なシステム言語を作ろう」だったことを忘れがちで、メモリ安全はその流れでついてきたものなんだ。

この話題が出るたびに、Dropboxのチームを思い出すんだ。新しいエンジニアがデータ構造への書き込みを同期させずにGoサーバーでセグフォルトを起こすのが通過儀礼みたいなものでさ。Swiftも(昔は?)同じ問題があって、Swiftがデータ構造への共有アクセスでセグフォルトを起こす様子を示すプログラムを書かなきゃいけなかった。GoはRustやJavaの意味でメモリ安全じゃないし、そんな風にラベル付けされるのが不思議だよ。

Swiftはこれを修正中だけど、遅くて辛い移行だね。最近まで安全じゃなかった危険なコードがたくさんあるし。

主に、前のものに比べて素晴らしい改善だったから(前のはめちゃくちゃ脆弱だったし)。

安全性は二元的じゃないから、そのコメントは意味がないよ。

21世紀のプログラミング言語なのに、Goにあったはずのものが全然ないっていうのはちょっと驚きだよね。でも、DockerとKubernetesがあるからいいか。

ちょっと気になるんだけど、一般的にマップみたいな基本的な構造はスレッドセーフじゃないから、変更する時は注意が必要だよね。これについてはGoの仕様書にもちゃんと書いてあるし。Dropboxの場合、実際には何が起こってたの?

共有アクセスでクラッシュするのは、安全な選択だよね。

そうそう、ここでの問題は「RustやJavaの意味でのメモリ安全性」が実際の用語の意味じゃないってことだよね。人々は「メモリ安全性」がプログラミング言語の公理みたいに話してるけど、実際はそうじゃない。これはソフトウェアセキュリティの専門用語なんだ。要するに、二つのグループの人たちがすれ違ってるだけ。Goのプログラマーが君が言ってる区別を知らないわけじゃないよ。言語の前提そのものだから、「共有はコミュニケーションで、コミュニケーションは共有でない」っていうのが基本なんだ。もちろん、これがうまくいかなかったから、現代のGoはたくさんの共有をして、たくさんの同期が必要になってるけどね。でも、みんなそれは理解してるよ。

Zigについても、まるでゆっくり進む車の事故を見ているような気分なんだ。彼らはメモリ安全だって主張してるけど(安全な最適化レベルを使えば「十分に」安全っていうのもあるけど、これは別の話)、RustのSend/Sync型に相当するものがないんだよね。実際、誰も十分な並行Zigコードを書いてなかったからあまり問題にならなかったんだと思う…ただ、今は言語にファーストクラスの非同期サポートを戻そうとしていて、他のスレッドでfutureを実行するから、一気にたくさんの問題が出てくるんじゃないかな。

もし私の理解が正しければ、ReleaseSafeでビルドされたシングルスレッドのZigプログラムでも、メモリ破損の脆弱性がないとは限らないんだ。例えば、もう存在しないローカル変数へのポインタをデリファレンスするのは、すべての最適化モードで未定義の動作になるからね。

Goは最も一般的な定義でメモリ安全だから、あるシナリオでセグフォルトがあったとしても関係ないよ。デュアルワード値のデータレースに関連する脆弱性やセキュリティ問題はどれくらいあったの?10年間Goを使ってきたけど、そんな問題は一度も聞いたことがないよ。

メモリ安全の最も一般的な定義は、文字通り「セグメンテーションフォルトを起こさない」ということだよね(明示的に安全でない操作を呼び出さない限り - ここでは「go」キーワードが安全でないべきだと思わない限りね)。

セグメンテーションフォルトは、メモリの問題を暴露する最もシンプルな方法だよ。レースコンディションを使って、到達するはずのない状態を再現するのは簡単だからね。で、君が言ってるように大きな疑問は「それが悪用される可能性はあるのか?」ってこと。私の仮定では、悪用できると思うけど、もっと簡単なターゲットがあるはず。でも、これはあくまで仮定で、どうやって確認すればいいのかもわからない。

核弾頭の爆発安全がなければ、家の安全はない。義務的なヘルメット法がなければ、歩行者の安全はない。戦車を運転しなければ、車の安全はない。

データレースによって悪用可能な本物のGoコード(意図的に悪用されるように書かれたコードではない)を見たことがないんだ。これが否定を証明するわけじゃないけど、セキュリティの観点から見ると、Goアプリケーションにとってこのリスクは優先する価値がないっていう良いヒントかもしれない。C/C++と比べると、現実の脆弱性の60-75%がメモリ安全の脆弱性だからね。メモリ安全は確かにスペクトラムで、リターンが減少していくって主張したいな。

メンテナンスって、一般的にCVEsよりもずっと大きな負担だよね。エクスプロイトは確かに悪いけど、エクスプロイトできないバグも結局は修正が必要なバグだし。メンテナンスは初期開発の「大きな」整数倍になるから、その要素を下げるものは、たとえそれが製品を出すためのコストがかかっても、価値があると思うよ。

私はやったよ!何かもらえるの?

メモリ安全は大事だよ。なぜなら、Cプログラムに対する多くのCVEはメモリ安全のバグだから。スレッド安全はGoプログラムに対するCVEの主要な原因ではないよ。理論的には良い議論だけど、実際には通用しないね。

Cプログラムでの典型的なメモリ安全の問題は、RCEを引き起こす可能性が高いよ。セグメンテーションフォルトを引き起こすスレッド安全の問題は、DoS攻撃につながる可能性が高いけど、不快ではあるけど、ずっと危険度は低い。レースコンディションは理論的にはもっと強力な攻撃につながる可能性があるけど、それを引き起こすのはずっと難しいはずだよ。

CVEはもっと悪いけど、データが壊れたりクラッシュしたりするスレッドバグも、誰かがトリアージして理解して修正する必要があるバグだからね。

それは違うよ。Javaはこれを正しく理解してるし、Fil-Cも同様だよ。だから、スレッドセーフじゃなくてもメモリ安全性はあるんだ。実際、そんなに難しくないよ。メモリ安全性は別の特性で、言語がスレッド安全性に依存しない限りはね。Go(や他のいくつかの言語)はそういう依存関係があるけど、すべてのメモリ安全な言語がそうとは限らないよ。