プログラミング
Nix が私のデバッガーの半分を書いた
Nix wrote half of my debugger (fzakaria.com)
要約
著者は、Nix の決定論的なビルド環境を利用した Rewind VM を使用して、デバッガー開発を進めています。Nix は、プログラムの正確な入力、デバッグシンボル、ソースコードなどを容易に提供するため、デバッガーに必要な要素の多くが Nix によって既に提供されていることに気づきました。これにより、デバッガーのソースパネル、スタックフレーム、スレッドレーンなどの機能開発が効率化されています。
全文翻訳
数日前、私は Rewind VM について書きました。これは、Nix ビルドのすべての実行が、スレッドスケジュールを含めて、入力の純粋関数である決定論的な VM です!私はこれを使用して、Nix ビルドにおける数多くの競合状態を見つけ、再現し、解決してきましたが、少し気が狂いそうになっています。この超能力は何であり、なぜ他の誰もそれを使用しないのでしょうか? このツールは、ソースパネル、スタックフレーム、ブックマーク、「比較」タブ、より多くの gdb サポート、各ステップで誰が CPU を保持していたかを示すスレッドレーン、そして競合状態が発生する正確なステップを見つけるのに役立つ「ここからチェック」を迅速に備えました。私は新しい機能ごとに、それが大きなコードの変更になるだろうと思っていましたが、常に同じことに遭遇しました:各機能の難しい部分はすでに完了しており、Nix がそれをやっていました。例えば、デバッガーは、プログラムの正確な入力、そのデバッグシンボル、ソースコード、その下のすべてのライブラリのソースコード、そして他の誰かがそれらすべてを自分のマシンで取得する方法を必要とします。それこそがまさに導出(derivation)であり、Nix はそれを容易にします。
§ 2 つのテラー、1 つの口座
競合状態の古典的な簡単な例は、2 つのスレッドが 1 つの銀行口座に入金することです。各入金は残高を読み取り、元帳に 1 行書き込み、次に残高に入金額を加えたものを保存します。もし 1 人のテラーがもう 1 人のテラーの読み取りと書き込みの間に入ると、古い残高で他の入金を上書きしてしまいます。
static void deposit(int teller, long amount) {
long seen = balance;
char line[64];
int n = snprintf(line, sizeof line, "teller %d: %ld + %ld\n", teller, seen, amount);
if (write(1, line, n) != n) return;
balance = seen + amount;
}
テラーが他のテラーの読み取りと書き込みの間に入ると、古い残高で他の入金を上書きしてしまいます。
私の 16 コアのラップトップでは、1,000 回の実行のうち 396 回で銀行がお金を失いました。taskset で 1 つのコアに固定すると、1,000 回の実行で 1 回もお金を失いませんでした:1 つのコアだけでは、入金の途中でスレッドを切り替えることはめったにないため、Rewind はスケジュールを乱します。
teller 1
balance
teller 2
read 150
read 150
store 200
store 150 + 10 = 160
テラー 1 の最後の入金が失われた
Rewind の VM は 1 つの CPU を持つため、最初の実行もパスします。rewind check は、乱されたスケジュールでビルドを再度実行します。各実行は、ゲストカーネルに異なるステップで再スケジュールするように要求し、競合状態が発生する正確なステップを絞り込みます:
$ rewind check --where github:fzakaria/rewindvm#bank
... step 3237 decides it: a reschedule there makes the run fail
passing: run 12feb5f832205a72, schedule 0
failing: run c2f1cfac0c288f3e, schedule 1
over steps 3237..3238 the two are the same run until step 3237 where the threads were:
3237 139/140 deposit (bank.c:24), on the CPU at the deciding step
3250 139/141 deposit (bank.c:24), at the first event that differs
このチェックをラップトップで実行するのに 11 秒かかりました。2 つの実行は同じマシンで、exit for exit ですが、ステップ 3237 までです。そこでは、失敗した実行だけが再スケジュールされます。
§ 無料のもの 1: 入力
check の引数は導出であり、flake 参照にすることができます。Nix の美しさは、導出の入力を知っていることです。それが、実行が再現可能であることを確認するために必要なすべてです。導出の入力は、ソース、コンパイラ、ライブラリ、カーネル、そして VM の設定です。
rewind nix は導出の入力を認識し、クロージャを読み取り専用の erofs イメージにパックし、その上に VM をブートします。実行の ID は、ストアパスのように、その入力のハッシュです。rewind show は、それを再度作成するコマンドを表示します:
$ rewind show 12feb5f8
rewind nix /nix/store/cr8rl40rjb9cmmcd9sxn9mpdrhvp53jj-bank-0.1.0.drv --epoch 1791331200 --clock branches --name bank-0.1.0 # 12feb5f832205a72
ビルドが成功すると、ゲストは各出力の NAR ハッシュを報告し、rewind nix はそれをホストのコピーおよび Nix がサブスティテュートするすべてのバイナリキャッシュと比較します。これは .narinfo のみを取得します:
$ rewind nix github:fzakaria/rewindvm#bank ... /nix/store/27a4q7w90jzp4wg5kaaajvb64zll6yqg-bank-0.1.0 dbf0df3de84b7a84 matches your store
これにより、VM 内のビルドが私のラップトップ上のビルドと同じであり、VM の実行が Nix によって再現可能であることを検証するのに役立ちます。
§ 無料のもの 2: すべてのシンボルとすべてのソース
ソースコードを見ているときは、コードのデバッグははるかに簡単です。Rewind アプリの新しいパネルまたはターミナルの rewind は、プレイヘッドでのプログラムのソースと、それを呼び出したスタックフレームを表示します。デバッガーはソースの場所を知る必要があります。Nix はそれも無料で提供してくれます。nixpkgs はパッケージを separateDebugInfo でビルドし、デバッグ情報は cache.nixos.org にデバッグ出力としてキャッシュされます。debuginfod を利用して、ビルド ID ごとにデバッグ情報とソースコードを取得できるため、Linux カーネルを含む VM 内の任意のバイナリのソースコードを見ることができます!😲
$ rewind where c2f1cfac
3250 process 139 (bank), thread 141, at step 3250
#4 deposit (bank.c:24)
22 int n = snprintf(line, sizeof line, "teller %d: %ld + %ld\n", teller, seen, amount);
23 > 24 if (write(1, line, n) != n)
25 return;
26 balance = seen + amount;
called from
#5 teller (bank.c:34)
called from
#6 start_thread (pthread_create.c:454)
§ fork された任意のステップの gdb
しかし、ソースコードだけでは不十分な場合があり、プログラムの状態を見る必要があります。rewind gdb は、プレイヘッドでの実行のフォークを、プロセスのすべてのスレッドとともに開きます。デバッガーはブレークポイント、ウォッチポイントを設定し、メモリ、レジスタ、変数を検査できます。ステップ 3249 では、テラー 2 が CPU を取り戻す直前に、balance 変数を監視してそれが変更されるまで続行できます。gdb が行うことは何も記録を変更せず、同じステップに巻き戻して再度フォークしたり、他の任意のステップでフォークしたりできます。gdb は同じ状態を見ます。gdb が十分でない場合は、rewind shell --with nixpkgs#strace は、VM 内の任意のステップで、PATH 上の nixpkgs の任意のパッケージとともにシェルを開きます。それは、さらに 1 つのクロージャがさらに 1 つのイメージにパックされたものです。
§ 2 つの実行を比較する
Rewind は 2 つの実行を比較することを非常に容易にします。アプリの「比較」タブ、またはターミナルの rewind compare は、両方の実行の最後の共通イベントと、最初に異なるイベントを表示します。比較タブは、両方の実行のイベントを、それらが分岐する直前から並べて表示し、何が異なるかをマークします。テラー 2 は、失敗した実行では 150 から、パスした実行では 200 から開始します。
§ CPU を誰が保持していたか
Rewind の VM は 1 つの CPU を持つため、任意のステップで正確に 1 つのスレッドが実行されています。競合は順序の問題です:どのスレッドがいつ実行されたか。トレースはそれを直接答えません。スレッドが行ったこと(書き込み、オープン、フォーク、終了、シグナル)を記録しますが、それらのイベント間のギャップで誰が実行していたかは記録しません。Rewind は、新しいものを何も記録せずにギャップを埋めることができます。各実行は正確に再生されるため、実行の任意の区間をステップごとに歩み、各ステップでゲストカーネルに CPU 上にどのスレッドがいるかを尋ねることができます。失敗した実行では、テラー 1(スレッド 140)はステップ 3238 で中断されます。これは入金の途中で、残高を読み取りましたが、まだ保存していません。テラー 2(スレッド 141)は 1 ステップ(3239)だけ CPU を取得し、残高を読み取ってから、テラー 1 が CPU を取り戻して完了します。
オレンジ色のバーの下にテラー 2 のイベントが隠れているため、少し見にくいです。
§ ここからチェックする
1 つの悪いインターリーブを見つけると、次の質問が浮かび上がります:それはどれくらい起こりやすいか? パスした実行は通常のケースなのか、それとも運が良かっただけなのか? rewind check は通常、多くのスケジュールでビルド全体を再度ビルドすることでそれに答えます。--run を使用すると、代わりに既存の実行から、選択した任意のステップから開始します。そこからスケジュールごとに実行をフォークし、各フォークはそこからのスレッドの異なる順序を取り、どれだけ異なって終了するかを数えます。ステップより前のすべては、そのままです。このテクニックを銀行の例に適用し、2 つの実行が分岐したステップ 3,221 から開始できます。そこから 16 のスケジュールを実行すると、すべて 16 がお金を失います。
33 スケジュール 0 は実行自体であり、パスしました。他のすべての行はフォークであり、終了しました。
2 は、残高が不足したため make check が失敗しています。
: $ rewind check --run 12feb5f8 --schedule-from 3221 --schedules 16 --all --no-narrow
schedule 0: