CHƯƠNG 10 · I/O · FINAL · ~120 phút

File System
& I/O Models

Chương cuối — File system và I/O. Hiểu inode, file descriptor, blocking vs non-blocking I/O, epoll, page cache, fsync. Đây là nền tảng giải thích tại sao Node.js xử lý I/O nhanh, tại sao Redis save bị chậm, tại sao Docker volume hoạt động được.

10.1 File System Overview

File system tổ chức dữ liệu trên storage device thành cấu trúc cây với:

  • Files: chuỗi byte có tên + metadata
  • Directories: container chứa file/directory khác
  • Mount points: gắn filesystem khác vào path

Filesystem layout (ext4 / XFS / FAT32)

┌─────────────────────────────────────────┐ │ Boot Block │ MBR / boot loader │ ├─────────────────────────────────────────┤ │ Super Block │ Metadata: size, free │ │ │ blocks, version... │ ├─────────────────────────────────────────┤ │ Inode bitmap │ Free/used inodes │ ├─────────────────────────────────────────┤ │ Block bitmap │ Free/used blocks │ ├─────────────────────────────────────────┤ │ Inode table │ Mảng inodes │ ├─────────────────────────────────────────┤ │ │ │ Data blocks │ Nội dung file │ │ │ (block ~4KB) │ │ │ └─────────────────────────────────────────┘

Filesystems thực tế

  • Linux: ext4 (default), XFS, btrfs, ZFS
  • macOS: APFS
  • Windows: NTFS
  • Distributed: HDFS, S3, GCS, Ceph
  • Network: NFS, SMB

10.2 Inode — "thẻ căn cước" của file

Mỗi file có 1 inode chứa metadata:

  • File size
  • Owner (UID), Group (GID)
  • Permission (rwx cho user/group/other)
  • Timestamps: atime (access), mtime (modify), ctime (inode change)
  • Link count (số hardlink)
  • File type (regular, directory, symlink, device, FIFO, socket)
  • Pointers tới data blocks

Inode KHÔNG chứa filename! Filename nằm trong directory entry. Đó là cách hardlink hoạt động.

Cấu trúc pointer trong inode (ext4)

┌─────────────────────────────────┐
│ Inode metadata (size, perms..)  │
├─────────────────────────────────┤
│ Direct[0..11]: 12 pointer       │ → 12 × 4KB = 48 KB
├─────────────────────────────────┤
│ Single Indirect ─→ block        │ → 1024 pointers × 4KB = 4 MB
│                    of pointers  │
├─────────────────────────────────┤
│ Double Indirect ─→ block ─→     │ → 1024² × 4KB = 4 GB
│                    of blocks    │
├─────────────────────────────────┤
│ Triple Indirect                 │ → 1024³ × 4KB = 4 TB
└─────────────────────────────────┘

Cấu trúc này cho phép file nhỏ chỉ tốn 1 inode + ít direct pointer; file lớn có nhiều tầng indirect.

Quan sát inode

ls -i myfile.txt          # show inode number
# 1234567 myfile.txt

stat myfile.txt           # full inode info
# File: myfile.txt
# Size: 100   Inode: 1234567   Links: 1
# Access: (0644/-rw-r--r--)  Uid: ( 1000/ user)
# Access: 2025-05-09 ...
# Modify: 2025-05-09 ...
# Change: 2025-05-09 ...

df -i                     # số inode còn dư trên filesystem
⚠️ Hết inode
Mỗi filesystem có số inode cố định khi format. Nếu tạo quá nhiều file nhỏ → có thể hết inode dù còn space. Lúc đó touch báo "No space left" dù df hiện còn GB. Check với df -i.

10.4 File Descriptor (fd)

Khi process mở file, kernel trả về file descriptor — một số nguyên nhỏ (vd 3, 4, 5...). Process dùng fd để đọc/ghi/đóng file.

Process (user space) Kernel ┌─────────────────┐ ┌──────────────┐ │ fd table │ ──── 3 ─────▶ │ Open File │ │ [0] stdin │ │ Description │ │ [1] stdout │ ──── 4 ─────▶ │ - offset │ │ [2] stderr │ │ - flags │ │ [3] file.txt │ │ - inode ptr │ │ [4] socket │ └──────┬───────┘ │ [5] ... │ │ └─────────────────┘ ▼ ┌──────────────┐ │ Inode │ └──────────────┘

fd là index trong file descriptor table của process. Nhiều fd có thể trỏ vào cùng open file description (vd sau dup).

Số fd tối đa

ulimit -n            # giới hạn fd của shell hiện tại — thường 1024 hoặc 4096
cat /proc/sys/fs/file-max    # giới hạn toàn hệ thống

# Web server cần handle 100K connection → tăng:
ulimit -n 65535
⚠️ "Too many open files"
Lỗi quen thuộc khi server bị leak fd (mở mà không đóng). Mỗi connection, mỗi file mở — đều tốn 1 fd. Web server hết fd → reject connection mới. Fix: code phải close() khi xong.

10.5 stdin / stdout / stderr

Mỗi process khởi tạo có sẵn 3 fd:

  • fd 0: stdin — đầu vào (thường là keyboard)
  • fd 1: stdout — đầu ra (thường là terminal)
  • fd 2: stderr — đầu ra lỗi (thường là terminal)

Redirect trong shell

# Stdout to file
ls > out.txt              # ghi đè
ls >> out.txt             # append

# Stderr to file
ls /no > out.txt 2> err.txt

# Stderr to stdout (rồi cùng vào file)
ls /no > all.txt 2>&1

# Tất cả to /dev/null (vứt đi)
ls >/dev/null 2>&1

# Pipe: stdout của ls vào stdin của grep
ls -l | grep .txt

# Stdin from file
sort < input.txt

Cơ chế: shell dup2 để thay đổi fd 0/1/2 trỏ vào file/pipe trước khi exec.

10.6 open / read / write / close — file lifecycle

#include <fcntl.h>
#include <unistd.h>

int main() {
    // 1. Open
    int fd = open("data.txt", O_RDWR | O_CREAT, 0644);
    if (fd < 0) { perror("open"); return 1; }

    // 2. Write
    const char *msg = "Hello\n";
    ssize_t written = write(fd, msg, 6);

    // 3. Seek (di chuyển offset)
    lseek(fd, 0, SEEK_SET);  // về đầu file

    // 4. Read
    char buf[100];
    ssize_t n = read(fd, buf, sizeof(buf));

    // 5. Close
    close(fd);
    return 0;
}

open() flags quan trọng

  • O_RDONLY, O_WRONLY, O_RDWR
  • O_CREAT: tạo nếu chưa có
  • O_EXCL: dùng với O_CREAT — fail nếu đã tồn tại (atomic create)
  • O_APPEND: ghi vào cuối file (atomic — tốt cho log)
  • O_TRUNC: cắt file về 0 byte khi mở
  • O_NONBLOCK: non-blocking I/O (xem 10.8)
  • O_DIRECT: bypass page cache, đọc/ghi thẳng disk
  • O_SYNC: write đảm bảo flush xuống disk

10.7 Blocking I/O

Mặc định, syscall I/O là blocking: thread đợi đến khi I/O xong.

// Server thread — 1 client 1 thread (blocking)
while (1) {
    int client = accept(server_fd, ...);  // BLOCK đến khi có connection
    pthread_create(&t, NULL, handle, client);
}

void* handle(void* arg) {
    int fd = *(int*)arg;
    char buf[1024];
    int n = read(fd, buf, 1024);  // BLOCK đến khi client gửi data
    write(fd, response, ...);      // BLOCK đến khi gửi xong
    close(fd);
}

Đơn giản nhưng scale kém: 10K client = 10K thread. Mỗi thread tốn 1MB stack → 10GB chỉ riêng stack. Đây là vấn đề C10K nổi tiếng — Apache prefork đụng phải.

10.8 Non-blocking I/O

Set fd thành non-blocking (O_NONBLOCK): syscall không đợi, trả về ngay.

fcntl(fd, F_SETFL, O_NONBLOCK);

int n = read(fd, buf, 1024);
if (n == -1) {
    if (errno == EAGAIN) {
        // Chưa có data — không phải error
        // Có thể làm việc khác, retry sau
    } else {
        // Lỗi thật
    }
}

Nhược: phải polling liên tục — vẫn tốn CPU. Cần cơ chế tốt hơn → I/O Multiplexing.

10.9 I/O Multiplexing — select / poll / epoll / kqueue

Ý tưởng: 1 thread quản lý nhiều fd, hỏi kernel "fd nào sẵn sàng đọc/ghi?". Kernel báo lại, thread chỉ thao tác fd đó.

select() — POSIX cổ

fd_set readfds;
FD_ZERO(&readfds);
FD_SET(fd1, &readfds);
FD_SET(fd2, &readfds);

int n = select(maxfd + 1, &readfds, NULL, NULL, NULL);
// n = số fd ready
// FD_ISSET(fd1, &readfds) → check từng fd

Nhược: giới hạn 1024 fd, copy fd_set cho mỗi call → O(n).

poll() — như select nhưng không giới hạn 1024

epoll (Linux) / kqueue (BSD/macOS) — modern

int epfd = epoll_create1(0);

// Đăng ký fd cần watch
struct epoll_event ev = {.events = EPOLLIN, .data.fd = client_fd};
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);

// Đợi event
struct epoll_event events[100];
int n = epoll_wait(epfd, events, 100, -1);

// Xử lý fd ready
for (int i = 0; i < n; i++) {
    if (events[i].events & EPOLLIN) {
        read(events[i].data.fd, ...);
    }
}

Tại sao epoll nhanh hơn select?

  • O(1) thay vì O(n): chỉ trả về fd ready, không scan hết
  • Không cần copy fd set mỗi lần
  • Hỗ trợ edge-triggered: chỉ thông báo khi state đổi (avoid trigger lặp)
  • Scale lên hàng trăm nghìn fd

nginx, Node.js, Redis, và mọi web server hiện đại đều dùng epoll/kqueue.

Pattern Reactor

Single-thread + epoll = pattern Reactor. Thread chính chạy event loop, dispatch event đến handler. Đây chính xác là cách Node.js libuv hoạt động (chương 3).

10.10 Async I/O — io_uring (mới nhất)

epoll hỏi "fd nào ready?" rồi mới đọc. Async I/O đi xa hơn: "kernel đọc giúp tôi, báo khi xong".

POSIX AIO (cũ, ít dùng)

API hỗn loạn, ít kernel support tốt.

io_uring (Linux 5.1+, 2019)

2 ring buffer giữa user và kernel: submission queue (SQ) và completion queue (CQ). User submit op vào SQ, kernel xử lý, push kết quả vào CQ. Ít syscall, batch nhiều op. Hiện đang là standard mới cho high-perf I/O.

Database, message broker, file server hiện đại đang chuyển sang io_uring.

10.11 Page Cache — kernel buffer cho file

Khi process read() file, kernel đọc từ disk vào page cache (vùng RAM kernel quản lý), rồi copy sang user buffer. Lần đọc tiếp theo (cùng page) → trả từ cache, không đọc disk lại.

Đó là lý do file vừa đọc xong, đọc lại cực nhanh.

# Đọc file 1GB lần đầu — ~5s
time cat bigfile > /dev/null

# Lần 2 — <1s vì đã cache
time cat bigfile > /dev/null

# Xoá page cache
echo 3 | sudo tee /proc/sys/vm/drop_caches

Write-back cache

write() không lập tức vào disk. Kernel ghi vào page cache, mark dirty, return success ngay. Background kthread ghi xuống disk sau (vài giây).

Đó là lý do copy file nhanh nhưng tháo USB ngay sau khi copy lại nguy hiểm — data có thể chưa flush.

Quan sát page cache

free -h
#               total   used   free   shared   buff/cache   available
# Mem:           16G    4G    1G     1G        11G          11G
# (buff/cache 11G — phần lớn là page cache, có thể giải phóng nếu cần)

10.12 fsync() & Durability

Sau write(), data chỉ ở page cache. Mất điện = mất data. Để chắc chắn data persist trên disk:

write(fd, buf, n);   // ghi vào page cache (nhanh)
fsync(fd);            // CHỜ đến khi data thật sự xuống disk (chậm)

fsync tốn ~5-50ms (HDD), ~200µs (SSD). Database (MySQL, PostgreSQL, Redis) gọi fsync mỗi commit để đảm bảo durability.

Trade-off database thực tế

  • fsync mỗi commit: an toàn nhất nhưng chậm. Default của PostgreSQL.
  • fsync định kỳ (vd mỗi giây): nhanh hơn, có thể mất 1s data nếu crash. MySQL innodb_flush_log_at_trx_commit=2, Redis AOF everysec.
  • Không fsync: nhanh nhất, mất nhiều data nếu crash. Chỉ dùng cho cache.

fsync vs sync vs fdatasync

  • fsync(fd): ghi data + metadata của file xuống disk
  • fdatasync(fd): chỉ ghi data + metadata cần (vd file size). Nhanh hơn fsync.
  • sync(): flush mọi dirty page (cả filesystem)

10.13 Node.js File I/O — kết nối với chương 3

Node.js fs API có 2 cách: callback + promise.

const fs = require('fs').promises;

// Read file
const data = await fs.readFile('input.txt', 'utf8');

// Write file
await fs.writeFile('output.txt', data);

// Append
await fs.appendFile('log.txt', 'new entry\n');

// Stream (cho file lớn)
const fs = require('fs');
const readStream = fs.createReadStream('huge.csv');
readStream.on('data', (chunk) => { /* xử lý */ });

// Watch directory
fs.watch('./uploads', (event, filename) => {
  console.log(event, filename);
});

Tại sao Node fs nhanh dù single-thread?

Như đã học chương 3: Node main thread không block. fs.readFile được delegate sang libuv thread pool (default 4 thread). Khi đọc xong, callback đẩy vào event loop queue.

Tip: nếu app I/O-heavy, tăng UV_THREADPOOL_SIZE: UV_THREADPOOL_SIZE=16 node app.js.

Async I/O qua io_uring

Node 20+ có experimental fs.openAsBlob và 1 số API dùng io_uring trên Linux. Trong tương lai có thể bypass thread pool.

Bài tập

Bài 1 — Inode & hardlink

Tạo file. ln tạo hardlink. ls -li xem inode. Xoá file gốc — hardlink vẫn dùng được. Giải thích.

Bài 2 — Symlink dangling

Tạo symlink. Xoá target. Cố cat symlink — error. Quan sát hành vi.

Bài 3 — Đếm fd của process

ls /proc/$$/fd liệt kê fd. lsof -p PID chi tiết. Mở file trong shell rồi kiểm tra.

Bài 4 — Page cache demo

Đọc file 100MB lần đầu — đo thời gian. Đọc lần 2 — nhanh hơn nhiều. echo 3 > /proc/sys/vm/drop_caches rồi đọc lần 3 — chậm lại.

Bài 5 — fsync benchmark

Viết C program: write 1000 lần với fsync vs không fsync. So sánh thời gian. Trên SSD vs HDD nếu có.

Bài 6 — epoll echo server

Viết server C dùng epoll handle 100 client đồng thời (echo back). 1 thread, không thread pool.

Bài 7 — Node.js stream big file

Viết Node script copy file 1GB bằng readFile (load hết) vs createReadStream. So sánh memory usage qua process.memoryUsage().

🧪 Quiz cuối chương

Câu 1. Inode chứa gì?

  • Filename
  • Path
  • Metadata (size, perms, timestamps) + pointers tới data blocks — KHÔNG có filename
  • Nội dung file

Đáp án: metadata + data pointers. Filename nằm trong directory entry trỏ về inode. Đó là cách hardlink hoạt động.

Câu 2. Hard link và Symbolic link khác nhau?

  • Hard link = nhiều filename cùng inode; Symlink = file đặc biệt chứa path
  • Cả hai cùng nghĩa
  • Hard link chỉ trên Linux
  • Symlink nhanh hơn

Đáp án: cùng inode vs path. Hard link không cross filesystem; symlink có thể bị broken.

Câu 3. Khi process mở file, kernel trả về?

  • Pointer trực tiếp đến file
  • File descriptor — số nguyên nhỏ, index trong fd table của process
  • Inode number
  • Path string

Đáp án: file descriptor. Giới hạn số fd là ulimit -n; web server hay phải tăng.

Câu 4. Tại sao epoll nhanh hơn select?

  • O(1) — chỉ trả về fd ready; select O(n) scan hết
  • epoll dùng SSD
  • select chỉ trên Windows
  • Cùng tốc độ

Đáp án: O(1). select scan hết fd_set; epoll register fd 1 lần, kernel chỉ trả ready ones.

Câu 5. Page cache trong Linux?

  • Cache cho page table
  • Cache cho CPU
  • Vùng RAM kernel cache nội dung file đã đọc/ghi — đọc lại nhanh
  • Cache cho process

Đáp án: cache nội dung file. Đó là lý do file vừa đọc, đọc lại nhanh. free -h cột buff/cache.

Câu 6. fsync() dùng để?

  • Đóng file
  • Đảm bảo data thật sự xuống disk (durability) — quan trọng cho database
  • Đọc file nhanh hơn
  • Cấp phát memory

Đáp án: ép flush xuống disk. Mất điện sau write nhưng không fsync = mất data.

Câu 7. stdin / stdout / stderr có fd lần lượt là?

  • 1, 2, 3
  • 3, 4, 5
  • 0, 1, 2
  • 10, 11, 12

Đáp án: 0, 1, 2. Mọi process khởi tạo có sẵn 3 fd này. Lý do 2>&1 redirect stderr sang stdout.

Câu 8. Lỗi "Too many open files" do?

  • Process đạt giới hạn fd (ulimit -n) — thường do leak fd (mở mà không đóng)
  • Hết RAM
  • Disk đầy
  • CPU quá tải

Đáp án: hết fd. Mỗi connection, mỗi file mở tốn 1 fd. Web server cần code close() đúng chỗ.

Tổng kết chương 10 — và toàn bộ giáo trình OS

  • ✅ Filesystem layout: superblock + inode table + data blocks
  • Inode = metadata + data pointers; KHÔNG có filename (filename ở dir entry)
  • ✅ Hard link (cùng inode) ≠ Symlink (file path đến target)
  • File Descriptor = số nguyên index trong fd table của process
  • ✅ stdin/stdout/stderr = fd 0/1/2; shell redirect dùng dup2
  • ✅ Blocking I/O đơn giản nhưng không scale; Non-blocking + I/O multiplexing là nền tảng web server hiện đại
  • epoll/kqueue O(1) → scale lên hàng trăm nghìn fd. Pattern Reactor.
  • io_uring: future of async I/O
  • Page Cache: read/write đi qua RAM trước → fast; trade-off với durability
  • fsync đảm bảo data xuống disk — đắt, dùng cho DB commit
  • ✅ Node.js fs API: callback/promise; libuv thread pool cho I/O
🎓 Kết thúc giáo trình OS Mastery

Bạn đã đi qua 10 chương — từ kernel/syscall đến file system. Kết hợp với DSA, bạn đã có 2/6 trụ cột nền tảng IT. Sau OS, các trụ cột tiếp theo:

  1. Networking — TCP/IP, HTTP, DNS, sockets
  2. Database — SQL, indexing, transaction
  3. OOP & Design Patterns
  4. System Design — kiến trúc hệ thống lớn

Đề xuất thực hành ngay sau khi học OS:

  • Đọc sách Operating Systems: Three Easy Pieces (free PDF) cho lý thuyết sâu hơn
  • Cài Linux VM hoặc dùng macOS terminal — thử mọi command đã học
  • Đọc man page: man 2 fork, man 2 mmap, man 7 epoll
  • Build mini project: web server đơn giản dùng epoll, hoặc shell tự viết

Chúc bạn thành công ở mọi vòng phỏng vấn backend / SRE / DevOps!

← Chương trước Chương 09: Paging Hoàn thành 🎉 Quay về trang chủ OS