← 全部文章 C# 语言

C# 多线程(四):异步调用容易踩的坑

2023 年 11 月 27 日 · 1 分钟读完

目录
  1. 坑一:for 循环里异步调用,参数不对
  2. 延伸:另外几个常见的坑
  3. 坑二:界面线程上 .Result 或 .Wait() 死锁
  4. 坑三:async void 吞掉异常、无法等待
  5. 坑四:忘了 await
  6. 坑五:async 方法里用 Thread.Sleep
  7. 坑六:多个任务同时改一个集合

坑一:for 循环里异步调用,参数不对

项目里在 for 循环中用 Task.Run 调用带参数的函数,结果参数全乱了:

static void Main(string[] args)
{
    for (int i = 0; i < 100; i++)
    {
        Task.Run(() => WriteSth(i));
    }
    Console.ReadKey();
}

static void WriteSth(int index) => Console.WriteLine(index);

本来想打印 0 ~ 99,结果打出来一大堆重复的数,还有 100。

原因:lambda 捕获的是变量 i 本身,不是它当时的值。Task.Run 只是把任务排进线程池,真正执行要等一会儿,而 for 循环不等,i 飞快地往上加。等任务真正运行、去读 i 的时候,它早就变了,循环结束时它是 100。

解决:在循环体里拷贝一份,每次循环都是一个新变量:

for (int i = 0; i < 100; i++)
{
    int index = i;
    Task.Run(() => WriteSth(index));
}

0 ~ 99 都打出来了,但顺序是乱的:Task.Run 不保证执行顺序。需要顺序就别并行,或者把结果按索引放进数组,最后再统一处理。

foreach 从 C# 5 起每次循环都是新变量,没有这个问题;for 循环至今仍然共用一个变量。

延伸:另外几个常见的坑

坑二:界面线程上 .Result 或 .Wait() 死锁

private void button1_Click(object sender, EventArgs e)
{
    string s = GetDataAsync().Result; // 界面卡死,永远不返回
}

private async Task<string> GetDataAsync()
{
    await Task.Delay(1000);
    return "ok";
}

await 完成后,默认要回到原来的线程(界面线程)继续执行;而界面线程正阻塞在 .Result 上等它,双方互相等待。解决:从头到尾都用 async,把按钮事件改成 async void,写 string s = await GetDataAsync();。写类库时可以在 await 后面加 .ConfigureAwait(false),不回原线程。

坑三:async void 吞掉异常、无法等待

除了事件处理器,不要写 async void。调用方拿不到 Task,既不能 await 它,也捕获不到里面的异常,未处理的异常会直接让进程崩溃。

坑四:忘了 await

SaveAsync();          // 少了 await:没等保存完就继续往下走,异常也丢了
await SaveAsync();    // 正确

编译器会给出 CS4014 警告,建议在项目里把它设为错误。

坑五:async 方法里用 Thread.Sleep

Thread.Sleep 会阻塞当前线程,在 async 方法里应该用 await Task.Delay(...),等待期间线程可以去干别的。

坑六:多个任务同时改一个集合

List<T> 不是线程安全的,多个 Task 同时 Add 可能丢数据甚至抛异常。用 ConcurrentBag<T>、ConcurrentQueue<T>,或者加 lock。

# C# # 多线程 # async

原文首发于 blog.csdn.net