【问题标题】:Task creation overhead任务创建开销
【发布时间】:2019-01-09 10:21:35
【问题描述】:

我正在读一本书“Terrell R. - Concurrency in .NET”。

有一个很好的代码示例:

Lazy<Task<Person>> person = new Lazy<Task<Person>>(
     async () =>
     {
         using (var cmd = new SqlCommand(cmdText, conn))
         using (var reader = await cmd.ExecuteReaderAsync())
         {
             // some code...
         }
     });

async Task<Person> FetchPerson()
{
    return await person.Value;
}

作者说:

因为 lambda 表达式是异步的,所以可以在 调用 Value 的任何线程,表达式将在 上下文。

据我了解,线程来到 FetchPerson 并卡在 Lamda 执行中。这真的很糟糕吗?有什么后果?

作为解决方案,作者建议创建一个任务:

Lazy<Task<Person>> person = new Lazy<Task<Person>>(
      () => Task.Run(
        async () =>
        {
            using (var cmd = new SqlCommand(cmdText, conn))
            using (var reader = await cmd.ExecuteReaderAsync())
            {
                // some code...
            }
        }));

这真的正确吗?这是一个 IO 操作,但是我们从 Threadpool 中窃取 CPU 线程。

【问题讨论】:

  • 不知道为什么作者建议使用Task.Run,但是在使用 async/await 时我们从不窃取 CPU 线程。当我们调用Task.Run 时,确定它可以在线程池上运行,但只有在第一次异步之前的方法的第一部分。一旦它达到异步,它就会返回到线程池。此外,一旦等待完成,它就会返回到原始线程上下文,所以从FetchPerson 的角度来看,代码在哪个线程上执行并不重要。

标签: c# asynchronous task lazy-evaluation


【解决方案1】:

由于 lambda 表达式是异步的,它可以在任何调用 Value 的线程上执行,并且表达式会在上下文中运行。

lambda 可以从任何线程运行(除非您注意允许哪些类型的线程访问 Lazy 的值),因此它将在该线程的上下文。这不是因为它是异步的,即使它是同步的,它也会在碰巧调用它的任何线程的上下文中运行。

据我了解,线程来到 FetchPerson 并卡在 Lamda 执行中。

lambda 是异步的,因此它(如果实施得当)几乎会立即返回。这就是异步的意思,因此它不会阻塞调用线程。

真的很糟糕吗?有什么后果?

如果您错误地实现了异步方法,并让它长时间运行同步工作,那么是的,您正在阻塞该线程/上下文。如果你不这样做,你就不是。

此外,默认情况下,异步方法中的所有延续都将在原始上下文中运行(如果它有 SynchonrizationContext 的话)。在您的情况下,您的代码几乎肯定不依赖于重用该上下文(因为您不知道调用者可能具有哪些上下文,我无法想象您编写了其余代码来使用它)。鉴于此,您可以在 await 的任何内容上调用 .ConfigureAwait(false),这样您就不会将当前上下文用于这些延续。这只是一个很小的性能改进,以免浪费时间在原始上下文上安排工作,等待其他任何需要它的东西,或者在不必要的时候让其他任何东西等待这个代码。

作为解决方案,作者建议创建一个任务:[...] 真的正确吗?

它不会破坏任何东西。它将调度工作在线程池线程中运行,而不是在原始上下文中运行。这将有一些额外的开销开始。只需将ConfigureAwait(false) 添加到await 的所有内容中,您就可以以较低的开销完成大致相同的事情。

这是一个 IO 操作,但是我们从 Threadpool 中窃取 CPU 线程。

那个 sn-p 将开始线程池线程上的 IO 操作。因为该方法仍然是异步的,所以一旦启动它就会将它返回到池中,并从池中获取一个新线程以在每次等待后再次开始运行。后者可能适用于这种情况,但是将启动初始异步操作的代码移动到线程池线程只会增加开销而没有实际价值(因为它是一个如此短的操作,您将花费更多的精力在线程上调度它池线程而不是仅仅运行它)。

【讨论】:

    【解决方案2】:

    确实,第一个访问Value 的线程将执行 lambda。 Lazy 完全不知道异步和任务。它只会运行那个委托。

    此示例中的委托将在调用线程上运行,直到遇到await。然后它会返回一个Task,即Task进入lazy,此时lazy就完全搞定了。

    该任务的其余部分将像任何其他任务一样运行。它将尊重await 发生时设置的SynchronizationContextTaskScheduler(这是await 行为的一部分)。这确实会导致代码在 UI 线程等意外上下文中运行。

    Task.Run 是一种避免这种情况的方法。它将代码移动到线程池,为其提供特定的上下文。开销在于将该工作排队到池中。池任务将在第一个await结束。所以这是 not 异步同步。没有引入阻塞。唯一的变化是基于 CPU 的线程工作发生了什么(现在确定性地在线程池上)。

    这样做很好。它是解决实际问题的简单、可维护、低风险的解决方案。关于是否值得这样做,有不同的意见。开销很可能无关紧要。我个人非常同情这种代码。

    如果您确定Value 的所有调用者都在合适的上下文中运行,那么您不需要这个。但如果你犯了一个错误,那就是一个严重的错误。所以你可以争辩说最好是防御性地插入Task.Run。务实,做有效的事。

    还要注意,Task.Run 是异步感知的(可以这么说)。它返回的任务本质上将解开内部任务(与Task.Factory.StartNew 不同)。所以像这里一样嵌套任务是安全的。

    【讨论】:

      【解决方案3】:

      我完全不明白为什么 Terrell R. 建议使用 Task.Run。它没有任何附加价值。在这两种情况下,lambda 都会被安排到线程池中。由于包含 IO 操作,线程池中的工作线程会在 IO 调用后被释放;当 IO 调用完成时,下一条语句将在线程池中的任意线程上继续。

      好像作者这样写:

      表达式将在上下文中运行

      是的,IO 调用的执行将在调用者的上下文中开始,但将在任意上下文中结束,除非您调用 .ConfigureAwait

      【讨论】:

      • 如果你不调用Task.Run,为什么 lambda 会“被安排到线程池”?这里没有涉及额外的线程(不使用Task.Run)。
      • lambda 将从当前线程开始,但直到第一个 await。之后由调度程序决定。
      • 是的。因此假设当前线程有SynchronizationContext,则不涉及线程池。
      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 2011-01-08
      • 2011-05-20
      • 2023-03-21
      • 2011-12-26
      • 1970-01-01
      相关资源
      最近更新 更多