
Zig Workshop #4: บั๊ก slice ที่ชี้ไปในอากาศ และวิธีแก้ด้วย arena
บทที่แล้วเราได้ 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 แดง โดยไม่ต้องตั้งค่าอะไรเลย
Navigation
- ก่อนหน้า: Workshop #3: แยกวิเคราะห์แบบไม่ copy
- ถัดไป: Workshop #5: ให้ comptime สร้าง CLI ให้
- โค้ดบทนี้: step-04-allocators
- ดูทั้งชุด: บทความแท็ก zig