1 ポイント 投稿者 GN⁺ 2 시간 전 | 1件のコメント | WhatsAppで共有
  • assert は、事前条件・事後条件・不変条件をコードで明示するための仕組みであり、型システムで強制できる制約は言語機能で表現するほうが望ましい
  • Zig の std.debug.assert はマクロではなく 通常の関数 で、unreachable によって到達不能な経路を示し、最適化にも利用される
  • Debug・ReleaseSafe では assert の失敗は panic によるクラッシュになるが、ReleaseFast・ReleaseSmall では unchecked illegal behavior となり、誤動作する可能性がある
  • 本番環境で assert を無効化すると、誤った前提を早期に発見する機会を失い、その後のコードが 誤った assert に依存して脆弱性につながることがある
  • ReleaseSafe と ReleaseFast のどちらを選ぶかはプログラムの優先順位次第だが、重要なのは assert を上書きして無効にするのではなく、誤った assert を修正すべき だという点にある

assert の役割と Zig の基本動作

  • assert は「この引数は null ではありえない」「この整数は偶数ではありえない」といった条件が常に真でなければならないことをコードで表現する仕組みである
    • 例: assert(my_arg != null);, assert(my_num % 2 != 0);
    • 型システムで制約を強制できるなら、assert より言語機能を使うほうがよい
    • Zig では通常のポインタ *Foo は null になれず、オプショナルポインタ ?*Foo は null を取りうるが、値へアクセスする前に確認が強制される
  • assert は 事前条件事後条件不変条件 を明示するのに適している
    • 良い assert は、プログラミングミスを捕まえるうえで単体テストより強力なことがある
    • ファジングと組み合わせると assert の効果はさらに高まる

Zig の unreachable と assert

  • Zig の assert は、不正なコード経路を示す言語機能である unreachable を土台としている
    • switch では、到達不能な分岐を .a => unreachable のように表せる
    • unreachable は文としても使え、任意の型の式が必要な位置でも使える
    • 到達不能な場合のために無理にダミー値を作る必要がない
  • Zig 標準ライブラリの std.debug.assert は次のように実装されている
    pub fn assert(ok: bool) void {
      if (!ok) unreachable; // assertion failure
    }
    
  • unreachable の情報は 最適化 に利用できる
    • コンパイラは到達不能な経路を削除でき、その情報が伝播することで非局所的な最適化も可能になる
    • すべての assert が性能向上につながるわけではないが、プログラマが容易に予想できない最適化が行われることもある

ビルドモードとランタイム安全性

  • Zig には DebugReleaseSafeReleaseFastReleaseSmall のビルドモードがある
    • この設定は必ずしもプログラム全体にグローバルにしか適用できないわけではない
    • 各依存関係を異なるモードでビルドでき、@setRuntimeSafety を使えば関数内のブロック単位でもランタイム安全性を調整できる
  • assert の失敗は Zig では “illegal behavior” になる
    • Checked モードである Debug、ReleaseSafe、@setRuntimeSafety(true) では panic によりプログラムがクラッシュする
    • Unchecked モードである ReleaseFast、ReleaseSmall、@setRuntimeSafety(false) では “unchecked illegal behavior” が発生し、プログラムは誤動作する
  • unchecked illegal behavior の結果は保証されない
    • 例の switch では、現在生成されるマシンコードの性質上、別の分岐へ飛んだように見えることがある
    • 別のコンパイラバージョンでは、まったく異なる誤動作になる可能性がある
    • 関連する挙動は godbolt の例 で確認できる
  • assert とその後の switch が ReleaseSafe と ReleaseFast でどう変わるかは、別の godbolt の例 でも確認できる
    • ReleaseFast では、関数がすべての比較を飛ばして true を返す形になる
    • この種の最適化は、ビデオゲームやその他のリアルタイムメディアアプリケーションが大きく依存している種類のものだ

Zig の assert はマクロではない

  • Zig の std.debug.assertマクロではなく通常の関数 である
    • Zig にはマクロがない
    • C/C++ 開発者が Zig に触れるとき、とくに驚く点のひとつである
  • C/C++ では assert を無効化すると、assert 呼び出し全体と渡された式がコメントアウトされたかのように振る舞うことが多い
    • そのため C/C++ では、副作用のある式を assert に入れてはいけない
    • assert が無効化されると、その演算自体が消える可能性があるからだ
  • Zig では関数呼び出し規則に従って、引数は関数呼び出し前に評価される
    • std.debug.assert の内部ロジックとは無関係に、引数式は評価される
    • したがって、次のように副作用のある式も assert に入れられる
    // assert that the remove operation is not a noop:
    assert(my_map.remove("expected-to-exist"));
    
  • 逆に、assert 条件を計算するために複雑な処理が必要な場合、unchecked モードでもその計算が必ず除去されるとは限らない
    • そのような場合は comptime if でコードをガードすべきである
    const builtin = @import("builtin");
    
    if (builtin.mode == .Debug) {
      var condition = ...;
      // whatever bookkeeping is necessary
      // to compute the condition
      assert(condition == .ok);
    }
    
  • C/C++ の意味論に慣れていると違和感があるかもしれないが、Zig では assert を一般に無効化しないという前提が置かれている

本番環境で assert を無効化する問題

  • assert には大きく分けて 3 つの選択肢がある
    • ランタイムチェックとして維持し、失敗時には panic によってプロセスをクラッシュさせる
    • assert を性能最適化に使うが、assert が誤っていた場合のプログラム誤動作を受け入れる
    • assert を完全に無効化する
  • std.debug.assert は assert の完全無効化をデフォルトではサポートしていない
    • ビルド時フラグを内部で確認する独自 assert を実装すれば、C/C++ 方式に近い挙動を作ることはできる
  • assert を無効化したくなる理由は、通常 2 つの要因が組み合わさった結果である
    • 性能コストやアプリケーションのクラッシュが嫌で、ランタイムチェックを維持したくない
    • assert が常に正しいと信じきれず、最適化に使われたときの誤動作を恐れている
  • matklad が関連議論で指摘しているように、クラッシュを避けるべき正当なエンジニアリング上の理由がある状況は存在する
    • しかし一般的なソフトウェアにおいて、クラッシュ回避をデフォルトにするのは悪い選択だと評価される
  • assert を無効化すると、不可能だと想定していた条件が実際に発生してもプログラムは動き続ける
    • プログラムは誤った前提のもとで実行を続け、これは unchecked illegal behavior でなくても一種の誤動作である
  • unchecked illegal behavior や C の undefined behavior が危険なのは、プログラムを weird machine に変えてしまう経路になりうるからである
    • 十分に複雑なソフトウェアでは、UIB がなくてもプログラムは意図しない形にねじ曲がりうる
    • 実行時に assert が偽になることは仕様からの逸脱であり、それ自体が意図しない処理を引き起こしうる
    • SQL injection は、UIB がなくても weird-machine 級の誤動作を引き起こす具体的かつ広く知られた例である
  • プログラム誤動作のコストが高すぎるなら、assert を有効にしておくべきである
    • 性能が極めて重要で、誤動作のリスクを受け入れられるなら、assert を最適化の機会として使うほうがよい
    • assert を無効化すると、性能向上を逃す一方で、実際より安全だと錯覚しやすい

誤った assert がコードベースを欺く仕組み

  • 中核となる危険は、誤った assert がテストでは露見せず、本番でのみ失敗しうることにある
    • すべての assert が常に真だと保証できるなら、assert を最適化に使うことは議論にならない
    • テストがすべての誤った assert を捕捉できると保証できるなら、本番での最適化も安全になる
    • 現実には誤った assert を書いてしまうことがあり、テストが必ず見つけてくれるわけでもない
  • 本番で assert を無効化すると、誤った assert をできるだけ早く発見する機会を失う
    • より深刻なのは、その後のコードがその誤った assert に依存して書かれ続けることだ
  • 例のコードでは、processThing は開始済みの thing に対してのみ呼ばれるべきだという前提を assert で置いている
    fn processThing(thing: Thing) void {
       // this function must always be invoked on
       // a thing that has already been started
       assert(thing.is_started);
    
       // ...
    }
    
  • この assert がテストでは失敗せず、本番では無効化されているため実際には偽になりうることを見落とすかもしれない
    • ユーザーから見える誤動作がなければ、問題がないかのように見えて開発が続いてしまう
  • その後、誰かが thing はすでに開始済みなのだから追加準備なしで baz を呼べると考え、コードを追加するかもしれない
    fn processThing(thing: Thing) void {
       // this function must always be invoked on
       // a thing that has already been started
       assert(thing.is_started);
    
       // ...
    
       // Since thing is already started, we don't
       // need to foo the bar before bazzing the qux.
       // It would be really bad to baz the qux otherwise,
       // so we add an assert for good measure.
       assert(thing.is_fooed);
       thing.baz(qux);
    }
    
  • 2 つ目の assert 自体の論理は正しくても、1 つ目の assert が実際には偽になりうるなら危険が生じる
    • テストでは 1 つ目の assert が失敗しないため、2 つ目の assert も失敗しない
    • 本番では assert が無効化されているため、脆弱性がコードベースに入り込んだ瞬間に気づけない可能性がある
  • コード中の assert が開発者を欺く状態になると、正しいコードを書くことが不当に難しくなる

選択肢はプログラムの優先順位によって変わる

  • プログラムごとに優先順位は異なり、誤動作リスクの最小化より性能を優先するのが正当化されるものもある
    • その場合、assert を最適化の機会に変える選択は自然である
  • 本番で assert を惰性的に無効化するのは、assert を有効にしておくよりも、また性能最適化を積極的に活用するよりも劣った選択だと評価される
    • ReleaseFast に 非常に 批判的 でありながら、assert の無効化は無批判に受け入れる態度は矛盾している
  • Zine は静的サイトジェネレータで、現在は主に個人ブログのビルドに使われている
    • 脅威モデルは定義されておらず、それが最優先事項でもない
    • Hugo より一桁高速に動作する点を重視して、ReleaseFast ビルドを配布している
  • Awebo は pre-alpha 段階のセルフホスト可能な Discord 代替である
    • 個人情報を扱い、インターネットに公開されるソフトウェアであることはすでに明らかである
    • 配布時には ReleaseSafe ビルドを提供する予定である
    • ただし、FFmpeg、Xiph Opus、SQLite のような中核依存の一部は ReleaseFast でビルドする予定だ
    • それらの依存関係では、性能向上のほうがプログラム誤動作リスクをさらに下げることより明確に重要だと判断している

実際のプロジェクトの選択とセキュリティ事例

Zig で完全には消えない暗黙の assert

  • 独自 assert は無効化できても、Zig 言語自体がコードに暗黙的に追加する assert は無効化できない
    • 整数オーバーフロー、0 除算、配列範囲外アクセスなどがこれに当たる
    • これらの条件はランタイム panic を引き起こすか、最適化目的に利用される
  • 本番で assert を無効化する慣行は、誤った assert がコードベースの中で腐り、増殖していく原因になりうる
    • その結果、UIB に対する被害妄想的な警戒が強まり、開発者が assert を再び有効にして結果と向き合うことを無意識に恐れるようになるかもしれない
  • 避けられない結論は、assert を無効化して覆い隠すのではなく、誤った assert を修正すべき だということだ
    • プログラムの正しさは、一部の部分集合ではなく全体を対象に追求すべきである

1件のコメント

 
GN⁺ 2 시간 전
Lobste.rs の意見
  • assert で単にクラッシュさせること、あるいは Rust のパニックのように処理だけをクラッシュさせる挙動が、おおむね最善だという点には同意する。だが、assert を最適化ヒントとして使うことが、単に取り除くより常に良いという点には同意しがたい
    第一に、任意の assert は最適化にあまり役立たないことが多く、最適化器がすぐ活用できない条件も多い。「この分岐は絶対に通らない」のような直接的な仮定を入れるのでなければ、コードのあちこちに無作為に仮定をばらまいて得られる性能向上は大きくない可能性が高い
    第二に、assert を仮定に変えると、ミスの 被害範囲 が大きく広がる。たとえばプロジェクトやユーザーごとに分離されたデータを処理するシステムで、本来ありえない状態を捕捉する assert が計算関数の途中にあるとする。リリースビルドでコストが高いので無効にする場合、単なる無効化なら被害は 1 つのプロジェクトやユーザーに限定され、その後の検査で検出されるかもしれない。一方、これを 未定義動作 にしてしまうと、計算が見当違いのコードへ飛んでメモリを任意に破壊し、すべてのプロジェクトのデータを損なう可能性がある
    結局、リリースビルドのデフォルトとして安全でない assert を選ぶのは、問題発生時に被害を局所化できる可能性を下げる代償として、コード中の任意の地点を性急に最適化するようなものだ。Rust は assert!() は常にパニック、debug_assert!() はデバッグモードでのみパニック、assert_unchecked() はデバッグではパニックしリリースでは最適化ヒントになる、という形でうまく設計されていると思う

    • ミスの被害範囲が心配なら ReleaseFast ではなく ReleaseSafe を使うべきだ
    • 個々の assert を無効にすることに反対しているのではなく、一般的な推奨慣行のように 一括で無効化してしまうこと に反対している
      性能への影響が大きすぎてリリースビルドに残せないという判断は完全に合理的だ。しかも計算コストの高い assert は、先に述べたように性能改善につながる可能性もほとんどない
      Zine にもそうした例がいくつかある:
      https://github.com/kristoff-it/zine/…
      https://github.com/kristoff-it/zine/…
      Zig には「デフォルトのリリースモード」はない。assert をどう扱うかは常に自分で選ぶ必要があり、全体適用の選択肢はクラッシュまたは最適化で、どちらもより標準的なデフォルトとは言えない
  • Ghostty でこれまで公開された比較的深刻な CVE 2 件が、どちらもメモリ破壊なしに任意コマンド実行へつながったという事実は、とても奇妙に感じられる。ReleaseFast で配布されていたのにそうだったという点は、世界の成り立ちについての自分の理解と真っ向から食い違う

    • それほど奇妙ではないと思う。深刻な脆弱性の 70% がメモリ関連だという報告を信じるとしても、それは C と C++ を基準にした話で、Zig はメモリ安全性で少し良い可能性がある。しかも 標本数 2 件 なら、だいたい 10 プロジェクトに 1 つくらいはこういう結果になっても不思議ではない
      ターミナルエミュレータを扱ったことのある立場からすると、これらの脆弱性はまさに予想できる種類の厄介な問題だ。開発者や研究者をおとしめる意図はないが、こうした妙な場所での コマンドインジェクション はこの分野ではほぼ付きものの問題で、他の分野で別種のインジェクション脆弱性が付きまとうのと似ている
  • プロダクションで「性能のために」 assert や境界検査を無効にしようという主張を、もうほぼ 40 年 聞き続けているのが面白い。この間にコンピュータは何桁も高速化し、ソフトウェアは誰の生活にもはるかに深く入り込んだのだから、ランタイムの正確性はかつてないほど重要になっている
    もっと生産的な話をすると、昔の Microsoft には、一般的な assertcheck のほかに、他ではあまり見かけない 報告用 assert があった。自分が完全には制御できない条件があり、真だと仮定しつつ、偽だった場合も防御的に処理し、実運用で本当に偽になるのかをログやテレメトリで知りたいときに使う。たとえば、ユーザーはあるリストに 1000 個を超えて追加しないと見込んで二次アルゴリズムを使う場合や、ネットワーク遅延は 200ms 未満だと見て往復回数の多いプロトコルを使う場合などだ

    • それは check と何が違うのか?
  • ここでリンクされている当人の一人として言うと、これは assert に対する自分の考えをばかげた 偽の二者択一 と戯画にしたものだ。別のコメント でも書いたように、未定義動作へ切り替えるかどうかは assert ごとに決めるのが望ましいと思っている。ReleaseFast への私の批判は、その選択を特定範囲のすべての assert だけでなく、すべての安全性検査と一緒くたにしてしまう点にある
    未修正の assert がクラッシュを起こすからといって無効化してしまうのは愚かだ、という kristoff の意見には同意する。ただし、「クラッシュか未定義動作か」だけが合理的な代替案だという点には同意しない。兄弟コメントの goldstein 側の考えのほうが、自分の考えに近い

  • assert_unchecked() の動作をグローバルなデフォルトにするのは擁護しにくいが、性能最適化の手法としては合理的かもしれない。すべての assert を仮定に変えたときにプロダクションビルドが大きく速くなるなら、性能向上の大半を生み出している 少数の仮定、できれば 1 つの仮定があるかもしれず、二分探索のような方法で見つけられるだろう

    • デフォルトはなく、ユーザーが明示的に ReleaseSafeReleaseFast/ReleaseSmall のどちらかを選ぶ仕組みだ
  • プログラム解析の文献では、コード内の主張または assert を二つの形に分ける双対性がある。一つはコードを取り巻く文脈、関数であれば呼び出し側が満たすべき条件であり、もう一つはコード自身、関数であれば関数が満たすべき条件である
    この区別は、契約や漸進的型付けの文献で標準的な学術概念である「責任(blame)」として見ると明確になる。文脈に対する主張が失敗した場合、それは我々の誤りではなく文脈や呼び出し側に責任があるが、呼び出し側が正しく主張自体がバグである可能性もある。コード自身に対する主張が失敗した場合、それは我々の責任だが、コードが正しく主張自体がバグである可能性もある
    関数レベルでは、事前条件は文脈に対する主張であり、事後条件はコードに対する主張である。ただし、どちらもコードの途中に入れられる。ある種の検証フレームワークでは、assert をコードに対する主張に、assume を文脈に対する主張に使う。一部のテストフレームワーク、特にランダムテストのフレームワークがこれをどう解釈するかとも関係している。assert が失敗するとテスト失敗として記録され、assume が失敗するとテストはスキップされる

    • BIND9 は、呼び出し側が満たすべき事前条件をマクロ REQUIRE() で、関数が保証する事後条件を ENSURE() で検査する、契約による設計に近いスタイルを採っている。中間検証用には INSIST()、ループやデータ構造用には INVARIANT() もある。関数ドキュメントには、事前条件と事後条件に対応する “requires” と “ensures” のメモがあるべきである
  • これは Bun を示唆しているようなので、つながりをもう少し明示的にしておきたい。Bun の作者である Jarred Sumner が 2024 年に、unreachableReleaseFast でパニックすべきだと提案した Zig の issue がある。そのスレッドにある Andrew Kelley と Matthew Lugg のコメントはこの議論に関係している
    => https://github.com/ziglang/zig/issues/19664
    Bun は独自の assert 関数群を使っており、リリースモードではパニックするか取り除かれるが、未定義動作は導入しない。ただし、Loris の脚注も覚えておくべきだ。「言語としての Zig は、無効化できない多くの assert をコードに暗黙のうちに追加する」という点である
    Bun の話を長くしすぎたくはない。小規模チームによる単一プロジェクトなのだから。要点は、少しでも不安があるなら ReleaseSafe を使うべきだということだ。ReleaseSafe は遅いという評判があるが、自分の小規模な Zig プロジェクトでは ReleaseSafe と ReleaseFast のベンチマーク差を測れなかった。それでもなお、多くの他言語より速い可能性は高い

    • 少しでも不安があるなら ReleaseSafe を使うべき、というのはその通りだ。さらに興味深い戦略も可能である。コードを変更している間、つまりバグを入れ込む可能性がある間は ReleaseSafe のままにし、コードが安定して実運用で検証されたあと、性能向上に価値があるなら ReleaseFast に切り替えられる
      あるいは文脈上妥当なら、ReleaseFast の実行ファイルを配布しておき、未定義動作に起因する非決定的なバグ報告が届き始めたら ReleaseSafe に戻すこともできる。そうすれば、どの assert が失敗したのか、範囲外アクセスやオーバーフローなども含めて実行可能なバグ報告を集め、コードを修正できる。そもそも ReleaseFast で配布すべきでない文脈でその判断をしてしまった場合でも、このやり方を勧めたいくらいだ :^)
      依存関係を調整し、@setRuntimeSafety を使ってプロジェクトの一部にだけ同じ方式を適用することもできる。結局のところ、賢くやろうという意思さえあれば必要な道具は一通り揃っている
  • assert 呼び出しの中に副作用のある式を入れてよい、という書き方はすべきではない。悪い慣行である。エラーチェックに assert を使うことも避けるべきだ。公平に言えば、筆者がそう主張しているようには見えない
    逆に、assert が複雑な計算に依存するなら、unchecked モードでもその計算が必ずしも除去されないため、comptime if で保護すべきだという説明も出てくる
    「マクロが残したトラウマを捨てて単純さを受け入れるよい機会」という言葉のアイロニーを、筆者が見落としていないことを願う。要するに「プログラムのビルドモードを考慮し、防御的な comptime if をあちこちにばらまかなければならない単純さ」を受け入れろという話なのだから

    • なぜ悪い慣行なのか?
  • C# で数値計算コードを少し書いており、リリースでは無効になる assert を多用している。密なループごとに実行するにはコストが高すぎるが、ユニットテストではルーチンが最初の NaN 入力を見た瞬間に即座に落ちるのが有用だ
    こうした NaN は、ユーザー入力から来るというより、最適化器が行ってはいけない場所へ進んでしまうなどのコードバグによって生じることが多く、より良い境界制約などが必要になる。もちろん、ユーザー入力に正規化が必要な場合もあるが、それはアルゴリズムの深部ではなく最も外側の境界で行うべきだ。ユーザー入力の正規化の結果として、アルゴリズム内部の不変条件を assert なしで証明できる証明システムがあればよいのだが、これはサイドプロジェクトであり、落ちても誰も死なない

  • assert をめぐる意見の不一致の 90% は、その語の定義が曖昧で複数あるために生じており、そのせいで思考とコミュニケーションが濁っている。だから概念を次の三つの名前に分け、厳密に使うべきである
    assert(bool)、あるいは Rust なら assert_unchecked() は、プログラマが常に真だと信じ、コンパイラも常に真だと仮定して最適化に使うものだ。旧来の言語における検査型 assert との連想を避けるには、assume() と呼ぶ方がよいかもしれない
    check(bool) は、条件が偽ならパニックし、真なら継続し、常にそのように動作する
    debug_check(bool) は、デバッグモードでは check() と同じで、リリースモードでは常に継続する。実際には、デバッグモードでデフォルト有効の --debug_checks フラグで制御される
    さらに、assert()check() に置き換えるコンパイラフラグ --check_asserts も必要だ。自分の assert が疑わしく検証したいときに使い、デバッグモードではデフォルトで有効にする。「assert」と言うときに何を意味するのかが十分に明確でなければ、成熟した議論は不可能で、言葉を浪費するだけになる