Làm chủ Asynchronous trong C#: Khám phá sâu TAP (Phần 1)

Intro

Lập trình bất đồng bộ trong .NET đã đi một chặng đường dài, tiến hóa từ các cách tiếp cận dựa trên callback phức tạp đến Task-based Asynchronous Pattern (TAP) duyên dáng. Với các nhà phát triển giàu kinh nghiệm, hiểu biết về TAP không chỉ là về việc viết code không chặn (non-blocking) — mà còn về việc thiết kế các hệ thống có khả năng mở rộng, dễ bảo trì và trực quan. Trong phần đầu tiên này, chúng ta sẽ khám phá nền tảng của TAP, quy ước của nó và các thành phần xây dựng trước khi đi vào triển khai ở Phần 2.

Mục Lục

  1. Sự tiến hóa của các Pattern Async: Từ APM/EAP đến TAP
  2. Quy ước đặt tên TAP: Tiền tố “Async” và các quy tắc đi kèm
  3. >Khởi chạy các thao tác Async: Khởi chạy nhanh

Sự tiến hóa của các Pattern Async: Từ APM/EAP đến TAP

Hãy hình dung một nhà phát triển .NET khoảng năm 2008. Để đọc từ một luồng bất đồng bộ, bạn có thể viết các phương thức BeginReadEndRead(pattern APM) hoặc kích hoạt phương thức ReadAsync với sự kiện hoàn thành tương ứng (pattern EAP). Các pattern cũ này hoạt động nhưng phức tạp và dễ gây lỗi. Ra đời TAP – được giới thiệu với .NET Framework 4 – sử dụng kiểu Task để biểu diễn toàn bộ thao tác bất đồng bộ trong một lần duy nhất. Với TAP, một phương thức làm tất cả mọi thứ: bạn gọi phương thức Async và nhận lại một Task (hoặc Task<TResult>) đại diện cho công việc đang tiến hành. Không còn cặp phương thức hoặc trình xử lý sự kiện tùy chỉnh; chỉ cần await task, và bạn đã hoàn tất.

Tại sao TAP trở thành pattern quen thuộc:

  • Sự đơn giản: TAP cô đọng các thao tác bất đồng bộ thành một phương thức duy nhất trả về Task. Sự thống nhất này khiến code dễ đọc và viết hơn, đặc biệt với hỗ trợ ngôn ngữ như từ khóa async/await.
  • >

  • Sự nhất quán: Kể từ .NET Framework 4, TAP là phương pháp khuyến nghị cho các API bất đồng bộ mới. Nó tận dụng thư viện System.Threading.Tasks cung cấp cơ sở hạ tầng mạnh mẽ cho việc xử lý đồng thời.
  • >

  • Tích hợp ngôn ngữ: C# (async/await), VB (Async/Await), và F# có hỗ trợ tích hợp cho TAP, khiến code bất đồng bộ trông gần như code đồng bộ, không cần callback hoặc quản lý thread rõ ràng.
  • >

Một so sánh nhanh — Giả sử chúng ta muốn cung cấp thao tác Read bất đồng bộ trong một lớp:

    >

  • APM (IAsyncResult): Tiết lộ các phương thức BeginRead(...)EndRead(...). Người gọi bắt đầu thao tác và sau đó hoàn thành bằng cách gọi End với IAsyncResult được trả về.
  • >

  • EAP (Event-based): Tiết lộ phương thức ReadAsync(...) (trả về void) và sự kiện ReadCompleted (với EventArgs chứa kết quả). Người gọi khởi tạo và xử lý hoàn thành thông qua sự kiện.
  • >

  • TAP (Task-based): Tiết lộ duy nhất ReadAsync(...) trả về Task<int>. Người gọi await task để nhận kết quả khi nó sẵn sàng.
  • >

TAP rõ ràng chiến thắng về sự rõ ràng và dễ sử dụng. Bây giờ, hãy đi sâu hơn về cách đặt tên các phương thức TAP và chữ ký của chúng trông như thế nào.

Quy ước đặt tên TAP: Tiền tố “Async” và các quy tắc đi kèm

Một đặc trưng của phương thức TAP là có tiền tố Async trong tên. Nếu bạn có phương thức GetData đồng bộ trả về một string, phiên bản bất đồng bộ nên được gọi là GetDataAsync và trả về Task<string>. Việc đặt tên này ngay lập tức cho người gọi thấy rõ phương thức là bất đồng bộ và cần được await hoặc xử lý theo cách bất đồng bộ.

Một số hướng dẫn về đặt tên và chữ ký của phương thức TAP:

>ul>

  • Sử dụng Async</strong> hậu tố</strong> cho các phương thức trả về kiểu awaitable (<code>Task, Task<TResult>, ValueTask, v.v.). Ví dụ: ReadAsync, SaveAsync, CalculateAsync.
  • >

  • Tránh Async</strong> hậu tố</strong> trên các phương thức <strong>bắt đầu</strong> một thao tác async nhưng không trả về task. Những trường hợp hiếm đó nên sử dụng động từ như <code>Begin hoặc Start để chỉ chúng không trực tiếp trả về kết quả để await. Ví dụ, một phương thức đơn giản kích hoạt công việc và trả về void (không phổ biến trong API hiện đại) có thể được đặt tên là BeginUpload thay vì UploadAsync (vì không có gì để await).
  • >

  • Task vs Task<TResult>: Nếu phương thức đồng bộ trả về void, phiên bản TAP trả về Task (không có kết quả). Nếu phương thức đồng bộ trả về kiểu TResult, phiên bản TAP trả về Task<TResult> của kiểu đó. Cách này, kết quả của thao tác (nếu có) sẽ sẵn có dưới dạng Result của task hoặc thông qua await.
  • >

  • Khớp tham số của phương thức đồng bộ: Thông thường, phương thức async nên nhận các tham số giống như phiên bản đồng bộ, cùng thứ tự. Nhưng có một ngoại lệ lớn: bất kỳ tham số out hoặc ref. Vì task chỉ có thể trả về một đối tượng, nhiều đầu ra nên được đóng gói thành tuple hoặc lớp tùy chỉnh như TResult của Task<TResult>. TAP hoàn toàn tránh outref bằng cách sử dụng kiểu trả về phong phú hơn.
  • >

  • Token hủy là được khuyến khích: Ngay cả khi phiên bản đồng bộ không có cơ chế hủy, hãy cân nhắc thêm tham số CancellationToken vào phương thức async. Điều này cho phép người gọi khả năng hủy thao tác nếu cần (sẽ thảo luận thêm về hủy ở phần dưới).
  • >

  • Báo cáo tiến độ (tùy chọn): Nếu thao tác của bạn lâu dài và có thể báo cáo tiến độ trung gian (ví dụ: phần trăm tải xuống hoặc byte đã xử lý), bạn có thể cung cấp một overload chấp nhận IProgress<T> để xuất bản các cập nhật tiến độ. Chúng ta sẽ thảo luận chi tiết này ở phần Tiến độ.
  • >

    Khởi chạy các thao tác Async: Khởi chạy nhanh

    Một phương thức TAP thường làm một chút công việc đồng bộ ở đầu, sau ngay lập tức khởi chạy thao tác bất đồng bộ thực sự. Đây là có chủ đích — bạn muốn phương thức trả về nhanh với một task, đặc biệt nếu nó được gọi trên thread UI. Bất kỳ chuẩn bị hoặc xác nhận dài nào đều nên được giảm thiểu trong thân phương thức, vì:

      >

    • Đáp ứng UI: Nếu một phương thức async được kích hoạt trên thread UI (phổ biến trong ứng dụng client), làm quá nhiều trước khi trả về task có thể làm đông cứng giao diện. Người dùng sẽ không đánh giá cao một nút bị giật lag vì phương thức DoWorkAsync của bạn đang xử lý số liệu _trước_ khi nó thực sự trở nên async.
    • >

    • Sự đồng thời: Thường thì bạn có thể khởi chạy nhiều thao tác async song song. Nếu mỗi trong số chúng bị tắc nghẽn làm công việc chuẩn bị, bạn mất đi lợi ích của sự đồng thời. Việc trả về nhanh cho phép các thao tác khác bắt đầu không chậm trễ.
    • >

    • Hoàn thành đường dẫn nhanh: Đôi khi thao tác có thể nhanh đến mức hoàn thành đồng bộ _bên trong_ phương thức async. Ví dụ, tưởng tượng ReadAsync tìm thấy dữ liệu được yêu cầu đã trong bộ nhớ đệm — nó có thể chỉ thiết lập kết quả và hoàn thành task ngay lập tức, tiết kiệm chi phí chuyển ngữ cảnh. TAP cho phép điều này bằng cách trả về một task đã hoàn thành nếu công việc được thực hiện ngay lập tức. Từ góc nhìn của người gọi, nó vẫn là một phương thức async, nhưng có thể hoàn thành gần như ngay lập tức.
    • >

    Xử lý lỗi trong các phương thức Async

    Xử lý lỗi trong TAP遵循 một nguyên tắc đơn giản: lỗi sử dụng ném ngay lập tức, lỗi hoạt động đi đến Task. Điều này có nghĩa là gì? Nếu người gọi sử dụng sai phương thức của bạn (ví dụ: chuyển null ở nơi không cho phép), bạn nên ném ngoại lệ đồng bộ, giống như một phương thức thông thường. Những ngoại lệ này (thường là ArgumentException, ArgumentNullException, v.v.) chỉ ra lỗi sử dụng và nên được bắt trong quá trình phát triển — chúng không nên là một phần của luồng runtime bình thường.

    Tất cả các ngoại lệ khác xảy ra trong quá trình thao tác bất đồng bộ nên được Task nắm giữ thay vì ném trực tiếp. Thực tế, bất kỳ ngoại lệ nào xảy ra sau phần đồng bộ ban đầu sẽ được lưu trong task được trả về:

      >

    • Nếu thao tác async thất bại, task chuyển sang trạng thái Faulted và giữ ngoại lệ (có thể truy cập thông qua task.Exception). Nếu bạn await task, ngoại lệ này (hoặc một trong số chúng) sẽ được ném lại trong code của bạn để xử lý.
    • >

    • Trong các kịch bản điển hình, một task chứa tối đa một ngoại lệ (lỗi đầu tiên gây ra thất bại). Tuy nhiên, nếu bạn làm điều gì đó như Task.WhenAll trên nhiều task, nhiều sự cố có thể được gộp lại. Trong những trường hợp này, task kết quả có thể chứa một AggregateException chứa tất cả các ngoại lệ bên trong. Nhưng khi await, theo mặc định chỉ ngoại lệ đầu tiên được ném lại (bạn vẫn có thể kiểm tra các ngoại lệ khác thông qua InnerExceptions của ngoại lệ).
    • >

    • Nếu một OperationCanceledException được ném trong quá trình thao tác async (và nó được liên kết với token hủy được chuyển vào), task kết thúc ở trạng thái Canceled thay vì Faulted. Từ góc nhìn của await, một task bị hủy cũng ném (nó sẽ ném OperationCanceledException để tín hiệu hủy).
    • >

    Task chạy ở đâu? Hiểu ngữ cảnh thực thi Task

    Một khía mạnh mạnh mẽ của TAP là nó không chỉ ra cách bạn thực hiện công việc async — nó chỉ chuẩn hóa cách bạn biểu diễn nó (với một Task). Là nhà phát triển của phương thức async, bạn được chọn môi trường đích để thực hiện công việc:

      >

    • Bạn có thể chạy thao tác trên thread pool (điển hình cho công việc tính toán-bound hoặc để chuyển tải I/O mà không chặn một thread cụ thể).
    • >

    • Bạn có thể sử dụng các thao tác async I/O do OS hoặc framework cung cấp không chiếm dụng bất kỳ thread trong khi chờ (ví dụ: sử dụng Stream.ReadAsync sử dụng I/O overlapped của OS; trong trường hợp này công việc xảy ra trong kernel và qua ngắt, không trên thread .NET chuyên dụng).
    • >

    • Bạn có thể buộc thực hiện đến một thread hoặc ngữ cảnh cụ thể nếu cần (ví dụ, một số framework UI yêu cầu các thao tác nhất định trên thread UI — mặc dù thông thường bạn sẽ không tiết lộ phương thức TAP công khai như vậy).
    • >

    • Bạn thậm chí có thể có một phương thức async không làm công việc gì cả, nhưng đơn giản trả về một Task được tín hiệu bởi một sự kiện khác trong hệ thống (ví dụ: trả về một task hoàn thành khi một tin nhập đến trên hàng đợi ở nơi khác).
    • >

    Vòng đời của một Task: Nóng vs Lạnh và TaskStatus

    Every Task in .NET goes through a life cycle defined by the TaskStatus enum, like Created, Running, RanToCompletion, Faulted, or Canceled. Most tasks you encounter in TAP are hot tasks – meaning they’re already started and running (or completed) by the time you get them. For example, when you call an async method, it begins execution and usually hits an await (at which point it may return a not-yet-completed Task to you). You don’t manually start these tasks; they’re hot out of the oven.

    However, Task _does_ have a way to be created without starting: if you directly instantiate a Task using its constructor, it begins in the Created state and will not run until you call Start(). These are sometimes called cold tasks. In TAP, you should almost never encounter cold tasks from a public API. In fact, all tasks returned from TAP methods must be active (hot) by the time you get them. If a TAP method internally uses the Task constructor (perhaps for some advanced scenario), it must call Start() on that task before returning it. Otherwise, if a caller awaited a cold task, it would never complete – definitely not what we want!

    Hủy bỏ: Từ bỏ một thao tác Async

    Real-world operations might need to be canceled — maybe the user hit a Stop button, or the work is obsolete by the time it would finish. TAP supports cancellation through the use of CancellationToken. It’s an optional feature: not every async method supports cancellation, but many do.

    If you want your TAP method to support cancellation:

      >

    • Add a CancellationToken parameter, typically optional with a default value (e.g., CancellationToken cancellationToken = default). By convention, name it cancellationToken for clarity.
    • >

    • In your implementation, you’ll need to periodically check cancellationToken.IsCancellationRequested or use the token in async operations that accept one. If a cancellation is requested, you should stop work and signal cancellation.
    • >

    Báo cáo tiến độ: Chúng ta gần đến nơi chưa?

    Long-running operations often have intermediate progress to report (for example, a file download can report how many bytes or what percentage is done). TAP doesn’t use events for progress like some older patterns did; instead, it relies on the IProgress<T> interface for a producer -> consumer callback mechanism.

    Thiết kế Overload: Hủy bỏ và Tiến độ (Kết hợp)

    By now, you might wonder: if I support both cancellation and progress in an API, how many overloads do I need? Potentially: a simple MethodAsync(…); MethodAsync(…, CancellationToken cancellationToken); MethodAsync(…, IProgress<T> progress); MethodAsync(…, CancellationToken cancellationToken, IProgress<T> progress); That’s four in total, which is a lot to maintain.

    Many TAP APIs that support cancellation and progress will provide just the extremes: one without either, and one with both. This avoids method overload explosion while still covering all scenarios.

    Chỉ mục