Một VM Ubuntu 22.04 cài mặc định trên laptop 4 lõi, 8 GB RAM. Bạn bấm nguồn, đi rót cốc nước, quay lại vẫn thấy màn hình đen với đúng một dòng login:. Chạy systemd-analyze sau khi đăng nhập, máy trả lời: tổng 41,3 giây, chia thành firmware 2,1 s, loader 0,3 s, kernel 1,9 s, userspace 37,0 s. Rồi systemd-analyze blame chỉ đúng một dòng đứng đầu: 30,0 giây cho một service chờ mạng lên.
Ba mươi giây đó không phải kernel chậm. Kernel đã làm xong việc của nó trong 1,9 giây, và trong 41,3 giây ấy nó chỉ chiếm 4,6%. Toàn bộ 39,4 giây còn lại là userspace: hàng trăm service khởi động theo thứ tự phụ thuộc, mỗi service là một tiến trình bình thường chạy ở user mode, có quyền bị giết, bị hoãn, hoặc chờ một thứ khác. Con số 41,3 giây này là kịch bản điển hình của một VM chưa tối ưu, không phải số tôi đo trên máy soạn bài; ba lệnh đầu ở khối 7 chỉ cách bạn đo máy mình.
Sự lệch pha giữa “kernel chỉ chiếm 4,6%” và “người dùng chờ 41,3 giây” là toàn bộ lý do bài này tồn tại. Khi máy chậm, phản xạ đầu tiên của mọi người là đổ cho kernel — nhưng kernel chỉ là một trong sáu mắt xích của chuỗi khởi động, và ở máy đã tối ưu nó cũng chỉ chiếm 29,1% (khối 5). Ba câu hỏi cần trả lời trước khi sửa bất cứ thứ gì: trong 6,4 giây của một lần boot lành, từng mili-giây nằm ở đâu; ai có quyền chạy đoạn code nào; và vì sao một chương trình ghi bừa vào con trỏ rỗng chỉ giết chính nó, không giết cả máy.
Câu trả lời cho câu hỏi thứ ba nằm ở chỗ khác với hai câu đầu, và nó là thứ kernel giữ độc quyền: quyền đổi mode, quyền cấp phát khung trang, quyền nói với phần cứng. Bài này đi từ lúc bấm nút nguồn tới dấu nhắc shell để chỉ ra chính xác kernel nhận quyền đó ở đâu, giữ nó bằng gì, và trả giá bao nhiêu cho mỗi lần phải dùng tới.
systemd-analyze và systemd-analyze blame, chỉ ra mốc nào chiếm nhiều mili-giây nhất, rồi tính phần trăm của nó trên tổng.getpid() rẻ hơn write() khoảng 230 lần.Kernel giống tổ trưởng của một toà nhà có một cầu dao duy nhất. Cư dân muốn dùng thang máy, nước, điện đều phải gọi tổ trưởng; tổ trưởng chỉ có một người nên mỗi lần gọi đều phải xếp hàng ngắn, và cư dân không được tự đấu dây — không phải vì họ không biết cách, mà vì một người đấu sai là cả toà cháy. Ẩn dụ này giải thích luôn hai đặc tính của mọi system call: nó tuần tự (một cửa vào) và nó đắt (mỗi lần qua cửa đều có giá cố định, dù việc bên trong chỉ là đọc một biến).
Phép chia thứ nhất — thang độ trễ. Mọi thứ trong môn học này là nỗ lực bắc cầu qua khoảng cách giữa các tầng lưu trữ. Bảng dưới quy tất cả về một mốc duy nhất: nếu đọc RAM mất 1 giây thì các tầng khác mất bao lâu.
| Nơi dữ liệu nằm | Độ trễ | Quy về “đọc RAM = 1 giây” |
|---|---|---|
| register | 0,3 ns | 0,003 giây |
| L1 cache | 1 ns | 0,01 giây |
| RAM | 100 ns | 1 giây |
| SSD (4 KiB, hàng đợi sâu) | 100 µs | 1.000 giây ≈ 17 phút |
| HDD (seek + quay) | 10 ms | 100.000 giây ≈ 1,16 ngày |
RAM nhanh hơn HDD 100.000 lần, tức năm bậc độ lớn. Con số đó không phải để học thuộc: nó là lý do tồn tại của cache, của page cache, của readahead, và của mọi thuật toán thay trang ở bài 10. Nếu đĩa chỉ chậm hơn RAM 3 lần, không ai viết bộ nhớ ảo.
Phép nhân thứ hai — một page fault đắt bằng bao nhiêu lệnh. Một page fault điển hình tốn 100 µs (số mô hình dùng xuyên suốt khoá; máy soạn bài đo được 2,84 µs cho một khung trang 16 KiB đã có sẵn khung trống, trường hợp nhẹ hơn nhiều vì không phải đọc đĩa). Trên CPU 3 GHz, 100 µs là 300.000 chu kỳ; với IPC khoảng 4 lệnh mỗi chu kỳ, CPU đã kịp chạy 75.000 lệnh trong lúc chờ một khung trang. Đó là toàn bộ lý do mọi hệ điều hành đều xoay quanh việc giảm số lần phải đi ra RAM hoặc đĩa, chứ không phải quanh việc chạy lệnh nhanh hơn.
Phép nhân thứ ba — đọc một file lớn. Kernel đọc 40 MiB từ SSD tuần tự 500 MB/s: 40 ÷ 500 × 1.000 = 80 ms. Con số 80 ms đó bằng khoảng 1/80 của một lần boot 6.430 ms, chỉ cho một lần đọc 40 MiB — và nó là hạng mục duy nhất trong ba phép tính mà bạn cắt được bằng cách mua phần cứng tốt hơn thay vì sửa phần mềm. Với 800 lần page fault 100 µs cộng lại cũng ra 80 ms — hai cách nhìn cùng một khối lượng: 40 MiB dữ liệu phải đi qua một dây chậm hơn CPU hàng trăm lần.
Phép chia thứ tư — ảo giác “máy chạy song song”. Máy 8 lõi, 300 tiến trình, mỗi lượt chạy 4 ms: một vòng quay là 300 ÷ 8 × 4 = 150 ms. Con số 150 ms nằm dưới ngưỡng cảm nhận khoảng 200 ms của người dùng, nên màn hình vẫn “sống” dù không tiến trình nào chạy liên tục. Nếu quantum là 40 ms, vòng quay thành 1.500 ms và mỗi lần gõ phím bạn thấy màn hình khựng. Cùng một bộ định thời, cùng một máy, chỉ đổi một tham số — đó là nội dung của bài 5, nhưng nó bắt đầu từ đây, vì 300 tiến trình ấy là do kernel tạo ra và kernel là thứ duy nhất biết chúng tồn tại.
Sáu cơ chế dưới đây là sáu mắt xích của một lần boot, theo đúng thứ tự thời gian. Mỗi cơ chế có ba nhãn cố định — Vì sao nó tồn tại, Cách nó chạy từng bước, Đánh đổi — vì một cơ chế trong hệ điều hành chỉ hiểu được khi biết nó sinh ra để chữa bệnh gì và nó tự tạo ra bệnh gì.
Vì sao. Sau khi nhận tín hiệu reset, CPU không biết gì về thế giới: nó không biết đĩa là gì, không có driver, không có bảng trang, và chỉ đọc được đúng một địa chỉ cố định trong bộ nhớ flash. Một cái máy ở trạng thái đó không thể tự nạp một hệ điều hành 8 MB từ ổ NVMe, vì việc nạp đòi hỏi driver NVMe, mà driver thì nằm trong hệ điều hành chưa được nạp. Vòng luẩn quẩn này được cắt bằng một chuỗi bốn bậc, mỗi bậc chỉ cần biết vừa đủ về bậc sau.
Cách nó chạy. Bậc 1 là firmware (UEFI): chạy từ flash, khởi tạo DRAM, dò PCIe, rồi tìm phân vùng EFI và nạp một file .efi. Trên timeline mẫu ở khối 5, bậc này tốn 1.234 ms — phần lớn là “memory training”, quá trình dò tham số của thanh RAM, và không phần mềm nào tối ưu được nó. Bậc 2 là bootloader (GRUB): đọc cấu hình, hiện menu 5 giây nếu bạn không bấm phím, rồi nạp vmlinuz và initrd vào RAM — 402 ms trong mẫu. Bậc 3 là kernel: tự giải nén (vmlinuz là ảnh nén), dựng bảng trang cho chính nó, khởi tạo bộ định thời, đếm CPU, nạp driver từ initrd — 1.871 ms. Bậc 4 là tiến trình đầu tiên: kernel gọi execve("/sbin/init"), tiến trình này nhận PID 1 và từ giây phút đó mọi thứ còn lại chạy ở user mode. systemd — tên của init trên Ubuntu — khởi động service theo thứ tự phụ thuộc và mở getty.
bấm nguồn
│
▼
┌──────────┐ 512 B đầu ┌──────────┐ vmlinuz ┌────────────┐
│ firmware │─────────────►│ GRUB │────────────►│ kernel │
│ UEFI │ từ flash │ (đĩa) │ + initrd │ ring 0 │
└──────────┘ └──────────┘ └─────┬──────┘
1.234 ms 402 ms 1.871 ms
│ execve(/sbin/init)
▼
┌───────────────────────┐
│ PID 1: systemd │
│ user mode, 2.923 ms │
│ ├─ udev, journald │
│ └─ getty ──► login: │
└───────────────────────┘
Đánh đổi. Mỗi bậc phải tin bậc trước, và đó là lỗ hổng của cả chuỗi: firmware bị nhiễm thì kernel sạch đến mấy cũng vô nghĩa, nên UEFI Secure Boot ký từng mắt xích. Cái giá thứ hai là initrd — một ảnh RAM chứa driver đĩa, phải nạp trước kernel để kernel đọc được đĩa thật; nó tốn 640 ms trong mẫu và là lý do phổ biến nhất khiến VM boot chậm hơn máy thật. Cái giá thứ ba: firmware chiếm 19,2% thời gian boot và là phần duy nhất bạn không sửa được bằng phần mềm, chỉ bằng cách bật fast boot trong UEFI.
Vì sao. Nếu chương trình người dùng ghi được vào thanh ghi điều khiển của CPU hoặc vào cổng I/O của đĩa, thì mọi cơ chế bảo vệ khác đều vô nghĩa: một dòng outb là đủ để ghi đè lên bất kỳ sector nào, kể cả sector chứa kernel. Vì vậy CPU có một bit trạng thái — bit mode — và phần cứng, không phải phần mềm, từ chối các lệnh đặc quyền khi bit đó nói “đang ở user mode”. Ở chế độ đó, outb, cli (tắt ngắt), lgdt (nạp bảng descriptor), hlt đều bị chặn ngay tại khâu giải mã lệnh, trước khi có bất kỳ tác dụng phụ nào.
Cách nó chạy. Chương trình muốn làm việc đặc quyền thì phải gọi system call: đặt số hiệu syscall vào thanh ghi, đặt tham số vào các thanh ghi còn lại, rồi chạy lệnh syscall (hoặc int 0x80 trên kiến trúc cũ). Lệnh này làm đúng ba việc trong phần cứng: lưu địa chỉ trả về, đổi bit mode sang kernel, và nhảy tới một địa chỉ do kernel đăng ký trước trong MSR — địa chỉ đó người dùng không ghi đè được. Kernel tra bảng syscall theo số hiệu, kiểm tra tham số (con trỏ có nằm trong vùng nhớ của tiến trình không, độ dài có vượt giới hạn không), làm việc, rồi chạy lệnh sysret để trả về user mode.
user mode (ring 3) kernel mode (ring 0)
┌──────────────────────┐ ┌──────────────────────────┐
│ write(1, buf, 5) │ syscall │ sys_write() │
│ 1. đặt số 1 vào rax │ ───────────────► │ kiểm tra fd 1 hợp lệ │
│ 2. buf, 5 vào rdi,r │ │ copy 5 byte từ buf │
│ 3. syscall │ ◄─────────────── │ trả về 5 trong rax │
└──────────────────────┘ sysret └──────────────────────────┘
▲ │
└── không có đường nào khác để vào kernel ──────┘
(không phải CALL, không phải JMP: chỉ syscall
hoặc một ngắt/fault do phần cứng phát)
Chi phí của ranh giới này đo được, và nó không đều. Trên máy soạn bài (macOS 27.0.1, Apple M1), ba lần chạy cho cùng một kết quả trong khoảng ±5%: một lời gọi hàm thường 0,34 ns; getpid() qua libc 1,3 ns; syscall(SYS_getpid) gọi thẳng số hiệu 121 ns; write(1, "", 0) — một syscall thật nhưng ghi 0 byte — 301 ns. Bốn con số đó kể hai câu chuyện. getpid() chỉ đắt gần 4 lần một lời gọi hàm, mà 1,3 ns thì không đủ thời gian cho hai lần đổi mode, nên câu trả lời rõ ràng không đến từ kernel: trên macOS nó đến từ một vùng nhớ chia sẻ mà kernel ánh xạ sẵn vào mọi tiến trình, cùng ý tưởng với vDSO trên Linux. Còn write() đắt gấp 885 lần một lời gọi hàm, và phần lớn cái giá đó là hai lần đổi mode cộng với việc xoá pipeline của CPU.
Đánh đổi. Ranh giới mode là thứ duy nhất khiến “một chương trình hỏng không kéo cả máy theo”, nhưng nó biến mọi thao tác I/O thành một cuộc gọi có giá cố định. Đọc 1 MiB bằng buffer 4 KiB cần 256 lần read(), tức 256 × 301 ns = 77 µs chỉ để trả tiền qua cửa — so với 2 ms thời gian đĩa thật, đó là 3,8% và chấp nhận được; nhưng đọc cùng 1 MiB bằng buffer 1 byte thì 1.048.576 lần gọi là 316 ms thuần overhead, gấp 158 lần thời gian I/O. Đây chính là lý do tồn tại của buffering và của readv(), và là bài toán của bài 2.
Vì sao. Nếu chỉ có system call, kernel chỉ chạy khi chương trình tự nguyện gọi nó. Một vòng lặp while(1); không gọi gì cả, nên nó sẽ giữ CPU tới khi tắt máy — và trên một hệ nhiều tiến trình, điều đó nghĩa là không tiến trình nào khác chạy được, kể cả tiến trình bạn đang gõ phím vào. Cần một cơ chế cưỡng chế, do phần cứng bảo đảm, không phụ thuộc thiện chí của chương trình.
Cách nó chạy. Timer lập trình được (PIT, HPET hoặc local APIC timer) đếm giảm và phát ngắt khi về 0. Trên nhân Linux phổ thông, tần số tick là 250 Hz, tức một ngắt mỗi 4 ms; mỗi ngắt, kernel kiểm tra xem tiến trình hiện tại đã dùng hết quantum chưa, nếu rồi thì đặt cờ “cần định thời lại” và gọi bộ định thời ở lần trả về tiếp theo. Ngoài timer, ngắt còn đến từ thiết bị: bàn phím, card mạng, đĩa báo xong một lệnh. Mỗi ngắt đều đi qua cùng một cửa: CPU lưu ngữ cảnh tối thiểu, tra bảng vector ngắt, nhảy vào handler ở kernel mode, xong thì iret về chỗ cũ.
Phần cứng không phân biệt “ngắt” với “lỗi”: cả hai đều là trap vào kernel, khác nhau ở nguồn gốc. Ngắt (interrupt) là bất đồng bộ, do thiết bị, xảy ra ở thời điểm không liên quan tới lệnh đang chạy. Lỗi (fault/exception) là đồng bộ, do chính lệnh vừa chạy gây ra, và xảy ra đúng tại lệnh đó — page fault là ví dụ chuẩn, và nó là lý do khối 4.4 tồn tại.
Đánh đổi. Ngắt mua được quyền cưỡng chế với giá 5 µs mỗi lần chuyển ngữ cảnh (giá trị tham chiếu dùng trong khoá; bài này chưa đo trực tiếp). Trên quantum 4 ms, 5 µs là 0,12% CPU — không đáng kể. Nhưng 1.000 ngắt mỗi giây từ card mạng 10 GbE, mỗi ngắt 5 µs, đã là 0,5% CPU chỉ để xử lý việc “có gói tin mới”, chưa tính việc xử lý gói. Đó là lý do tồn tại của NAPI (gom nhiều gói trong một ngắt) và của tickless kernel: nếu không có việc gì, kernel tắt timer hẳn thay vì đánh thức 250 lần mỗi giây — máy laptop tiết kiệm được vài phần trăm pin, đổi lại độ chính xác của các phép đo thời gian giảm.
Vì sao. Có hai bài toán không thể giải đồng thời bằng cách cấp phát thật. Thứ nhất, 300 tiến trình mỗi cái cần vài trăm MB “riêng” không thể cùng nằm trong 8 GB RAM. Thứ hai, nếu tiến trình A ghi vào địa chỉ 0x1000 và tiến trình B cũng ghi vào 0x1000, chúng phải là hai ô nhớ khác nhau, nếu không thì chẳng có gì gọi là cô lập. Cả hai được giải bằng cùng một phép đổi: địa chỉ chương trình dùng (địa chỉ ảo) không phải địa chỉ vật lý, mà là chỉ số vào một bảng do kernel quản lý.
Cách nó chạy. Khi tiến trình chạm một trang chưa có trong bảng trang, MMU phát page fault; kernel kiểm tra địa chỉ đó có nằm trong vùng nhớ hợp lệ của tiến trình không, nếu có thì cấp một khung trang vật lý, xoá 0, ghi ánh xạ vào bảng trang, rồi cho lệnh chạy lại — lần này MMU dịch được và không có fault nữa. Nếu địa chỉ không hợp lệ, kernel không cấp gì cả mà gửi SIGSEGV cho tiến trình. Cùng một cơ chế, hai kết cục hoàn toàn khác nhau, và sự khác biệt đó là toàn bộ nội dung của “bảo vệ bộ nhớ”.
Đo trên máy soạn bài (macOS 27.0.1, Apple M1, page 16 KiB): mmap 1 GiB ẩn danh mất 0,003 ms vì nó chỉ tạo một vùng nhớ ảo; ghi vào byte đầu của từng trang tốn 186 ms cho 65.536 trang, tức 2,84 µs mỗi khung. Nhìn qua thì 186 ms là “chậm”, nhưng phép chia cho thấy chi phí thật nằm ở đâu: 2,84 µs cho 16 KiB nghĩa là 0,17 ns mỗi byte, tức khoảng nửa chu kỳ CPU mỗi byte — gần như toàn bộ là băng thông ghi RAM, không phải chi phí sổ sách của kernel.
Mô hình cho Ubuntu 22.04 (page 4 KiB, số mô hình, chưa đo): 1 GiB là 262.144 trang, mỗi fault 0,7 µs, tổng 183 ms. Đặt cạnh 186 ms đo được trên macOS, hai con số gần bằng nhau dù số trang chênh 4 lần — vì tổng số byte phải xoá 0 là như nhau. Đây là bài học định lượng đầu tiên của môn: page size đổi số lần trap, không đổi khối lượng dữ liệu phải đi qua bộ nhớ. Bảng trang cũng vậy: 262.144 trang cần 262.144 bit trong bitmap = 32 KiB cho 1 GiB; với 64 GiB RAM là 16.777.216 khung và 2 MiB bitmap — vẫn đủ nhỏ để kernel giữ trong RAM thường.
int *p; /* biến chưa khởi tạo: giá trị rác, thường là 0 hoặc NULL */
*p = 42; /* ghi vào trang số 0 — trang này kernel cố tình không ánh xạ */
Đánh đổi. Cấp phát trễ đổi “cấp phát nhanh” lấy “lỗi xuất hiện muộn”: malloc(4 GiB) trên máy 8 GB vẫn trả về con trỏ hợp lệ, và chương trình chỉ chết khi thật sự ghi tới trang thứ 2.000.001 — đúng chỗ khó debug nhất. Cái giá thứ hai là mỗi trang mới tốn một trap, nên chương trình chạm rải rác trên vùng nhớ lớn sẽ chậm hơn hẳn chương trình chạm tuần tự. Cái giá thứ ba là page size: 16 KiB giảm số trap 4 lần so với 4 KiB nhưng làm mỗi lần đọc 1 byte từ đĩa phải mang theo 16 KiB, tức lãng phí 4 lần băng thông khi dữ liệu thưa. Không có page size đúng cho mọi workload, chỉ có page size phù hợp với cách chương trình chạm bộ nhớ.
Vì sao. Ổ đĩa và card mạng không gắn vào bus của CPU theo kiểu “mỗi byte một lệnh”. Nếu CPU phải tự đọc từng byte từ thanh ghi thiết bị rồi ghi vào RAM, nó sẽ làm đúng việc của một chiếc xe tải: chở từng viên gạch. DMA (Direct Memory Access) trao việc đó cho một bộ điều khiển nhỏ, để CPU làm việc khác trong lúc dữ liệu chảy.
Cách nó chạy. Kernel dựng một descriptor — vài chục byte ghi địa chỉ nguồn, địa chỉ đích, độ dài — rồi ghi địa chỉ descriptor vào thanh ghi của bộ điều khiển DMA. Bộ điều khiển tự đọc/ghi bộ nhớ, xong thì phát một ngắt. Với 1 MiB, copy bằng CPU theo vòng lặp byte tốn khoảng 4 chu kỳ mỗi byte, tức 4.194.304 chu kỳ, tức 1,4 ms trên CPU 3 GHz; bằng DMA tốn 2 µs dựng descriptor cộng một ngắt — nhanh hơn 700 lần.
Đánh đổi. Với khối nhỏ, DMA thua. Cùng giả định 4 chu kỳ mỗi byte và 2 µs overhead cố định, điểm hoà vốn là 2 µs × 3 GHz ÷ 4 = 1.500 byte, tức khoảng 1,5 KiB: dưới ngưỡng đó, dựng descriptor đắt hơn copy trực tiếp. Trong driver thật, ngưỡng này cao hơn nhiều — 4 đến 16 KiB — vì còn phải cộng rào bộ nhớ (memory barrier), xử lý cache không nhất quán, và đôi khi cả một bounce buffer vì thiết bị không truy cập được vùng nhớ trên 4 GiB. Đó là lý do driver dùng DMA cho khối dữ liệu lớn và copy bằng CPU cho header nhỏ, và là lý do một write() 5 byte không bao giờ đi qua DMA.
Vì sao. Sau khi kernel khởi tạo xong, nó không “chạy chương trình” theo nghĩa thông thường: kernel không có hàm main gọi systemd. Nó tạo một tiến trình — cấp một khối điều khiển tiến trình (PCB, trên Linux là task_struct), một không gian địa chỉ riêng, một stack kernel — rồi nạp chương trình init vào không gian đó và nhảy về user mode ở đúng điểm vào của chương trình. Từ đó kernel chỉ còn chạy khi được gọi hoặc bị ngắt.
Cách nó chạy. Mỗi tiến trình có một entry trong danh sách liên kết các task, một PID duy nhất, trạng thái (READY, RUNNING, BLOCKED, ZOMBIE), và con trỏ tới bảng trang của nó. Khi chuyển từ tiến trình A sang B, kernel lưu thanh ghi của A vào PCB của A, nạp thanh ghi của B, đổi thanh ghi CR3 sang bảng trang của B (đây là thao tác làm TLB mất hiệu lực), rồi iret. Toàn bộ “ảo giác song song” là chuỗi thao tác đó, lặp lại 250 lần mỗi giây trên mỗi lõi.
Số lượng tiến trình không miễn phí: mỗi tiến trình tốn một stack kernel, thường 16 KiB, nên 300 tiến trình là 4,7 MiB RAM chỉ để giữ stack kernel chưa dùng tới — cộng thêm task_struct, bảng trang, và các vùng nhớ ảo. Đó là lý do bài 4 tồn tại: thread chia sẻ không gian địa chỉ, nên 8 thread tốn một bảng trang thay vì 8, và đó là phần lớn khác biệt về chi phí giữa hai mô hình.
Đánh đổi. Mỗi lần chuyển ngữ cảnh là 5 µs không sinh ra việc hữu ích, cộng thêm thiệt hại không tính được bằng micro-giây: cache L1/L2 của lõi bị tiến trình mới xả sạch, TLB phải nạp lại. Với quantum 4 ms và 300 tiến trình, 0,12% CPU là con số nhìn thấy được; nhưng nếu chương trình vừa chạy có working set 256 KiB trong L2 và chương trình tiếp theo có working set khác, thì 256 KiB đó phải nạp lại từ L3 hoặc RAM — 256 KiB ÷ 20 GB/s ≈ 13 µs, gấp gần ba lần chi phí chuyển ngữ cảnh danh nghĩa. Chuyển ngữ cảnh ít thì trễ tăng; chuyển nhiều thì cache chết. Đây là toàn bộ lý do tồn tại của các tham số định thời ở bài 5.
Hai bảng dưới là hai đường đi cụ thể. Bảng thứ nhất là một lần boot, cộng dồn theo mili-giây, khớp từng dòng với output của boot_timeline.py ở khối 6. Bảng thứ hai là đường đi của một lần gõ phím tới khi thấy chữ, để thấy kernel nằm ở đâu trong một thao tác mà người dùng coi là tức thời.
Trace A — sáu mốc của một lần boot. Đơn vị ms; số là kịch bản mẫu của một VM Ubuntu 22.04, không phải số đo trên máy soạn bài.
| Mốc | Nhóm | ms | % | Cộng dồn |
|---|---|---|---|---|
| firmware (UEFI POST) | firmware | 1.234 | 19,2% | 1.234 |
| bootloader (GRUB) | loader | 402 | 6,3% | 1.636 |
| kernel (giải nén + init) | kernel | 1.871 | 29,1% | 3.507 |
| initrd / udev probe | userspace | 640 | 10,0% | 4.147 |
| systemd default.target | userspace | 2.103 | 32,7% | 6.250 |
| shell prompt | userspace | 180 | 2,8% | 6.430 |
| TỔNG | 6.430 | 100,0% | 6.430 |
Ba điều đọc được từ bảng này. Thứ nhất, mốc lớn nhất không phải kernel mà là systemd default.target với 2.103 ms, tức 32,7% — và nó là phần dễ sửa nhất, vì mỗi service trong đó là một file cấu hình. Thứ hai, firmware cộng bootloader là 1.636 ms, tức 25,4%, và gần như không sửa được bằng phần mềm. Thứ ba, userspace tổng cộng 2.923 ms, tức 45,5% — gần một nửa thời gian boot nằm ngoài kernel, nên một kỹ sư chỉ đọc mã kernel sẽ bỏ qua nửa vấn đề.
Trace B — một lần gõ ls rồi Enter. Các dòng có nhãn (mô hình) là số suy ra từ giả định đã nêu, không phải số đo; các số 301 ns, 5 µs và 0,7 µs là những con số đã dùng ở khối 3 và khối 4, đưa vào đây để cộng thành một đường đi hoàn chỉnh.
| Bước | Việc xảy ra | Ai làm | Thời gian | Cộng dồn |
|---|---|---|---|---|
| 1 | phím nhấn, USB HID báo qua ngắt (poll 1 kHz) | controller + kernel | 1 ms | 1,0 ms |
| 2 | kernel đẩy ký tự vào buffer tty, đánh thức shell | kernel | 5 µs | 1,005 ms |
| 3 | shell chờ tới lượt chạy (quantum 4 ms) | bộ định thời | ≤ 4 ms | ≤ 5,0 ms |
| 4 | read(2) trả về dòng “ls” |
kernel | 301 ns | 5,005 ms |
| 5 | fork() + execve("/bin/ls") (mô hình) |
kernel | 50 µs | 5,055 ms |
| 6 | ls chạm 140 KiB text: 35 trang × 0,7 µs |
MMU + kernel | 24,5 µs | 5,080 ms |
| 7 | getdents64 đọc 200 entry, page cache hit |
kernel | 2 µs | 5,082 ms |
| 8 | write(1, …) đẩy 1.200 byte ra tty |
kernel | 301 ns | 5,082 ms |
| 9 | terminal giả lập vẽ 40 dòng × 80 cột (mô hình) | userspace | 5 ms | ≈ 10,1 ms |
Dòng đáng chú ý là dòng 9: phần lớn thời gian người dùng cảm nhận được nằm ở chương trình vẽ terminal, không nằm ở kernel. Cộng toàn bộ phần kernel ở dòng 2, 4, 5, 6, 7, 8 lại được khoảng 82 µs trên 10,1 ms, tức dưới 1%. Trong 10 ms đó, 299 tiến trình khác cũng được chạy — mỗi tiến trình vài mili-giây — và không tiến trình nào trong số chúng biết rằng bạn vừa gõ ls. Đó là toàn bộ nội dung của chữ “ảo giác”: kernel không làm cho mỗi tiến trình chạy nhanh, nó chỉ làm cho mỗi tiến trình không phải biết về các tiến trình khác.
Lưu script dưới đây thành boot_timeline.py (bản trong repo ở content/demo/boot_timeline.py), rồi chạy python3 boot_timeline.py. Không cần thư viện ngoài. Sáu số mặc định là kịch bản ở Trace A; thay bằng số của bạn bằng cách truyền sáu tham số: python3 boot_timeline.py 2100 300 1900 600 37000 180.
#!/usr/bin/env python3
"""boot_timeline.py — dựng timeline khởi động dạng ASCII và in ba phép tính của bài 1.
Chạy: python3 boot_timeline.py
python3 boot_timeline.py 1234 402 1871 640 2103 180 # ms cho sáu mốc, theo thứ tự
Không cần thư viện ngoài. Sáu số mặc định là một lần boot Ubuntu 22.04 trong VM:
bốn nhóm lấy từ `systemd-analyze` (firmware / loader / kernel / userspace),
hai mốc cuối là phần bên trong userspace lấy từ `systemd-analyze blame`.
Thay bằng số của máy bạn rồi chạy lại.
"""
import sys
# (tên mốc, ms, nhóm mà `systemd-analyze` báo cáo)
STAGES = [
("firmware (UEFI POST)", 1234, "firmware"),
("bootloader (GRUB)", 402, "loader"),
("kernel (giải nén + init)", 1871, "kernel"),
("initrd / udev probe", 640, "userspace"),
("systemd default.target", 2103, "userspace"),
("shell prompt", 180, "userspace"),
]
SCALE_MS = 100.0 # một ký tự █ = 100 ms
BAR_MAX = 60 # thanh dài nhất 60 ký tự, dài hơn thì cắt và thêm …
GROUPS = ["firmware", "loader", "kernel", "userspace"]
def vn(x, nd=1):
"""Số theo cách viết Việt Nam: 6.430 và 19,2."""
return f"{x:,.{nd}f}".replace(",", "_").replace(".", ",").replace("_", ".")
def fmt_seconds(s):
"""0,003 / 1,00 / 100.000 — giữ ba chữ số có nghĩa."""
if s < 0.01:
return "<0,01"
if s < 10:
return vn(s, 2)
return vn(s, 0)
def timeline(stages):
total = sum(ms for _, ms, _ in stages)
print(f"{'mốc':<27}{'nhóm':<11}{'ms':>6}{'%':>7}{'cộng dồn':>10} timeline")
cum = 0
for name, ms, group in stages:
cum += ms
chars = round(ms / SCALE_MS) # 1 ký tự = 100 ms
capped = min(chars, BAR_MAX) # chặn thanh dài quá 60 ký tự
bar = "█" * max(1, capped) + ("…" if chars > BAR_MAX else "")
print(f"{name:<27}{group:<11}{vn(ms, 0):>6}{vn(100 * ms / total):>7}%"
f"{vn(cum, 0):>10} {bar}")
print(f"{'TỔNG':<27}{'':<11}{vn(total, 0):>6}{vn(100.0):>7}%{vn(cum, 0):>10}")
print("\nnhóm theo systemd-analyze:")
for g in GROUPS:
ms = sum(v for _, v, k in stages if k == g)
print(f" {g:<11}{vn(ms, 0):>6} ms{vn(100 * ms / total):>8}%")
return total
def computations(total, stages):
print("\n--- ba phép tính của bài ---")
# (1) thang độ trễ, quy ước 100 ns đọc RAM = 1 giây người
print("(1) thang độ trễ, quy ước 100 ns đọc RAM = 1 giây người")
ladder = [("register", "0,3 ns", 0.3e-9), ("L1 cache", "1 ns", 1e-9),
("RAM", "100 ns", 100e-9), ("SSD (4 KiB, sâu)", "100 µs", 100e-6),
("HDD (seek + quay)", "10 ms", 10e-3)]
ram = 100e-9
for name, human, s in ladder:
print(f" {name:<22}{human:>9} = {fmt_seconds(s / ram):>8} giây nếu RAM = 1 giây")
print(f" RAM nhanh hơn HDD {vn(10e-3 / 100e-9, 0)} lần")
# (2) một page fault 100 µs trên CPU 3 GHz
us, ghz, ipc = 100, 3.0, 4
cycles = us * 1e-6 * ghz * 1e9
print(f"\n(2) 1 page fault {us} µs @ {ghz:.0f} GHz = {vn(cycles, 0)} chu kỳ "
f"≈ {vn(cycles / ipc, 0)} lệnh (IPC {ipc:.0f})")
# (3) đọc 40 MiB tuần tự từ SSD 500 MB/s
mib, mbps = 40, 500
ms = mib / mbps * 1000
print(f"(3) đọc {mib} MiB @ {mbps} MB/s = {vn(ms, 0)} ms "
f"= {vn(ms * 1000 / us, 0)} lần page fault {us} µs")
# (4) vòng quay của bộ định thời: 300 tiến trình trên 8 lõi, quantum 4 ms
n, cores, q = 300, 8, 4
print(f"\n(4) {n} tiến trình / {cores} lõi × quantum {q} ms = "
f"{vn(n / cores * q, 0)} ms vòng quay; chuyển ngữ cảnh 5 µs = "
f"{vn(5 / (q * 1000) * 100, 2)}% CPU")
# (5) tỉ lệ kernel trong tổng thời gian boot
kern = sum(v for _, v, k in stages if k == "kernel")
fw = sum(v for _, v, k in stages if k in ("firmware", "loader"))
print(f"(5) kernel {vn(kern, 0)} ms / tổng {vn(total, 0)} ms = "
f"{vn(100 * kern / total)}%; firmware+loader = {vn(fw, 0)} ms = "
f"{vn(100 * fw / total)}%")
def main(argv):
stages = STAGES
if len(argv) > 1:
vals = [int(x) for x in argv[1:]]
if len(vals) != len(STAGES):
sys.exit(f"cần đúng {len(STAGES)} số (ms), nhận {len(vals)}")
stages = [(n, v, g) for (n, _, g), v in zip(STAGES, vals)]
computations(timeline(stages), stages)
if __name__ == "__main__":
main(sys.argv)
Output thật của lần chạy dùng để viết bài (không sửa một ký tự):
mốc nhóm ms % cộng dồn timeline
firmware (UEFI POST) firmware 1.234 19,2% 1.234 ████████████
bootloader (GRUB) loader 402 6,3% 1.636 ████
kernel (giải nén + init) kernel 1.871 29,1% 3.507 ███████████████████
initrd / udev probe userspace 640 10,0% 4.147 ██████
systemd default.target userspace 2.103 32,7% 6.250 █████████████████████
shell prompt userspace 180 2,8% 6.430 ██
TỔNG 6.430 100,0% 6.430
nhóm theo systemd-analyze:
firmware 1.234 ms 19,2%
loader 402 ms 6,3%
kernel 1.871 ms 29,1%
userspace 2.923 ms 45,5%
--- ba phép tính của bài ---
(1) thang độ trễ, quy ước 100 ns đọc RAM = 1 giây người
register 0,3 ns = <0,01 giây nếu RAM = 1 giây
L1 cache 1 ns = 0,01 giây nếu RAM = 1 giây
RAM 100 ns = 1,00 giây nếu RAM = 1 giây
SSD (4 KiB, sâu) 100 µs = 1.000 giây nếu RAM = 1 giây
HDD (seek + quay) 10 ms = 100.000 giây nếu RAM = 1 giây
RAM nhanh hơn HDD 100.000 lần
(2) 1 page fault 100 µs @ 3 GHz = 300.000 chu kỳ ≈ 75.000 lệnh (IPC 4)
(3) đọc 40 MiB @ 500 MB/s = 80 ms = 800 lần page fault 100 µs
(4) 300 tiến trình / 8 lõi × quantum 4 ms = 150 ms vòng quay; chuyển ngữ cảnh 5 µs = 0,12% CPU
(5) kernel 1.871 ms / tổng 6.430 ms = 29,1%; firmware+loader = 1.636 ms = 25,4%
Dòng cần đọc kỹ là dòng nhóm: userspace 45,5% so với kernel 29,1% và firmware cộng loader 25,4%. Ba phần gần bằng nhau, nên tối ưu boot là bài toán chia ba, không phải bài toán “sửa kernel”. Chạy lại script với sáu số của máy bạn — nếu tổng vượt 10.000 ms, phần vượt gần như chắc chắn nằm ở nhóm userspace, và systemd-analyze blame ở khối 7 sẽ chỉ ra service nào.
Widget dưới đây dùng đúng sáu mốc và đúng công thức của boot_timeline.py, chạy hoàn toàn trong trình duyệt, không gọi mạng. Kéo thanh của một mốc để thấy phần trăm và thanh timeline đổi ngay — mục đích là thấy tổng 6.430 ms phụ thuộc vào cả sáu số, và một service chờ 30 giây làm mọi thứ khác thành nhiễu.
Sáu mốc, đơn vị mili-giây. Công thức giống hệt boot_timeline.py: phần trăm = mốc ÷ tổng, thanh dài theo tỉ lệ. Bốn nhóm
firmware / loader / kernel / userspace là bốn số mà systemd-analyze in ra. Mục tiêu 4.000 ms ở bài tập (c) là mốc so sánh.
Toàn bộ khối này dành cho Ubuntu (máy thật hoặc VM 22.04). Mọi con số ở đây là số kỳ vọng ≈, chưa đo trên máy soạn bài — macOS không có systemd-analyze và không có /proc, nên đây đúng là phần bạn phải tự chạy rồi đối chiếu.
systemd-analyze
# Startup finished in 2.104s (firmware) + 302ms (loader) + 1.871s (kernel) + 37.023s (userspace) = 41.300s
systemd-analyze blame | head -3
# 30.001s NetworkManager-wait-online.service
# 640ms snapd.seeded.service
# 180ms systemd-udev-settle.service
systemd-analyze critical-chain
# graphical.target @41.302s
# └─multi-user.target @41.302s
# └─NetworkManager-wait-online.service @11.301s + 30.001s
Bốn số trong dòng đầu của systemd-analyze chính là bốn nhóm mà script ở khối 6 in ra, và đây là chính cái VM ở khối 1: 2,104 s + 302 ms + 1,871 s + 37,023 s = 41,300 s. Kỳ vọng trên một VM Ubuntu 22.04 chưa tối ưu: tổng 8–15 s; trên máy thật dùng NVMe: 3–6 s. Dòng đầu của blame thường là NetworkManager-wait-online.service hoặc snapd.seeded.service với 1–3 s; ở đây nó là 30,001 s, và cách sửa là systemctl disable --now NetworkManager-wait-online.service, tiết kiệm trọn số giây đó.
Đối chiếu phần cứng và hạt nhân:
nproc; free -h | head -2; cat /proc/loadavg
# 4
# total used free
# Mem: 7.7Gi 1.2Gi 4.1Gi
# 0.08 0.12 0.09 1/412 8821
getconf PAGE_SIZE
# 4096
cat /proc/1/comm
# systemd
nproc cho số lõi (4), free -h cho RAM, /proc/loadavg cho ba số trung bình 1/5/15 phút và cặp 1/412 — 1 tiến trình đang chạy trên 412 tiến trình đang tồn tại. Khi số đầu của loadavg gần bằng nproc, mọi tiến trình đều phải xếp hàng, và vòng quay 150 ms ở khối 3 trở thành vòng quay thật của máy bạn. getconf PAGE_SIZE trả 4.096 trên Ubuntu: đó là lý do 1 GiB có 262.144 trang, gấp 4 lần số trang trên máy soạn bài.
Đo chi phí system call — bài thực hành nhỏ nhất của chương, 18 dòng C, chạy được trên cả Linux và macOS:
#include <stdio.h>
#include <time.h>
#include <unistd.h>
static double now_ns(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return ts.tv_sec * 1e9 + ts.tv_nsec;
}
int main(void) {
const long N = 1000000;
double t0 = now_ns();
for (long i = 0; i < N; i++) getpid();
double t_sc = (now_ns() - t0) / N;
printf("getpid(): %.1f ns\n", t_sc);
return 0;
}
Kỳ vọng trên Ubuntu 22.04: 250–400 ns mỗi lần gọi, vì trên Linux getpid() vẫn đi qua kernel (glibc không đệm PID như macOS). Trên máy soạn bài, cùng đoạn code này in ra 1,3 ns — chênh gần 200 lần, và đó không phải vì Linux chậm hơn mà vì hai hệ điều hành chọn hai cách trả lời cùng một câu hỏi. Đây là ví dụ sạch nhất cho quy tắc đọc số: luôn hỏi con số được đo ở đâu trước khi so sánh.
Lỗi 1 — Tưởng mọi lời gọi hệ thống đều vào kernel. Triệu chứng: bạn đo getpid() được 1,3 ns rồi kết luận “system call rẻ”. Nguyên nhân: một số hàm trong libc được trả lời từ trang nhớ chỉ đọc do kernel ánh xạ vào user space (vDSO trên Linux, commpage trên macOS) — không có lần đổi mode nào xảy ra. Cách sửa: đo bằng một syscall chắc chắn phải vào kernel, ví dụ write(1, "", 0), và so hai số cạnh nhau; nếu chênh trên 50 lần thì một trong hai không phải syscall thật.
Lỗi 2 — Cộng thời gian boot từ log của kernel. Triệu chứng: số bạn cộng tay ra 4.200 ms nhưng systemd-analyze báo 6.430 ms. Nguyên nhân: firmware và bootloader chạy trước khi kernel có đồng hồ để đo, nên log kernel không bao giờ chứa hai mốc đầu — 25,4% thời gian đã biến mất khỏi phép cộng. Cách sửa: lấy đúng bốn nhóm từ systemd-analyze, và chỉ dùng log kernel để chia nhỏ phần bên trong kernel.
Lỗi 3 — Tưởng malloc cấp phát bộ nhớ. Triệu chứng: chương trình xin 4 GiB trên máy 8 GB vẫn chạy 10 phút, rồi chết ở giữa vòng lặp với OOM killer. Nguyên nhân: malloc chỉ ghi một vùng nhớ ảo (VMA); khung trang thật chỉ được cấp ở page fault đầu tiên của từng trang. Cách sửa: nếu cần biết chắc, ghi thử vào byte đầu mỗi trang ngay sau khi cấp; nếu muốn kernel từ chối sớm, đặt vm.overcommit_memory = 2.
Lỗi 4 — Gọi page fault là interrupt. Triệu chứng: mô tả “kernel bị ngắt bởi page fault” rồi giải thích sai luồng xử lý. Nguyên nhân: page fault là fault đồng bộ, xảy ra đúng tại lệnh gây ra nó và bắt buộc phải xử lý xong rồi chạy lại lệnh đó; interrupt là bất đồng bộ, đến từ thiết bị, và lệnh đang chạy không liên quan. Cách sửa: dùng phép thử — bỏ lệnh đó đi thì biến cố có còn xảy ra không? Page fault biến mất, interrupt thì không.
(a) Tính tay. Một VM có 4 KiB mỗi trang, 4 GiB RAM, kernel nạp initrd 96 MiB từ SSD đọc tuần tự 400 MB/s. Tính bốn số, mỗi số là một con số cụ thể: (i) thời gian đọc initrd, tính bằng ms; (ii) initrd chiếm bao nhiêu khung trang; (iii) nếu mỗi khung phải xoá 0 với chi phí 0,5 µs, tổng thời gian xoá là bao nhiêu ms; (iv) máy 2 lõi, 180 tiến trình: một vòng quay dài bao nhiêu ms với quantum 5 ms, và bao nhiêu với quantum 2 ms; và ở quantum 2 ms, chi phí chuyển ngữ cảnh 5 µs chiếm bao nhiêu phần trăm CPU?
(b) Sửa lỗi. Hàm in bảng dưới đây in cột “cộng dồn” lệch một dòng so với output đúng ở khối 6:
cum = 0
for name, ms, group in stages:
print(f"{name:<27}{cum:>10}")
cum += ms
Chỉ ra triệu chứng cụ thể (dòng đầu in gì, dòng cuối in gì), sửa bằng cách đổi đúng một dòng, và cho biết sau khi sửa, dòng kernel in cột cộng dồn bằng bao nhiêu.
(c) Thiết kế. VM của bạn boot 6.430 ms và yêu cầu là dưới 4.000 ms, không đổi phần cứng. Nêu ba thay đổi cụ thể và với mỗi thay đổi ghi rõ bạn kỳ vọng tiết kiệm bao nhiêu mili-giây, dựa trên số ở Trace A. Ràng buộc: một trong ba thay đổi phải nằm ở nhóm userspace, một ở nhóm kernel (ví dụ bỏ initrd bằng cách biên dịch driver đĩa vào nhân), và tổng ba số bạn kỳ vọng phải cộng lại vượt 2.430 ms — nếu không, thiết kế của bạn chưa đạt yêu cầu.
Kernel không phải “chương trình quản lý” nói chung — nó là đoạn code duy nhất được chạy ở ring 0, và mọi thứ khác trong bài này, từ bảo vệ bộ nhớ tới ảo giác 300 tiến trình chạy song song, chỉ là hệ quả của việc giữ độc quyền đó.
Năm thuật ngữ: kernel mode, system call, interrupt, page fault, PID 1.
man 2 syscall, man 2 mmap, man 5 proc — đường vào kernel và các bộ đếm trong /proc.man 1 systemd-analyze — bốn nhóm firmware / loader / kernel / userspace và định dạng của blame, critical-chain.Documentation/admin-guide/mm/concepts.rst (page fault, lazy allocation), Documentation/scheduler/ (tick và quantum).getconf PAGE_SIZE; một chương trình C mmap 1 GiB rồi ghi byte đầu mỗi trang, đo 186 ms cho 65.536 trang; đoạn C đo chi phí syscall in ở khối 7.