ภาพวาดโทนมิ้นต์สว่าง อิกัวนาตัวเล็กใส่เอี๊ยมยืนหลังเคาน์เตอร์ไม้ ผลักกล่องไม้เปล่าที่มีป้ายเล็กสีอำพันติดอยู่ข้างฝาข้ามเคาน์เตอร์มาให้ ด้านหลังเป็นชั้นวางกล่องเปล่าหลายขนาด

Zig 0.16 บทที่ 8: allocator คืออะไร และใครเป็นเจ้าของหน่วยความจำ

พี่โชว์Engineering

ทุกบทที่ผ่านมามีคำว่า gpa โผล่มาตลอด บทนี้จะอธิบายว่ามันคืออะไร ทำไมต้องส่งไปทุกที่ และทำไมนี่คือการตัดสินใจออกแบบที่สำคัญที่สุดของ Zig

เอกสารทางการเขียนไว้ตรงมาก:

ภาษา C มีการจัดการหน่วยความจำด้วยมือเหมือน Zig แต่ต่างกันตรงที่ C มี allocator ตั้งต้นให้ คือ malloc, realloc และ free … แต่ตามธรรมเนียมของ Zig ไม่มี allocator ตั้งต้น ฟังก์ชันที่ต้องขอหน่วยความจำจะรับพารามิเตอร์ Allocator แทน

ประโยคเดียวนี้อธิบายทั้งข้อดีข้อเสียของ Zig ในเรื่องหน่วยความจำ


ทำไมถึงไม่มี allocator ตั้งต้น

ลองคิดถึงฟังก์ชันใน Java ที่ชื่อ split() มันขอหน่วยความจำไหม ตอบไม่ได้จากชื่อ ต้องเปิดโค้ดดู และต่อให้รู้ว่าขอ ก็เลือกไม่ได้ว่าจะให้ขอจากไหน

ใน Zig ถ้าฟังก์ชันหนึ่งต้องขอหน่วยความจำ signature มันจะบอกคุณเอง เพราะต้องมีพารามิเตอร์ std.mem.Allocator และเมื่อเป็นพารามิเตอร์แล้ว คนเรียกเป็นคนเลือก ว่าจะให้ขอจากที่ไหน

ประโยชน์ที่ตามมาสี่ข้อ:

  1. ไลบรารีใช้ได้ทุกที่ — เอกสารบอกว่าเพราะ allocator เป็นพารามิเตอร์ standard library จึงใช้บนเป้าหมาย freestanding (ไม่มีระบบปฏิบัติการ) ได้
  2. ทดสอบได้ว่าโค้ดรับมือกับหน่วยความจำเต็มไหม — ส่ง allocator ที่แกล้งพังเข้าไป (บทที่แล้วทำไปแล้ว)
  3. จับ leak ได้อัตโนมัติ — ส่ง allocator ที่นับให้ แล้วเทสจะฟ้องเอง
  4. เปลี่ยนกลยุทธ์ได้ทีละจุดเรียก — ฟังก์ชันเดิม เรียกด้วย arena ตอนทำงานเป็นชุด และเรียกด้วย allocator ทั่วไปตอนอื่น

ต้นทุนคือคุณต้องพิมพ์ gpa ต่อกันไปทุกชั้น เหมือนที่ต้องส่ง io ในบทที่ 4 — Zig เลือกความชัดเจนแลกกับความสะดวก อย่างสม่ำเสมอ


สี่ฟังก์ชันที่ประกอบเป็น allocator

std.mem.Allocator เป็น struct ที่มีแค่สองฟิลด์ — pointer ไปยังข้อมูลภายในของ allocator ตัวจริง กับตาราง vtable ที่มีฟังก์ชันสี่ตัว: alloc, resize, remap, free

ที่เหลือทั้งหมด (create, destroy, dupe, realloc, alignedAlloc) เป็นฟังก์ชันอำนวยความสะดวกที่สร้างบนสี่ตัวนี้

const std = @import("std");

test "alloc, free, create, destroy" {
    const gpa = std.testing.allocator;

    // alloc = ขอหลายชิ้น ได้ slice กลับมา
    const buf = try gpa.alloc(u8, 16);
    defer gpa.free(buf);
    @memset(buf, 'x');
    try std.testing.expectEqual(@as(u8, 'x'), buf[15]);

    // create = ขอชิ้นเดียว ได้ pointer
    const one = try gpa.create(u32);
    defer gpa.destroy(one);
    one.* = 42;
    try std.testing.expectEqual(@as(u32, 42), one.*);

    // dupe = ขอที่แล้ว copy ข้อมูลให้เลย
    const copy = try gpa.dupe(u8, "hello");
    defer gpa.free(copy);
    try std.testing.expectEqualStrings("hello", copy);
}

ทุกฟังก์ชันที่ขอหน่วยความจำคืน error{OutOfMemory} ได้ — ใน Zig หน่วยความจำเต็มเป็น error ธรรมดา ไม่ใช่การหยุดโปรแกรม เอกสารอธิบายเหตุผลไว้ว่า หลายภาษาเลือกให้โปรแกรมตายไปเลยเมื่อขอหน่วยความจำไม่ได้ แต่ “ตามธรรมเนียม โปรแกรมเมอร์ Zig ไม่ถือว่านั่นเป็นทางออกที่น่าพอใจ” เพราะ Windows ไม่ได้แจกหน่วยความจำเกินจริง ระบบฝังตัวก็ไม่ได้แจก และงานเรียลไทม์ต้องจองหน่วยความจำล่วงหน้าอยู่แล้ว

resize กับ remap เป็นของที่คนไม่ค่อยใช้ตรง ๆ แต่ควรรู้ว่ามี

const std = @import("std");

test "resize อาจสำเร็จหรือไม่สำเร็จ" {
    const gpa = std.testing.allocator;
    var buf = try gpa.alloc(u8, 100);
    defer gpa.free(buf);

    // resize = ขอขยาย/ย่อ "ที่เดิม" ถ้าทำไม่ได้จะคืน false โดยไม่ย้ายอะไร
    if (gpa.resize(buf, 50)) {
        buf = buf[0..50];
        try std.testing.expectEqual(@as(usize, 50), buf.len);
    }

    // realloc = ถ้าขยายที่เดิมไม่ได้ก็ย้ายที่ให้ พร้อม copy ข้อมูล
    buf = try gpa.realloc(buf, 200);
    try std.testing.expectEqual(@as(usize, 200), buf.len);
}

ความต่างสำคัญ: resize ไม่ย้ายที่ — ถ้าย้ายไม่ได้ก็คืน false แล้วของเดิมยังอยู่ที่เดิม ส่วน realloc ย้ายให้ ซึ่งแปลว่า pointer หรือ slice เก่าที่ชี้เข้าไปในก้อนนั้นใช้ไม่ได้อีก

remap เพิ่มเข้ามาตั้งแต่ 0.14 เพราะบาง allocator (เช่น arena) ต้องห้ามย้ายที่ เลยต้องแยกสองความหมายนี้ออกจากกัน


ธรรมเนียมความเป็นเจ้าของ

Zig ไม่มีระบบตรวจความเป็นเจ้าของแบบ Rust ทุกอย่างจึงอยู่บนธรรมเนียมที่ต้องเขียนให้ชัดและตั้งชื่อให้ตรง

const std = @import("std");

/// ผลลัพธ์เป็นของคนเรียก — ต้อง free เอง
fn joinPath(gpa: std.mem.Allocator, dir: []const u8, name: []const u8) ![]u8 {
    const out = try gpa.alloc(u8, dir.len + 1 + name.len);
    errdefer gpa.free(out); // พลาดกลางทางแล้วคืนให้ ไม่ทิ้ง leak

    @memcpy(out[0..dir.len], dir);
    out[dir.len] = '/';
    @memcpy(out[dir.len + 1 ..], name);
    return out;
}

test "ใครเป็นเจ้าของผลลัพธ์" {
    const gpa = std.testing.allocator;

    const p = try joinPath(gpa, "/var/log", "app.log");
    defer gpa.free(p); // คนเรียกรับผิดชอบ

    try std.testing.expectEqualStrings("/var/log/app.log", p);
}

ธรรมเนียมที่ standard library ยึดและคุณควรยึดตาม:

  • gpa: Allocator เป็นพารามิเตอร์ตัวแรกเสมอ เห็นปุ๊บรู้ว่าฟังก์ชันนี้ขอหน่วยความจำ
  • ฟังก์ชันที่คืน slice ที่ขอมาใหม่ ให้เขียน doc comment บอกว่าคนเรียกเป็นเจ้าของ std ใช้คำว่า “Caller owns the memory”
  • ชื่อที่ขึ้นต้นด้วย dupe, toOwnedSlice, alloc แปลว่าได้ของใหม่ที่ต้องคืนเอง ส่วนชื่ออย่าง items, get, find แปลว่าได้ของที่ยืมมา ห้ามคืน
  • errdefer คู่กับทุกการขอที่อาจพลาดกลางทาง

ผมย้ำอีกครั้งว่า Zig ไม่มี borrow checker เอกสารเขียนไว้ตรง ๆ ว่า “เป็นความรับผิดชอบของโปรแกรมเมอร์ Zig ที่จะไม่เข้าถึง pointer เมื่อหน่วยความจำที่มันชี้อยู่ไม่มีอีกแล้ว” ธรรมเนียมพวกนี้จึงไม่ใช่เรื่องสไตล์ แต่คือระบบป้องกันชั้นเดียวที่คุณมี


เขียน allocator ของตัวเอง

เพราะ allocator เป็นแค่ struct สองฟิลด์ คุณจึงห่อของเดิมแล้วแทรกพฤติกรรมเองได้ ตัวอย่างนี้นับว่าขอหน่วยความจำกี่ครั้ง รวมกี่ไบต์ — ซึ่งเป็นเครื่องมือที่จะใช้หนักมากในชุดบทความเรื่อง optimization

const std = @import("std");

const CountingAllocator = struct {
    child: std.mem.Allocator,
    bytes: usize = 0,
    calls: usize = 0,

    fn allocator(self: *CountingAllocator) std.mem.Allocator {
        return .{
            .ptr = self,
            .vtable = &.{
                .alloc = alloc,
                .resize = resize,
                .remap = remap,
                .free = free,
            },
        };
    }

    fn alloc(ctx: *anyopaque, len: usize, alignment: std.mem.Alignment, ra: usize) ?[*]u8 {
        const self: *CountingAllocator = @ptrCast(@alignCast(ctx));
        const p = self.child.rawAlloc(len, alignment, ra) orelse return null;
        self.bytes += len;
        self.calls += 1;
        return p;
    }

    fn resize(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, new_len: usize, ra: usize) bool {
        const self: *CountingAllocator = @ptrCast(@alignCast(ctx));
        return self.child.rawResize(memory, alignment, new_len, ra);
    }

    fn remap(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, new_len: usize, ra: usize) ?[*]u8 {
        const self: *CountingAllocator = @ptrCast(@alignCast(ctx));
        return self.child.rawRemap(memory, alignment, new_len, ra);
    }

    fn free(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, ra: usize) void {
        const self: *CountingAllocator = @ptrCast(@alignCast(ctx));
        self.child.rawFree(memory, alignment, ra);
    }
};

test "เขียน allocator เองด้วย vtable" {
    var counter: CountingAllocator = .{ .child = std.testing.allocator };
    const gpa = counter.allocator();

    const a = try gpa.alloc(u8, 10);
    defer gpa.free(a);
    const b = try gpa.alloc(u8, 25);
    defer gpa.free(b);

    try std.testing.expectEqual(@as(usize, 2), counter.calls);
    try std.testing.expectEqual(@as(usize, 35), counter.bytes);
}

รูปแบบ { ptr, vtable } นี้คือวิธีที่ Zig ทำ interface แบบที่ชนิดข้อมูลไม่ต้องรู้จักกันตอนคอมไพล์ — แบบเดียวกับที่ std.Io และ std.Io.Writer ใช้ ภาษาไม่มีคำสั่ง interface ให้ แต่มีรูปแบบที่ทั้ง standard library ใช้ร่วมกัน


Alignment

const std = @import("std");

test "alignment" {
    const gpa = std.testing.allocator;

    const aligned = try gpa.alignedAlloc(u8, .@"64", 10);
    defer gpa.free(aligned);
    try std.testing.expectEqual(@as(usize, 0), @intFromPtr(aligned.ptr) % 64);

    try std.testing.expectEqual(@as(usize, 8), std.mem.Alignment.of(u64).toByteUnits());
}

ตั้งแต่ 0.14 alignment เป็นชนิดข้อมูลของตัวเอง (std.mem.Alignment) ไม่ใช่ u8 ลอย ๆ — หมายเหตุการปล่อยเวอร์ชันบอกว่า “ค่าตัวเลขเหมือนเดิม แต่ตอนนี้มีความปลอดภัยเชิงชนิดข้อมูลและมีเมธอดที่สะดวกติดมาด้วย”

ใช้จริงเมื่อไหร่: ตอนทำงานกับ SIMD, ตอนคุยกับฮาร์ดแวร์ หรือตอนจัดข้อมูลให้ตรงกับ cache line (บนเครื่องผมคือ 128 ไบต์) เพื่อเลี่ยงปัญหา false sharing ระหว่างเธรด


จับ leak: ของจริงที่ผมรันให้ดู

นี่คือส่วนที่ทำให้การส่ง allocator ไปมาคุ้มค่าที่สุด ผมเขียนเทสที่จงใจลืมคืนหน่วยความจำ (บล็อกข้างล่างเป็นโค้ดบางส่วน ตัดบรรทัด @import ออก)

test "ลืมคืนหน่วยความจำ" {
    const gpa = std.testing.allocator;
    var list: std.ArrayList(u8) = .empty;
    // ลืม defer list.deinit(gpa);
    try list.appendSlice(gpa, "leak me");
    try std.testing.expectEqual(@as(usize, 7), list.items.len);
}

ผลจริงจาก zig test ตัดมาเฉพาะส่วนสำคัญ

1/1 a8leak.test.ลืมคืนหน่วยความจำ...OK
[DebugAllocator] (err): memory address 0x100ce0000 leaked:
.../std/array_list.zig:1235:56: in ensureTotalCapacityPrecise (test)
                const new_memory = try gpa.alignedAlloc(T, alignment, new_capacity);
...
a8leak.zig:7:25: in test.ลืมคืนหน่วยความจำ (test)
    try list.appendSlice(gpa, "leak me");

All 1 tests passed.
1 errors were logged.
1 tests leaked memory.
error: the following test command failed with exit code 1

มีสามอย่างที่น่าสนใจในผลนี้

หนึ่ง — ตัวเทสเองขึ้นว่า OK เพราะเทสไม่ได้ล้มเหลว มัน leak ต่างหาก บรรทัดที่ตัดสินคือ 1 tests leaked memory. และรหัสจบโปรแกรมเป็น 1 (ผมตรวจแล้ว) ซึ่งทำให้ CI แดง

สอง — รายงานชี้ถึงบรรทัดที่ขอหน่วยความจำ ไม่ใช่แค่บอกว่ามี leak แต่บอกว่า leak ก้อนนี้เกิดจากบรรทัดไหน เพราะ DebugAllocator เก็บ stack trace ตอนขอไว้ให้

สาม — ไม่ต้องตั้งค่าอะไรเลย std.testing.allocator เป็น DebugAllocator อยู่แล้ว ทุกเทสที่คุณเขียนได้การตรวจ leak ฟรี

หมายเหตุ: เอกสาร langref บางจุดยังเขียนว่า std.testing.allocator เป็น FixedBufferAllocator — ข้อมูลนั้นเก่าแล้ว ซอร์สของ 0.16 ยืนยันว่าเป็น DebugAllocator

แล้ว double free ล่ะ

ผมลองเขียนโปรแกรมที่ free ก้อนเดิมสองครั้งด้วย DebugAllocator ผลที่ได้ ไม่ใช่ข้อความบอกว่า “Double free detected” อย่างที่หลายคนคาด แต่เป็น

Segmentation fault at address 0x1004e0000
.../std/mem/Allocator.zig:448:5: in free__anon_27100 (a8double)
    @memset(bytes, undefined);
    ^
a8double.zig:9:13: in main (a8double)
    gpa.free(p);

เหตุผลคือ DebugAllocator ตั้งใจ ไม่นำที่อยู่เดิมกลับมาใช้ซ้ำ และคืนหน้าหน่วยความจำให้ระบบทันทีที่ไม่ใช้แล้ว พอ free รอบสองพยายามเขียนทับข้อมูลในก้อนที่ถูกคืนไปแล้ว จึงเกิด segmentation fault ตั้งแต่ก่อนที่ตรรกะตรวจ double free จะได้ทำงาน

ผลลัพธ์ในทางปฏิบัติยังดี — โปรแกรมตายตรงบรรทัดที่ผิดพอดี ซึ่งไล่ตามง่ายกว่าการปล่อยให้หน่วยความจำเสียหายเงียบ ๆ แล้วไปพังที่อื่นทีหลังมาก แต่ก็ต่างจากที่หลายบทความบอกไว้ ผมเลยเอาผลจริงมาให้ดู


สรุปบทนี้

  • Zig ไม่มี allocator ตั้งต้น ฟังก์ชันที่ขอหน่วยความจำต้องรับ Allocator เป็นพารามิเตอร์ — เห็นได้จาก signature
  • interface มีแค่สี่ฟังก์ชัน: alloc, resize, remap, free ที่เหลือสร้างบนนี้
  • resize ไม่ย้ายที่ ส่วน realloc ย้ายให้ — สำคัญมากถ้ามี slice ชี้อยู่
  • หน่วยความจำเต็มเป็น error.OutOfMemory ไม่ใช่การหยุดโปรแกรม
  • ธรรมเนียม: gpa เป็นพารามิเตอร์แรก, บอกให้ชัดว่าใครเป็นเจ้าของผลลัพธ์, errdefer คู่กับทุกการขอ
  • เขียน allocator เองได้ด้วยรูปแบบ { ptr, vtable } เดียวกับที่ std ใช้
  • std.testing.allocator จับ leak ให้ฟรี และทำให้ CI แดงด้วยรหัสจบ 1 พร้อมชี้บรรทัดที่ขอ
  • double free กับ DebugAllocator บนเครื่องผมจบด้วย segmentation fault ตรงบรรทัดที่ผิด ไม่ใช่ข้อความอธิบาย

บทหน้าเป็นภาคปฏิบัติ — allocator มีให้เลือกกี่แบบ แต่ละแบบเหมาะกับงานไหน ตัวไหนหายไปใน 0.16 บ้าง และ arena ที่ทำให้โค้ดจัดการหน่วยความจำหายไปเกือบหมด