ภาพวาดโทนมิ้นต์สว่าง อิกัวนาตัวเล็กยืนเลือกภาชนะจากชั้นไม้ที่มีถาดใหญ่ใบเดียว ถังเล็ก กล่องช่องเท่ากันหลายช่อง และกระป๋องจิ๋ว กำลังยกถาดใหญ่ที่มีขอบสีอำพันลงมาใช้

Zig 0.16 บทที่ 9: เลือก allocator ให้ถูกงาน และของที่หายไปใน 0.16

พี่โชว์Engineering

บทที่แล้วเราเห็นแล้วว่า allocator คือ interface สี่ฟังก์ชันที่ส่งไปมาได้ บทนี้คือภาคปฏิบัติ — มีให้เลือกกี่แบบ แบบไหนเหมาะกับงานอะไร และตัวไหนที่ทุก tutorial ยังสอนอยู่แต่ ถูกลบไปแล้วใน 0.16

จุดที่อยากให้เห็นคือ การเลือก allocator ให้ตรงงานมักได้ผลมากกว่าการไปแก้อัลกอริทึม เพราะมันเปลี่ยนทั้งจำนวนครั้งที่คุยกับระบบ ปริมาณโค้ดที่ต้องเขียน และโอกาสเกิดบั๊ก


Arena: ขอเยอะ คืนทีเดียว

ถ้าจะจำ allocator ตัวเดียวจากบทนี้ ให้จำตัวนี้

แนวคิดคือ ขอหน่วยความจำเป็นก้อนใหญ่จากข้างล่างทีเดียว แล้วแจกย่อยไปเรื่อย ๆ โดยไม่คืนทีละชิ้น พอจบงานก็คืนทั้งก้อนพร้อมกัน เอกสารทางการเขียนไว้ว่า “เมื่อใช้ allocator แบบนี้ ไม่ต้อง free อะไรด้วยมือเลย ทุกอย่างถูกคืนพร้อมกันตอนเรียก arena.deinit()”

const std = @import("std");

const Request = struct { path: []const u8, tags: []const []const u8 };

fn handleRequest(arena: std.mem.Allocator, raw: []const u8) !Request {
    var tags: std.ArrayList([]const u8) = .empty;

    var it = std.mem.tokenizeScalar(u8, raw, ',');
    const path = it.next() orelse return error.BadRequest;
    while (it.next()) |t| try tags.append(arena, try arena.dupe(u8, t));

    return .{
        .path = try arena.dupe(u8, path),
        .tags = try tags.toOwnedSlice(arena),
    };
}

test "arena: ไม่ต้อง free ทีละชิ้น" {
    var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
    defer arena.deinit(); // คืนทีเดียวจบ
    const a = arena.allocator();

    const r = try handleRequest(a, "/index.html,html,cache");

    try std.testing.expectEqualStrings("/index.html", r.path);
    try std.testing.expectEqual(@as(usize, 2), r.tags.len);
    try std.testing.expectEqualStrings("cache", r.tags[1]);
}

สังเกตว่าใน handleRequest ไม่มี free และไม่มี defer สำหรับคืนหน่วยความจำเลยสักบรรทัด และเทสนี้ผ่านด้วย std.testing.allocator ที่จับ leak — แปลว่าไม่มีอะไรรั่วจริง ๆ เพราะ arena.deinit() เก็บให้หมด

โค้ดที่หายไปคือโค้ดที่ไม่มีบั๊ก นี่คือคุณค่าหลักของ arena ก่อนจะพูดถึงเรื่องความเร็วด้วยซ้ำ

วัดให้เห็นว่าลดการขอลงจริงแค่ไหน

ผมเทียบสองแบบ: ขอตรง ๆ 50 ครั้ง กับขอผ่าน arena 50 ครั้ง แล้วนับว่า allocator ข้างล่างถูกเรียกกี่ครั้ง

const std = @import("std");

test "arena ลดจำนวนครั้งที่คุยกับ allocator ข้างล่าง" {
    // แบบที่ 1: ขอ-คืนตรง ๆ ทีละชิ้น
    var direct = std.testing.FailingAllocator.init(std.testing.allocator, .{});
    {
        const a = direct.allocator();
        for (0..50) |i| {
            const p = try a.alloc(u8, 32 + i);
            a.free(p);
        }
    }

    // แบบที่ 2: ผ่าน arena
    var backing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
    {
        var arena: std.heap.ArenaAllocator = .init(backing.allocator());
        defer arena.deinit();
        const a = arena.allocator();
        for (0..50) |i| _ = try a.alloc(u8, 32 + i);
    }

    std.debug.print("\n  direct={d} ครั้ง / arena backing={d} ครั้ง\n", .{ direct.allocations, backing.allocations });
    try std.testing.expect(backing.allocations < direct.allocations);
}

ผลจริงที่พิมพ์ออกมา:

  direct=50 ครั้ง / arena backing=5 ครั้ง

50 ครั้ง เหลือ 5 ครั้ง เพราะ arena ขอก้อนใหญ่มาแล้วแจกย่อยเอง ต้องไปขอเพิ่มเฉพาะตอนก้อนเดิมหมด

เหมาะกับงานแบบไหน: งานที่มีขอบเขตชัดว่า “จบเมื่อไหร่” — หนึ่ง HTTP request, หนึ่งเฟรมของเกม, หนึ่งไฟล์ที่ประมวลผล, หรือ CLI ที่ทำงานแล้วจบ

ของใหม่ใน 0.16: ArenaAllocator เป็น thread-safe และทำงานแบบ lock-free แล้ว (แต่ reset() ยังไม่ thread-safe) หมายเหตุการปล่อยเวอร์ชันไม่ได้ให้ตัวเลขไว้ ผมจะวัดเองในชุดบทความ optimization

ใช้ซ้ำด้วย reset

const std = @import("std");

test "arena reset: ใช้ซ้ำโดยไม่คืนที่ให้ระบบ" {
    var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
    defer arena.deinit();
    const a = arena.allocator();

    for (0..3) |_| {
        const tmp = try a.alloc(u8, 1024);
        @memset(tmp, 0);
        _ = arena.reset(.retain_capacity); // เก็บที่ไว้ใช้รอบหน้า
    }
}

reset มีสามโหมด: .free_all (คืนให้ระบบหมด), .retain_capacity (เก็บที่ไว้ ใช้รอบหน้าได้เลยไม่ต้องขอใหม่) และ .{ .retain_with_limit = n } (เก็บไว้ไม่เกิน n ไบต์)

.retain_capacity คือท่ามาตรฐานของ server ที่วนรับงาน — รอบแรกขอจากระบบ รอบต่อ ๆ ไปแทบไม่ต้องขอเลย


FixedBufferAllocator: ไม่แตะ heap เลย

const std = @import("std");

fn formatStatusLine(buf: []u8, code: u16, path: []const u8) ![]const u8 {
    var fba: std.heap.FixedBufferAllocator = .init(buf);
    const a = fba.allocator();
    return std.fmt.allocPrint(a, "{d} {s}", .{ code, path });
}

test "FixedBufferAllocator: งบจำกัด รู้เพดานแน่นอน" {
    var buf: [256]u8 = undefined;
    const line = try formatStatusLine(&buf, 404, "/missing");
    try std.testing.expectEqualStrings("404 /missing", line);

    // งบไม่พอ → ได้ error ไม่ใช่แอบไปขอ heap
    var tiny: [8]u8 = undefined;
    try std.testing.expectError(error.OutOfMemory, formatStatusLine(&tiny, 404, "/missing"));
}

buffer อยู่บน stack ทั้งหมด ไม่มีการคุยกับระบบปฏิบัติการเลย เหมาะกับระบบฝังตัว โค้ดที่ห้ามใช้ heap หรือ hot path ที่รู้เพดานแน่นอน

บทเรียนที่ผมเจอเองตอนเขียนตัวอย่างนี้: ตอนแรกผมให้ buffer 64 ไบต์ ทั้งที่ข้อความผลลัพธ์ยาวแค่ 12 ไบต์ แล้วมันพังด้วย error.OutOfMemory เพราะ allocPrint ขอที่เผื่อไว้มากกว่าความยาวสุดท้ายพอสมควร ถ้าอยากได้แบบไม่ขอหน่วยความจำเลยจริง ๆ ใช้ std.fmt.bufPrint ตรง ๆ จะตรงไปตรงมากว่า


MemoryPool: object ชนิดเดียว จำนวนมาก

const std = @import("std");

const TreeNode = struct { value: u32, left: ?*TreeNode = null, right: ?*TreeNode = null };

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

    var pool: std.heap.MemoryPool(TreeNode) = .empty;
    defer pool.deinit(gpa);

    const root = try pool.create(gpa);
    root.* = .{ .value = 1 };

    const left = try pool.create(gpa);
    left.* = .{ .value = 2 };
    root.left = left;

    try std.testing.expectEqual(@as(u32, 2), root.left.?.value);

    pool.destroy(left); // คืนเข้าคิวว่าง ให้ create ครั้งหน้าหยิบไปใช้ต่อ
}

เอกสารในซอร์สบอกไว้ว่า “ใช้ตัวนี้เมื่อต้องสร้าง object ชนิดเดียวกันจำนวนมาก เพราะมันเร็วกว่า allocator ทั่วไป” ข้างในคือ arena สำหรับขยาย บวกรายการที่ว่างแบบร้อยต่อกันสำหรับนำกลับมาใช้ซ้ำ

เปลี่ยนใน 0.16: MemoryPool กลายเป็นแบบ unmanaged แล้ว ใช้ .empty แล้วส่ง gpa ให้ create/deinit — ของเก่าที่เขียน MemoryPool(T).init(gpa) ใช้ไม่ได้


stackFallback ใน 0.16 → BufferFirstAllocator ใน 0.17

const std = @import("std");

test "buffer ก่อน แล้วค่อย fallback" {
    var buffer: [128]u8 = undefined;

    // 0.17 เปลี่ยนชื่อและ API; กิ่งที่ไม่ตรงเวอร์ชันจะถูกตัดทิ้งตอน comptime
    var state = if (comptime @hasDecl(std.heap, "BufferFirstAllocator"))
        std.heap.BufferFirstAllocator.init(&buffer, std.testing.allocator)
    else
        std.heap.stackFallback(128, std.testing.allocator);
    const a = if (comptime @hasDecl(std.heap, "BufferFirstAllocator"))
        state.allocator()
    else
        state.get();

    const small = try a.alloc(u8, 64); // พอดีกับ buffer แรก
    defer a.free(small);
    const large = try a.alloc(u8, 256); // ใหญ่เกิน buffer จึงใช้ fallback
    defer a.free(large);

    try std.testing.expectEqual(@as(usize, 64), small.len);
    try std.testing.expectEqual(@as(usize, 256), large.len);
}

เหมาะกับงานที่ “ปกติเล็ก แต่บางทีใหญ่” เช่นเก็บ path หรือ buffer ชั่วคราว บน 0.16 ใช้ stackFallback(...).get(); ตั้งแต่ 0.17 ใช้ BufferFirstAllocator.init(&buffer, fallback).allocator() ชื่อใหม่บอกพฤติกรรมตรงกว่าและให้เราถือ buffer เองอย่างชัดเจน


ตารางเลือกใช้

allocatorใช้เมื่อระวัง
ArenaAllocatorงานที่มีขอบเขตจบชัดเจน — request, เฟรม, CLI ที่รันแล้วจบfree ทีละชิ้นไม่คืนที่จริง; reset ไม่ thread-safe
FixedBufferAllocatorห้ามแตะ heap, ระบบฝังตัว, รู้เพดานแน่นอนallocator() ไม่ thread-safe ใช้ threadSafeAllocator() แทน
DebugAllocatorตอนพัฒนาและตอนเทส — จับ leak, double freeช้าและเปลืองมาก เอกสารบอกเองว่า “ขอ 1 ไบต์ ประสิทธิภาพต่ำกว่า 1%“
smp_allocatorงานจริงโหมด ReleaseFast ที่มีหลายเธรดเป็น singleton ใช้ตัวเดียวทั้งโปรเซส
page_allocatorขอก้อนใหญ่ ๆ นาน ๆ ครั้ง หรือใช้เป็นฐานให้ arenaยิง syscall ทุกครั้งที่ขอและคืน ห้ามใช้กับก้อนเล็กจำนวนมาก
MemoryPool(T)object ชนิดเดียวจำนวนมาก เช่น node ของต้นไม้ทุกชิ้นขนาดเท่ากันตาม T
stackFallback (0.16) / BufferFirstAllocator (0.17)ปกติเล็ก บางทีใหญ่API เปลี่ยนตามเวอร์ชัน; buffer ต้องมีอายุยาวกว่า allocation
c_allocatorตอนลิงก์กับ libc อยู่แล้วใช้ได้เฉพาะเมื่อลิงก์ libc
std.testing.allocatorในเทสเท่านั้นเป็น DebugAllocator ห้ามเอาไปวัดความเร็ว

Juicy Main: 0.16 เลือก allocator ให้อัตโนมัติ

ของใหม่ที่ควรรู้ — main(init: std.process.Init) มี allocator เตรียมไว้ให้สองตัว

  • init.gpa — allocator ทั่วไปที่ Zig เลือกให้ตามการตั้งค่าของ build
  • init.arena — arena ที่อายุเท่ากับทั้งโปรเซส คืนให้อัตโนมัติตอนโปรแกรมจบ

init.arena สะดวกมากสำหรับ CLI: ของที่ต้องอยู่จนจบโปรแกรม เช่น argument ที่แปลงแล้ว หรือ config ให้ขอจากตรงนี้ ไม่ต้องเขียนโค้ดคืนเลย

แต่มีกับดักที่ต้องรู้ก่อนจะไปทำ benchmark: ตัวที่ init.gpa เลือกให้ เปลี่ยนตาม build mode — โหมด Debug ได้ DebugAllocator (ช้าและตรวจเยอะ) ส่วนโหมด Release ได้ตัวที่เร็วกว่ามาก

แปลว่าถ้าคุณวัดเวลาโปรแกรมในโหมด Debug เทียบกับ ReleaseFast คุณกำลังเทียบสองอย่างพร้อมกัน ทั้งการปรับโค้ดของคอมไพเลอร์และ allocator คนละตัว ชุดบทความเรื่อง optimization จะแยกสองตัวแปรนี้ออกจากกันและวัดให้ดูทีละอย่าง


ของที่หายไปใน 0.16

ถ้าเจอชื่อพวกนี้ในตัวอย่างที่ Google มา แปลว่าเป็นของเก่า ทั้งหมดนี้ผมตรวจแล้วว่าไม่มีอยู่จริงใน 0.16

ชื่อเก่าสถานะใช้อะไรแทน
std.heap.GeneralPurposeAllocatorถูกลบstd.heap.DebugAllocator
std.heap.ThreadSafeAllocatorถูกลบหมายเหตุ 0.16 บอกว่ามันเป็น anti-pattern ให้ใช้ allocator ที่ lock-free เองอย่าง Arena หรือ Smp
std.heap.raw_c_allocatorถูกลบstd.heap.c_allocator
std.heap.SbrkAllocatorเปลี่ยนชื่อstd.heap.BrkAllocator
MemoryPool(T).init(gpa)เปลี่ยนรูปแบบ.empty แล้วส่ง gpa ให้ create/deinit

เทียบกับภาษาอื่น

ZigCC++RustGo / Java / C#
ใครจัดการคุณ ผ่าน allocator ที่เลือกเองคุณ ผ่าน malloc/freeRAII + smart pointerownership + DropGC
เลือกกลยุทธ์ต่อจุดเรียกได้ได้ยากได้ผ่าน custom allocator (ซับซ้อน)ได้ผ่าน allocator API (ยังไม่นิ่ง)ไม่ได้
หน่วยความจำเต็มerror.OutOfMemory ที่ต้องจัดการคืน NULL (คนมักไม่เช็ค)std::bad_allocมักหยุดโปรแกรมหยุดโปรแกรม
leak ตรวจได้ตอนเทสได้ฟรีทุกเทสต้องใช้ valgrind/ASanต้องใช้เครื่องมือเสริมหายากกว่าแต่ยังเกิดได้GC เก็บให้ (แต่ leak เชิงตรรกะยังมี)
use-after-freeเกิดได้เกิดได้เกิดได้คอมไพเลอร์กันไม่เกิด

แถวสุดท้ายคือราคาที่ต้องจ่าย และผมจะย้ำทุกครั้งที่มีโอกาส — Zig ให้ความชัดเจนและการควบคุม แต่ไม่ได้ให้การรับประกันแบบ Rust


สรุปบทนี้

  • arena คือของที่ควรหยิบก่อนเสมอเมื่องานมีขอบเขตจบชัด — โค้ดคืนหน่วยความจำหายไปเกือบหมด และวัดได้ว่าลดการขอจาก 50 ครั้งเหลือ 5
  • reset(.retain_capacity) ทำให้ arena ใช้ซ้ำได้โดยแทบไม่ต้องขอที่ใหม่
  • FixedBufferAllocator ไม่แตะ heap เลย แต่ allocPrint ขอที่เผื่อมากกว่าความยาวจริง
  • MemoryPool สำหรับ object ชนิดเดียวจำนวนมาก และเป็น unmanaged แล้วใน 0.16
  • 0.16 ใช้ stackFallback(...).get(); 0.17 ย้ายแนวคิดเดียวกันไป BufferFirstAllocator(...).allocator()
  • init.gpa ใน 0.16 เปลี่ยน allocator ตาม build mode — สำคัญมากตอนทำ benchmark
  • GeneralPurposeAllocator, ThreadSafeAllocator, raw_c_allocator ถูกลบไปแล้วจริง ๆ

บทหน้าเป็นบทยาวที่รวบทุกอย่าง — ทำไม Zig ถึงน่าสนใจ เทียบกับ C, C++, Rust, Go, C# และ Java แบบตรงไปตรงมา รวมถึงข้อจำกัดที่ต้องรู้ก่อนเอาไปใช้งานจริง