繁体   English   中英

在 Unity/C# 中,.Net 的 async/await 是否真的启动了另一个线程?

[英]In Unity / C#, does .Net's async/await start, literally, another thread?

对于在 Unity 中专门研究这个困难主题的人来说很重要

一定要看到我问的另一个问题,其中提出了相关的关键问题:

特别是在 Unity 中,await 真正返回到“哪里”?


对于 C# 专家来说,Unity 是单线程的1

在另一个线程上进行计算等是很常见的。

当你在另一个线程上做某事时,你经常使用 async/wait 因为,呃,所有优秀的 C# 程序员都说这是做到这一点的简单方法!

void TankExplodes() {

    ShowExplosion(); .. ordinary Unity thread
    SoundEffects(); .. ordinary Unity thread
    SendExplosionInfo(); .. it goes to another thread. let's use 'async/wait'
}

using System.Net.WebSockets;
async void SendExplosionInfo() {

    cws = new ClientWebSocket();
    try {
        await cws.ConnectAsync(u, CancellationToken.None);
        ...
        Scene.NewsFromServer("done!"); // class function to go back to main tread
    }
    catch (Exception e) { ... }
}

好的,所以当你这样做时,当你在 Unity/C#中以更传统的方式启动一个线程时,你会“像往常一样”做所有事情(所以使用 Thread 或其他任何东西或让本机插件或操作系统或任何东西来做它)情况可能是)。

一切都很好。

作为一个跛脚的 Unity 程序员,他只知道足够的 C# 来完成一天的工作,我一直认为上面的 async/await 模式实际上会启动另一个线程

事实上,当您使用整洁的异步/等待模式时,上面的代码是否真的启动了另一个线程,或者 c#/.Net 是否使用其他一些方法来实现任务?

也许它在 Unity 引擎中的工作方式与“一般使用 C#”不同或特别适用? (IDK?)

请注意,在 Unity 中,它是否是一个线程会极大地影响您必须如何处理后续步骤。 因此这个问题。


问题:我意识到有很多关于“正在等待线程”的讨论,但是,(1) 我从来没有在 Unity 设置中看到过这个讨论/回答(这有什么不同吗?IDK?)(2)我从来没有看到一个明确的答案!


1一些辅助计算(例如物理等)是在其他线程上完成的,但实际的“基于帧的游戏引擎”是一个纯线程。 (以任何方式“访问”主引擎框架线程都是不可能的:在编程时,例如,在另一个线程上编写本机插件或某些计算时,您只需在引擎框架线程上为组件留下标记和值即可查看和在运行每一帧时使用。)

这篇阅读: 任务(仍然)不是线程并且异步不是并行可能会帮助您了解幕后发生的事情。 简而言之,为了让您的任务在单独的线程上运行,您需要调用

Task.Run(()=>{// the work to be done on a separate thread. }); 

然后你可以在任何需要的地方等待该任务。

回答你的问题

“事实上,当您使用整洁的异步/等待模式时,上面的代码是否真的启动了另一个线程,或者 c#/.Net 是否使用其他一些方法来实现任务?”

不 - 它没有。

如果你做了

await Task.Run(()=> cws.ConnectAsync(u, CancellationToken.None));

然后cws.ConnectAsync(u, CancellationToken.None)将在单独的线程上运行。

作为对这里评论的回答,修改了更多解释的代码:

    async void SendExplosionInfo() {

        cws = new ClientWebSocket();
        try {
            var myConnectTask = Task.Run(()=>cws.ConnectAsync(u, CancellationToken.None));

            // more code running...
await myConnectTask; // here's where it will actually stop to wait for the completion of your task. 
            Scene.NewsFromServer("done!"); // class function to go back to main tread
        }
        catch (Exception e) { ... }
    }

您可能不需要在单独的线程上使用它,因为您正在执行的异步工作不受 CPU 限制(或者看起来如此)。 因此你应该没问题

 try {
            var myConnectTask =cws.ConnectAsync(u, CancellationToken.None);

            // more code running...
await myConnectTask; // here's where it will actually stop to wait for the completion of your task. 
            Scene.NewsFromServer("done!"); // continue from here
        }
        catch (Exception e) { ... }
    }

按顺序它会做与上面代码完全相同的事情,但在同一个线程上。 它将允许“ ConnectAsync ”之后的代码执行,并且只会停止等待“ ConnectAsync ”的完成,它说await并且因为“ ConnectAsync ”不是CPU绑定你(从某种意义上说,它有点并行在其他地方完成,即网络)将有足够的果汁来运行您的任务,除非您在“....”中的代码也需要大量 CPU 绑定工作,您宁愿并行运行。

此外,您可能希望避免使用async void,因为它仅用于顶级函数。 尝试在您的方法签名中使用async Task 您可以在此处阅读更多相关信息。

不,异步/等待并不意味着 - 另一个线程。 它可以启动另一个线程,但不是必须的。

在这里你可以找到关于它的非常有趣的帖子: https : //blogs.msdn.microsoft.com/benwilli/2015/09/10/tasks-are-still-not-threads-and-async-is-not-parallel/

重要通知

首先,你的问题的第一个陈述有问题。

Unity 是单线程的

Unity不是单线程的; 实际上,Unity 是一个多线程环境。 为什么? 只需转到官方 Unity 网页并在那里阅读:

高性能多线程系统:充分利用当今(和未来)可用的多核处理器,无需大量编程。 我们实现高性能的新基础由三个子系统组成:C# 作业系统,它为您提供了一个安全且简单的沙箱来编写并行代码; 实体组件系统 (ECS),默认情况下编写高性能代码的模型,以及生成高度优化的本机代码的 Burst 编译器。

Unity 3D 引擎使用称为“Mono”的 .NET 运行时,它本质上多线程的。 对于某些平台,托管代码将转换为本机代码,因此不会有 .NET Runtime。 但无论如何,代码本身将是多线程的。

因此,请不要陈述误导性和技术上不正确的事实。

您所争论的只是一个声明,即Unity有一个主线程,它以基于帧的方式处理核心工作负载 这是真实的。 但它不是新的和独特的东西! 例如,在 .NET Framework(或从 3.0 开始的 .NET Core)上运行的 WPF 应用程序也有一个主线程(通常称为UI 线程),并且工作负载在该线程上使用 WPF Dispatcher程序以基于帧的方式进行处理(调度器队列、操作、帧等)但是所有这些都不会使环境成为单线程的! 这只是处理应用程序逻辑的一种方式。


回答你的问题

请注意:我的回答仅适用于运行 .NET 运行时环境 (Mono) 的此类 Unity 实例。 对于将托管 C# 代码转换为本机 C++ 代码并构建/运行本机二进制文件的那些实例,我的回答很可能至少是不准确的。

你写:

当你在另一个线程上做某事时,你经常使用 async/wait 因为,呃,所有优秀的 C# 程序员都说这是做到这一点的简单方法!

C# 中的asyncawait关键字只是使用 TAP( 任务异步模式)的一种方式。

TAP 用于任意异步操作。 一般来说,没有线程 我强烈建议阅读Stephen Cleary 的这篇名为“没有线程”的文章 (如果您不知道,Stephen Cleary 是著名的异步编程大师。)

使用async/await功能的主要原因是异步操作。 您使用async/await不是因为“您在另一个线程上做某事”,而是因为您有一个必须等​​待的异步操作。 无论是否有后台线程,此操作将运行与否 - 这对您来说无关紧要(好吧,几乎;见下文)。 TAP 是一个隐藏这些细节的抽象层。

事实上,当您使用整洁的异步/等待模式时,上面的代码是否真的启动了另一个线程,或者 c#/.Net 是否使用其他一些方法来实现任务?

正确答案是:视情况而定

  • 如果ClientWebSocket.ConnectAsync抛出参数验证异常(例如,当uri为 null 时抛出ArgumentNullException ),则不会启动新线程
  • 如果该方法中的代码完成得非常快,则该方法的结果将同步可用,不会启动新线程
  • 如果ClientWebSocket.ConnectAsync方法的实现是不涉及线程的纯异步操作,则您的调用方法将被“挂起”(由于await ) - 因此不会启动新线程
  • 如果方法实现涉及线程,并且当前TaskScheduler能够在正在运行的线程池线程上调度此工作项,则不会启动新线程 相反,工作项将在已经运行的线程池线程上排队
  • 如果所有线程池线程都已忙,则运行时可能会根据其配置和当前系统状态生成新线程,所以是的 -可能会启动一个新线程,并且工作项将在该新线程上排队

你看,这非常复杂。 但这正是将 TAP 模式和async/await关键字对引入 C# 的原因。 这些通常是开发人员不想打扰的事情,所以让我们将这些东西隐藏在运行时/框架中。

@agfc 陈述了一个不太正确的事情:

“这不会在后台线程上运行该方法”

await cws.ConnectAsync(u, CancellationToken.None);

“但这会”

await Task.Run(()=> cws.ConnectAsync(u, CancellationToken.None));

如果ConnectAsync的同步部分实现很小,则任务调度程序可能会在两种情况下同步运行该部分。 因此,这两个片段可能完全相同,具体取决于调用的方法实现。

请注意, ConnectAsync有一个Async后缀并返回一个Task 这是一个基于约定的信息,表明该方法是真正异步的。 在这种情况下,您应该始终更喜欢await MethodAsync()不是await Task.Run(() => MethodAsync())

进一步有趣的阅读:

我不喜欢回答我自己的问题,但事实证明这里没有一个答案是完全正确的。 (然而,这里的许多/所有答案在不同方面都非常有用)。

事实上,实际的答案可以简而言之:

在等待之后恢复执行的线程由SynchronizationContext.Current控制。

而已。

因此,在任何特定版本的 Unity(并注意,截至 2019 年,他们正在彻底改变 Unity - https://unity.com/dots ) - 或者实际上任何 C#/.Net 环境 - 此页面上的问题可以正确回答。

本次后续 QA 中出现了完整信息:

https://stackoverflow.com/a/55614146/294884

等待之后的代码将在另一个线程池线程上继续。 在处理方法中的非线程安全引用时,这可能会产生后果,例如 Unity、EF 的 DbContext 和许多其他类,包括您自己的自定义代码。

以下面的例子为例:

    [Test]
    public async Task TestAsync()
    {
        using (var context = new TestDbContext())
        {
            Console.WriteLine("Thread Before Async: " + Thread.CurrentThread.ManagedThreadId.ToString());
            var names = context.Customers.Select(x => x.Name).ToListAsync();
            Console.WriteLine("Thread Before Await: " + Thread.CurrentThread.ManagedThreadId.ToString());
            var result = await names;
            Console.WriteLine("Thread After Await: " + Thread.CurrentThread.ManagedThreadId.ToString());
        }
    }

输出:

------ Test started: Assembly: EFTest.dll ------

Thread Before Async: 29
Thread Before Await: 29
Thread After Await: 12

1 passed, 0 failed, 0 skipped, took 3.45 seconds (NUnit 3.10.1).

请注意, ToListAsync之前和之后的代码在同一线程上运行。 所以在等待任何结果之前,我们可以继续处理,尽管异步操作的结果将不可用,只有创建的Task可用。 (可以中止、等待等)一旦我们放入了一个await ,接下来的代码将被有效地拆分为一个延续,并且将/可以在不同的线程上返回。

这适用于在线等待异步操作时:

    [Test]
    public async Task TestAsync2()
    {
        using (var context = new TestDbContext())
        {
            Console.WriteLine("Thread Before Async/Await: " + Thread.CurrentThread.ManagedThreadId.ToString());
            var names = await context.Customers.Select(x => x.Name).ToListAsync();
            Console.WriteLine("Thread After Async/Await: " + Thread.CurrentThread.ManagedThreadId.ToString());
        }
    }

输出:

------ Test started: Assembly: EFTest.dll ------

Thread Before Async/Await: 6
Thread After Async/Await: 33

1 passed, 0 failed, 0 skipped, took 4.38 seconds (NUnit 3.10.1).

同样,await 之后的代码是在原来的另一个线程上执行的。

如果要确保调用异步代码的代码保留在同一线程上,则需要使用Task上的Result来阻止线程,直到异步任务完成:

    [Test]
    public void TestAsync3()
    {
        using (var context = new TestDbContext())
        {
            Console.WriteLine("Thread Before Async: " + Thread.CurrentThread.ManagedThreadId.ToString());
            var names = context.Customers.Select(x => x.Name).ToListAsync();
            Console.WriteLine("Thread After Async: " + Thread.CurrentThread.ManagedThreadId.ToString());
            var result = names.Result;
            Console.WriteLine("Thread After Result: " + Thread.CurrentThread.ManagedThreadId.ToString());
        }
    }

输出:

------ Test started: Assembly: EFTest.dll ------

Thread Before Async: 20
Thread After Async: 20
Thread After Result: 20

1 passed, 0 failed, 0 skipped, took 4.16 seconds (NUnit 3.10.1).

因此,就 Unity、EF 等而言,在这些类不是线程安全的情况下,您应该谨慎使用异步。 例如,以下代码可能会导致意外行为:

        using (var context = new TestDbContext())
        {
            var ids = await context.Customers.Select(x => x.CustomerId).ToListAsync();
            foreach (var id in ids)
            {
                var orders = await context.Orders.Where(x => x.CustomerId == id).ToListAsync();
                // do stuff with orders.
            }
        }

就代码而言,这看起来不错,但 DbContext不是线程安全的,当根据初始客户负载的等待查询订单时,单个 DbContext 引用将在不同的线程上运行。

当异步调用比同步调用有显着优势时使用异步,并且您确定延续将仅访问线程安全代码。

我很惊讶在这个线程中没有提到 ConfigureAwait。 我一直在寻找一个确认,即 Unity 以与“常规”C# 相同的方式执行 async/await,从我上面看到的情况来看,情况似乎确实如此。

事情是,默认情况下,等待的任务将在完成后在相同的线程上下文中恢复。 如果您在主线程上等待,它将在主线程上恢复。 如果您等待来自 ThreadPool 的线程,它将使用线程池中的任何可用线程。 您始终可以为不同的目的创建不同的上下文,例如数据库访问上下文等等。

这就是 ConfigureAwait 有趣的地方。 如果在 await 之后链接对ConfigureAwait(false)的调用,则是在告诉运行时您不需要在同一上下文中恢复,因此它将在来自 ThreadPool 的线程上恢复。 省略对 ConfigureAwait 的调用可以安全地播放,并将在相同的上下文(主线程、DB 线程、ThreadPool 上下文,无论调用者所在的上下文)中恢复。

因此,从主线程开始,您可以像这样在主线程中等待和恢复:

    // Main thread
    await WhateverTaskAync();
    // Main thread

或者像这样转到线程池:

    // Main thread
    await WhateverTaskAsync().ConfigureAwait(false);
    // Pool thread

同样,从池中的一个线程开始:

    // Pool thread
    await WhateverTaskAsync();
    // Pool thread

相当于:

    // Pool thread
    await WhateverTaskAsync().ConfigureAwait(false);
    // Pool thread

要返回主线程,您将使用传输到主线程的 API:

    // Pool thread
    await WhateverTaskAsync().ConfigureAwait(false);
    // Pool thread
    RunOnMainThread(()
    {
        // Main thread
        NextStep()
    });
    // Pool thread, possibly before NextStep() is run unless RunOnMainThread is synchronous (which it normally isn't)

这就是为什么人们说调用 Task.Run 在池线程上运行代码。 虽然等待是多余的......

   // Main Thread
   await Task.Run(()
   {
       // Pool thread
       WhateverTaskAsync()
       // Pool thread before WhateverTaskAsync completes because it is not awaited
    });
    // Main Thread before WhateverTaskAsync completes because it is not awaited

现在,调用 ConfigureAwait(false) 并不能保证在单独的线程中调用 Async 方法中的代码。 它仅说明从等待返回时,您不能保证处于同一线程上下文中。

如果您的 Async 方法如下所示:

private async Task WhateverTaskAsync()
{
    int blahblah = 0;

    for(int i = 0; i < 100000000; ++i)
    {
        blahblah += i;
    }
}

...因为实际上 Async 方法中没有 await,您将收到编译警告,并且它将全部在调用上下文中运行。 根据其 ConfigureAwait 状态,它可能会在相同或不同的上下文中恢复。 如果您希望该方法在池线程上运行,您可以这样编写 Async 方法:

private Task WhateverTaskAsync()
{
    return Task.Run(()
    {
        int blahblah = 0;

        for(int i = 0; i < 100000000; ++i)
        {
            blahblah += i;
        }
    }
}

希望这可以为其他人清除一些事情。

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM