
Zig Workshop #2: อ่านไฟล์ 94 MB ด้วยแรม 1.8 MB
บทที่แล้วเราได้โครงที่รันได้ บทนี้ให้มันอ่านไฟล์จริง
โจทย์ที่ต้องคิดตั้งแต่บรรทัดแรก: access log ของเซิร์ฟเวอร์จริงมีขนาดเป็นกิกะไบต์ ถ้าเขียนโค้ดแบบ “อ่านทั้งไฟล์เข้ามาก่อนแล้วค่อยแยกบรรทัด” — ซึ่งเป็นวิธีที่ Python, Go และ Java สอนกันเป็นค่าเริ่มต้น — โปรแกรมจะกินแรมเท่าขนาดไฟล์
โค้ดบทนี้อยู่ที่ step-02-read-lines
อ่านทีละบรรทัดด้วย Io.Reader
const std = @import("std");
const Io = std.Io;
/// ตัด \r ท้ายบรรทัดที่ติดมากับไฟล์จาก Windows
fn trimEol(line: []const u8) []const u8 {
return std.mem.trimEnd(u8, line, "\r");
}
const Counts = struct {
lines: u64 = 0,
bytes: u64 = 0,
};
fn countReader(reader: *Io.Reader) !Counts {
var c: Counts = .{};
while (try reader.takeDelimiter('\n')) |raw| {
const line = trimEol(raw);
c.lines += 1;
c.bytes += line.len;
}
return c;
}
fn countFile(io: Io, path: []const u8) !Counts {
const file = try Io.Dir.cwd().openFile(io, path, .{});
defer file.close(io); // close ก็ต้องส่ง io
var read_buf: [64 * 1024]u8 = undefined; // buffer เป็นของเรา
var fr = file.reader(io, &read_buf);
return countReader(&fr.interface);
}
test "countReader นับบรรทัดและไบต์" {
var r: Io.Reader = .fixed("one\ntwo\r\nthree\n");
const c = try countReader(&r);
try std.testing.expectEqual(@as(u64, 3), c.lines);
try std.testing.expectEqual(@as(u64, 11), c.bytes); // 3 + 3 + 5 หลังตัด \r
}
test "trimEol รองรับทั้ง LF และ CRLF" {
try std.testing.expectEqualStrings("hello", trimEol("hello"));
try std.testing.expectEqualStrings("hello", trimEol("hello\r"));
}
สังเกตว่า countReader รับ *Io.Reader ไม่ใช่ path หรือ file — แยกตรรกะออกจากที่มาของข้อมูล ผลคือเทสข้างบนไม่ต้องแตะดิสก์เลย ใช้ Io.Reader.fixed(...) ป้อนข้อความตรง ๆ ได้ และตัวโปรแกรมจริงจะป้อนไฟล์หรือ stdin ก็ได้เหมือนกัน
รูปแบบนี้คือของที่ควรทำเป็นนิสัยตั้งแต่แรก ไม่ใช่ตอนที่โค้ดโตแล้วค่อยมารื้อ
buffer ทำอะไร และทำไมต้องเป็นเรา
file.reader(io, &read_buf) — 64 กิโลไบต์นั้นคือขนาดที่ระบบปฏิบัติการจะถูกเรียกครั้งหนึ่ง
ทุกครั้งที่ takeDelimiter ต้องการข้อมูลใหม่ มันจะดูใน buffer ก่อน ถ้าหมดแล้วค่อยขอจากระบบมาเติมทีเดียว 64 กิโลไบต์ ไฟล์ 94 เมกะไบต์จึงเรียกระบบราวหนึ่งพันห้าร้อยครั้ง แทนที่จะเป็นล้านครั้งถ้าอ่านทีละบรรทัดตรง ๆ
เวอร์ชันก่อน ๆ Zig มี BufferedReader ให้ห่อ ซึ่งคนลืมห่อกันประจำจนกลายเป็นบั๊กประสิทธิภาพยอดฮิต 0.16 เลยตัดปัญหาด้วยการบังคับให้ส่ง buffer มาตั้งแต่แรก การไม่มี buffer กลายเป็นสิ่งที่ต้องเขียนออกมาให้เห็น (&.{}) ไม่ใช่ค่าเริ่มต้นที่หลุดมาโดยไม่ตั้งใจ
takeDelimiter และกับดักที่ต้องรู้
takeDelimiter('\n') คืน optional — ได้ null เมื่อหมดไฟล์ เลยเขียน while (...) |line| ได้สวย ๆ
แต่บรรทัดที่ได้มาคือ slice ที่ชี้เข้าไปใน read_buf ไม่ได้ copy ให้
พอวนรอบถัดไปแล้ว reader ต้องเติมข้อมูลใหม่ ข้อมูลเก่าใน buffer จะถูกเขียนทับ และ slice ที่คุณเก็บไว้จะกลายเป็นขยะทันที โดยที่คอมไพเลอร์ไม่เตือนอะไรเลย เพราะ Zig ไม่มี borrow checker
ตอนนี้เรายังไม่เก็บบรรทัดไว้ จึงยังไม่มีปัญหา แต่บทที่ 4 ของ workshop เราจะเก็บ path ลงในตารางนับ — และตรงนั้นแหละที่บั๊กนี้จะโผล่มาจริง ๆ เราจะจงใจสร้างมันขึ้นมาให้เห็นแล้วแก้กัน
\r ที่มองไม่เห็น
ไฟล์ที่สร้างบน Windows จบบรรทัดด้วย \r\n พอเราตัดที่ \n ตัว \r จะติดมาท้ายบรรทัดด้วย
ผลของการลืม: "/index.html\r" กับ "/index.html" เป็นคนละ key ในตารางนับ — สถิติจะแตกเป็นสองแถวโดยที่คุณมองไม่เห็นความต่างบนหน้าจอเลย เป็นบั๊กที่หาสาเหตุยากมาก
บรรทัดเดียวจบ และมีเทสคุมไว้แล้วข้างบน — โค้ดบางส่วนที่สำคัญที่สุดของความเข้ากันได้ข้ามระบบคือบรรทัดนี้
// โค้ดบางส่วน — บรรทัดสำคัญที่สุดของความเข้ากันได้ข้ามระบบ
return std.mem.trimEnd(u8, line, "\r");
วัดกันจริง: streaming เทียบกับอ่านทั้งไฟล์
ผมเขียนโปรแกรมสองแบบที่นับบรรทัดเหมือนกัน ต่างกันแค่วิธีอ่าน แล้ววัดบนไฟล์ log ขนาด 94 MB (1,000,000 บรรทัด) ที่สร้างจาก generator ของ workshop นี้ (seed คงที่ ใครก็สร้างไฟล์เดียวกันได้)
เครื่องที่วัด: Apple M5 Pro, macOS, Zig 0.16.0, -O ReleaseSafe, วัดด้วย /usr/bin/time -l หลังอุ่นเครื่องแล้ว
วิธีอ่าน เวลา แรมสูงสุด
streaming (buffer 64 KiB) 0.01-0.02s 1,835,008 bytes (1.8 MB)
readFileAlloc (ทั้งไฟล์) 0.03-0.04s 100,810,752 bytes (100 MB)
streaming ใช้แรมน้อยกว่า 55 เท่า และเร็วกว่าด้วย
เรื่องแรมเป็นไปตามคาด แต่เรื่องเวลาน่าสนใจกว่า — หลายคนคิดว่า “อ่านทีเดียวต้องเร็วกว่าสิ” ความจริงคือการอ่านทั้งไฟล์ต้องขอหน่วยความจำ 94 MB จากระบบก่อน ซึ่งต้องแตะทุกหน้าหน่วยความจำ (page fault) แล้วค่อยไล่อ่าน ส่วน streaming ทำงานอยู่ใน buffer เล็ก ๆ ที่อยู่ใน cache ของ CPU ตลอด
และที่สำคัญกว่าตัวเลข: แรมของ streaming ไม่ขึ้นกับขนาดไฟล์ ไฟล์ 10 GB ก็ยังใช้ 1.8 MB เท่าเดิม ส่วนอีกวิธีจะพยายามขอ 10 GB แล้วล้มเหลว
ทำให้ zlog count ใช้งานได้
ส่วนที่เหลือคือวนเปิดไฟล์ตาม argument แล้วรวมผล (โค้ดบางส่วนของ main)
// โค้ดบางส่วนของ main — ส่วนที่วนอ่านไฟล์ตามที่ผู้ใช้ระบุ
var total: Counts = .{};
for (args[2..]) |path| {
const c = countFile(io, path) catch |err| {
try w.print("zlog: {s}: {t}\n", .{ path, err });
return 1; // รหัสจบ 1 = ทำงานผิดพลาด
};
total.lines += c.lines;
total.bytes += c.bytes;
}
try w.print("lines={d} bytes={d}\n", .{ total.lines, total.bytes });
รับหลายไฟล์ตั้งแต่แรก ไม่ใช่ไฟล์เดียว เพราะบน Windows ตัว shell ไม่ได้ขยาย *.log ให้เหมือน bash — ถ้าโปรแกรมรับได้แค่ไฟล์เดียว ผู้ใช้ Windows จะทำงานกับหลายไฟล์ไม่ได้เลย
ลองรัน:
zig build run -- count access.log
# lines=1000000 bytes=94000000
สรุปบทนี้
- แยกตรรกะให้รับ
*Io.Readerไม่ใช่ path — เทสได้โดยไม่แตะดิสก์ - buffer เป็นพารามิเตอร์ใน 0.16 และ 64 KiB คือขนาดที่ใช้ได้ดีสำหรับงานอ่านไฟล์
takeDelimiterคืน slice ที่ ยืมมาจาก buffer อย่าเก็บข้ามรอบtrimEnd(line, "\r")หนึ่งบรรทัด กันบั๊กสถิติแตกจากไฟล์ Windows- วัดจริงแล้ว streaming ใช้แรม 1.8 MB เทียบกับ 100 MB และยังเร็วกว่า บนไฟล์ 94 MB เดียวกัน
- รับหลายไฟล์ตั้งแต่แรกเพราะ Windows ไม่ขยาย glob ให้
บทหน้าเราจะแยกวิเคราะห์แต่ละบรรทัดให้เป็นข้อมูลที่ใช้งานได้ โดยไม่ขอหน่วยความจำเพิ่มแม้แต่ไบต์เดียว
Navigation
- ก่อนหน้า: Workshop #1: วางโครง CLI
- ถัดไป: Workshop #3: แยกวิเคราะห์แบบไม่ copy
- โค้ดบทนี้: step-02-read-lines
- ดูทั้งชุด: บทความแท็ก zig