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 TaskTask<T>, mô hình hoạt động bất đồng bộ. Nó hỗ trợ bởi từ khóa asyncawait . 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ặc Task<T> ở trong phương thức async
  • Đố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.


Mớ code này diễn đạt rõ ý đồ của cái button, nó không yêu cầu quản lý luồng background bằng cách thủ công, và nó cũng không block lại nữa.

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 TaskTask<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>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:

  1. 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à , thì cái bạn đang làm là I/O-bound

  2. 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à , 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óa await 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 handlers

    Vì đâ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 hay Task<T>). Những cách sử dụng khác của async 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ộ
  • 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

Bài đăng phổ biến