一、多线程环境为什么需要特殊处理异常

想象一下你正在指挥一个交响乐团,每个乐手就像是一个线程。如果小提琴手突然断弦(抛出异常),而指挥(主线程)没注意到,整个演出就会乱套。在多线程编程中,未捕获的异常就像这个断弦的小提琴,会让程序悄无声息地崩溃。

在C#中,线程池线程或手动创建的线程如果抛出未处理异常,会直接导致进程终止。比如下面这个简单的例子:

// 技术栈:.NET 6.0
using System;
using System.Threading;

class Program
{
    static void Main()
    {
        // 创建一个会抛出异常的线程
        var thread = new Thread(DoWork);
        thread.Start();

        // 主线程继续执行
        Console.WriteLine("主线程还在快乐地运行...");
        Thread.Sleep(2000); // 假装在干活
    }

    static void DoWork()
    {
        throw new InvalidOperationException("线程爆炸了!");
        // 这里异常不会被捕获,程序直接崩溃
    }
}

运行这段代码,你会看到程序直接退出,连句"Goodbye"都不说。这就是典型的线程异常"黑洞"问题。

二、捕获线程异常的四种实战方案

1. 传统的try-catch包裹大法

最直观的方法就是在线程方法内部加try-catch,就像给线程穿上防弹衣:

static void DoWork()
{
    try
    {
        throw new InvalidOperationException("这次有防护罩!");
    }
    catch (Exception ex)
    {
        Console.WriteLine($"捕获到异常:{ex.Message}");
    }
}

不过这种方法有个致命缺点——如果DoWork里调用了其他会抛出异常的方法,每个方法都得加try-catch,代码会变成千层饼。

2. 全局异常处理机制

.NET提供了全局异常捕获的终极武器,适合处理那些漏网之鱼:

// 在程序启动时注册全局处理器
AppDomain.CurrentDomain.UnhandledException += (sender, args) => 
{
    var ex = (Exception)args.ExceptionObject;
    Console.WriteLine($"全局捕获到异常:{ex.Message}");
    Environment.Exit(1); // 优雅退出
};

注意这个事件虽然能捕获异常,但程序仍然会终止。它更适合记录日志或发送警报。

3. Task异常处理更优雅

如果使用Task(推荐),异常处理就友好多了:

var task = Task.Run(() => 
{
    throw new FileNotFoundException("文件去哪了?");
});

try
{
    task.Wait(); // 在这里会抛出AggregateException
}
catch (AggregateException ae)
{
    ae.Handle(ex => 
    {
        Console.WriteLine($"处理了{ex.GetType().Name}");
        return true; // 标记为已处理
    });
}

Task会把所有异常打包成AggregateException,就像快递包裹一样统一处理。

4. async/await的现代战争

在异步方法中,异常处理就像同步代码一样自然:

async Task DoAsyncWork()
{
    try
    {
        await Task.Run(() => throw new NotImplementedException());
    }
    catch (Exception ex)
    {
        Console.WriteLine($"异步捕获:{ex.Message}");
    }
}

这是最推荐的方式,既保持了代码清晰度,又具备完整的异常处理能力。

三、高级技巧:异常传递与状态维护

有时候我们需要把子线程的异常传递回主线程,这时候可以考虑使用共享状态:

class ThreadResult
{
    public Exception Exception { get; set; }
    public bool Success => Exception == null;
}

static void Main()
{
    var result = new ThreadResult();
    var thread = new Thread(() => 
    {
        try
        {
            // 模拟工作
            throw new DivideByZeroException();
        }
        catch (Exception ex)
        {
            result.Exception = ex; // 保存异常
        }
    });

    thread.Start();
    thread.Join(); // 等待线程结束

    if (!result.Success)
    {
        Console.WriteLine($"主线程收到异常报告:{result.Exception.Message}");
    }
}

这种方法特别适合需要收集多个线程执行结果的场景。

四、实战中的陷阱与最佳实践

  1. 不要吞掉异常:空catch块是万恶之源,至少记录日志
  2. 区分异常类型:文件不存在和内存不足的处理方式应该不同
  3. 资源清理:使用using或者finally确保资源释放
  4. 取消操作:配合CancellationToken实现优雅终止
  5. 日志记录:记录完整的调用栈信息

下面是个综合示例:

async Task ProcessFilesAsync(IEnumerable<string> files, CancellationToken ct)
{
    var tasks = files.Select(async file => 
    {
        try
        {
            ct.ThrowIfCancellationRequested();
            using var stream = File.OpenRead(file);
            // 处理文件内容...
        }
        catch (OperationCanceledException)
        {
            Console.WriteLine("操作被取消");
            throw;
        }
        catch (FileNotFoundException ex)
        {
            Console.WriteLine($"文件失踪:{ex.FileName}");
            return false;
        }
        catch (Exception ex)
        {
            Console.WriteLine($"处理{file}时出错:{ex}");
            return false;
        }
        return true;
    });

    try
    {
        await Task.WhenAll(tasks);
    }
    catch
    {
        // 这里可以处理聚合异常
    }
}

五、总结与选型建议

对于现代C#开发,我的推荐优先级是:

  1. 首选async/await模式
  2. 其次使用Task.ContinueWith处理后续
  3. 必要时才用原始线程+全局处理

记住,多线程异常处理的核心思想是:永远不要让异常悄无声息地消失。就像飞机上的黑匣子,即使坠毁了也要知道原因在哪。

不同的应用场景需要不同的策略:

  • 后台服务:全局捕获+日志+自动重启
  • UI应用:捕获后显示友好提示
  • 批处理:记录错误继续后续任务