
Zig 0.16 บทที่ 8: allocator คืออะไร และใครเป็นเจ้าของหน่วยความจำ
ทุกบทที่ผ่านมามีคำว่า gpa โผล่มาตลอด บทนี้จะอธิบายว่ามันคืออะไร ทำไมต้องส่งไปทุกที่ และทำไมนี่คือการตัดสินใจออกแบบที่สำคัญที่สุดของ Zig
เอกสารทางการเขียนไว้ตรงมาก:
ภาษา C มีการจัดการหน่วยความจำด้วยมือเหมือน Zig แต่ต่างกันตรงที่ C มี allocator ตั้งต้นให้ คือ
malloc,reallocและfree… แต่ตามธรรมเนียมของ Zig ไม่มี allocator ตั้งต้น ฟังก์ชันที่ต้องขอหน่วยความจำจะรับพารามิเตอร์Allocatorแทน
ประโยคเดียวนี้อธิบายทั้งข้อดีข้อเสียของ Zig ในเรื่องหน่วยความจำ
ทำไมถึงไม่มี allocator ตั้งต้น
ลองคิดถึงฟังก์ชันใน Java ที่ชื่อ split() มันขอหน่วยความจำไหม ตอบไม่ได้จากชื่อ ต้องเปิดโค้ดดู และต่อให้รู้ว่าขอ ก็เลือกไม่ได้ว่าจะให้ขอจากไหน
ใน Zig ถ้าฟังก์ชันหนึ่งต้องขอหน่วยความจำ signature มันจะบอกคุณเอง เพราะต้องมีพารามิเตอร์ std.mem.Allocator และเมื่อเป็นพารามิเตอร์แล้ว คนเรียกเป็นคนเลือก ว่าจะให้ขอจากที่ไหน
ประโยชน์ที่ตามมาสี่ข้อ:
- ไลบรารีใช้ได้ทุกที่ — เอกสารบอกว่าเพราะ allocator เป็นพารามิเตอร์ standard library จึงใช้บนเป้าหมาย freestanding (ไม่มีระบบปฏิบัติการ) ได้
- ทดสอบได้ว่าโค้ดรับมือกับหน่วยความจำเต็มไหม — ส่ง allocator ที่แกล้งพังเข้าไป (บทที่แล้วทำไปแล้ว)
- จับ leak ได้อัตโนมัติ — ส่ง allocator ที่นับให้ แล้วเทสจะฟ้องเอง
- เปลี่ยนกลยุทธ์ได้ทีละจุดเรียก — ฟังก์ชันเดิม เรียกด้วย 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 ที่ทำให้โค้ดจัดการหน่วยความจำหายไปเกือบหมด
Navigation
- ก่อนหน้า: บทที่ 7: error ในงานจริง
- ถัดไป: บทที่ 9: เลือก allocator ให้ถูกงาน
- ดูทั้งชุด: บทความแท็ก zig