CodeGym /Các khóa học /C# SELF /Xử lý ngoại lệ trong mã bất đồng bộ

Xử lý ngoại lệ trong mã bất đồng bộ

C# SELF
Mức độ , Bài học
Có sẵn

1. Phương thức bất đồng bộ và ngoại lệ

Chúng ta đã quen rằng nếu trong mã xảy ra điều gì đó xấu (ví dụ chia cho không hoặc cố truy cập file không tồn tại), một ngoại lệ sẽ được ném và ta có thể bắt bằng try-catch. Mọi thứ đơn giản khi mã chạy tuần tự trên một luồng. Nhưng ngay khi xuất hiện bất đồng bộ, thế giới giống như không gian vũ trụ: ngoại lệ có thể "xuất hiện" xa so với chỗ bạn mong đợi, hoặc thậm chí bị bỏ qua.

Nguyên nhân là phương thức bất đồng bộ thường trả về một Task (Task), mà việc thực thi nó tiếp tục SAU khi bạn đã rời khỏi phương thức. Ngoại lệ có thể xảy ra khi luồng chính đã "thả" việc thực thi và tiếp tục công việc khác. Vì vậy cấu trúc try-catch quen thuộc quanh lời gọi phương thức bất đồng bộ không luôn hoạt động giống như mã đồng bộ.

Hãy xem ví dụ đơn giản. Giả sử trong mini-app của chúng ta có phương thức bất đồng bộ như sau:

// Fragments của ứng dụng: tính toán bất đồng bộ "gửi báo cáo"
public async Task SendReportAsync()
{
    // Ở đây có thể là gọi mạng hoặc truy cập tệp
    await Task.Delay(100);
    throw new InvalidOperationException("Lỗi khi gửi báo cáo!");
}

Và đây là cách ta có thể gọi nó:

SendReportAsync();
Console.WriteLine("Tiếp tục làm việc...");

Trực quan hóa

flowchart TD
    Start["Luồng chính"]
    Call[/"Gọi SendReportAsync()"/]
    Continue["Công việc tiếp tục..."]
    Exception["Ngoại lệ xảy ra trong Task"]
    Unhandled["Lỗi không được xử lý!"]
    Start --> Call --> Continue
    Call -.- Exception --> Unhandled

Kết luận: nếu phương thức bất đồng bộ trả về Task, và bạn không chờ Task hoàn thành (await hoặc .Wait()), ngoại lệ sẽ bị "bỏ qua". Trong trường hợp tốt nhất runtime sẽ ghi log kiểu "Unobserved exception in task". Trong trường hợp xấu nhất — bạn hoàn toàn mất lỗi và sẽ mất thời gian dài tìm nguồn gốc của "bug bí ẩn".

2. Làm sao bắt ngoại lệ đúng trong mã bất đồng bộ?

Sử dụng await + try-catch

Xem cách đúng:

try
{
    await SendReportAsync(); // Chờ Task hoàn thành
    Console.WriteLine("Báo cáo đã gửi thành công!");
}
catch (Exception ex)
{
    Console.WriteLine($"Úi! Có gì đó sai: {ex.Message}");
}

Vì sao hoạt động? Khi bạn đặt await trước lời gọi bất đồng bộ, C# sẽ chia phương thức của bạn thành hai phần: trước và sau await. Nếu phần bất đồng bộ phát sinh ngoại lệ, nó sẽ "nhảy ra" ngay nơi bạn đã đặt await, và bạn có thể bắt nó bằng try-catch như bình thường.

Ví dụ cho ứng dụng

Thêm xử lý lỗi gửi báo cáo vào demo của chúng ta:

public async Task StartReportProcessAsync()
{
    try
    {
        await SendReportAsync();
        Console.WriteLine("Báo cáo đã gửi thành công!");
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Lỗi khi gửi báo cáo: {ex.Message}");
    }
}

Và gọi:

await StartReportProcessAsync();

.Wait(), .Result — không phải tối ưu nhưng còn dùng được trong console

Đôi khi, nhất là trong ứng dụng console, bạn không thể dùng await ở cấp cao nhất (phiên bản C# cũ hơn, phương thức Main). Khi đó buộc phải chờ đồng bộ bằng .Wait() hoặc .Result.

try
{
    SendReportAsync().Wait();
}
catch (AggregateException aggEx)
{
    foreach (var ex in aggEx.InnerExceptions)
        Console.WriteLine($"Lỗi: {ex.Message}");
}

Tại sao thế? Gọi .Wait().Result luôn bọc ngoại lệ gốc vào một AggregateException. Đây là một container có thể chứa một hoặc nhiều ngoại lệ. Trong đó có thể có một (hoặc nhiều!) ngoại lệ bên trong, nên bạn phải lặp để bóc từng ngoại lệ ra. Xem thêm về AggregateException trong tài liệu chính thức.

Quan trọng!

Trong các phiên bản .NET hiện đại (từ C# 7.1 trở đi) bạn có thể khai báo Main là async và dùng await ngay tại điểm vào:

static async Task Main(string[] args)
{
    await StartReportProcessAsync();
}

3. Ngoại lệ trong các Task "fire-and-forget"

Nếu bạn khởi chạy phương thức bất đồng bộ mà không chờ nó hoàn thành và không giữ tham chiếu tới Task thì sao?

SendReportAsync(); // "Quên" Task

Trong tình huống như vậy xảy ra vấn đề: ngoại lệ trong Task sẽ không được ai xử lý. Đôi khi (tùy môi trường và cấu hình) ứng dụng có thể crash. Đôi khi thì runtime chỉ log cảnh báo. Đây không phải bug của C#, mà là hệ quả của cách Task hoạt động.

Làm sao làm đúng?

  • Lý tưởng: đừng dùng "fire-and-forget" nếu bạn không chắc Task có thể thất bại một cách gây hại.
  • Nếu phương thức bất đồng bộ thực sự phải chạy theo kiểu "fire-and-forget", hãy xử lý lỗi rõ ràng bên trong phương thức.
public async Task SendReportSafeAsync()
{
    try
    {
        await Task.Delay(100);
        throw new InvalidOperationException("Lỗi khi gửi!");
    }
    catch (Exception ex)
    {
        // Log hoặc xử lý lỗi
        Console.WriteLine($"[Log] Ngoại lệ: {ex.Message}");
    }
}

// Gọi
SendReportSafeAsync();

Khuyến nghị tổng quát: Nếu Task không được ai theo dõi và bạn không dùng await, nhất thiết bọc thân phương thức bất đồng bộ trong try-catch. Như vậy bạn không mất lỗi và ít nhất có thể log nó.

4. Ngoại lệ và các Task song song: Task.WhenAll và bạn bè

Thực tế thường cần chạy nhiều Task độc lập cùng lúc và chờ chúng hoàn thành. Ví dụ khi bạn gửi báo cáo tới nhiều địa chỉ cùng song song:

var tasks = new List<Task>
{
    SendReportAsync(),
    SendReportAsync(),
    SendReportAsync()
};

await Task.WhenAll(tasks);

Chuyện gì xảy ra nếu một (hoặc vài) Task ném ngoại lệ?

Làm sao bắt những lỗi này?

Khi dùng await Task.WhenAll(tasks) — nếu ít nhất một Task hoàn thành với lỗi, await sẽ ném ngoại lệ của Task đầu tiên lỗi (thường thì nó không bị bọc vào AggregateException).
Nhưng có điểm cần lưu ý: nếu có nhiều Task cùng thất bại, thì sẽ ném một AggregateException chứa các ngoại lệ bên trong.

try
{
    await Task.WhenAll(tasks);
}
catch (Exception ex)
{
    // Nếu là AggregateException — bóc ra
    if (ex is AggregateException agg)
    {
        foreach (var inner in agg.InnerExceptions)
            Console.WriteLine($"Lỗi ở Task: {inner.Message}");
    }
    else
    {
        Console.WriteLine($"Lỗi: {ex.Message}");
    }
}

Với await trên một Task đơn lẻ, ngoại lệ thường không bị bọc trong AggregateException. Nhưng với WhenAll — điều này có thể xảy ra!

5. Delegate bất đồng bộ và xử lý lỗi

Trong các ứng dụng có UI (WPF, WinForms, ASP.NET) handler sự kiện thường viết dưới dạng async lambda. Nếu ngoại lệ trong handler "văng" ra ngoài, kết quả phụ thuộc vào framework UI: ứng dụng có thể crash hoặc im lặng nuốt lỗi.

Khuyến nghị

Luôn dùng try-catch bên trong các delegate bất đồng bộ:

button.Click += async (sender, args) =>
{
    try
    {
        await SendReportAsync();
    }
    catch (Exception ex)
    {
        MessageBox.Show($"Lỗi: {ex.Message}");
    }
};
2
Nhiệm vụ
C# SELF, mức độ, bài học
Đã khóa
Xử lý ngoại lệ bất đồng bộ trong handler
Xử lý ngoại lệ bất đồng bộ trong handler
1
Khảo sát/đố vui
, cấp độ , bài học
Không có sẵn
Lập trình bất đồng bộ
Bất đồng bộ vs. Đa luồng
Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION