Polling, interrupt và DMA — ai tốn CPU khi chép dữ liệu
Ba cách CPU biết thiết bị đã xong việc: hỏi liên tục, chờ ngắt, hay giao hẳn phần chép cho DMA engine. Chi phí thật của từng cách và khi nào polling lại thắng.
TL;DR: Sau khi CPU ra lệnh cho thiết bị, còn hai câu hỏi phải trả lời: làm sao biết nó xong, và ai chép dữ liệu. Câu đầu có hai đáp án — polling (CPU hỏi đi hỏi lại thanh ghi trạng thái) và interrupt (thiết bị chủ động báo, CPU đi làm việc khác trong lúc chờ). Câu sau có một đáp án đổi cục diện: DMA, khi một engine riêng chép thẳng giữa thiết bị và RAM, còn CPU chỉ đặt lệnh rồi nhận một ngắt lúc xong. Và trực giác "polling luôn phí" là sai: ở tốc độ gói tin cao, chính interrupt mới là thứ giết chết máy, nên Linux chuyển sang polling qua NAPI.
Bài trước dừng ở chỗ CPU ghi được vào thanh ghi lệnh. Nhưng ra lệnh xong thì làm gì tiếp? Ổ NVMe cần vài trăm micro-giây mới trả lời — quãng đó CPU chạy được hàng trăm nghìn lệnh. Ngồi chờ hay đi làm việc khác, và ai sẽ bê dữ liệu về: đó là toàn bộ nội dung bài này.
1. Analogy — ba cách chờ ấm nước sôi
Bạn cắm ấm rồi cần nước sôi để pha trà. Cách thứ nhất là đứng cạnh ấm, cứ vài giây lại mở nắp nhìn. Nước sôi phát là biết ngay, nhưng suốt quãng đó bạn không làm được gì khác.
Cách thứ hai là dùng ấm có còi: bạn đi làm việc khác, còi kêu thì quay lại. Đổi lại, mỗi lần bị gọi thì bạn phải bỏ dở việc đang làm, và lúc quay về còn mất công nhớ lại mạch cũ.
Cách thứ ba khác hẳn về bản chất: bạn nhờ người nhà rót nước sang bình, còn mình chỉ cần được báo một tiếng lúc xong. Ở đây bạn không những khỏi chờ, mà còn khỏi bê.
| Trong bếp | Trong máy tính |
|---|---|
| Đứng mở nắp nhìn liên tục | Polling — đọc thanh ghi trạng thái trong vòng lặp |
| Ấm có còi | Interrupt — thiết bị phát tín hiệu IRQ |
| Bỏ dở việc, nhớ chỗ đang làm | Chuyển ngữ cảnh khi vào trình xử lý ngắt |
| Nhờ người rót hộ | DMA engine chép giữa thiết bị và RAM |
| Được báo một tiếng lúc rót xong | Một ngắt duy nhất báo cả lô đã xong |
2. Biết khi nào xong — hỏi liên tục hay chờ được gọi?
Cách đơn giản nhất là đọc đi đọc lại thanh ghi trạng thái cho tới khi bit "xong" bật lên:
/* Kick off the transfer, then spin until the device reports completion. */
writel(CMD_START, regs + CMD_OFFSET);
while (!(readl(regs + STATUS_OFFSET) & STATUS_DONE))
cpu_relax();
Vòng lặp này không sai, nó chỉ đắt. Suốt thời gian chờ, lõi CPU đó chạy 100% mà không sinh ra công việc hữu ích nào, và hệ điều hành cũng không xếp được tiến trình khác vào chỗ ấy.
Nhưng polling có một ưu thế mà không cách nào khác bù được: độ trễ phản ứng thấp nhất có thể. Bit trạng thái vừa đổi là vòng lặp thấy ngay ở lần đọc kế tiếp, không phải đợi phần cứng phát tín hiệu, không phải đợi CPU chuyển sang trình xử lý ngắt.
Cách thứ hai đảo chiều chủ động. CPU ra lệnh xong thì bỏ đi làm việc khác, thậm chí hệ điều hành cho tiến trình khác chạy trên lõi đó. Khi xong việc, thiết bị kéo một đường tín hiệu IRQ (interrupt request); CPU hoàn tất lệnh đang chạy dở, cất trạng thái, rồi nhảy vào trình xử lý ngắt của driver.
Bài đó dựng cơ chế: ngắt là sự kiện bất đồng bộ từ phần cứng, còn trap sinh ra từ chính lệnh đang chạy. Ở đây ta nhìn cơ chế ấy từ phía thiết bị, và hỏi cái giá của nó khi tần suất lên cao.
Cái giá nằm ở chỗ mỗi lần ngắt đều kéo theo một cú chuyển ngữ cảnh: cất thanh ghi, chuyển sang ngăn xếp kernel, chạy handler, rồi khôi phục. Từng lần thì rẻ, cùng bậc với chi phí chuyển ngữ cảnh ở bài trạng thái và context switch. Vấn đề là nhân lên: một card mạng 10 Gb/s nhận gói nhỏ sinh hàng trăm nghìn sự kiện mỗi giây, và cái giá vài micro-giây mỗi lần trở thành toàn bộ ngân sách CPU.
Ngắt còn phá cache: handler chen vào giữa, đẩy dữ liệu nóng của tiến trình đang chạy ra khỏi cache. Đây là khoản chi phí không hiện ra trong bất kỳ phép đo "thời gian chạy handler" nào.
3. DMA — giao hẳn việc chép cho một engine riêng
Hai cách trên mới trả lời chuyện biết khi nào xong. Còn dữ liệu thì sao? Nếu CPU phải tự đọc từng word qua thanh ghi dữ liệu rồi ghi vào RAM, thì đọc một file 100 MB nghĩa là CPU ngồi chép 100 MB — đúng nghĩa đen.
DMA (Direct Memory Access) cắt CPU ra khỏi đường dữ liệu. Trên PCIe hiện đại, chính thiết bị đóng vai bus master: driver dựng sẵn trong RAM một descriptor mô tả cần chép gì đi đâu, ghi địa chỉ descriptor đó qua MMIO, rồi thiết bị tự đọc mô tả và chuyển thẳng dữ liệu vào RAM. CPU chỉ chạm vào hai đầu — đặt lệnh và nhận ngắt báo xong.

Tài liệu DMA API của kernel chia hai kiểu ánh xạ. Coherent DMA cấp một vùng nhớ mà cả CPU lẫn thiết bị nhìn thấy như nhau mọi lúc, không cần đồng bộ tay — tiện cho các cấu trúc dùng chung lâu dài như vòng descriptor. Streaming DMA ánh xạ một buffer cho đúng một lần truyền rồi gỡ, rẻ hơn nhưng driver phải tự tôn trọng quyền sở hữu: đã trao buffer cho thiết bị thì CPU không được chạm vào cho tới khi truyền xong.
Hai chi tiết dễ bị bỏ qua. Địa chỉ mà thiết bị dùng không nhất thiết là địa chỉ vật lý của CPU: khi có IOMMU, thiết bị làm việc trên một không gian địa chỉ riêng được ánh xạ lại, vừa gom được các trang rời rạc thành một vùng liền mạch, vừa chặn thiết bị hỏng ghi bậy vào bộ nhớ kernel. Và nếu thiết bị không địa chỉ hoá được vùng nhớ đang chứa dữ liệu, kernel phải chép qua một bounce buffer trung gian — lúc đó bản sao mà DMA vốn sinh ra để tránh lại quay về, chỉ là ở chỗ ít ai nhìn.
4. Vì sao ở tốc độ cao polling lại thắng interrupt?
Đây là chỗ trực giác "polling luôn phí" gãy.
Hình dung card mạng nhận một triệu gói mỗi giây và mỗi gói phát một ngắt. Với chi phí vài micro-giây cho mỗi lần vào ra handler, riêng việc xử lý thông báo đã ngốn hết CPU, chưa đụng tới việc xử lý gói. Tệ hơn, hệ thống rơi vào livelock: ngắt có mức ưu tiên cao hơn code thường, nên CPU cứ bị kéo vào handler mà không bao giờ còn thời gian chạy phần xử lý gói ở tầng trên. Máy trông như treo, tải thì 100%, mà thông lượng hữu ích tụt về gần không.
Linux giải bài này bằng NAPI, cơ chế xử lý sự kiện của tầng mạng: gói đầu tiên tới vẫn phát ngắt, nhưng handler lập tức tắt ngắt của thiết bị đó và đăng ký một vòng poll. Từ đó, kernel chủ động lấy gói theo lô cho tới khi hết việc, rồi mới bật ngắt trở lại. Vòng poll có một budget — số gói tối đa xử lý mỗi lượt — để một card đang bận không chiếm dụng CPU mãi mãi và bỏ đói các thiết bị khác.
Kết quả là hệ thống tự chuyển chế độ theo tải: lúc rảnh thì chạy theo ngắt để khỏi đốt CPU vô ích, lúc dồn dập thì chuyển sang polling để trả một cú thông báo cho cả trăm gói thay vì một cú cho mỗi gói. Cũng đúng logic ấy, NVMe đời mới cho phép polling hàng đợi hoàn tất khi cần độ trễ cực thấp, chấp nhận đốt một lõi để đổi lấy vài micro-giây.
5. Pitfall của riêng concept này
❌ Nhầm 1 — coi ba cơ chế như ba lựa chọn thay thế nhau.
✅ Polling và interrupt cùng trả lời "biết khi nào xong", còn DMA trả lời "ai chép". Một driver NVMe thật dùng DMA để chuyển dữ liệu và interrupt để biết lô nào xong, rồi có thể chuyển sang polling khi cần độ trễ thấp.
❌ Nhầm 2 — tưởng polling luôn là dấu hiệu code kém.
✅ Với thiết bị trả lời trong vài trăm nano-giây, chi phí dựng và thu một ngắt còn lớn hơn thời gian chờ. Đó là lý do NAPI cố ý chuyển sang polling khi tải cao, và là lý do driver độ trễ thấp chấp nhận đốt hẳn một lõi.
❌ Nhầm 3 — chạm vào buffer đã trao cho thiết bị trong streaming DMA.
✅ Trong lúc thiết bị còn quyền sở hữu buffer, CPU đọc ra dữ liệu cũ còn ghi vào thì hỏng dữ liệu, và triệu chứng thì rời rạc theo thời điểm nên cực khó tái hiện. Phải gỡ ánh xạ hoặc đồng bộ theo đúng API trước khi chạm lại.
6. 📚 Đào sâu (tuỳ chọn)
- Dynamic DMA mapping Guide — docs.kernel.org — phân biệt coherent với streaming DMA, quy ước ai đang sở hữu buffer, vai trò của IOMMU và bounce buffer.
- NAPI — docs.kernel.org — cơ chế tắt ngắt rồi chuyển sang vòng poll, ý nghĩa của budget, và phần busy-polling cho workload cần độ trễ thấp.
7. Liên hệ các bài khác
- Thiết bị, MMIO và PCIe — chỗ dựng ra thanh ghi lệnh và thanh ghi trạng thái mà cả ba cơ chế ở đây đều dùng tới.
- Interrupt và trap — cơ chế ngắt nhìn từ phía hệ điều hành; bài này thêm cái giá của nó khi tần suất lên cao.
- Hành trình một lệnh
read()— ráp DMA và ngắt vào đúng chặng cuối của tuyến đường xuống thiết bị. - Host và device — cũng chính DMA này, nhưng ở quy mô gigabyte mỗi lần chuyển sang GPU.
8. Tóm tắt
- Hai câu hỏi tách bạch: biết khi nào xong (polling hay interrupt) và ai chép dữ liệu (CPU hay DMA engine).
- Polling đốt lõi CPU nhưng cho độ trễ thấp nhất; interrupt trả lõi về cho việc khác nhưng mỗi lần báo tốn một cú chuyển ngữ cảnh cộng thiệt hại cache.
- DMA để thiết bị tự chuyển dữ liệu vào RAM sau khi đọc descriptor, nên CPU chỉ chạm hai đầu; IOMMU ánh xạ lại địa chỉ cho thiết bị, còn bounce buffer thì lén mang bản sao quay lại.
- NAPI đảo chiều khi tải cao: tắt ngắt, poll theo lô có budget, tránh livelock — bằng chứng rằng "polling luôn phí" là trực giác sai.
9. Tự kiểm tra
Q1Một service nhận gói tin dồn dập bỗng dừng phục vụ: công cụ đo báo CPU 100% ở phần xử lý ngắt, còn thông lượng hữu ích gần bằng không. Cơ chế nào gây ra chuyện này, và Linux tránh nó bằng cách gì?▸
Đây là livelock do ngắt. Mỗi gói tới phát một ngắt, mà ngắt có mức ưu tiên cao hơn code thường, nên CPU liên tục bị kéo vào trình xử lý ngắt và không còn thời gian chạy phần xử lý gói ở tầng trên. Máy vẫn bận rộn tối đa nhưng chẳng gói nào đi hết được đường.
NAPI tránh chuyện đó bằng cách để gói đầu tiên phát ngắt, rồi lập tức tắt ngắt của thiết bị và chuyển sang vòng poll lấy gói theo lô. Vòng poll có budget giới hạn số gói mỗi lượt để một card bận không bỏ đói thiết bị khác; hết việc thì kernel mới bật ngắt trở lại.
Q2Vì sao nói DMA không phải là 'lựa chọn thay cho interrupt'? Một driver NVMe điển hình dùng những cơ chế nào cùng lúc?▸
Hai thứ trả lời hai câu hỏi khác nhau. Polling và interrupt trả lời làm sao biết thiết bị đã xong; DMA trả lời ai bê dữ liệu từ thiết bị vào RAM. Bỏ DMA đi thì CPU phải tự chép từng word qua thanh ghi dữ liệu, bất kể nó biết tin bằng cách nào.
Driver NVMe thật dùng cả hai: DMA để chuyển dữ liệu, và interrupt để biết lô lệnh nào đã hoàn tất. Khi cần độ trễ cực thấp, nó còn có thể chuyển sang poll hàng đợi hoàn tất, chấp nhận đốt một lõi CPU để bớt vài micro-giây.
Q3Khi nào một vòng lặp polling đốt 100% một lõi CPU lại là lựa chọn kỹ thuật đúng, thay vì dấu hiệu code kém?▸
Khi thời gian chờ ngắn hơn chi phí dựng và thu một ngắt. Thiết bị trả lời trong vài trăm nano-giây thì việc cất trạng thái, nhảy vào handler, rồi khôi phục lại tốn nhiều hơn chính quãng chờ, chưa kể thiệt hại cache mà handler gây cho tiến trình đang chạy. Đổi một lõi lấy độ trễ thấp nhất có thể là đánh đổi hợp lý trong hệ thống giao dịch tần suất cao hay đường dữ liệu mạng tốc độ lớn — và cũng là lý do NAPI chủ động chuyển sang polling đúng lúc tải cao.
Q4Trong streaming DMA, driver ánh xạ một buffer rồi trao cho thiết bị, nhưng lại đọc buffer đó ngay để 'kiểm tra tiến độ'. Hậu quả là gì và vì sao lỗi này khó bắt?▸
Quy ước quyền sở hữu bị phá. Trong lúc thiết bị còn giữ buffer, nội dung CPU đọc ra có thể là dữ liệu cũ chưa được cập nhật, còn nếu driver ghi vào thì nó giẫm lên đúng vùng thiết bị đang đổ dữ liệu. Kết quả là dữ liệu hỏng một phần chứ không phải lỗi rõ ràng.
Khó bắt vì hậu quả phụ thuộc thời điểm: cùng một đoạn code chạy trăm lần đúng, lần thứ một trăm lẻ một sai, và sai theo kiểu vài byte lệch chứ không crash. Cách đúng là gỡ ánh xạ hoặc gọi hàm đồng bộ theo DMA API trước khi CPU chạm lại buffer.
Q5Một máy ảo bật IOMMU cho thiết bị passthrough. IOMMU thêm một tầng ánh xạ vào đường DMA — nó đem lại hai lợi ích gì?▸
Thứ nhất là cách ly: không có IOMMU, thiết bị làm chủ bus ghi được vào bất kỳ đâu trong RAM vật lý, nên một firmware lỗi hoặc bị chiếm quyền là đè được cả bộ nhớ kernel; IOMMU giới hạn nó trong đúng những trang đã ánh xạ. Thứ hai là gom vùng nhớ: buffer lớn thường nằm rải rác trên nhiều trang vật lý rời nhau, IOMMU ánh xạ chúng thành một dải liền mạch dưới góc nhìn thiết bị nên chuyển được một mạch thay vì chia thành nhiều lần truyền nhỏ.
Bài tiếp theo: Hành trình một lệnh read() — từ ứng dụng xuống mặt đĩa
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
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