Asynchronous Programming - #2 Lập trình bất đồng bộ
Nếu bạn có bất kỳ yêu cầu về I/O-bound (giống như là yêu cầu dữ liệu từ mạng lưới, truy cập database, hoặc đọc viết trên file của hệ thống), bạn sẽ muốn tận dụng lập trình bất đồng bộ. Bạn có thể có code CPI-bound, như là thể hiện một phép tính phức tạp, mà tình huống đó phù hợp viết code bất đồng bộ.
C# có một mô hình lập trình bất đồng bộ ở cấp ngôn ngữ, mà nó cho phép dễ viết code bất đồng bộ mà không cần phải sắp xếp các lệnh gọi lại hoặc tuần theo thư viện hỗ trợ bất đồng bộ. Theo sau mô hình đó là Task-based Asynchronous Pattern (TAP)
Tổng quan về mô hình bất đồng bộ
Cái chính của lập trình bất đồng bộ là đối tượng Task
và Task<T>
, mô hình hoạt động bất đồng bộ. Nó hỗ trợ bởi từ khóa async
và await
. Model này đơn giản trong hầu hết các trường hợp.
- Đối với I/O-bound code, bạn chờ một hoạt động trả về một
Task
hoặcTask<T>
ở trong phương thứcasync
- Đối với CPU-bound code, bạn chờ một hoạt động mà được bắt đầu một luồng background với phương thức
Task.Run
Từ khóa await là nơi xảy ra điều kỳ diệu. Nó đưa quyền kiểm soát cho chỗ gọi cái phương thức nơi mà đang await á, và nó cuối cùng cho phép UI được phản ứng hoặc một service được kéo dài ra. Trong khi có nhiều cách để viết code bất đồng bộ hơn async và await, nhưng trong bài viết này sẽ chỉ tập trung vào cấu trúc cấp ngôn ngữ.
Ví dụ về I/O-bound: Tải dữ liệu từ web service
Bạn có thể cần tải một vài dữ liệu từ một webservice khi nhấn vô cái button nhưng không muốn block UI lại. Nó có thể được hoàn thành như này:
Code thể hiện ý định (tải dữ liệu bất đồng bộ) không cần phải tương tác quá nhiều với đối tượng Task
Ví dụ về CPU-bound: Làm một phép cho một trò chơi
Giả sử bạn đang viết một mobile game khi nhấn cái button có thể gây sát thương cho nhiều kẻ thù trên màn hình. Thực hiện tính toán sát thương có thể khá tốn tài nguyên, và làm trên luồng UI sẽ có thể làm game dừng lại để tính toán cho xong.
Cách tốn nhất là xử lý bằng cách bắt đầu một background thread, sử dụng Task.Run và chờ kết quả của nó bằng await. Điều này sẽ làm cho UI mượt hơn khi nó tính toán xong.
Cái gì đã xảy ra ở dưới đống code này nhỉ
Có rất nhiều phần di chuyển khi chạy code bất đồng bộ. Nếu bạn tò mò có gì diễn ra ở dưới những lớp bao bọc Task
và Task<T>
, hãy đọc Async in-dept để biết thêm nhiều thông tin nha.
Về khía cạnh của C#, trình biên dịch sẽ chuyển code của bạn vào một state machine ở đó sẽ theo dõi mọi thứ như là thực thi khi chờ xong và tiếp tục thực thi khi background job xong.
Về mặt lý thuyết, đây là một thực thi của Promise Model of asynchrony
Những ý chính cần phải hiểu
- Code bất đồng bộ có thể được sử dụng cho cả I/O-bound và CPU-bound code, nhưng cách dùng khác nhau tùy mỗi tình huống.
- Code bất đồng bộ sử dụng
Task<T>
vàTask
, là cấu trúc được sử dụng để mô hình hóa công việc đã làm xong ở dưới background - Từ khóa
async
trả về một phương thức trong một phương thức bất đồng bộ, và bạn phải sử dụng await để lấy nội dung của nó. - Khi từ khóa
await
được sử dụng, nó hoãn gọi phương thức gọi và trả lại quyền điều khiển đó lại cho ở chỗ gọi phương thức đó cho tới khi await task xong await
chỉ có thể sử dụng trong một phương thức bất đồng bộ
Nhận biết CPU-bound và I/O-bound
Hai ví dụ hồi nãy chỉ rằng bạn có thể sử dụng async và await cho I/O-bound và CPU-bound. Nhưng quan trọng là bạn phải xác định là khi nào một cái job cần làm I/O-bound hay CPU-bound bởi vì nó có thể ảnh hưởng lớn đến hiệu suất của code và có thể dẫn đến sử dụng sai cấu trúc này.
Dưới đây là hai câu hỏi bạn nên hỏi trước khi bạn viết bất kỳ dòng code nào:
-
Code của bạn có đang chờ cái gì hông, ví dụ như lấy dữ liệu từ database?
Nếu câu trả lời của bạn là có, thì cái bạn đang làm là I/O-bound
-
Code của bạn có đang thực hiện phép tính toán tốn kém nhiều không?
Nếu câu trả lời của bạn là có, thì có nghĩa cái bạn đang làm CPU-bound
Nếu cái bạn làm là I/O-bound, sử dụng async và await không cần Task.Run. Bạn không thử dụng Task Parallel Library. Lý do nó được nêu trong Async in Depth
Nếu cái bạn làm CPU-bound và bạn quan tâm về khả năng phản ứng, sử dụng async và await, nhưng sinh ra công việc trên một thread khác với Task.Run Nếu công việc đó phù hợp làm đồng thời hoặc song song, cũng có thể cân nhắc sử dụng Task Parallel Library.
Ngoài ra, bạn cũng nên đo lường việc thực thi code của mình. Ví dụ, bạn có thể thấy chính bạn trong tình huống CPI-bound làm không tốn kém nhiều so với chuyển sang đa luồng. Mọi sự lựa chọn đều phải trả giá, và bạn nên chọn cái đúng với tình huốngn của mình.
Ví dụ
Thông tin quan trọng và lời khuyên
-
Phương thức
async
cần phải có từ khóaawait
trong body của phương thức nếu không nó sẽ không bao giờ có kết quả gì cả. -
Thêm "Async" làm hậu tố cho những phương thức bất đồng bộ mà bạn viết (đây là .NET convention)
-
async void
chỉ sử dụng cho event handlersVì đây là cách duy nhát để event handler hoạt động bất đồng bộ bởi vì events không có kiểu trả về (do đó không thể sử dụng
Task
hayTask<T>
). Những cách sử dụng khác củaasync void
không tuân theo mô hình TAP (Task Asynchronous Programming) và có thể gây khó khăn khi sử dụng như là:- Trong phương thức
async void
không thể quăng exception ở ngoài phương thức này được - Mấy phương thức
async void
khó test - Mấy phương thức
async void
có thể gây ảnh hưởng xấu nếu nhưng người gọi không muốn nó bất đồng bộ
- Trong phương thức
-
Nhớ xử lý cẩn thận khi sử dụng lambda expression LINQ bất đồng bộ
LINQ sử dụng deferred execution (hoãn thực thi), nghĩa là nó sẽ thực khi ở một thời điểm mà bạn không mong đợi. Việc thêm mấy luồng này vô nữa thì sẽ dễ dẫn đến bế tắc nếu như không viết đúng. Ngoài ra, lồng code bất đồng bộ như thế này có thể làm nó càng thêm khó khăn để thực thi code. Bất đồng bộ và LINQ mạnh nhưng nên sử dụng cùng nhau nếu có thể một cách cẩn thận và rõ ràng.
-
Viết code chờ Task trong một non-blocking maner (cách không bị chặn)
Chặn cái luồng hiện tại nghĩa là nó đang chờ một Task để hoàn thành có thể dẫn đến bế tắc (deadblock) và chặn luồng ( blocked context thread) và có thể yêu cầu xử lý nhiều lỗi. Bảng dưới đây cung cấp cách đối phó đang chờ một một non-blocking.
Sử dụng... | Thay vì... | Khi đang làm... |
await
|
Task.Wait hoặc Task.Result
|
Khi đang muốn lấy kết quả từ background task |
await Task.WhenAny
|
Task.WaitAny
|
Khi đang chờ cho bất kỳ Task nào hoàn thành xong |
await Task.WhenAll
|
Task.WaitAll
|
Khi đang chờ cho tất cả Task nào hoàn thành xong |
await Task.Delay
|
Thread.Sleep
|
Khi đang chờ trong một khoảng thời gian |
Cân nhắc sử dụng
ValueTask
nếu có thểTrả về một đối tượng Task từ phương thức bất đồng bộ có thể dẫn đến hiện tượng tắt nghẽn hiệu suất. Task là một reference type, vì thế sử dụng nó nghĩa là phân bổ một đối tượng. Trong trường hợp phương thức được khai báo với modifier
async
trả về kết quả được lưu trong bộ nhớ cached hoặc hoàn toàn đồng bộ, phân bổ thêm có thể làm giảm hiệu suất đáng kể. Nó có thể trở nên đắt giá nếu những phân bổ đó xảy ra trong vòng lặp chặt chẽ.-
Cân nhắc sử dụng ConfigureAwait(false)
Một câu hỏi phổ biến là "khi nào tôi nên sử dụng phương thức Task.ConfigureAwait(Boolean)?" Phương thức trả về một thực thể Task để cấu hình bộ chờ của nó. Điều này là cân nhắc quan trọng và cài đặt nó không đúng có thể sẽ tác động đến hiệu suất và thậm chí là dẫn đến deadblocks.
-
Viết ít code trạng thái
Đừng phụ thuộc vào trạng thái của đối tượng toàn cục hoặc thực thi phương thức nhất định. Thay vào đó, chỉ phục thuộc vào giá trị trả về của phương thức. Tại sao?
- Code dễ nhận định hơn
- Code dễ test hơn
- Mix code đồng bộ và bất đồng bộ dễ hơn
- Các điều kiện có thể tránh được hoàn toàn
- Phụ thuộc vào giá trị trả về làm cho việc điều phối code bất đồng bộ đơn giản hơn
- (Bonus) nó hoạt động tốt hơn với dependency injection
Có một mục tiêu được đề xuất là tìm hiểu về Referential Transparency (Tham chiếu minh mạch) trong code của bạn. Làm như vậy sẽ tạo ra một cơ sở để có thể dự đoán, kiểm tra và bảo trì.
Nguồn: Asynchronous Programming
Nhận xét
Đăng nhận xét