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

Zig Workshop #4: บั๊ก slice ที่ชี้ไปในอากาศ และวิธีแก้ด้วย arena

พี่โชว์Engineering

บทที่แล้วเราได้ parser ที่เร็วเพราะไม่ copy อะไรเลย บทนี้เราจะเก็บสถิติ — และตรงนี้แหละที่ “ไม่ copy” กลายเป็นปัญหา

โค้ดบทนี้อยู่ที่ step-04-allocators


บั๊กที่คอมไพเลอร์ปล่อยผ่าน

เป้าหมายง่ายมาก: นับว่าแต่ละ path ถูกเรียกกี่ครั้ง โค้ดที่ทุกคนจะเขียนคือแบบนี้ (โค้ดบางส่วน)

// โค้ดบางส่วน — เขียนแบบนี้แล้วคอมไพล์ผ่าน แต่ผลลัพธ์ผิด
while (try reader.takeDelimiter('\n')) |raw| {
    const line = try parseLine(raw);
    const gop = try map.getOrPut(gpa, line.path); // ❌ key ยืมมาจาก read buffer
    if (!gop.found_existing) gop.value_ptr.* = 0;
    gop.value_ptr.* += 1;
}

line.path ชี้เข้าไปใน buffer ขนาด 64 กิโลไบต์ของ reader พอ reader เติมข้อมูลรอบถัดไป key ที่ map เก็บไว้จะชี้ไปยังข้อความอื่นทันที

ผลที่ได้: สถิติมั่วแบบเงียบ ๆ — path หายไปบ้าง โผล่ซ้ำบ้าง ตัวเลขรวมไม่ตรงกับจำนวนบรรทัด และมันจะดูปกติถ้าไฟล์ทดสอบของคุณเล็กกว่า 64 กิโลไบต์ เพราะข้อมูลทั้งหมดอยู่ใน buffer เดียวไม่เคยถูกทับ

นี่คือบั๊กประเภทที่แย่ที่สุด: ผ่านตอนทดสอบ พังตอนใช้จริง


แก้ให้ถูก: copy ครั้งเดียวตอนเห็นครั้งแรก

ทางแก้ไม่ใช่ copy ทุกบรรทัด (แบบนั้นก็เสียประโยชน์ของ zero-copy ไปหมด) แต่คือ copy เฉพาะครั้งแรกที่เห็น key นั้น แล้วครั้งต่อ ๆ ไปใช้ของเดิม

log จริงมี path ซ้ำมหาศาล — ล้านบรรทัดอาจมี path ไม่ถึงร้อยแบบ เทคนิคนี้เรียกว่า interning และปลายทางที่เหมาะสำหรับเก็บของพวกนี้คือ arena เพราะทุกชิ้นมีอายุเท่ากันหมดคืออยู่จนจบการทำงาน

const std = @import("std");

pub const Entry = struct {
    key: []const u8,
    hits: u64,
    bytes: u64,
};

pub const Counter = struct {
    arena: std.heap.ArenaAllocator,
    map: std.StringHashMapUnmanaged(Entry) = .empty,

    pub fn init(child: std.mem.Allocator) Counter {
        return .{ .arena = .init(child) };
    }

    pub fn deinit(self: *Counter, gpa: std.mem.Allocator) void {
        self.map.deinit(gpa);
        self.arena.deinit(); // คืนข้อความที่ intern ไว้ทั้งหมดทีเดียว
    }

    /// `key` ยืมมาจาก buffer ที่กำลังจะถูกเขียนทับ จึง copy เฉพาะครั้งแรก
    pub fn add(self: *Counter, gpa: std.mem.Allocator, key: []const u8, bytes: u64) !void {
        const gop = try self.map.getOrPut(gpa, key);
        if (!gop.found_existing) {
            const owned = try self.arena.allocator().dupe(u8, key);
            gop.key_ptr.* = owned; // เปลี่ยน key ให้ชี้ของที่เราเป็นเจ้าของ
            gop.value_ptr.* = .{ .key = owned, .hits = 0, .bytes = 0 };
        }
        gop.value_ptr.hits += 1;
        gop.value_ptr.bytes += bytes;
    }

    pub fn count(self: Counter) usize {
        return self.map.count();
    }
};

test "key ที่ยืมมาอยู่รอดแม้ buffer ถูกเขียนทับ" {
    const gpa = std.testing.allocator;
    var c: Counter = .init(gpa);
    defer c.deinit(gpa);

    var buf: [16]u8 = undefined;
    @memcpy(buf[0..5], "/here");
    try c.add(gpa, buf[0..5], 10);

    // จำลอง reader เติมข้อมูลใหม่ทับ buffer เดิม
    @memcpy(buf[0..5], "XXXXX");

    try std.testing.expectEqual(@as(usize, 1), c.count());
    try std.testing.expect(c.map.get("/here") != null); // ยังหาเจอ
}

test "นับซ้ำถูกต้อง" {
    const gpa = std.testing.allocator;
    var c: Counter = .init(gpa);
    defer c.deinit(gpa);

    try c.add(gpa, "/index.html", 100);
    try c.add(gpa, "/index.html", 200);
    try c.add(gpa, "/about", 50);

    try std.testing.expectEqual(@as(usize, 2), c.count());
    try std.testing.expectEqual(@as(u64, 2), c.map.get("/index.html").?.hits);
    try std.testing.expectEqual(@as(u64, 300), c.map.get("/index.html").?.bytes);
}

เทสแรกคือหัวใจของบทนี้ — มันจำลองสิ่งที่ reader ทำจริง ๆ (เขียนทับ buffer) แล้วพิสูจน์ว่าข้อมูลที่เราเก็บไว้ยังถูกต้อง ถ้าลบสามบรรทัดที่ dupe ออก เทสนี้จะล้มเหลวทันที ลองดูได้

gop.key_ptr.* = owned; เป็นบรรทัดที่คนพลาดบ่อย — ไม่ใช่แค่เก็บสำเนาไว้ในค่า แต่ต้องเปลี่ยน key ของ map เอง ให้ชี้ของที่เราเป็นเจ้าของด้วย ไม่งั้น map จะยังจำ key ที่ยืมมาไว้เปรียบเทียบ


getOrPut: hash ครั้งเดียว แทนที่จะสามครั้ง

คนที่มาจาก Java หรือ C# มักเขียนแบบนี้ (โค้ดบางส่วน)

// โค้ดบางส่วน — ทำงานถูกแต่ทำงานซ้ำสามรอบ
if (map.contains(key)) {                    // hash รอบที่ 1
    const v = map.get(key).?;               // hash รอบที่ 2
    try map.put(gpa, key, v + 1);           // hash รอบที่ 3
} else {
    try map.put(gpa, key, 1);
}

getOrPut ทำทุกอย่างในการเดินตารางครั้งเดียว แล้วคืน pointer ไปยังช่องนั้นให้เราแก้ค่าตรง ๆ พร้อมบอกว่าเจอของเดิมไหม

นี่คือ pattern เดียวกับ entry().or_insert_with() ของ Rust และ computeIfAbsent ของ Java ส่วน Go ไม่มีตัวเทียบตรง ๆ


เรียง top-N

const std = @import("std");

const Entry = struct { key: []const u8, hits: u64 };

fn moreHits(_: void, a: Entry, b: Entry) bool {
    if (a.hits != b.hits) return a.hits > b.hits;
    return std.mem.lessThan(u8, a.key, b.key); // เท่ากันให้เรียงตามชื่อ ผลจะคงที่
}

test "sort.pdq เรียงจากมากไปน้อยแบบผลลัพธ์คงที่" {
    var rows = [_]Entry{
        .{ .key = "/b", .hits = 5 },
        .{ .key = "/a", .hits = 9 },
        .{ .key = "/c", .hits = 5 },
    };

    std.sort.pdq(Entry, &rows, {}, moreHits);

    try std.testing.expectEqualStrings("/a", rows[0].key);
    try std.testing.expectEqualStrings("/b", rows[1].key); // 5 เท่ากัน เรียงตามชื่อ
    try std.testing.expectEqualStrings("/c", rows[2].key);
}

std.sort.pdq คือ pattern-defeating quicksort — เร็ว ใช้หน่วยความจำเพิ่มระดับ log(n) แต่ ไม่เสถียร (ของที่ค่าเท่ากันอาจสลับที่) นั่นคือเหตุผลที่ตัวเปรียบเทียบต้องมีเงื่อนไขสำรองว่าเมื่อ hits เท่ากันให้เรียงตามชื่อ ไม่งั้นผลลัพธ์จะเปลี่ยนไปมาระหว่างรอบ ทำให้เทสไม่นิ่งและผู้ใช้สับสน

ถ้าต้องการความเสถียรจริง ๆ ใช้ std.sort.block แทน แต่ช้ากว่า


leak ที่ตรวจได้ฟรี

std.testing.allocator จับ leak ให้ทุกเทสโดยไม่ต้องตั้งค่าอะไร ลองแกล้งลบ defer c.deinit(gpa); ออกจากเทสแล้วรันดู จะได้แบบนี้

1/2 counter.test.นับซ้ำถูกต้อง...OK
[DebugAllocator] (err): memory address 0x... leaked:
.../std/hash_map.zig:...: in ensureTotalCapacityContext
...
All 2 tests passed.
1 tests leaked memory.
error: the following test command failed with exit code 1

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


ผลลัพธ์ของบทนี้

zig build run -- top access.log --by=path
lines=1000000 skipped=0 bytes=32764526771

top 10 by path (10 distinct)
  100835  /health
  100225  /img/logo.png
  100143  /api/v1/users

ตอนนี้ --by ยังเลือกได้แค่ path กับ ip เพราะเราเขียน switch ไว้เอง บทหน้าเราจะให้ comptime อ่านโครงสร้าง LogLine แล้วสร้างรายการฟิลด์ให้เอง — เพิ่มฟิลด์ใหม่ใน struct แล้ว CLI รองรับทันทีโดยไม่ต้องแก้อะไรอีก


สรุปบทนี้

  • slice ที่ parser คืนมา ยืมมาจาก buffer เก็บลงโครงสร้างที่อายุยาวกว่าไม่ได้ — และคอมไพเลอร์ไม่เตือน
  • แก้ด้วย interning: copy ครั้งเดียวตอนเห็นครั้งแรก ลง arena ที่คืนทีเดียวจบ
  • อย่าลืม gop.key_ptr.* = owned; ไม่ใช่แค่เก็บใส่ค่า
  • getOrPut แทน contains + get + put — เดินตารางครั้งเดียว
  • sort.pdq ไม่เสถียร ต้องมีเงื่อนไขสำรองเพื่อให้ผลลัพธ์คงที่
  • std.testing.allocator ทำให้ leak กลายเป็น CI แดง โดยไม่ต้องตั้งค่าอะไรเลย