Ví dụ cần gọi API? thêm await. Cần query database? Thêm await. Tạo một function mới? Thêm async trước function. Khi gọi function? Thêm await. Nhưng đã bao giờ tự hỏi, nếu bỏ "syntax sugar" này, hệ thống sẽ chạy thế nào, và thực sự bên dưới chuyện gì đang xảy ra?
"Tắc nghẽn mạch máu" của lập trình đồng bộ (synchronous)
Hãy hình dung hệ thống (đặc biệt là Web API) có một nguồn tài nguyên quý giá gọi là Thread Pool (hồ chứa các luồng). Mỗi khi có 1 request từ người dùng gửi lên, hệ thống sẽ bốc 1 Thread trong cái hồ ra xử lý.
Nếu code theo kiểu đồng bộ, giả sử gọi một hàm GetUserData() tốn 3 giây chọc vào database, query và trả về kết quả. Trong suốt 3 giây đó, cái Thread xử lý request kia sẽ đứng "há miệng chờ sung". Nghĩa nó không làm gì cả, nó đứng yên, mà cũng không chịu nhường chỗ cho người khác. Nếu có 1000 người cùng gọi lúc đó, Thread pool cạn kiệt, CPU và RAM vẫn rảnh rỗi nhưng ứng dụng sẽ bị nghẽn, từ đó sinh ra lỗi timeout. Và đó là lúc async và await ra đời.
Cú lừa của await - tưởng là "chạy tiếp" nhưng hóa ra nó đang "tạm dừng"
Có một lầm tưởng phổ biến mà bản thân cũng từng suy nghĩ là nó đúng: "Khi dùng await, hệ thống vừa chờ kết quả, vừa chạy tiếp các dòng code phía dưới để tiết kiệm thời gian". Không, hoàn toàn không phải nha
public async Task<User> GetUserAsync(int id)
{
Console.WriteLine("Bắt đầu xử lý...");
// Đụng đến dòng await
var data = await _api.GetData(id);
// Dòng code này có được chạy ngay không? -> KHÔNG!
Console.WriteLine("Đã lấy xong data");
return data;
}
Sự thật dòng code chạm tới chữ await _api.GetData(id), luồng chạy của hàm này sẽ bị tạm dừng (suspend) ngay tại đó. Các dòng code bên dưới như Console.WriteLine sẽ phải chờ đến khi data được lấy xong.
Nếu nói vậy, nếu phải chờ thì hơn lập trình đồng bộ chỗ nào?
Điểm ăn tiền nằm ở hành động "Trả lại quyền điều khiển". Nghĩa là khi đụng await, thay vì bắt cái Thread (luồng của CPU) phải đứng im, chờ đợi, không được nhúc nhích, nào xong mới được thả ra. Thì bây giờ, trình biên dịch nó sẽ "nhả" cái Thread ra và trả về cho Thread Pool. Thread đó lập tức đi xử lý các request của người dùng khác. Nhờ vậy, máy tính sẽ không bị "kẹt", tài nguyên sẽ được sử dụng tối đa.
Vậy trình biên dịch nó "làm phép" thế nào?
"Nếu cái Thread mà bị mang đi làm việc khác, thì lúc query database xong, ai là người nhờ để quay lại chạy tiếp các dòng code bên dưới?"
Đó là lúc mà trình biên dịch C# xuất hiện. Khi gõ từ khóa async, trình biên dịch không hề biên dịch một hàm thông thường. Nó đập bỏ hàm và biến đổi thành class dạng State Machine thừa kế interface IAsyncStateMachine.
State Machine sẽ chia hàm thành nhiều mảnh (mỗi chữ await là một nhát cắt). Sau đó, nó sẽ duy trì trạng thái như sau:
- State 0: chạy từ đầu hàm đến khi bắt gặp chữ await đầu tiên.
- State 1 (Suspended): khi gặp await, nó lưu lại toàn bộ các biến cục bộ (local variables) hiện tại vào bộ nhớ, nhả Thread đi làm việc khác, và đăng ký một "lời thề", nào xong sẽ quay lại (continuation).
- State 2 (Completed): khi database xử lý xong và trả kết quả, hệ thống báo hiệu. CLR (Common Language Runtime) sẽ bốc một Thread bất kì từ Thread Pool (không nhất thiết phải là Thread cũ), nạp lại các biến cục bộ đã lưu, chạy tiếp "mảnh" code còn lại.
Kết luận
Async và Await sinh ra không phải để làm ứng dụng chạy nhanh hơn (thậm chí việc cấp phát State Machine còn tốn thêm chi phí tài nguyên). Nhưng nó sinh ra để tăng khả năng chịu tải của hệ thống, giúp server tận dụng tối đa lượng Thread ít ỏi phục vụ hàng ngàn request đồng thời mà không bị treo.
Khi hiểu được State Machine và cách Thread được điều phối đi, trả Thread, điều phối Thread khác xử lý, lúc này sẽ không còn code theo kiểu "tâm linh" nữa, đã vậy còn né được một số bẫy như Deadlock hay Starvation trong thực tế.
No comments yet. Be the first to comment!