CodeGym /Các khóa học /C# SELF /Ngoại lệ trong các tác vụ "fire and forget"

Ngoại lệ trong các tác vụ "fire and forget"

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

1. Fire and forget là gì?

Trong lập trình, thuật ngữ fire and forget có nghĩa là khởi chạy một tác vụ mà không chờ nó hoàn thành. Trong C# và .NET thường làm điều này với các tác vụ Task, start rồi không await, không giữ reference và thực chất là quên luôn.

// Nút bấm khởi chạy tác vụ nền, nhưng không await ở đâu cả.
button.Click += (s, e) =>
{
    Task.Run(() => DolgayaOperatsiya());
};

Nghe hấp dẫn: "để nó chạy nền, mình làm việc khác". Nhưng với cách này, nếu trong task xảy ra ngoại lệ, sẽ không ai biết kịp — nó bị mất một cách im lặng.

2. Cách xử lý ngoại lệ trong Task

Chuẩn: await và xử lý lỗi

Cách tiêu chuẩn để làm việc với async là dùng await. Nếu trong task có lỗi, nó sẽ được ném tại chỗ chờ:

try
{
    await SomeOperationAsync(); // nếu ở trong đây có Exception, nó sẽ rơi vào catch
}
catch(Exception ex)
{
    Console.WriteLine("Úi! Trong task xảy ra lỗi: " + ex.Message);
}

Tức là khi bạn chờ task, bạn sẽ không bỏ sót ngoại lệ.

Nhưng các task "fire and forget" thì không ai chờ!

public void ZapustitBezOzhidaniya()
{
    // Task tồn tại tự nó. Không ai chờ nó...
    Task.Run(() => {
        // Ở đâu đó có chuyện xấu xảy ra:
        throw new InvalidOperationException("Ôi, mọi thứ hỏng hết!");
    });
    // Method kết thúc, task im lặng chạy nền.
}

Nếu trong task như vậy xảy ra ngoại lệ, nó sẽ không bị ném ra luồng chính. Ứng dụng tiếp tục chạy như chưa có gì xảy ra.

Quan trọng

Trong .NET một task có ngoại lệ chưa được xử lý sẽ chuyển sang trạng thái Faulted. Nhưng nếu bạn không chờ nó (await, .Result, .Wait(), v.v.), sẽ không ai đọc ngoại lệ đó, và nó không xuất hiện ở code gọi.

Thực tế "bên dưới nắp capo" là gì?

Với các task không ai chờ, cơ hội duy nhất để chúng bị chú ý là sự kiện TaskScheduler.UnobservedTaskException. Nó được kích hoạt khi garbage collector (GC) tìm thấy một task có ngoại lệ chưa được quan sát. Nhưng việc này không xảy ra ngay lập tức và không phải ở nơi bạn mong muốn — không nên dựa vào nó.

3. Minh họa: lỗi Fire-and-forget

// Ví dụ: khởi chạy fire-and-forget task ngay từ Main
using System;
using System.Threading.Tasks;

class Program
{
    static void Main(string[] args)
    {
        FireAndForgetExample();

        Console.WriteLine("Luồng chính tiếp tục chạy...");
        // Cho task thời gian để kết thúc
        Task.Delay(2000).Wait();
    }

    static void FireAndForgetExample()
    {
        Task.Run(() =>
        {
            Console.WriteLine("Fire-and-forget task bắt đầu!");
            Task.Delay(500).Wait();
            throw new InvalidOperationException("Lỗi bên trong fire-and-forget task!");
        });
    }
}

Nếu chạy code này... không có gì đặc biệt xảy ra. Lỗi xảy ra nhưng chương trình không biết. Đôi khi IDE sẽ show cảnh báo ở Output Window, nhưng với người dùng — không có thông tin gì.

Tại sao điều này nguy hiểm trong dự án thực tế?

  • Bugs phức tạp, khó tái tạo ("thỉnh thoảng không chạy - không rõ vì sao").
  • Mất dữ liệu hoặc logic im lặng (ví dụ, email không được gửi).
  • Trong production — không có tín hiệu về vấn đề nếu không có logging.

4. Cách đúng để xử lý lỗi trong fire-and-forget

Log và xử lý lỗi bên trong chính task

Mức an toàn tối thiểu — bắt ngoại lệ ngay trong fire-and-forget task:

Task.Run(() =>
{
    try
    {
        // Code dài/nguy hiểm của bạn
        throw new InvalidOperationException("Có chuyện không ổn!");
    }
    catch (Exception ex)
    {
        // Log lỗi hoặc thông báo cho user
        Console.WriteLine("Fire-and-forget: bắt được ngoại lệ: " + ex.Message);
        // Có thể ghi vào file log, gửi alert, v.v.
    }
});

Async void (và vì sao không nên làm vậy)

async void DangerousFireAndForget()
{
    // Gì đó nguy hiểm
    throw new Exception("Bùm!");
}

Các method async void về bản chất là fire-and-forget: không thể chờ chúng, chúng không trả về Task. Ngoại lệ từ chúng sẽ bay vào global handler của ứng dụng (ví dụ AppDomain.UnhandledException) và thường dẫn tới crash process. Chỉ dùng async void cho event handlers — và cũng phải cẩn thận.

Dùng helper để bọc xử lý lỗi

Tiện lợi khi tách việc start safe fire-and-forget vào một wrapper:

// Phương thức chung để chạy fire-and-forget an toàn
public static void RunSafeFireAndForget(Func<Task> taskFactory)
{
    Task.Run(async () =>
    {
        try
        {
            await taskFactory();
        }
        catch (Exception ex)
        {
            // Log ngoại lệ
            Console.WriteLine("Fire-and-forget (safe): " + ex);
            // Có thể thêm gửi vào hệ thống monitoring!
        }
    });
}

// Sử dụng:
RunSafeFireAndForget(async () =>
{
    await Task.Delay(1000);
    throw new InvalidOperationException("Bên trong fire-and-forget!");
});

Ví dụ thực tế: gửi email

// Nút gửi email:
private void buttonSend_Click(object sender, EventArgs e)
{
    Task.Run(() => SendEmail());
}

// Method gửi:
private void SendEmail()
{
    try
    {
        // Có thể là gửi thực tế
        throw new Exception("SMTP-server không truy cập được!");
    }
    catch (Exception ex)
    {
        // Ghi log
        File.AppendAllText("errors.log", $"Lỗi gửi mail: {ex.Message}\n");
    }
}

5. Còn UnobservedTaskException thì sao?

Như biện pháp cuối cùng, .NET cung cấp sự kiện TaskScheduler.UnobservedTaskException. Nó được gọi nếu task kết thúc với lỗi, không ai chờ nó, và object task bị GC. Không nên dựa vào cơ chế này — đó là "cơ hội cuối cùng".

TaskScheduler.UnobservedTaskException += (sender, e) =>
{
    Console.WriteLine("Global UnobservedTaskException: " + e.Exception);
    e.SetObserved(); // Nhớ gọi, nếu không app có thể kết thúc bất ngờ!
};

Chi tiết: TaskScheduler.UnobservedTaskException.

6. Những điểm cần lưu ý

So sánh sơ bộ các cách tiếp cận

Phương pháp Ngoại lệ được xử lý? Nơi bắt lỗi Rủi ro "mất" lỗi
await
Tại code gọi Thấp
Fire-and-forget không try/catch Không Không đâu cả Rất cao
Fire-and-forget có try/catch Bên trong chính task Thấp (nếu bạn log)
async void-method Không (bay vào global) Global handler Cao

Cách thiết kế fire-and-forget đúng

  • Nếu kết quả hoặc trạng thái task quan trọng — đừng dùng fire-and-forget. Dùng await hoặc giữ Task để chờ sau.
  • Fire-and-forget chỉ hợp lý cho các tác vụ nền thực sự không quan trọng (ví dụ gửi telemetry).
  • Luôn bọc fire-and-forget vào method riêng và bắt/log ngoại lệ.
  • Với scenario nền phức tạp, dùng queue/worker: Hangfire, Quartz.NET.

Ứng dụng thực tế và phỏng vấn

Trong phỏng vấn thường hỏi: "Điều gì xảy ra nếu trong fire-and-forget task có ngoại lệ?" hay "Tại sao không thể dùng async void ở mọi nơi?" Đáp án đúng: bạn chịu trách nhiệm với lỗi trong task nền — hoặc bắt, log và phân tích, hoặc nhận các bug ma.

So sánh "fire-and-forget" và await

Tình huống Độ tin cậy xử lý lỗi Áp dụng
Await bình thường Tuyệt vời Mọi nơi cần kết quả hoặc quan tâm success/fail
Fire-and-forget Kém (nếu không xử lý thủ công) Chỉ cho các tác vụ nền và không quan trọng
Fire-and-forget có try/catch Tốt (nếu log) Tác vụ nền không cần kết quả nhưng cần biết khi có lỗi

Trong bài tiếp theo sẽ bàn về xử lý lỗi trong các tác vụ song song trả về nhiều kết quả. Còn bây giờ nhớ: nếu bạn vừa "bắn" một cái gì đó, đừng quên kiểm tra xem có tới mục tiêu không!

7. Lỗi thường gặp khi làm việc với fire-and-forget

Lỗi #1: Bỏ qua ngoại lệ trong fire-and-forget.
Người mới hy vọng ngoại lệ sẽ "nổi lên" đâu đó. Không có try-catch và logging thì ngoại lệ bị mất, dẫn tới bug khó phát hiện.

Lỗi #2: Dùng async void ngoài event handler.
Những method này ném ngoại lệ vào global handler (ví dụ AppDomain.UnhandledException), có thể gây crash app.

Lỗi #3: Bắt quá mức mọi ngoại lệ.
Bắt tất cả ngoại lệ trong task có thể che dấu vấn đề nên xử lý ở code gọi, làm khó debug.

Lỗi #4: Xem nhẹ logging.
Không log lỗi trong fire-and-forget thì không thể biết có sự cố, đặc biệt trên production.

2
Nhiệm vụ
C# SELF, mức độ, bài học
Đã khóa
Trình xử lý toàn cục `UnobservedTaskException`
Trình xử lý toàn cục `UnobservedTaskException`
Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION