Open16

Zig メモ

voluntasvoluntas

Zig に関する自分なりのメモを残しています。

学ぶモチベーション

  • C++ や Rust を学ぶには難しそう
  • Erlang VM の NIF 経由で Zig で書かれたライブラリを呼び出したい
  • Zig で HWA を利用したエンコーダーとか作ってみたい
  • macOS もしくは Linux で動けば良い
  • Wasm に出力してブラウザで利用したい

Zig の良いところ

  • zig build は C のビルドツールとしても有用そう
  • allocator の概念がわかりやすい
  • std.crypto が充実している
  • zig test がよくできている
  • switch が好み

利用目的

  • WebTransport
  • HTTP/3
  • QUIC
  • HTTP/2
  • MLS (C++ / Wasm)
  • Signal Protocol (C++ / Wasm)
  • AV1 / VPX / Opus / Lyra

翻訳

自分向けに

voluntasvoluntas

std.crypto

crypto.random.bytes

test "crypto.random.bytes" {
    var buf: [16]u8 = undefined;
    const crypto = @import("std").crypto;
    crypto.random.bytes(&buf);
}

crypto.sign.Ed25519.sign

test "crypto.sign.Ed25519.sign" {
    const std = @import("std");
    const testing = std.testing;
    const crypto = std.crypto;

    const kp = try crypto.sign.Ed25519.KeyPair.create(null);

    var msg = "abc";
    const sig: [64]u8 = try crypto.sign.Ed25519.sign(msg, kp, null);

    try crypto.sign.Ed25519.verify(sig, msg, kp.public_key);
    
    var bad_msg = "xyz";
    try testing.expectError(
        error.SignatureVerificationFailed,
        crypto.sign.Ed25519.verify(sig, bad_msg, kp.public_key),
    );
}

crypto.dh.X25519.KeyPair.fromEd25519 / crypto.dh.X25519.scalarmult

test "crypto.dh.X25519.scalarmult" {
    const std = @import("std");
    const testing = std.testing;
    const crypto = std.crypto;

    var seed1: [32]u8 = undefined;
    crypto.random.bytes(&seed1);

    var seed2: [32]u8 = undefined;
    crypto.random.bytes(&seed2);

    const kp1 = try crypto.sign.Ed25519.KeyPair.create(seed1);

    const kp2 = try crypto.sign.Ed25519.KeyPair.create(seed2);

    const xkp1 = try crypto.dh.X25519.KeyPair.fromEd25519(kp1);
    const xkp2 = try crypto.dh.X25519.KeyPair.fromEd25519(kp2);

    const pk1: [32]u8 = try crypto.dh.X25519.publicKeyFromEd25519(kp1.public_key);
    const pk2: [32]u8 = try crypto.dh.X25519.publicKeyFromEd25519(kp2.public_key);

    const o1 = try crypto.dh.X25519.scalarmult(xkp1.secret_key, pk2);
    const o2 = try crypto.dh.X25519.scalarmult(xkp2.secret_key, pk1);

    try testing.expectEqual(o1, o2);
}

crypto.aead.aes_gcm

const std = @import("std");
const crypto = std.crypto;
const testing = std.testing;

const Aes256Gcm = std.crypto.aead.aes_gcm.Aes256Gcm;

test "Aes256Gcm - Message and associated data" {
    const key: [Aes256Gcm.key_length]u8 = [_]u8{0x00} ** Aes256Gcm.key_length;
    const nonce: [Aes256Gcm.nonce_length]u8 = [_]u8{0x00} ** Aes256Gcm.nonce_length;
    const text = "Test with message";
    const ad = "Test with associated data";

    var cipher_text: [text.len]u8 = undefined;
    var plain_text: [text.len]u8 = undefined;
    var tag: [Aes256Gcm.tag_length]u8 = undefined;

    Aes256Gcm.encrypt(&cipher_text, &tag, text, ad, nonce, key);
    try Aes256Gcm.decrypt(&plain_text, &cipher_text, tag, ad, nonce, key);
    try testing.expectEqualSlices(u8, text[0..], plain_text[0..]);
}
voluntasvoluntas

zig command

zig init-exe

$ zig init-exe
info: Created build.zig
info: Created src/main.zig
info: Next, try `zig build --help` or `zig build run`
$ zig build run
info: All your codebase are belong to us.
$ ./zig-out/bin/spam
info: All your codebase are belong to us.

zig test

$ zig test src/main.zig
All 1 tests passed.
voluntasvoluntas

2022-07-10 時点での https://ziglang.org/documentation/master/#Memory の DeepL Pro 翻訳。

メモリ

Zig言語はプログラマに代わってメモリ管理を行いません。このため、Zigにはランタイムがなく、Zigのコードはリアルタイム・ソフトウェア、OSカーネル、組み込みデバイス、低遅延サーバなど多くの環境でシームレスに動作します。その結果、Zigのプログラマは常にこの問いに答えられなければなりません。

バイトはどこにあるのか?

Zigと同様、C言語もメモリ管理を手動で行っています。しかし、Zigとは異なり、C言語にはmalloc、realloc、freeというデフォルトのアロケータがあります。libc とリンクするとき、Zig はこのアロケーターを std.heap.c_allocator で公開します。しかし、慣習として、Zig にはデフォルトのアロケータはありません。その代わり、アロケートを必要とする関数は Allocator パラメータを受け取ります。同様に、std.ArrayList のようなデータ構造もその初期化関数で Allocator パラメータを受け付けます。

const std = @import("std");
const Allocator = std.mem.Allocator;
const expect = std.testing.expect;

test "using an allocator" {
    var buffer: [100]u8 = undefined;
    const allocator = std.heap.FixedBufferAllocator.init(&buffer).allocator();
    const result = try concat(allocator, "foo", "bar");
    try expect(std.mem.eql(u8, "foobar", result));
}

fn concat(allocator: Allocator, a: []const u8, b: []const u8) ![]u8 {
    const result = try allocator.alloc(u8, a.len + b.len);
    std.mem.copy(u8, result, a);
    std.mem.copy(u8, result[a.len..], b);
    return result;
}

上の例では、100 バイトのスタック・メモリが FixedBufferAllocator を初期化するために使われ、それが関数に渡されます。便宜上、グローバルな FixedBufferAllocator が std.testing.allocator にあり、基本的なリーク検出を行うことができるようになっています。

Zig には std.heap.GeneralPurposeAllocator でインポート可能な汎用アロケータがあります。しかし、アロケータの選択ガイドに従うことが推奨されます。

アロケータの選択

どのアロケーターを使用するかは、様々な要因によって決まります。以下にフローチャートを示しますので、判断の参考にしてください。

  1. ライブラリを作っているのか?この場合、アロケータをパラメータとして受け取り、ライブラリのユーザがどのアロケータを使うかを決めるのがベストです。

  2. libc をリンクしていますか? この場合、少なくともメインのアロケータは std.heap.c_allocator が正しい選択だと思われます。

  3. 必要な最大バイト数は、コンパイル時に分かっている数で制限されていますか? この場合、スレッドセーフが必要かどうかによって std.heap.FixedBufferAllocator か std.heap.ThreadSafeFixedBufferAllocator を使ってください。

  4. プログラムがコマンドラインアプリケーションで、基本的な循環パターンなしに最初から最後まで実行され(ビデオゲームのメインループやウェブサーバーのリクエストハンドラなど)、最後にすべてを一度に解放することが理にかなっていると思いますか?このような場合、このパターンに従うことをお勧めします。
    この種類のアロケータを使用する場合、手動で何かを解放する必要はありません。この種のアロケータを使う場合、手動で何かを解放する必要はありません。すべてが一度に解放され、 arena.deinit() が呼び出されます。

  5. アロケーションは、ビデオゲームのメインループやウェブサーバのリクエストハンドラのような周期的なパターンの一部でしょうか?例えば、ビデオゲームのフレームが完全にレンダリングされた後や、Webサーバーのリクエストが処理された後など、サイクルの終了時にすべての割り当てを一度に解放できる場合、std.heap.ArenaAllocatorは素晴らしい候補となります。前の箇条書きで示したように、これによってアリーナ全体を一度に解放することができます。また、メモリの上限を設定できる場合は、std.heap.FixedBufferAllocator を使用すると、さらに最適化できることに注意しましょう。

  6. テストを書いていて、error.OutOfMemoryが正しく処理されることを確認したいですか?この場合は std.testing.FailingAllocator を使ってください。

  7. テストを書いていますか?この場合は std.testing.allocator を使ってください。

  8. 最後に、上記のどれにも当てはまらない場合は、汎用のアロケータが必要です。Zigの汎用アロケータは、設定オプションのcomptime構造体を受け取り、型を返す関数として提供されている。一般的には、メイン関数に std.heap.GeneralPurposeAllocator をひとつセットアップし、アプリケーションの様々な部分にそのアロケータやサブアロケータを渡していくことになる。

  9. Allocatorを実装することも検討できます。

バイトはどこにあるのか?

foo "のような文字列リテラルは、グローバル定数データセクションにあります。このため、このように文字列リテラルをミュータブルスライスに渡すとエラーになります。

fn foo(s: []u8) void {
    _ = s;
}

test "string literal to mutable slice" {
    foo("hello");
}

しかし、そのスライスを定数にすれば、うまくいきます。

fn foo(s: []const u8) void {
    _ = s;
}

test "string literal to constant slice" {
    foo("hello");
}

文字列リテラルと同様に、const宣言も、コンパイル時に値が分かっている場合は、グローバル定数データセクションに格納されます。また、コンパイル時変数もグローバル定数データセクションに格納されます。

関数内のvar宣言は、その関数のスタックフレームに格納されます。関数が戻ると、関数のスタックフレームにある変数へのポインタは無効な参照となり、その参照解除は未確認の未定義動作となります。

トップレベルまたは構造体宣言のvar宣言は、グローバルデータセクションに格納されます。

allocator.alloc や allocator.create で確保されたメモリの格納場所は、アロケータの実装によって決定されます。

ヒープ割り当て失敗

多くのプログラミング言語では、ヒープ割り当てに失敗した場合、無条件にクラッシュすることで対処しています。Zigのプログラマは、慣習として、これが満足のいく解決策であるとは考えていません。その代わりに、error.OutOfMemoryはヒープ割り当ての失敗を表し、Zigライブラリはヒープ割り当ての失敗で処理が正常に完了しなかったときはいつでもこのエラーコードを返します。

Linuxなどの一部のOSでは、デフォルトでメモリのオーバーコミットが有効になっているため、ヒープ割り当ての失敗を処理することは無意味であると主張する人もいます。この理由には多くの問題があります。

  • オーバーコミット機能を持つのは一部のオペレーティング・システムだけです。
    • Linuxはデフォルトでオーバーコミットが有効になっていますが、設定可能です。
    • Windows はオーバーコミットしません。
    • 組み込みシステムにはオーバーコミットがありません。
    • 趣味のOSでは、オーバーコミットがある場合とない場合があります。
  • リアルタイムシステムの場合、オーバーコミットがないだけでなく、通常、アプリケーションごとにメモリの最大量があらかじめ決められています。
  • ライブラリを書くときの主な目的の1つは、コードの再利用です。アロケーションの失敗を正しく処理することで、ライブラリはより多くのコンテキストで再利用されるようになります。
  • オーバーコミットが有効であることに依存するようになったソフトウェアもありますが、その存在は数え切れないほどのユーザ体験の破壊の原因になっています。オーバーコミットを有効にしたシステム、例えばデフォルト設定のLinuxでは、メモリが枯渇しそうになると、システムがロックして使えなくなる。このとき、OOM Killer はヒューリスティックに基づき kill するアプリケーションを選択します。この非決定的な判断により、重要なプロセスが強制終了されることが多く、システムを正常に戻すことができないことがよくあります。

再帰

再帰は、ソフトウェアをモデリングする際の基本的なツールである。しかし、しばしば見落とされがちな問題があります:無制限のメモリ割り当てです。

再帰はZigで活発に実験されている分野なので、ここに書かれていることは最終的なものではありません。0.3.0のリリースノートで、再帰の状況を要約して読むことができます。

簡単にまとめると、現在のところ再帰は期待通りに動作しています。Zigのコードはまだスタックオーバーフローから保護されていませんが、Zigの将来のバージョンでは、Zigのコードからのある程度の協力が必要ですが、そのような保護を提供することが予定されています。

寿命と所有権

ポインタの指すメモリが利用できなくなったときに、ポインタがアクセスされないようにするのは、Zigプログラマの責任です。スライスは他のメモリを参照するという点で、ポインタの一種であることに注意してください。

バグを防ぐために、ポインタを扱うときに従うと便利な規則があります。一般に、関数がポインターを返す場合、その関数のドキュメントでは、誰がそのポインターを「所有」しているかを説明する必要があります。この概念は、プログラマがポインタを解放することが適切である場合、そのタイミングを判断するのに役立ちます。

例えば、関数のドキュメントに「返されたメモリは呼び出し元が所有する」と書かれていた場合、その関数を呼び出すコードは、いつそのメモリを解放するかという計画を持っていなければなりません。このような場合、おそらく関数は Allocator パラメータを受け取ります。

時には、ポインタの寿命はもっと複雑な場合があります。例えば、std.ArrayList(T).items スライスは、新しい要素を追加するなどしてリストのサイズが次に変更されるまで有効である。

関数やデータ構造のAPIドキュメントでは、ポインタの所有権と有効期限について細心の注意を払って説明する必要があります。所有権とは、ポインタが参照するメモリを解放する責任が誰にあるかということであり、寿命とは、メモリがアクセス不能になる時点(未定義動作が発生しないように)を決めることである。

voluntasvoluntas

hexToBytes

const hex = "160303";
var buf: [hex.len / 2]u8 = undefined;
const data = try std.fmt.hexToBytes(buf[0..], hex);
std.debug.print("{s}\n", .{std.fmt.fmtSliceHexUpper(data)});
voluntasvoluntas

zigup

Zig は基本的に master を使っていくのが良いのですが、master は日々更新されていくので追従するのが面倒です。バイナリのダウンロードも面倒です。

zigup を使うことで気軽に master を維持することが出来ます。rustup の zig 版です。

marler8997/zigup: Download and manage zig compilers.

~/zig/ とかフォルダを作ってパスを通して zigup をいれたらあとは zigup master でいけます。

voluntasvoluntas

ビット単位での読み込み

readBits を利用するパターン

test "readBits" {
    // zig fmt: off
    const recv_data = [_]u8{
        0x80, 0x64, 0x00, 0x01,
    };

    // zig fmt: on
    var fbs = std.io.fixedBufferStream(&recv_data);
    var bit_stream = std.io.bitReader(.Big, fbs.reader());
    var out_bits: usize = undefined;
    const version = try bit_stream.readBits(u2, 2, &out_bits);

    try testing.expectEqual(2, header.version);
}

packed struct を利用するパターン

Endian の影響を受ける。 packed(endian) struct などが検討されているようだったが、
今はどうなるのかわかっていない。packed struct は便利なので endian を考慮できるようにして欲しい。

// Little Endian 前提のコード
pub const RtpHeader = packed struct(u8) {
    csrc_count: u4,
    extension: u1,
    padding: u1,
    version: u2,
};

test "packed struct @bitCast" {
    // zig fmt: off
    const recv_data = [_]u8{
        0x80, 0x64, 0x00, 0x01,
    };

    // zig fmt: on
    var fbs = std.io.fixedBufferStream(&recv_data);
    var reader = fbs.reader();
    const raw_header = try reader.readIntBig(u8);
    const header = @bitCast(RtpHeader, raw_header);

    try testing.expectEqual(2, header.version);
}
voluntasvoluntas

スレッドセーフなカウンターの利用

std.atomic.Atomic を利用する。

const std = @import("std");
var counter = std.atomic.Atomic(u64).init(0);

pub fn main() !void {
    // 戻り値は変更前の値
    _ = counter.fetchAdd(1, .SeqCst);
    const count = counter.load(.SeqCst);
}

fetchAdd / fetchSub はドキュメント検索でてこないので、Atomic のコードを読む。

voluntasvoluntas

スレッドプールの利用

スレッドプールを利用してスレッドセーフなキューを使って見る例。
スレッドセーフなカウンターの例も組み合わせてる。

const std = @import("std");
const ThreadPool = std.Thread.Pool;
const Queue = std.atomic.Queue;
const Node = std.atomic.Queue([]const u8).Node;

// キューはグローバルに置く
var queue = std.atomic.Queue([]const u8).init();
// カウンターもグローバルに置く
// カウンターこれ一つ一つ定義しないとダメなのか
var counter = std.atomic.Atomic(u64).init(0);

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    var allocator = gpa.allocator();

    var thread_pool: ThreadPool = undefined;
    try thread_pool.init(.{ .allocator = allocator });
    defer thread_pool.deinit();

    var line_buffer: [256]u8 = undefined;
    while (true) {
        // ここでブロックしてる
        const buffer_size = try std.io.getStdIn().read(&line_buffer);

        // キューに追加するための node を用意
        const node = allocator.create(Node) catch unreachable;
        // お作法
        node.* = .{
            .prev = undefined,
            .next = undefined,
            .data = line_buffer[0..buffer_size],
        };

        // キューへ追加
        queue.put(node);

        try thread_pool.spawn(runTask, .{&allocator});
    }
}

fn runTask(allocator: *std.mem.Allocator) void {
    const node = queue.get() orelse return;
    // キューからノードを取り出して、表示
    std.debug.print("node.data={s}", .{node.data});
    // fetchAdd の戻りは前の値
    _ = counter.fetchAdd(1, .SeqCst);
    // 今の値を取りたいので load で今の値を取得する
    const count = counter.load(.SeqCst);
    // 処理数をプリント
    std.debug.print("count={d}\n", .{count});
    // queue はメモリを管理してくれないので、取得したやつが破棄する
    allocator.destroy(node);
}