Skip to content
Go back

Task 不是线程,await 也不保证切线程

「下单成功后发一封确认邮件」的代码,最后写成了 _ = _mailer.SendAsync(order);。上线一周没人发现邮件一封都没发出去:SendAsync 里的异常被吞进任务里,调用方连一条日志都没有。

这类问题出在对 Task 的默认理解上。Task 是一次异步操作的承诺,它不绑定线程;await 在操作尚未完成时把控制权交还调用方,同样不保证换个线程。原文《What’s in a Task?》(Ricardo Peres,2026 年 9 月 21 日)把 Task 描述成「设计成在后台运行的操作」,把 await 说成「让另一个线程调用并等它完成」。这两句话会把人引向同一个坑:以为任务背后有个线程,以为把它丢掉就安全了。

Task 是承诺,不是线程

Task 和 Task<T> 表示一次异步操作,Task<T> 从 Task 派生,多带一个结果。这个对象暴露的是「什么时候结束、结束得怎么样」,和「谁在执行」没有关系。

有三件事说明它为什么不等于线程:

Task<int> completed = Task.FromResult(42);   // 没有任何线程参与
await completed;                              // 直接同步继续,不切换线程

Task.Run 是把同步工作丢到线程池的明确动作,它适合 CPU 密集的代码。把 I/O 操作塞进 Task.Run,只是换了个线程去等,没有变快。

await 只是把控制权交还

await 做的事只有两件,取决于它等的东西是否已经完成:

续体在哪个线程上恢复,由捕获的 SynchronizationContext 或 TaskScheduler 决定。控制台程序里通常回到线程池;WPF、WinForms 里回到 UI 线程;ASP.NET Core 没有同步上下文,续体在线程池上跑。ConfigureAwait(false) 表示「不需要回到原来的上下文」,它不等于「一定切换到线程池」。

所以「await 会让另一个线程来执行这个方法」只说对了 Task.Run 那一种情况。原文后面把 GetAwaiter().GetResult() 描述成「这次真的阻塞」,方向是对的,但阻塞本身不是唯一风险——在单线程同步上下文里,它还可能死锁,因为续体要回到那个已经被堵住的线程。

三个检查点,细节需要补

原文给 fire-and-forget 列了三个要检查的属性:IsCompleted、IsCanceled、Exception。方向没错,实际用起来要补几处:

IsCompleted 在成功、失败、取消三种终态下都为 true。要判断「成功完成」,用 IsCompletedSuccessfully(.NET Core 2.1 与 .NET Standard 2.1 起可用),或者直接看 IsFaulted / IsCanceled。

Task<T>.Result 在任务未完成时会阻塞调用线程;任务失败时它抛 AggregateException,取消时抛的是 AggregateException,里面包着一个 TaskCanceledException。Task.Exception 同理,未失败时是 null,失败时是 AggregateException,可能装着多个异常。

GetAwaiter().GetResult() 也会阻塞,区别在于它抛出的是第一个原始异常,不再包一层 AggregateException。如果确实需要同步等待,官方文档给的优先级是 GetAwaiter().GetResult()、Task.Run,最后才是 Wait() / .Result——越往后,异常包装和死锁风险越高。

一个 Task 从创建到结束只有三种终态:完成、失败、取消,分别对应 IsCompletedSuccessfully、IsFaulted 和 IsCanceled(概念示意)

「稍后检查」通常等不到

三个检查点的前提是「任务对象还在我手上,而且我有机会回头检查」。fire-and-forget 的写法恰好把这两点都破坏了。

未观测异常在 .NET 4.5 之后默认不再让进程崩溃。任务被 GC 终结时,如果异常一直没人读取,TaskScheduler.UnobservedTaskException 会触发一次——注意是终结时,不是失败时,而且进程很可能已经走过这段逻辑,甚至已经退出。要恢复「未观测就终止进程」的旧行为,得显式打开 <ThrowUnobservedTaskExceptions enabled="true" />。于是「稍后再看 Exception」在真实服务里经常变成「永远没看」。

编译器其实已经提示过。返回 Task 的调用没有被 await 时会报 CS4014:「Because this call is not awaited, execution of the current method continues before the call is completed.」加一个 _ = 可以显式消掉警告,但 discard 只是把返回的 Task 丢掉,异常仍然留在那个没人观察的对象上。警告消失了,问题没消失。

还有生命周期问题。ASP.NET Core 里每个请求有独立的 DI scope,请求结束就释放。在 fire-and-forget 的任务里继续使用请求作用域的 DbContext、HttpContext 或别的 scoped 服务,这些服务那时已经被容器释放,继续访问属于未定义行为,最常见的表现是 ObjectDisposedException。host 关闭时,没人等的任务也会被直接截断。

安全丢弃任务的一种写法

如果这段后台工作真的可丢、且不影响响应,至少把异常和取消接住,让它变成日志而不是静默消失:

public static class BackgroundRunner
{
    public static void Run(
        Func<CancellationToken, Task> operation,
        CancellationToken cancellationToken,
        ILogger logger)
    {
        _ = Observe(operation, cancellationToken, logger);
    }

    private static async Task Observe(
        Func<CancellationToken, Task> operation,
        CancellationToken cancellationToken,
        ILogger logger)
    {
        try
        {
            await operation(cancellationToken).ConfigureAwait(false);
        }
        catch (OperationCanceledException)
            when (cancellationToken.IsCancellationRequested)
        {
            logger.LogDebug("Background operation cancelled");
        }
        catch (Exception exception)
        {
            logger.LogError(exception, "Background operation failed");
        }
    }
}

调用方把取消令牌传进去,通常是 IHostApplicationLifetime.ApplicationStopping 或自己的 CancellationTokenSource:

BackgroundRunner.Run(
    token => _mailer.SendAsync(order, token),
    _lifetime.ApplicationStopping,
    logger);

Observe 自己不会失败,_ = 丢掉的只是一个已经包住所有异常的任务。这段代码仍然有边界:它不提供重试、不保证执行、不限制并发,也不能让请求等它。数量一多,应该换成队列加消费者。

什么时候不该丢弃任务

目标做法
结果要带回响应直接 await,别绕
请求结束之后还要可靠执行Channel<T> 入队,BackgroundService 消费,scope 自己建
短命、可丢的旁路(埋点、缓存预热)上面的 helper,配日志和取消

最后一种才是 fire-and-forget 的合理位置。发邮件、写审计、扣库存这些要么有结果、要么必须完成的事情,都不属于这一类。

要收一下手里的代码,可以搜三个地方:_ = 开头、返回 Task 却没被 await 的调用,以及 async void 事件处理器之外的 async void。再搜一遍 .Result、.Wait()、GetAwaiter().GetResult(),逐个确认它们所在的方法没有同步上下文。三处都清完,Task 和线程的关系就不必再靠猜了。

参考


Tags


Previous

C# 字符串里的孤立代理项,SIMD 怎么修

Next

PR 不跑云端 CI,改用本地 signoff