不创建新线程的 C# 异步

Posted

技术标签:

【中文标题】不创建新线程的 C# 异步【英文标题】:C# Async without creating a new thread 【发布时间】:2020-01-28 01:20:15 【问题描述】:

我看到很多帖子解释 C# 中的 async/await 不要创建像这样的新线程:tasks are still not threads and async is not parallel。我想自己测试一下,所以我写了这段代码:

private static async Task Run(int id)

    Console.WriteLine("Start:\t" + id + "\t" + System.Threading.Thread.CurrentThread.ManagedThreadId);

    System.Threading.Thread.Sleep(500);

    Console.WriteLine("Delay:\t" + id + "\t" + System.Threading.Thread.CurrentThread.ManagedThreadId);

    await Task.Delay(100);

    Console.WriteLine("Resume:\t" + id + "\t" + System.Threading.Thread.CurrentThread.ManagedThreadId);

    System.Threading.Thread.Sleep(500);

    Console.WriteLine("Exit:\t" + id + "\t" + System.Threading.Thread.CurrentThread.ManagedThreadId);


private static async Task Main(string[] args)

    Console.WriteLine("Action\tid\tthread");

    var task1 = Run(1);
    var task2 = Run(2);

    await Task.WhenAll(task1, task2);

令人惊讶的是,我最终得到了如下所示的输出:

Action  id      thread
Start:  1       1
Delay:  1       1
Start:  2       1
Resume: 1       4  < ------ problem here
Delay:  2       1
Exit:   1       4
Resume: 2       5
Exit:   2       5

在我看来,确实是在创建新线程,甚至允许两段代码同时运行?我需要在非线程安全的环境中使用 async/await,所以我不能让它创建新线程。为什么任务“2”当前正在运行时允许任务“1”恢复(Task.Delay 之后)?

我尝试将ConfigureAwait(true) 添加到所有await 中,但没有任何改变。

谢谢!

【问题讨论】:

您是说您有一段特定的代码需要一次访问一个线程吗?因为同步锁定可以让你做到这一点。 我需要这种更通用的方式。我希望能够对将在单个线程上异步运行的任务进行排队,并且当一个任务“等待”时,其他任务可以在该线程上运行 从 async/await 中使用不会导致创建新线程,CLR 从线程池线程中使用此目的。 谢谢@RahmatAnjirabi!我假设这是等待/没有正确理解。一如既往地感谢支持。虽然不知道为什么我被否决了...... 【参考方案1】:

关于第一个问题 - async/await 不会自己创建线程,这与您期望的这种说法大不相同 - 当代码使用 async/await 时从未创建线程。

在您的特定情况下,调度延迟返回的计时器代码正在选择一个线程池线程来运行其余代码。请注意,如果您将使用具有非空同步上下文的环境(如链接文章中的 WPF),那么延迟后继续执行的代码将返回到原始线程并等待另一个任务/主代码此时正在运行。

第二点——如何确保非线程安全对象不被其他线程访问:你真的必须知道什么操作可以触发代码在其他线程上运行。 IE。例如,System.Timers.Timer 会这样做,即使它看起来不像创建线程。在保证执行将在相同 (WinForms/WPF) 或至少一次仅一个 (ASP.Net) 线程上继续执行的环境中使用 async/await 可以让您大部分时间到达那里。

如果您想要更强的保证,您可以遵循 WinForms/WPF 方法,该方法强制对方法的访问位于同一个线程(称为主 UI 线程)上,并提供使用 async/await 和同步上下文的指导,以确保继续执行与await-ed 相同的线程(并且还直接提供Invoke 功能供其他人自己管理线程)。这可能需要使用一些“线程强制”代理来包装您不拥有的类。

【讨论】:

【参考方案2】:

查看Alexei Levenkov 响应并阅读this 后,如果我没有在win32 console app 中运行测试,它看起来应该可以正常工作。如果我将此代码放入WinForm app 并在按钮单击事件中调用它,它会按预期工作,不会创建/使用额外的线程。 我还发现这个库解决了控制台应用程序的问题:Nito.AsyncEx。使用 Nito.AsyncEx.AsyncContext.Run 创建了我正在寻找的行为并给出了这些结果:

Action  id      thread
Start:  1       1
Delay:  1       1
Start:  2       1
Delay:  2       1
Resume: 1       1
Exit:   1       1
Resume: 2       1
Exit:   2       1

这样,总时间比依次运行任务 1 和 2 的时间要少,但仍然不需要我成为线程安全的。

【讨论】:

【参考方案3】:

Async-await 使用 线程,如果碰巧所有线程池线程当时都忙,则创建新线程。所以说 async-await 不创建线程并不完全准确。不同之处在于通常如何使用这些线程。在经典的多线程场景中,您创建新线程的目的是让它们保持活动一段时间。您不会启动一个新线程来执行 1 毫秒的工作负载。您通常会运行 CPU 密集型计算,或者从磁盘读取一些大文件,或者调用一些可能需要一段时间才能响应的 Web 方法。在其中一些示例中,线程实际上并没有做太多工作,它们只是等待大部分时间从磁盘或网络驱动程序获取一些数据。在等待期间,它们会消耗相当大的内存块,大约在每个线程 1 MB 左右,只是为了它们自己的存在。

进入 async-await 的世界,其中很少有线程可以通过空闲来浪费资源。这些是共享线程池的线程,准备好响应来自驱动程序、计算机时钟或其他地方的事件。通常,线程池线程的每个单独的工作负载都相当小。它可以是一毫秒甚至更少。 .NET 基础架构在处理大量计划任务方面的能力令人印象深刻:

var stopwatch = Stopwatch.StartNew();
var tasks = new List<Task>();
int sum = 0;
foreach (var i in Enumerable.Range(0, 100_000))

    tasks.Add(Task.Run(async () =>
    
        await Task.Delay(i % 1000);
        Interlocked.Increment(ref sum);
    ));

Console.WriteLine($"Waiting, Elapsed: stopwatch.ElapsedMilliseconds msec");
await Task.WhenAll(tasks);
Console.WriteLine($"Sum: sum, Elapsed: stopwatch.ElapsedMilliseconds msec");

等待,经过:296 毫秒 总和:100000,经过:1898 毫秒

这适用于 .NET Framework 4.8。 .NET Core 3.0 表现更好。

您处理任务和 async-await 的次数越多,您就越信任基础架构并为其提供越来越细粒度的工作。例如,从文本文件中读取行。仅仅为了从文件中读取一行而启动线程会很疯狂,但是对于任务和异步等待,它一点也不疯狂:StreamReader.ReadLineAsync

【讨论】:

【参考方案4】:

您正在尝试使用异步,但最终您希望像同步任务一样工作,即使您使用睡眠或延迟,您也不确定任务是否会在那时完成。

如果您不想使用异步提供的好东西,请使用标准方式或:将所有任务放在 run 方法上或(创建一个方法并从 run 调用它多次);使用 1 个线程,这样它就不会锁定您的计算机,并且所有内容都将在 1 个线程中执行。

【讨论】:

以上是关于不创建新线程的 C# 异步的主要内容,如果未能解决你的问题,请参考以下文章

C# 异步操作 async await 的用法

C#多线程之线程池篇2

C# 异步方法加await调用不就变成同步方法了吗

C#异步调用的好处和方法分享

C# 多线程初级汇总

C# 使用成员函数的委托来创建新线程