CodeGym /Các khóa học /C# SELF /Chiến lược tổng hợp xử lý lỗi:

Chiến lược tổng hợp xử lý lỗi: Task, async/ await, AggregateException

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

1. Giới thiệu

Viết chương trình mà không bao giờ crash — cũng giống như tin rằng bug sợ IDE của bạn. Thực tế là khi code càng phức tạp (nhất là trong môi trường đa luồng và bất đồng bộ), bug càng tinh vi hơn. Bỏ qua ngoại lệ trong các luồng và task dễ gây rò rỉ tài nguyên, treo ứng dụng, mất dữ liệu hoặc crash không ngờ đến sau nhiều giờ chạy.

Trong bài này chúng ta sẽ gom các best practice xử lý lỗi trong lập trình đa luồng và bất đồng bộ trên C#. Bạn sẽ biết cách bắt ngoại lệ đúng, cách bóc tách “đống” lỗi đồng thời (kiểu AggregateException), làm gì với các task bị khởi chạy rồi “mất tích”, và tại sao phớt lờ ngoại lệ là nguy hiểm và vô nghĩa.

Tại sao không đơn giản được

  • Luồng nơi phát sinh lỗi có thể khác luồng nơi bạn đặt try-catch.
  • Task bất đồng bộ không ném ngoại lệ ngay — chúng bị “đóng gói” và chờ được xử lý khi await (hoặc khi đợi đồng bộ).
  • Khi chạy nhiều task cùng lúc (ví dụ Task.WhenAll) có thể có nhiều lỗi — phải tính tới hết.
  • Hoạt động kiểu fire-and-forget có thể hoàn toàn “mất” ngoại lệ nếu không có handler rõ ràng.

Hiện tượng này là phân tách ngữ cảnh thực thi. Hãy tưởng tượng chương trình như một rạp xiếc có nhiều sân khấu: nếu có lửa ở một sân, sân khác chưa chắc thấy ngay. Quan trọng là biết cách theo dõi và dập những “đám cháy” đó.

2. Ngoại lệ trong Task: TaskTask<TResult>

Task báo lỗi như thế nào

Khi trong Task xảy ra ngoại lệ chưa được xử lý, nó không “văng” ra ngoài ngay. Task sẽ trở thành Faulted, và ngoại lệ được lưu bên trong. Lấy được nó bằng cách:

  • Đợi hoàn thành với await (hoặc qua task.Wait()/task.Result — nhưng tốt hơn là đừng làm thế);
  • Kiểm tra thuộc tính task.Exception — sẽ có AggregateException ở đó.

Ví dụ


// Phương thức async có lỗi
async Task FailAsync()
{
    await Task.Delay(100);
    throw new InvalidOperationException("Có gì đó không ổn");
}

async Task MainAsync()
{
    try
    {
        await FailAsync();
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Lỗi: {ex.Message}");
    }
}

Nếu không đặt try-catch, chương trình sẽ crash. Nếu đặt — ngoại lệ sẽ bị bắt đúng, ngay cả khi lỗi xảy ra trong một task khác.

AggregateException: lỗi theo lô

Khi await Task.WhenAll(tasks) lỗi từ nhiều task sẽ được gom vào một AggregateException (trong InnerExceptions của nó).


async Task MultiFailAsync()
{
    Task t1 = Task.Run(() => throw new InvalidOperationException("Lỗi 1"));
    Task t2 = Task.Run(() => throw new ArgumentException("Lỗi 2"));
    try
    {
        await Task.WhenAll(t1, t2);
    }
    catch (Exception ex)
    {
        if (ex is AggregateException agg)
        {
            foreach (var e in agg.InnerExceptions)
                Console.WriteLine($"Ngoại lệ: {e.Message}");
        }
        else
        {
            Console.WriteLine($"Lỗi đơn lẻ: {ex.Message}");
        }
    }
}

Chú ý: khi dùng await Task.WhenAll(tasks) .NET sẽ “mở” AggregateException, và trong catch bạn sẽ nhận được ngoại lệ “đầu tiên”. Danh sách đầy đủ có thể truy xuất qua task.Exception.InnerExceptions nếu task kết thúc với lỗi.

3. «Fire and Forget»: tại sao không thể quên task

“Chạy rồi quên” thường dẫn tới lỗi ẩn. Ví dụ:


Task.Run(() => { throw new Exception("Bùm!"); }); // Lỗi "bay" vào chân không.

Runtime hiện đại giữ lỗi trong Task và có thể kết thúc process vì ngoại lệ chưa được quan sát. Cách tốt nhất — giữ tham chiếu tới task và/hoặc đăng ký sự kiện TaskScheduler.UnobservedTaskException.

Làm thế nào cho đúng?

  • Giữ lại Task để chờ nó hoàn thành và xử lý lỗi;
  • Với fire‑and‑forget nên đặt handler ngay bên trong delegate.

Task.Run(() => {
    try 
    {
        // Code có thể ném ngoại lệ
    }
    catch (Exception ex)
    {
        // Log, thông báo, nhưng không để lỗi bay ra ngoài
        Console.WriteLine($"Lỗi trong fire-and-forget: {ex.Message}");
    }
});

4. Lỗi trong Thread (Thread): “bắt ngoại lệ từ ngoài?”

Ngoại lệ trong một Thread mới không thể bị bắt bằng try-catch bên ngoài — chỉ có thể bắt bên trong thân luồng.


var thread = new Thread(() =>
{
    try
    {
        throw new Exception("Lỗi trong luồng");
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Bắt được lỗi bên trong luồng: {ex.Message}");
    }
});
thread.Start();

Nếu không đặt handler, ngoại lệ chỉ kết thúc luồng đó (nếu là background: thread.IsBackground = true). Với luồng không phải background, ngoại lệ chưa được xử lý có thể kết thúc toàn bộ process. Luôn đặt try-catch bên trong luồng.

Làm sao trả kết quả và lỗi từ luồng?

  • Dùng queue/collection để truyền kết quả/lỗi;
  • Mô hình event;
  • Tốt hơn là chuyển sang dùng Task — xử lý lỗi dễ hơn.

5. Vòng lặp song song: bắt lỗi theo cách riêng

Trong vòng lặp song song, lỗi từ các nhánh khác nhau được gom vào AggregateException.


try
{
    Parallel.For(0, 5, i =>
    {
        if (i % 2 == 0)
            throw new Exception($"Lỗi ở vòng lặp {i}");
    });
}
catch (AggregateException ex)
{
    foreach (var e in ex.InnerExceptions)
        Console.WriteLine($"[Vòng lặp song song] Lỗi: {e.Message}");
}

Nếu cần log lỗi cục bộ và tiếp tục các nhánh khác, đặt try-catch bên trong mỗi nhánh.

6. Xử lý lỗi khi hủy Task

Khi hủy qua CancellationToken theo quy ước sẽ ném OperationCanceledException — đây không phải là lỗi, mà là dừng có chủ ý.


async Task DoWorkAsync(CancellationToken token)
{
    for (int i = 0; i < 10; i++)
    {
        token.ThrowIfCancellationRequested();
        await Task.Delay(100);
    }
}

// Ở đâu đó trong code:
var cts = new CancellationTokenSource();
var task = DoWorkAsync(cts.Token);
cts.Cancel(); // Khoảng 200 ms sau

try
{
    await task;
}
catch (OperationCanceledException)
{
    Console.WriteLine("Hoạt động đã bị hủy!");
}

Ngoài ThrowIfCancellationRequested(), nhiều phương thức hỗ trợ token (ví dụ, Task.Delay, HttpClient.GetAsync) sẽ tự ném OperationCanceledException. Kiểm tra xem API có hỗ trợ hủy hay không.

7. Những chi tiết hữu ích

Các cách tiếp cận cho mọi tình huống

  • Đặt try-catch ở mức cao nhất của phương thức async — vậy bạn sẽ bắt được lỗi “chạy trốn”.
  • Đừng bỏ qua Task: giữ tham chiếu, đợi hoàn thành, log lỗi.
  • Với thao tác song song (Task.WhenAll / Parallel.ForEach) nhớ tới AggregateException.
  • Tách biệt hủy với lỗi: bắt OperationCanceledException riêng.
  • Log lỗi, đặc biệt những lỗi “yên lặng”, không làm sập app.
  • Giữ chi tiết: log tất cả ngoại lệ lồng nhau, đừng chỉ log ngoại lệ đầu tiên.
  • Xử lý lỗi “tại chỗ”: nếu không biết làm gì — ít nhất hãy log.

Nên bắt lỗi ở đâu trong mã đa luồng và bất đồng bộ

flowchart TD
    A[Luồng chính / UI] -->|Khởi chạy Task| B[Task/async]
    B -->|Bên trong task| C[try/catch bên trong phương thức async]
    B -->|await ở luồng chính| D[try/catch quanh await]
    A -->|Khởi chạy Thread| E[Thread]
    E -->|Bên trong| F[try/catch bên trong luồng]
    B -->|Nhiều task| G[Task.WhenAll / Parallel.ForEach]
    G -->|Lỗi| H[AggregateException]

8. Lỗi điển hình và các "bẫy"

Lỗi №1: đợi task qua .Result hoặc .Wait(). Có thể gây deadlock và/hoặc AggregateException bất ngờ.

Lỗi №2: khởi chạy fire‑and‑forget mà không có try-catch bên trong — task có thể chết im lặng, không có chuẩn đoán.

Lỗi №3: bỏ sót lỗi trong vòng lặp song song — một phần công việc không được thực hiện nhưng bạn không biết.

Lỗi №4: không phân biệt hủy (OperationCanceledException) và lỗi thực sự.

Lỗi №5: chỉ log ngoại lệ đầu tiên trong nhiều task — các ngoại lệ khác bị “ẩn”.

Lỗi №6: tái sử dụng cùng một đối tượng Exception cho mọi task — mỗi lỗi nên có instance riêng.

Lỗi №7: không xử lý ngoại lệ trong UI-thread — lỗi nền không được chú ý, giao diện hành xử “ma mị”.

2
Nhiệm vụ
C# SELF, mức độ, bài học
Đã khóa
Xử lý ngoại lệ trong tác vụ bất đồng bộ
Xử lý ngoại lệ trong tác vụ bất đồng bộ
1
Khảo sát/đố vui
, cấp độ , bài học
Không có sẵn
Ngoại lệ trong Thread cổ điển
Xử lý lỗi trong code bất đồng bộ
Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION