簡體   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