I/O, Lưu trữ & Tài nguyên/Hành trình một lệnh read() — từ ứng dụng xuống mặt đĩa
13/26
Bài 13 / 26~15 phútĐường đi I/O & GPUMiễn phí lượt xem

Hành trình một lệnh read() — từ ứng dụng xuống mặt đĩa

Xuyên hết các tầng: syscall, VFS, page cache, block layer và I/O scheduler, driver, thiết bị, rồi ngắt báo xong. Mỗi tầng thêm bao nhiêu micro-giây vào độ trễ.

TL;DR: Một dòng read(fd, buf, n) trông như một thao tác duy nhất, nhưng bên dưới nó là một chuỗi chặng: chuyển sang chế độ kernel qua syscall, VFS định tuyến tới filesystem đúng loại, tra page cache — và nếu trúng thì hành trình dừng ngay ở đây, chưa hề chạm đĩa. Trượt cache thì mới đi tiếp: filesystem dịch offset trong file thành số hiệu block, block layer gom yêu cầu thành bio rồi xếp vào hàng đợi, driver dựng descriptor cho DMA, thiết bị chuyển dữ liệu vào RAM và phát một ngắt báo xong. Chênh lệch giữa hai kịch bản là ba bậc độ lớn, và nó nằm gọn ở chỗ có trúng cache hay không.

Bạn viết read(fd, buf, 4096). Một dòng. Nhưng đo bằng strace -T thì đôi khi nó trả về sau vài micro-giây, đôi khi sau vài mili-giây — chênh cả nghìn lần với cùng một dòng code, cùng một file. Hai bài trước cho bạn thanh ghi, DMA và ngắt; bài này ráp chúng vào đúng vị trí trên tuyến đường và trả lời vì sao có hai số đo cách xa nhau đến thế.

1. Analogy — một yêu cầu đi qua nhiều cửa

Bạn nộp đơn xin trích lục giấy tờ ở phường. Nhân viên đọc đơn rồi kiểm ngăn kéo ngay cạnh bàn — hồ sơ mới rút hôm qua thường còn nằm đó, và có sẵn thì bạn cầm bản sao về sau hai phút.

Không có thì đơn mới thật sự đi tiếp: xuống bộ phận lưu trữ, tra sổ xem hồ sơ nằm kệ nào, ghép đơn của bạn với mấy đơn khác cùng khu vực cho đỡ phải xuống hầm nhiều lần, rồi cử người đi lấy. Bạn ngồi chờ, và được gọi tên khi hồ sơ lên tới nơi.

Ở phườngTrong kernel
Nộp đơn ở quầyread() — chuyển sang chế độ kernel
Nhân viên xác định loại giấy tờVFS định tuyến tới đúng filesystem
Ngăn kéo cạnh bànPage cache
Tra sổ xem hồ sơ ở kệ nàoFilesystem dịch offset thành số block
Ghép nhiều đơn cùng khu vựcBlock layer gom và gộp yêu cầu
Người xuống hầm lấy hồ sơDriver và thiết bị chuyển dữ liệu bằng DMA
Được gọi tên khi hồ sơ tớiNgắt hoàn tất đánh thức tiến trình

2. Hai chặng đầu — vào kernel, rồi hỏi cache trước

read() không phải hàm thường. Nó là syscall: CPU chuyển từ chế độ người dùng sang chế độ kernel, đổi ngăn xếp, rồi kernel chạy code của nó trên chính lõi ấy.

Nhớ lại system call là gì

Bài đó dựng cơ chế chuyển chế độ và cái giá của nó. Ở đây chỉ cần giữ lại một điều: đó là chặng rẻ nhất trên toàn tuyến đường, nên chương trình chậm vì I/O thì thủ phạm gần như không bao giờ là bản thân syscall.

Vào tới kernel, yêu cầu gặp VFS (Virtual File System) — lớp trừu tượng khiến read() chạy như nhau trên ext4, XFS, NFS hay /proc. VFS tra bảng file descriptor xem fd trỏ tới đối tượng nào, rồi gọi hàm read mà filesystem đó đã đăng ký.

Chặng thứ ba mới là chặng quyết định. Trước khi nghĩ tới đĩa, kernel tra page cache xem những trang tương ứng với vùng dữ liệu đang xin đã nằm sẵn trong RAM chưa. Trúng thì kernel chép thẳng từ page cache sang bộ đệm của ứng dụng, read() trả về, và không một yêu cầu I/O nào được sinh ra. Hành trình kết thúc ở đây.

Tuyen duong mot lenh read voi cong re page cache, phan con lai dong khung la khoi chi chay khi truot

Đây chính là page cache của bài module trước, giờ hiện ra ở đúng vị trí của nó: một cổng rẽ nằm rất sớm, và mọi thứ phía sau chỉ chạy khi cổng ấy đóng.

3. Chặng giữa — block layer gom việc lại trước khi làm

Trượt cache thì filesystem phải trả lời một câu cụ thể: byte thứ 8192 của file này nằm ở block nào? Với ext4 hay XFS, câu trả lời tra ra từ extent trong inode — cấu trúc bạn đã gặp ở bài filesystem và inode.

Có số block rồi, kernel dựng một bio: đọc hay ghi, bắt đầu từ sector nào, dài bao nhiêu, đổ vào những trang nhớ nào. Đây là đơn vị làm việc chuẩn của block layer, và nó cố tình không nói gì về loại thiết bị bên dưới.

Điều đáng chú ý là block layer không chuyển tiếp bio xuống driver ngay. Nó giữ lại một nhịp, vì ba việc chỉ làm được khi có nhiều yêu cầu trong tay cùng lúc: gộp các yêu cầu liền kề thành một yêu cầu lớn hơn (đáng tiền nhất với ổ quay, vì tiết kiệm một lần seek), sắp xếp lại thứ tự theo chính sách của I/O scheduler, và chia phối vào đúng hàng đợi phần cứng.

Kiến trúc hiện tại là blk-mq (multi-queue), sinh ra vì mô hình một hàng đợi có khoá dùng chung không theo kịp NVMe. Tài liệu kernel mô tả nó thành hai tầng: các hàng đợi staging ở phần mềm, gắn theo CPU nên nhiều lõi xếp yêu cầu mà không giẫm khoá của nhau, rồi đẩy xuống các hàng đợi dispatch ánh xạ vào hàng đợi thật của thiết bị. Mỗi yêu cầu mang một tag, và thiết bị dùng chính tag đó để báo lại yêu cầu nào vừa xong — nhờ vậy hoàn tất mới ra được ngoài thứ tự.

Bộ lập lịch cắm vào tầng này: mq-deadline đặt hạn chót cho mỗi yêu cầu để không ai bị bỏ đói, BFQ chia băng thông theo tiến trình nên hợp máy để bàn, Kyber giữ độ trễ thấp bằng cách siết độ sâu hàng đợi. Với NVMe, nhiều hệ thống bỏ hẳn lập lịch (none) vì thiết bị tự sắp xếp tốt hơn phần mềm.

4. Chặng cuối — driver, DMA và ngắt hoàn tất

Tới đây thì hai bài trước ráp vào. Driver lấy yêu cầu ra khỏi hàng đợi dispatch, dựng descriptor mô tả cần đọc sector nào và đổ vào những trang RAM nào, rồi ghi một giá trị qua MMIO để đánh thức thiết bị.

Sau đó CPU rời đi. Thiết bị tự đọc descriptor và chuyển dữ liệu thẳng vào RAM bằng DMA. Xong việc, nó phát một ngắt; handler tra tag để biết yêu cầu nào hoàn tất, đánh dấu trang trong page cache là hợp lệ, rồi đánh thức tiến trình đang ngủ. Kernel chép từ page cache sang bộ đệm ứng dụng, read() trả về.

Các chặng chênh nhau rất xa — đây là thứ tự độ lớn để so sánh, không phải số đo trên máy cụ thể:

ChặngBậc độ lớn
Chuyển chế độ vào kernelhàng trăm nano-giây
VFS định tuyến, tra page cachehàng trăm nano-giây tới vài micro-giây
Chép từ page cache sang buffervài micro-giây cho một trang
Xếp hàng và lập lịch ở block layervài tới vài chục micro-giây
Chờ NVMe trả dữ liệuhàng chục tới hàng trăm micro-giây
Chờ ổ quay trả dữ liệuvài tới hàng chục mili-giây

Cột phải cho ngay một kết luận: mọi chặng phần mềm cộng lại vẫn nhỏ hơn hẳn một lần chạm thiết bị. Tối ưu đúng chỗ vì thế hầu như luôn nghĩa là giảm số lần phải xuống tới thiết bị, không phải làm các tầng kernel chạy nhanh hơn.

5. Tự điền — đường về đi ngược lối nào?

Bạn vừa đi hết chiều xuống. Chiều lên thì sao? Điền vào ba ô trống dưới đây trước khi đọc tiếp — mỗi ô một câu, nói rõ ai làm việc đó.

BướcAi làm và làm gì
Thiết bị đọc xong sector/* TODO 1: dữ liệu đi từ thiết bị vào RAM bằng cách nào? */
Kernel biết yêu cầu đã xong/* TODO 2: nhờ tín hiệu gì, và làm sao biết đúng yêu cầu nào? */
Tiến trình nhận được dữ liệu/* TODO 3: ai đánh thức nó, và dữ liệu được chép từ đâu sang đâu? */
Tự điền trước khi xem đáp án

Gợi ý định hướng, không phải đáp án: chiều xuống có ba nhân vật (CPU, DMA engine, thiết bị) — chiều lên cũng vậy, nhưng vai của họ đổi chỗ. Hỏi từng bước: nhân vật nào đang chủ động, và nhân vật nào đang ngồi chờ?

Đáp án. Bước 1: thiết bị tự đổ dữ liệu vào các trang RAM đã ghi trong descriptor bằng DMA, CPU không tham gia — đây là điểm bất đối xứng lớn nhất, vì chiều xuống CPU chủ động ra lệnh còn chiều lên thiết bị chủ động ghi. Bước 2: thiết bị phát ngắt hoàn tất, handler tra tag đi kèm để biết đúng yêu cầu nào vừa xong, vì thứ tự hoàn tất không nhất thiết trùng thứ tự gửi đi. Bước 3: handler đánh dấu trang trong page cache là hợp lệ rồi đánh thức tiến trình; kernel chép từ page cache sang bộ đệm ứng dụng — nghĩa là dữ liệu bị chép hai lần, và chỗ chép thừa này chính là thứ bài zero-copy sẽ cắt.

6. Pitfall của riêng concept này

Nhầm 1 — benchmark read() mà không tính tới page cache.

✅ Lần chạy thứ hai gần như luôn trúng cache, nên con số đo được là tốc độ của RAM chứ không phải của đĩa. Muốn đo thiết bị thật thì phải xoá cache trước mỗi lượt hoặc mở file với O_DIRECT.

Nhầm 2 — tưởng strace thấy read() mất 5 ms nghĩa là thiết bị chậm.

✅ Con số đó gộp cả thời gian nằm chờ trong hàng đợi block layer. Khi độ sâu hàng đợi cao, phần lớn 5 ms có thể là xếp hàng chứ không phải thời gian thiết bị làm việc.

Nhầm 3 — nghĩ đổi I/O scheduler là mẹo tăng tốc chung.

✅ Bộ lập lịch chỉ đổi thứ tự phục vụ, không tạo thêm băng thông. Nó đáng chỉnh khi vấn đề là công bằng hay độ trễ đuôi.

7. 📚 Đào sâu (tuỳ chọn)

📚 Nguồn kỹ thuật

8. Liên hệ các bài khác

9. Tóm tắt

  • Chẩn đoán theo thứ tự: thấy hai lần đọc cùng file chênh nhau nghìn lần thì soi page cache trước, đừng vội nghi driver hay đĩa.
  • Block layer cố ý giữ yêu cầu lại một nhịp để gộp và sắp xếp; blk-mq chia hai tầng hàng đợi và dùng tag để hoàn tất ngoài thứ tự.
  • Mọi chặng phần mềm cộng lại vẫn nhỏ hơn một lần chạm thiết bị, nên tối ưu đúng nghĩa là giảm số lần xuống tới thiết bị.
  • Chỗ chép hai lần trên đường về (page cache sang bộ đệm ứng dụng) chính là thứ bài zero-copy sẽ cắt.

10. Tự kiểm tra

Tự kiểm tra
Q1
Cùng một chương trình đọc cùng một file: lần chạy đầu mất 4 ms, lần thứ hai mất 6 micro-giây. Chặng nào trên tuyến đường giải thích chênh lệch này, và những chặng nào đã bị bỏ qua hẳn ở lần thứ hai?

Page cache. Lần đầu trượt cache nên yêu cầu phải đi hết đường: filesystem dịch offset thành block, block layer xếp hàng và lập lịch, driver dựng descriptor, thiết bị chuyển dữ liệu rồi phát ngắt. Lần thứ hai các trang đã nằm sẵn trong RAM nên kernel chép thẳng sang bộ đệm ứng dụng và trả về. Bị bỏ qua hẳn là toàn bộ phần từ filesystem trở xuống: không bio nào được dựng, không yêu cầu nào vào hàng đợi, không DMA và không ngắt.

Q2
Vì sao block layer cố tình giữ yêu cầu lại một nhịp thay vì đẩy thẳng xuống driver? Việc trì hoãn đó mua lại được gì?

Ba việc chỉ làm được khi có nhiều yêu cầu trong tay cùng lúc: gộp các yêu cầu liền kề để tiết kiệm một lần seek trên ổ quay, sắp xếp lại thứ tự để bộ lập lịch thực thi chính sách công bằng hay hạn chót, và chia phối vào đúng hàng đợi phần cứng để nhiều lõi không giẫm khoá của nhau. Cái giá là chút độ trễ thêm cho yêu cầu đơn lẻ — đánh đổi đáng với ổ quay, còn với NVMe thì thường không, nên nhiều hệ thống đặt scheduler về none và để thiết bị tự lo.

Q3
Thiết bị NVMe hoàn tất các yêu cầu không theo đúng thứ tự chúng được gửi đi. Kernel làm sao biết ngắt vừa nhận ứng với yêu cầu nào?

Nhờ tag gắn kèm mỗi yêu cầu khi nó rời hàng đợi dispatch. Thiết bị trả lại tag đó trong thông báo hoàn tất, nên handler tra ngược ra đúng yêu cầu, đúng trang nhớ đích và đúng tiến trình đang chờ. Không có cơ chế này thì kiến trúc nhiều hàng đợi không hoạt động được: thiết bị nhận hàng nghìn lệnh song song mà kernel chẳng có cách nào ghép thông báo với yêu cầu.

Q4
Một đồng nghiệp đề xuất tăng tốc service bằng cách đổi I/O scheduler. Câu hỏi nào cần trả lời trước khi thử, và vì sao đây thường không phải mẹo tăng thông lượng?

Cần biết service đang đau vì gì: thông lượng thấp, độ trễ trung bình cao, hay độ trễ đuôi và tranh chấp giữa các tiến trình. Bộ lập lịch chỉ đổi thứ tự phục vụ; nó không tạo thêm băng thông, nên nút thắt là băng thông thì đổi nó vô ích. BFQ đáng thử khi vấn đề là công bằng — một tiến trình nền ghi ồ ạt làm các tiến trình khác khựng. Trên NVMe thì none thường thắng, vì thiết bị tự sắp xếp tốt hơn phần mềm.

Q5
Trên tuyến đường có bao nhiêu lần dữ liệu bị chép, tính từ lúc thiết bị đọc xong tới lúc ứng dụng đọc được biến của mình? Lần chép nào là thứ zero-copy nhắm tới?

Hai lần. Lần thứ nhất do DMA: thiết bị đổ dữ liệu vào các trang page cache, CPU không tham gia. Lần thứ hai do CPU: kernel chép từ page cache sang bộ đệm nằm trong không gian địa chỉ của ứng dụng, vì hai vùng đó tách biệt nhau. Zero-copy nhắm vào lần thứ hai, và chỉ cắt được khi ứng dụng không thật sự cần nhìn thấy dữ liệu — đọc file rồi đẩy thẳng ra socket chẳng hạn.

Bài tiếp theo: Chờ I/O thế nào — blocking, non-blocking và epoll

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên

Bài tiếp theo

Chờ I/O thế nào — blocking, non-blocking và epoll