使用 .NET 8/9 中的 Async/Await 避免常见错误并提高性能

科技   2024-12-04 05:38   山东  


在.NET 8中,异步编程对于构建响应迅速且高效的应用程序至关重要。如果使用得当,async/await关键字能够简化异步代码的复杂性,但它也并非毫无挑战。在本文中,我们将探讨开发人员常犯的错误以及避免这些错误的实用策略,所有内容都将基于实际的编码场景展开。

让我们深入了解如何改进你的异步代码并提升你的.NET开发技能吧。

理解.NET中的异步/等待(Async/Await)

async和await关键字旨在让异步编程变得直观。它们允许开发人员编写看似同步但在幕后异步运行的非阻塞、高效代码。

为何使用异步/等待(Async/Await)?

  • 响应性:对于UI应用程序来说至关重要,可防止在长时间运行的操作期间界面冻结。

  • 高效性:在等待异步操作完成时,能够释放线程去执行其他任务。

关键概念:

  • async:将一个方法标记为异步,意味着该方法可以包含异步操作。

  • await:暂时挂起方法的执行,直到等待的任务完成,在此期间允许其他任务执行。

常见错误及避免方法

1. 未等待异步任务

问题: 如果没有等待一个异步任务,意味着代码会在不等待其完成的情况下继续执行。这可能导致未处理的异常以及不可预测的行为。

示例

// 错误示例
public async void FetchData()
{
GetDataAsync(); // 未等待任务
}

// 正确示例
public async Task FetchData()
{
await GetDataAsync(); // 等待任务
}

解决方案: 始终使用await来确保正确的错误传播以及任务的完成。

预期输出: 当你运行应用程序时,你应该会看到类似如下的输出:

Starting without awaiting...
Elapsed time without awaiting: 0 ms
Starting with awaiting...
Data fetched
Elapsed time with awaiting: 2000 ms

解释

  • 未使用await时:方法FetchDataWithoutAwaiting不会等待GetDataAsync完成。结果就是,经过的时间非常短(0毫秒),但“Data fetched”消息稍后才出现,这表明任务是异步完成的。

  • 使用await时:方法FetchDataWithAwaiting会等待GetDataAsync完成。经过的时间大约是2000毫秒,这与GetDataAsync中的延迟相匹配,并且“Data fetched”消息会在此时间范围内出现。

结论: 不等待异步任务可能导致不可预测的行为以及未处理的异常。正确地等待任务能确保代码等待任务完成,从而实现正确的错误传播以及可预测的执行流程。

遵循最佳实践,在调用异步方法时始终使用await,这样你就能在.NET应用程序中编写更可靠且更易于维护的异步代码。

2. 异步方法中不当使用async void

问题: async void方法旨在用于事件处理程序。在其他地方使用它们会使错误处理变得复杂,并扰乱异步流程。

示例

// 错误示例
public async void ProcessData()
{
await Task.Delay(1000);
}

// 正确示例
public async Task ProcessData()
{
await Task.Delay(1000);
}

解决方案: 除非是处理事件,否则在异步方法中返回TaskTask<T>

预期输出: 当你运行应用程序时,你应该会看到类似如下的输出:

Starting with async void...
Elapsed time with async void: 0 ms
Starting with async Task...
Data processed with async void
Data processed with async Task
Elapsed time with async Task: 1000 ms

解释

  • 使用async void时:方法ProcessDataAsyncVoid不返回Task,所以Main方法不会等待它完成。结果就是,经过的时间非常短(0毫秒),但“Data processed with async void”消息稍后会出现,这表明任务是异步完成的。

  • 使用async Task时:方法ProcessDataAsyncTask返回Task,所以Main方法会等待它完成。经过的时间大约是1000毫秒,这与ProcessDataAsyncTask中的延迟相匹配,并且“Data processed with async Task”消息会在此时间范围内出现。

结论: 在非事件处理程序方法中使用async void可能导致不可预测的行为,并使错误处理变得复杂。正确地使用async Task能确保调用代码可以等待异步操作完成,从而实现正确的错误传播以及可预测的执行流程。

遵循最佳实践,在异步方法中返回TaskTask<T>,这样你就能在.NET应用程序中编写更可靠且更易于维护的异步代码。

3. 使用.Wait() 或.Result阻塞异步任务

问题: 使用诸如.Wait()或.Result之类的阻塞方法可能导致死锁,并削弱异步编程的优势。

示例

// 错误示例
public void LoadData()
{
var data = GetDataAsync().Result; // 阻塞线程
}

// 正确示例
public async Task LoadData()
{
var data = await GetDataAsync(); // 非阻塞
}

解决方案: 使用await来替代阻塞调用,以保持应用程序的响应性并防止死锁。

预期输出: 当你运行应用程序时,你应该会看到类似如下的输出:

Starting with blocking.Result...
Data loaded with blocking.Result
Elapsed time with blocking.Result: 2000 ms
Starting with await...
Data loaded with await
Elapsed time with await: 2000 ms

解释

  • 使用阻塞.Result时:方法LoadDataBlocking使用.Result来阻塞线程,直到GetDataAsync完成。经过的时间大约是2000毫秒,这与GetDataAsync中的延迟相匹配。“Data loaded with blocking.Result”消息会在延迟之后出现。

  • 使用await时:方法LoadDataAsync使用await来处理异步操作,不会阻塞线程。经过的时间同样大约是2000毫秒,并且“Data loaded with await”消息会在延迟之后出现。

结论: 使用诸如.Wait()或.Result之类的阻塞方法可能导致死锁,并削弱异步编程的优势。正确地使用await能确保调用代码在不阻塞线程的情况下处理异步操作,从而实现更好的性能和响应性。

4. 误用ConfigureAwait(false)

问题: 虽然ConfigureAwait(false)在库或后台代码中用于提高性能很有用,但在UI应用程序中使用它可能导致同步问题。

示例

// 在UI上下文中错误的用法
public async Task LoadData()
{
await Task.Delay(1000).ConfigureAwait(false);
UpdateUI(); // 可能导致问题
}

// 在非UI上下文中正确的用法
public async Task LoadData()
{
await Task.Delay(1000).ConfigureAwait(false);
ProcessData(); // 安全可用
}

解决方案: 将ConfigureAwait(false)限制在不需要同步的非UI上下文中使用。

预期输出: 当你运行应用程序时,你应该会看到类似如下的输出:

Starting with ConfigureAwait(false) in a simulated UI context...
Data loaded with ConfigureAwait(false) in UI context
Elapsed time with ConfigureAwait(false) in UI context: 1000 ms
Starting with ConfigureAwait(false) in a non-UI context...
Data loaded with ConfigureAwait(false) in non-UI context
Elapsed time with ConfigureAwait(false) in non-UI context: 1000 ms

解释

  • 在模拟UI上下文中使用ConfigureAwait(false)时:方法LoadDataWithConfigureAwaitFalseInUIContext使用了ConfigureAwait(false),这在实际的UI应用程序中可能会导致问题,因为它不会返回到原始的同步上下文。不过,在这个控制台应用程序中,它能顺利完成,经过的时间大约是1000毫秒。

  • 在非UI上下文中使用ConfigureAwait(false)时:方法LoadDataWithConfigureAwaitFalseInNonUIContext在非UI上下文中正确地使用了ConfigureAwait(false)。经过的时间同样大约是1000毫秒,并且方法能顺利完成。

结论: 在UI上下文中使用ConfigureAwait(false)可能导致同步问题,因为它不会返回到原始的同步上下文。这在更新UI元素时可能导致异常或不可预测的行为。在非UI上下文中正确地使用ConfigureAwait(false)可以通过避免不必要的上下文切换来提高性能。

遵循最佳实践,将ConfigureAwait(false)限制在非UI上下文中使用,这样你就能在.NET应用程序中编写更高效且更可靠的异步代码。

5. 过度使用Task而不是ValueTask

问题: 对于经常同步返回的方法使用Task可能导致不必要的堆分配,影响性能。

示例

// 使用Task
public async Task<int> GetDataAsync()
{
return await Task.FromResult(42);
}

// 使用ValueTask
public async ValueTask<int> GetDataAsync()
{
return await new ValueTask<int>(42);
}

解决方案: 对于经常同步完成的方法,选择使用ValueTask来减少内存开销。

预期输出: 当你运行应用程序时,你应该会看到类似如下的输出:

Starting with Task...
Elapsed time with Task: [time in ms] ms
Starting with ValueTask...
Elapsed time with ValueTask: [time in ms] ms

解释

  • 使用Task时:方法GetDataWithTask使用Task.FromResult,这可能导致不必要的堆分配。经过的时间将针对大量迭代进行测量。

  • 使用ValueTask时:方法GetDataWithValueTask使用ValueTask,对于同步完成的情况可避免堆分配。经过的时间将针对相同数量的迭代进行测量。

结论: 对于经常同步返回的方法,使用ValueTask替代Task可以减少内存开销并提高性能。遵循最佳实践,在合适的场景中选择使用ValueTask,这样你就能在.NET应用程序中编写更高效且性能更佳的异步代码。

例如:

Starting with Task...
Elapsed time with Task: 1500 ms
Starting with ValueTask...
Elapsed time with ValueTask: 1200 ms

在这个示例中,与使用Task相比,使用ValueTask带来了显著的性能提升。

其他最佳实践

6. 处理任务取消

使用CancellationToken来提供一种干净利落的方式取消正在进行的任务。

public async Task FetchDataAsync(CancellationToken cancellationToken)
{
await Task.Delay(1000, cancellationToken);
}

预期输出: 当你运行应用程序时,你应该会看到类似如下的输出:

Starting task without cancellation...
Data fetched
Elapsed time without cancellation: 1000 ms
Starting task with cancellation...
Task was canceled.
Elapsed time with cancellation: 500 ms

解释

  • 不取消时:方法FetchDataAsync在延迟1000毫秒后正常完成。经过的时间大约是1000毫秒。

  • 取消时:使用CancellationTokenSource.CancelAfter在500毫秒后取消方法FetchDataAsync。任务会抛出OperationCanceledException,经过的时间大约是500毫秒。

结论: 使用CancellationToken提供了一种干净利落的方式取消正在进行的任务,能更好地控制异步操作。通过正确地处理任务取消,你可以提升应用程序的响应性和可靠性。

7. 使用IAsyncEnumerable流式传输数据

对于像流式传输分页结果或分块处理数据这样的场景,IAsyncEnumerable提供了一种高效的解决方案。

public async IAsyncEnumerable<int> GetDataStreamAsync()
{
for (int i = 0; i < 10; i++)
{
await Task.Delay(500);
yield return i;
}
}

预期输出: 当你运行应用程序时,你应该会看到类似如下的输出:

Starting data stream...
Received data: 0
Received data: 1
Received data: 2
Received data: 3
Received data: 4
Received data: 5
Received data: 6
Received data: 7
Received data: 8
Received data: 9
Elapsed time for data stream: 5000 ms

解释

  • 数据流式传输:方法GetDataStreamAsync异步地流式传输数据,每500毫秒产生一个新值。Main方法中的await foreach循环会在数据可用时处理每一块数据。

  • 性能测量:流式传输所有10块数据所经过的时间大约是5000毫秒,这与Task.Delay调用引入的总延迟相匹配。

结论: 使用IAsyncEnumerable为流式传输分页结果或分块处理数据等场景提供了一种高效的解决方案。通过利用它,你可以异步地处理数据流,提升应用程序的响应性和可扩展性。

8. 实现IAsyncDisposable

通过实现IAsyncDisposable来确保在异步上下文中正确地清理资源。

public class AsyncResource : IAsyncDisposable
{
public async ValueTask DisposeAsync()
{
await Task.Delay(100); // 模拟异步清理
}
}

预期输出: 当你运行应用程序时,你应该会看到类似如下的输出:

Starting resource usage...
Using resource...
Cleaning up resource...
Elapsed time for resource usage and cleanup: 600 ms

解释

  • 资源使用AsyncResource类实现IAsyncDisposable以确保正确地清理资源。UseResourceAsync方法通过500毫秒的延迟来模拟资源使用。

  • 异步清理DisposeAsync方法通过100毫秒的延迟来模拟异步清理。

  • 性能测量:使用资源和执行清理所经过的时间大约是600毫秒,这与Task.Delay调用引入的总延迟相匹配。

结论: 实现IAsyncDisposable能确保在异步上下文中正确地清理资源,实现高效且非阻塞的资源处置。遵循最佳实践并实现IAsyncDisposable,这样你就能在.NET应用程序中编写更可靠且更易于维护的异步代码。

在.NET 8中掌握异步/等待(async/await)对于编写高效、可维护且响应迅速的应用程序至关重要。通过避免诸如不当使用async void或使用.Wait()阻塞等常见陷阱,并利用像ValueTaskIAsyncEnumerable这样的特性,你可以优化自己的异步编程技能。

如果你喜欢我的文章,请给我一个赞!谢谢

架构师老卢
资深软件架构师, 分享编程、软件设计经验, 教授前沿技术, 分享技术资源(每天发布电子书),每天进步一点点...
 最新文章