
Zig 0.16 บทที่ 9: เลือก allocator ให้ถูกงาน และของที่หายไปใน 0.16
บทที่แล้วเราเห็นแล้วว่า 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 เลือกให้ตามการตั้งค่าของ buildinit.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 |
เทียบกับภาษาอื่น
| Zig | C | C++ | Rust | Go / Java / C# | |
|---|---|---|---|---|---|
| ใครจัดการ | คุณ ผ่าน allocator ที่เลือกเอง | คุณ ผ่าน malloc/free | RAII + smart pointer | ownership + Drop | GC |
| เลือกกลยุทธ์ต่อจุดเรียกได้ | ได้ | ยาก | ได้ผ่าน 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 — สำคัญมากตอนทำ benchmarkGeneralPurposeAllocator,ThreadSafeAllocator,raw_c_allocatorถูกลบไปแล้วจริง ๆ
บทหน้าเป็นบทยาวที่รวบทุกอย่าง — ทำไม Zig ถึงน่าสนใจ เทียบกับ C, C++, Rust, Go, C# และ Java แบบตรงไปตรงมา รวมถึงข้อจำกัดที่ต้องรู้ก่อนเอาไปใช้งานจริง