ภาพวาดโทนมิ้นต์สว่าง อิกัวนาตัวเล็กหมุนมือหมุนสีอำพันเพื่อเลื่อนแถบกระดาษยาวผ่านช่องไม้แคบทีละท่อน อ่านเฉพาะท่อนที่อยู่ในช่องตอนนั้น

Zig Workshop #2: อ่านไฟล์ 94 MB ด้วยแรม 1.8 MB

พี่โชว์Engineering

บทที่แล้วเราได้โครงที่รันได้ บทนี้ให้มันอ่านไฟล์จริง

โจทย์ที่ต้องคิดตั้งแต่บรรทัดแรก: 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 ให้

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