Có một câu hỏi thú vị được đặt ra: "Luồng (Thread) sinh ra để CPU có thứ mà thực thi mã nguồn. Vậy nếu Thread bị giải phóng, bên trong hàm async đó lấy tài nguyên gì để tiếp tục chạy và chờ kết quả trả về?"
Để trả lời, ta sẽ chia làm 2 trường hợp.
Tác vụ tính toán nặng (CPU-bound)
Giả sử khi dùng Task.Run(() => CalculateSomeThing()). Trường hợp này dễ hiểu: Request Thread ban đầu được giải phóng, nhưng hệ thống .NET đã bốc một Worker Thread khác từ Thread Pool chạy ngầm ở background để xử lý vòng lặp tính toán đó. Do đó, vẫn có một Thread đang chiếm dụng CPU.
Tác vụ gọi ra bên ngoài (I/O-bound)
Đây mới là trọng tâm bài viết. Giả sử khi gõ await _dbContext.Users.ToListAsync() (gọi database lấy thông tin của người dùng) hoặc đơn giản hơn await _httpClient.GetAsync() (gọi API), mọi chuyện hoàn toàn khác.
Câu trả lời gây shock nhất ở đây: "There is no thread" (Không có bất kì Thread nào đang chạy).
Tại sao lại vậy? Khi chờ kết quả từ database hay API, không có một Thread hay một chu kì CPU nào của ứng dụng .NET bị lãng phí để "ngồi đợi" cả. Mã nguồn phần mềm lúc này đang hoàn toàn...bất động.
Vậy cái gì đang làm việc bên dưới để có thể xử lý bên trong một hàm dùng async/await mà trả về kết quả thực thi?
Luồng chạy thực sự dưới tầng đáy
Khi ứng dụng cần lấy data qua mạng, luồng xử lý sẽ di qua các bước sau:
Uỷ quyền cho hệ điều hành (OS):
Khi đoạn code C# chạy đến await, nó đóng gói trạng thái (State Machine) lại và gọi một lệnh xuống hệ điều hành (thông qua Win32 API trên Windows, hoặc Syscall trên Linux/MacOS). Lệnh này sẽ mang thông điệp: "Ê OS, báo cho cái Card mạng (NIC) gửi cục data này tới server database dùm, khi nào có kết quả thì gọi tao nha".
Buông tay và giải phóng
Ngay khi OS nhận lệnh và đẩy xuống cho Trình điều khiển thiết bị (Device Driver), .NET Runtime lập tức thu hồi Thread đó về Thread Pool (như bài trước đã đề cập). CPU của bạn chuyển sang xử lý tác vụ khác,
Phần cứng tự làm việc (DMA)
Lúc này, card mạng từ máy chủ của bạn sẽ tự động gửi và nhận dữ liệu thông qua dây cáp mạng. Dữ liệu từ database trả về sẽ được card mạng ghi trực tiếp vào thanh RAM của máy chủ thông qua một cơ chế gọi là DMA (Direct Memory Access). Toàn bộ quá trình di chuyển data này do chip của phần cứng tự làm, CPU không hề can thiệp.
Tiếng chuông báo thức (Hardware Interrupt)
Khi Card mạng đã chép xong data vào RAM, nó làm cách nào để báo cho CPUP? Nó phát ra một tín hiệu điện tử gọi là Hardware Interrupt (Ngắt phần cứng). Tín hiệu này giống như tiếng chuông báo thức: "Dậy đi CPU, tải xong data vào RAM rồi nè, xử lý tiếp đi!".
Thức tỉnh State Machine
Hệ điều hành lúc này nhận được tín hiệu ngắt, nó sẽ đẩy một thông báo hoàn thành vào hàng đợi (queue), được gọi là IOCP trên Windows, hoặc epoll/kqueue trên Linux/MacOS.
.NET Runtime vốn luôn cắm chốt để lắng nghe cái hàng đợi này, khi thấy có kết quả, nó lập tức báo:
- Bốc một Thread bất kỳ đang rảnh từ Thread Pool (không nhất thiết là Thread ban đầu).
- Nạp lại mớ biến cục bộ từ State Machine.
- Cho Thread đó chạy tiếp các dòng code nằm phía dưới chữ await.
Kết luận
Async/Await cho I/O-bound là một nghê thuật "đùn đẩy trách nhiệm". Ứng dụng .NET đùn đẩy cho OS, OS đùn đẩy cho phần cứng. Trong thời gian mà phần cứng nó làm việc cày cuốc cho ra kết quả, ứng dụng lúc này thảnh thơi thu hồi Thread về để phục vụ cho hàng ngàn user khác.
Hiểu được khái niệm "There is no thread" sẽ nhận ra tại sao một server bình thường, với số lượng Thread Pool hạn chế nhưng có thể gánh được lượng truy cập khổng lồ nếu biết cách ấp dụng bất đồng bộ đúng cách.
No comments yet. Be the first to comment!