5 个破坏真实系统的 .NET 异步和线程假设

作者:微信公众号:【架构师老卢】
2-1 20:46
41

5 个破坏真实系统的 .NET 异步和线程假设

它们编译,它们测试,但它们仍然失败

108

收听

分享

更多

CodeToDeploy

一份技术人员学习、构建和成长的出版物。关注以加入我们超过 50 万的月度读者群体

medium.com

6,500 多门技术课程。提升您的技能 — 免费开始!

6,500 多门技术课程。提升您的技能 — 免费开始!

引言

异步和线程问题是 .NET 中最难排查的错误之一,不是因为工具不好,而是因为我们的心智模型常常是错误的。很多代码看起来是正确的,通过了测试,甚至在低负载下表现良好,直到突然之间出现问题。

如果你不是会员,我也为你准备好了!❤

如果你喜欢这篇文章,请考虑鼓掌、订阅或给我买杯咖啡以示支持!❤

本文中的五个错误并非初学者错误。它们是我在经验丰富的开发人员编写的真实代码库中反复看到的模式。每一个通常都源于一个看似合理的假设,但一旦涉及到并发、负载或故障,这个假设就会被证明是错误的。

如果你关心正确性、可扩展性和可调试性,那么这些问题值得你弄清楚。

1. 异步不保证共享状态的线程安全

有一个长期存在的误解,认为使用异步就能以某种方式避免线程问题。事实并非如此。

private int _counter;

public async Task IncrementAsync()
{
    _counter++;
    await Task.Delay(10);
}

这段代码不是线程安全的。它是否是异步的无关紧要。多个调用者可以交错执行,并像同步代码一样轻易地破坏状态。

异步引入的是并发性,而不是安全性。如果多个操作可以访问可变的共享状态,你仍然需要同步。

private readonly SemaphoreSlim _mutex = new(1, 1);

public async Task IncrementAsync()
{
    await _mutex.WaitAsync();
    try
    {
        _counter++;
    }
    finally
    {
        _mutex.Release();
    }
}

重点是:异步改变的是代码运行的时间,而不是有多少东西可以接触你的数据。如果存在共享状态,你仍然必须明确地为并发进行设计。

2. 阻塞异步代码 ( .Result/.Wait )

这仍然是最常见的异步错误,也仍然是破坏性最强的错误之一。

var result = DoWorkAsync().Result;

DoWorkAsync().Wait();

乍一看,这似乎无害。你有一个异步方法,你需要同步获得结果,所以你等待。问题在于 .NET 中的异步是建立在线程池调度和延续(continuations)之上的。在等待异步工作时阻塞一个线程可能会耗尽线程池,或者根据上下文完全导致死锁。

在 ASP.NET 中,这通常表现为请求在负载下挂起。在 UI 应用中,它会变成死锁。在服务中,它会悄悄地破坏吞吐量。

正确的修复方法不是“内部异步”而外部仍然阻塞。修复方法是让异步一直向上流动。

var result = await DoWorkAsync();

3. “发后即忘”任务与未观察到的故障

“发后即忘”式的异步既诱人又危险。

_ = ProcessAsync();

这行代码看起来很无辜。没有警告,没有编译器错误。但是你刚刚启动了一项可能会失败、抛出异常或挂起的工作,而你对此一无所知。

如果 ProcessAsync 抛出异常,该异常可能会丢失、稍后被记录,或根据时机和运行时行为使进程崩溃。这些结果没有一个是好的。

“发后即忘”只有在以下情况下是安全的:

  • 任务不可能失败,或者
  • 故障被明确处理和观察

一个更安全的模式如下所示:

Task.Run(async () =>
{
    try
    {
        await ProcessAsync();
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Background task failed");
    }
});

更好的是,长时间运行的后台工作应该由一个适当的抽象来拥有,比如一个托管服务、一个队列或一个可以跟踪生命周期和故障的监督者组件。

没有所有权的异步工作是技术债。它会在最糟糕的时候暴露出来。

4. 传递 CancellationToken 并不意味着你支持取消

.NET 中的取消是协作式的。仅仅接受一个 CancellationToken 参数本身不做任何事,除非你主动检查并遵守它。

public async Task DoWorkAsync(CancellationToken token)
{
    await Task.Delay(5000);
}

这个方法接受一个令牌,但它不支持取消。延迟将总是会完成。

正确的取消要求你将令牌传递给可取消的操作,并在长时间运行的工作中检查它。

public async Task DoWorkAsync(CancellationToken token)
{
    await Task.Delay(5000, token);
    token.ThrowIfCancellationRequested();
}

对于 CPU 密集型循环,这甚至更重要。

foreach (var item in items)
{
    token.ThrowIfCancellationRequested();
    Process(item);
}

那些假装支持取消但忽略令牌的 API 会制造一种虚假的控制感。调用者认为他们可以停止工作,但实际上不能。

如果你的方法接受一个令牌,它就在做出一个承诺。要么遵守它,要么就不要接受它。

5. 对 IO 密集型工作使用并行

并行不是一个性能按钮。它是一个用于 CPU 密集型工作负载的工具。

await Task.WhenAll(urls.Select(DownloadAsync));

如果 DownloadAsync 是真正的异步和 IO 密集型,这是可以的。但是很多人在只需要异步,或者工作已经是 IO 密集型的地方,却会使用 Parallel.ForEachTask.Run

Parallel.ForEach(urls, url =>
{
    DownloadAsync(url).Wait();
});

这是非常有害的。你在浪费线程来等待 IO,这降低了可扩展性并增加了争用。

经验法则是简单的:

  • CPU 密集型工作 → 并行
  • IO 密集型工作 → 异步

在不了解工作负载的情况下混合使用它们,几乎总是让事情变得更糟,而不是更好。

结论

异步和线程的错误很少是显而易见的。它们隐藏在看起来合理、通过测试、甚至在开发中也能正常工作的代码背后。这些故障会在负载下、服务中断期间,或者当时间上的微小变化足以暴露缺陷时出现。

感谢您成为社区的一员

在您离开之前:

👉 请务必为作者鼓掌并关注 ️👏 ️️

👉 关注我们: Linkedin | Medium

👉 CodeToDeploy 技术社区已在 Discord 上线 — 立即加入!

注意:这篇文章可能包含附属链接。

相关留言评论
昵称:
邮箱:
阅读排行