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 |
|---|---|---|---|
|
Có | 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 | Có | 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.
GO TO FULL VERSION