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

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

2025年7月25日原文(ralfj.de)

概要

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

Hacker Newsで議論の続きを見る