【C# 】各种等待大全:从入门到精通

文章目录

    • 引言
    • [1. 基础篇:阻塞式等待](#1. 基础篇:阻塞式等待)
      • [1.1 `Thread.Sleep`](#1.1 Thread.Sleep)
      • [1.2 `SpinWait` 与 `Thread.SpinWait`](#1.2 SpinWaitThread.SpinWait)
      • [1.3 `Task.Wait` / `Task.Result`](#1.3 Task.Wait / Task.Result)
    • [2. 现代异步篇:非阻塞等待](#2. 现代异步篇:非阻塞等待)
      • [2.1 `await Task`](#2.1 await Task)
      • [2.2 `Task.WhenAll` / `Task.WhenAny`](#2.2 Task.WhenAll / Task.WhenAny)
      • [2.3 `await Task.Delay` 与 `Task.Delay`](#2.3 await Task.DelayTask.Delay)
      • [2.4 `await using` 与异步释放](#2.4 await using 与异步释放)
    • [3. UI 交互篇:等待界面更新](#3. UI 交互篇:等待界面更新)
      • [3.1 `Application.DoEvents` (WinForms) - 慎用](#3.1 Application.DoEvents (WinForms) - 慎用)
      • [3.2 `Task.Delay` + 消息循环](#3.2 Task.Delay + 消息循环)
      • [3.3 `Dispatcher.Invoke` / `Dispatcher.InvokeAsync`](#3.3 Dispatcher.Invoke / Dispatcher.InvokeAsync)
    • [4. 高级篇:条件等待与并发协调](#4. 高级篇:条件等待与并发协调)
      • [4.1 `ManualResetEventSlim` / `AutoResetEvent`](#4.1 ManualResetEventSlim / AutoResetEvent)
      • [4.2 `Monitor.Wait` / `Monitor.Pulse`](#4.2 Monitor.Wait / Monitor.Pulse)
      • [4.3 `TaskCompletionSource`](#4.3 TaskCompletionSource)
    • [5. 性能与效率篇:精细化等待](#5. 性能与效率篇:精细化等待)
      • [5.1 `ValueTask`](#5.1 ValueTask)
      • [5.2 `PeriodicTimer` (.NET 6+)](#5.2 PeriodicTimer (.NET 6+))
      • [5.3 `CancellationToken`](#5.3 CancellationToken)
    • [6. 测试篇:等待断言](#6. 测试篇:等待断言)
      • [6.1 `WaitUntil` (自定义轮询)](#6.1 WaitUntil (自定义轮询))
      • [6.2 `Microsoft.Extensions.Time.Testing` 时间模拟](#6.2 Microsoft.Extensions.Time.Testing 时间模拟)
    • 总结:如何选择正确的等待方式?

引言

在C#开发中,"等待"是一个无法绕开的话题。无论是为了控制程序执行流程、处理异步操作,还是在单元测试中模拟时间流逝,选择合适的等待策略至关重要。如果使用不当,轻则导致界面卡死(死锁),重则引发性能瓶颈。

本文将全面梳理C#中各种"等待"的实现方式,分析其底层原理、适用场景以及潜在的陷阱。


1. 基础篇:阻塞式等待

这类等待会阻塞当前线程,直到条件满足。它们简单粗暴,但在UI线程或高并发场景下需要谨慎使用。

1.1 Thread.Sleep

最经典的静态方法,让当前线程暂停指定的毫秒数或时间跨度。

csharp 复制代码
// 让当前线程休眠 2 秒
Thread.Sleep(2000);
Thread.Sleep(TimeSpan.FromSeconds(2));

特点:

  • 阻塞:线程不会归还给线程池,CPU资源被占用(虽然不执行代码,但线程上下文依然存在)。
  • 精度:依赖于系统时钟的解析度(通常约为15ms),不适合高精度计时。
  • 场景 :仅适用于控制台应用、后台线程或测试代码。严禁在UI线程(WinForms/WPF)中使用,否则界面会"假死"。

1.2 SpinWaitThread.SpinWait

"自旋等待"是一种极短时间的等待,它不阻塞线程,而是让CPU执行空转循环。

csharp 复制代码
// 短时间自旋,常用于等待几微秒或几十个时钟周期
Thread.SpinWait(100);

// 或者使用 SpinWait 结构体进行更智能的自旋
var spinWait = new SpinWait();
while (!condition)
{
    spinWait.SpinOnce(); // 前几次自旋,之后可能会让出时间片
}

特点:

  • 高性能延迟:避免了线程上下文切换的开销。
  • 适用场景:等待极短的时间(预期等待时间远小于线程切换开销,例如<1微秒),或者在高并发无锁编程中等待其他线程修改变量。

1.3 Task.Wait / Task.Result

在异步编程普及初期,很多人习惯通过 .Wait().Result 将异步转为同步。

csharp 复制代码
Task<int> task = GetDataAsync();
int result = task.Result; // 阻塞线程直到任务完成
// 或者
task.Wait();

⚠️ 危险操作:

  • 死锁风险 :在同步上下文(如UI线程或ASP.NET Classic上下文)中调用 .Wait().Result,若任务内部需要回到原上下文,则会导致死锁。
  • 推荐 :使用 await 替代,除非是在控制台应用的 Main 方法中(C# 7.1+ 支持异步Main)。

2. 现代异步篇:非阻塞等待

这是现代C#开发的核心。async/await 模式允许线程在等待时不阻塞,从而提高应用程序的吞吐量和响应性。

2.1 await Task

最基础的异步等待,等待一个 Task 完成。

csharp 复制代码
// 等待一个异步方法完成
await Task.Delay(1000); // 类似 Thread.Sleep,但非阻塞

// 等待 HTTP 请求
using var client = new HttpClient();
string data = await client.GetStringAsync("https://api.example.com");

原理: 编译器将 await 后的代码包装成状态机。当等待的 Task 未完成时,当前方法返回一个未完成的 Task 给调用者,线程被释放去做其他事情。

2.2 Task.WhenAll / Task.WhenAny

当你需要同时等待多个任务时。

csharp 复制代码
// 并行等待多个任务全部完成
var tasks = new List<Task> { Task.Delay(1000), Task.Delay(2000) };
await Task.WhenAll(tasks); // 总耗时约 2 秒

// 等待任意一个任务先完成
Task first = await Task.WhenAny(tasks);
Console.WriteLine("有任务完成了");

注意: 使用 WhenAll 时,如果多个任务抛出异常,通常只会抛出第一个异常。建议使用 Task.WhenAll 结合 await 后检查 Exception 属性来获取所有异常。

2.3 await Task.DelayTask.Delay

这是 Thread.Sleep 的异步版本。

csharp 复制代码
// 非阻塞等待 5 秒
await Task.Delay(5000);

本质: 它创建一个 Task,使用定时器在指定时间后设置 Task 为完成状态,期间不占用线程。

2.4 await using 与异步释放

对于需要异步关闭的资源(如数据库连接、文件流),C# 8.0+ 引入了异步释放。

csharp 复制代码
await using var stream = new MyAsyncDisposableStream();
// 使用完自动调用 DisposeAsync

3. UI 交互篇:等待界面更新

在 WinForms、WPF 或 MAUI 中,我们经常需要等待界面渲染完成或用户输入。

3.1 Application.DoEvents (WinForms) - 慎用

古老的方法,强制处理消息队列中的消息。

csharp 复制代码
for (int i = 0; i < 100; i++)
{
    label1.Text = i.ToString();
    Application.DoEvents(); // 让UI有机会刷新
    Thread.Sleep(50);
}

⚠️ 缺点: 不可重入,可能导致意想不到的按钮点击事件触发,且难以维护。推荐使用异步方法配合数据绑定。

3.2 Task.Delay + 消息循环

现代UI编程中,利用 await 配合 Task.Delay 即可实现非阻塞的延迟刷新。

csharp 复制代码
private async void Button_Click(object sender, EventArgs e)
{
    for (int i = 0; i <= 100; i++)
    {
        progressBar.Value = i;
        await Task.Delay(50); // 释放UI线程,让界面响应,延迟结束后继续
    }
}

3.3 Dispatcher.Invoke / Dispatcher.InvokeAsync

在WPF中,若你在后台线程更新UI,必须通过 Dispatcher 切换到UI线程。

csharp 复制代码
await Dispatcher.InvokeAsync(() =>
{
    // 这段代码在 UI 线程执行
    textBlock.Text = "更新完成";
});

4. 高级篇:条件等待与并发协调

4.1 ManualResetEventSlim / AutoResetEvent

经典的内核同步对象,用于线程间的信号通知。

csharp 复制代码
var signal = new ManualResetEventSlim(false);

// 线程 A 等待信号
Task.Run(() =>
{
    Console.WriteLine("等待信号...");
    signal.Wait(); // 阻塞
    Console.WriteLine("收到信号");
});

// 线程 B 发出信号
Task.Run(() =>
{
    Thread.Sleep(2000);
    signal.Set(); // 释放等待
});

升级版: SemaphoreSlim 支持异步等待(WaitAsync)。

4.2 Monitor.Wait / Monitor.Pulse

C# lock 语句的底层实现,用于更复杂的条件变量等待。

csharp 复制代码
lock (_locker)
{
    while (!condition)
    {
        Monitor.Wait(_locker); // 释放锁并等待脉冲
    }
}

4.3 TaskCompletionSource

最强大的等待控制机制。你可以手动控制一个 Task 何时完成,非常适合将回调函数(Callback)转换为异步 Task。

csharp 复制代码
public Task<bool> WaitForUserConfirmation()
{
    var tcs = new TaskCompletionSource<bool>();
    
    // 假设有一个按钮点击事件
    button.Click += (s, e) => 
    {
        tcs.SetResult(true); // 触发等待继续
    };
    
    return tcs.Task;
}

// 使用
bool confirmed = await WaitForUserConfirmation();

5. 性能与效率篇:精细化等待

5.1 ValueTask

为了减少高频异步调用中的堆内存分配,可以使用 ValueTask

csharp 复制代码
public async ValueTask<int> GetValueAsync()
{
    // 如果结果能同步返回,ValueTask 避免了分配 Task 对象
    return await _cache.GetOrCreateAsync(...);
}

5.2 PeriodicTimer (.NET 6+)

用于循环执行异步任务的高效定时器。

csharp 复制代码
// 每隔 1 秒执行一次,且支持异步等待
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
while (await timer.WaitForNextTickAsync())
{
    Console.WriteLine($"{DateTime.Now:O}");
    // 如果这里的代码执行超过1秒,下一次触发会推迟
}

5.3 CancellationToken

等待并不是无限期的。在所有的异步等待中,都应该考虑传递 CancellationToken 以支持超时或用户取消。

csharp 复制代码
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));

try
{
    await Task.Delay(10000, cts.Token);
}
catch (OperationCanceledException)
{
    Console.WriteLine("等待被取消或超时");
}

6. 测试篇:等待断言

在单元测试或集成测试中,经常需要等待某个条件成立(如数据库数据写入、异步事件触发)。

6.1 WaitUntil (自定义轮询)

许多测试框架(如 Playwright、Selenium)提供了内置的等待,如果不具备,可以手写轮询:

csharp 复制代码
public static async Task WaitUntil(Func<bool> predicate, int timeoutMs = 5000)
{
    var stopwatch = Stopwatch.StartNew();
    while (stopwatch.ElapsedMilliseconds < timeoutMs)
    {
        if (predicate()) return;
        await Task.Delay(100);
    }
    throw new TimeoutException("等待条件未满足");
}

// 使用
await WaitUntil(() => myFlag == true);

6.2 Microsoft.Extensions.Time.Testing 时间模拟

.NET 8+ 提供了时间抽象,便于在单元测试中模拟时间流逝,而无需真正等待。

csharp 复制代码
var timeProvider = new FakeTimeProvider();
var delayTask = Task.Delay(TimeSpan.FromHours(1), timeProvider);
// 模拟时间快进
timeProvider.Advance(TimeSpan.FromHours(1));
await delayTask; // 立即完成

总结:如何选择正确的等待方式?

场景 推荐方案 禁忌
UI 响应延迟 await Task.Delay Thread.Sleep
异步任务完成 await task task.Wait() / task.Result
多个异步任务 Task.WhenAll / Task.WhenAny 循环 await
极短时间的自旋锁 SpinWait Thread.Sleep(1)
线程间信号通知 SemaphoreSlim.WaitAsync ManualResetEvent.WaitOne (阻塞)
回调转异步 TaskCompletionSource 硬编码回调地狱
定时循环任务 PeriodicTimer while + Task.Delay
单元测试时间 FakeTimeProvider 实际 Task.Delay 导致测试变慢

最后的重要提醒:

ASP.NET Core 或 客户端应用程序中,请尽可能使用 async/await 链向上传递,避免混用阻塞式等待和异步代码。遵循 "一路异步(Async All the Way)" 原则,才能让你的 C# 程序既高效又稳定。

希望这篇"大全"能帮助你彻底理清 C# 中的等待机制。如果你有更深入的问题,欢迎留言讨论!

相关推荐
@小码农6 小时前
2026年信息素养大赛【星火征途】图形化编程复赛和决赛模拟题B
开发语言·数据结构·c++·算法
JMchen1236 小时前
NDK新趋势——Rust与Android深度集成实战
android·开发语言·rust·jni·内存安全·android ndk·移动端性能
代码羊羊6 小时前
Rust 闭包全方位详解:语法、捕获规则、Fn 三特征、返回值实战
开发语言·后端·rust
Hello eveybody6 小时前
学习C++的好处
开发语言·c++
hhb_6186 小时前
Perl脚本自动化日志分析与数据批量处理实操案例
开发语言·自动化·perl
wjs20246 小时前
XPath 实例
开发语言
十五年专注C++开发6 小时前
CMake基础: Qt之qt5_wrap_ui
开发语言·c++·qt·ui
南境十里·墨染春水6 小时前
C++日志 1——日志系统的概念与分类
开发语言·c++