簡體   English   中英

如果 async-await 不創建任何額外的線程,那么它如何使應用程序響應?

[英]If async-await doesn't create any additional threads, then how does it make applications responsive?

一次又一次,我看到它說使用async - await不會創建任何額外的線程。 這是沒有意義的,因為計算機似乎一次做不止一件事情的唯一方式是

  • 實際上一次做不止一件事情(並行執行,利用多個處理器)
  • 通過調度任務並在它們之間切換來模擬它(做一點A,一點B,一點A等等)

因此,如果async - await兩者都沒有,那么它如何使應用程序響應? 如果只有 1 個線程,那么調用任何方法都意味着在執行任何其他操作之前等待該方法完成,並且該方法中的方法必須等待結果才能繼續,依此類推。

實際上,async/await 並不是那么神奇。 完整的主題非常廣泛,但對於您的問題的快速而完整的答案,我認為我們可以做到。

讓我們在 Windows 窗體應用程序中處理一個簡單的按鈕單擊事件:

public async void button1_Click(object sender, EventArgs e)
{
    Console.WriteLine("before awaiting");
    await GetSomethingAsync();
    Console.WriteLine("after awaiting");
}

我將明確談論GetSomethingAsync現在返回的任何內容。 假設這將在 2 秒后完成。

在傳統的非異步世界中,您的按鈕單擊事件處理程序將如下所示:

public void button1_Click(object sender, EventArgs e)
{
    Console.WriteLine("before waiting");
    DoSomethingThatTakes2Seconds();
    Console.WriteLine("after waiting");
}

當您單擊表單中的按鈕時,應用程序將出現大約 2 秒鍾的凍結時間,同時我們等待此方法完成。 發生的情況是“消息泵”,基本上是一個循環,被阻塞了。

這個循環不斷地詢問窗口“有沒有人做過什么,比如移動鼠標,點擊了什么?我需要重新繪制什么嗎?如果是這樣,告訴我!” 然后處理那個“東西”。 這個循環收到一條消息,表示用戶單擊了“button1”(或來自 Windows 的等效類型的消息),並最終調用了上面的button1_Click方法。 在此方法返回之前,此循環現在一直在等待。 這需要 2 秒,在此期間,不會處理任何消息。

大多數處理窗口的事情都是使用消息完成的,這意味着如果消息循環停止發送消息,即使只有一秒鍾,用戶也會很快注意到它。 例如,如果您將記事本或任何其他程序移到您自己的程序之上,然后又移開,則會向您的程序發送一連串的繪畫消息,指示現在突然再次可見的窗口區域。 如果處理這些消息的消息循環正在等待某事,被阻塞,則沒有繪制完成。

那么,如果在第一個例子中, async/await沒有創建新線程,它是如何做到的?

那么,發生的情況是你的方法被分成了兩個。 這是那些廣泛的主題類型之一,所以我不會詳細介紹,但足以說明該方法分為以下兩件事:

  1. 導致await所有代碼,包括對GetSomethingAsync的調用
  2. 以下所有代碼await

插圖:

code... code... code... await X(); ... code... code... code...

重新排列:

code... code... code... var x = X(); await X; code... code... code...
^                                  ^          ^                     ^
+---- portion 1 -------------------+          +---- portion 2 ------+

該方法基本上是這樣執行的:

  1. 它執行一切await
  2. 它調用GetSomethingAsync方法,該方法完成它的工作,並返回將在未來 2 秒內完成的內容

    到目前為止,我們仍然在對 button1_Click 的原始調用中,發生在主線程上,從消息循環調用。 如果await的代碼需要很多時間,UI 仍然會凍結。 在我們的例子中,沒有那么多

  3. await關鍵字與一些巧妙的編譯器魔法一起所做的是,它基本上類似於“好吧,你知道嗎,我將在這里簡單地從按鈕單擊事件處理程序返回。當你(例如,我們'正在等待)開始完成,讓我知道,因為我還有一些代碼要執行”。

    實際上,它會讓SynchronizationContext 類知道它已完成,這取決於當前正在運行的實際同步上下文,它將排隊等待執行。 Windows 窗體程序中使用的上下文類將使用消息循環正在抽取的隊列對其進行排隊。

  4. 因此它返回到消息循環,現在可以自由地繼續發送消息,例如移動窗口、調整窗口大小或單擊其他按鈕。

    對於用戶,UI 現在再次響應,處理其他按鈕點擊,調整大小,最重要的是重繪,因此它不會出現凍結。

  5. 2 秒后,我們等待的事情完成了,現在發生的是它(嗯,同步上下文)將一條消息放入消息循環正在查看的隊列中,說“嘿,我得到了更多的代碼你去執行”,這段代碼就是await之后的所有代碼。
  6. 當消息循環到達該消息時,它基本上會在它停止的地方“重新進入”該方法,就在await之后並繼續執行該方法的其余部分。 請注意,此代碼再次從消息循環中調用,因此如果此代碼碰巧在沒有正確使用async/await情況下做了一些冗長的事情,它將再次阻塞消息循環

有許多移動部件在引擎蓋下這里,所以這里有一些詳細信息的鏈接,我會說:“如果你需要它”,但這個話題相當廣泛,而且知道其中的一些運動部件是相當重要的。 你總會明白 async/await 仍然是一個有漏洞的概念。 一些潛在的限制和問題仍然會泄漏到周圍的代碼中,如果沒有泄漏,您通常最終不得不調試一個看似沒有充分理由的隨機中斷的應用程序。


好的,那么如果GetSomethingAsync啟動一個將在 2 秒內完成的線程呢? 是的,那么顯然有一個新線程在起作用。 然而,這個線程並不是因為這個方法的異步性,而是因為這個方法的程序員選擇了一個線程來實現異步代碼。 幾乎所有異步 I/O都不使用線程,它們使用不同的東西。 async/await本身不會啟動新線程,但顯然“我們等待的事情”可以使用線程來實現。

.NET 中有很多東西不一定自己啟動線程,但仍然是異步的:

  • Web 請求(以及許多其他需要時間的網絡相關事物)
  • 異步文件讀寫
  • 還有更多,一個好兆頭是所討論的類/接口是否具有名為SomethingSomethingAsyncBeginSomethingEndSomethingIAsyncResult涉及IAsyncResult

通常這些東西不使用引擎蓋下的線程。


好的,所以你想要一些“廣泛主題的東西”?

好吧,讓我們向Try Roslyn詢問我們的按鈕點擊:

試試羅斯林

我不會在這里鏈接完整生成的類,但它是非常血腥的東西。

我在我的博客文章There Is No Thread 中對其進行了完整解釋。

總之,現代 I/O 系統大量使用 DMA(直接內存訪問)。 網卡、視頻卡、HDD 控制器、串行/並行端口等上有特殊的專用處理器。這些處理器可以直接訪問內存總線,並且完全獨立於 CPU 處理讀/寫。 CPU 只需要將包含數據的內存位置通知給設備,然后就可以做自己的事情,直到設備發出中斷通知 CPU 讀/寫完成。

一旦操作進行中,CPU 就沒有工作要做,因此也就沒有線程了。

計算機似乎一次做多於 1 件事的唯一方法是 (1) 實際上一次做多於 1 件事,(2) 通過調度任務並在它們之間切換來模擬它。 所以如果 async-await 不做這些

並不是說 await兩者都沒有。 請記住, await的目的不是使同步代碼神奇地異步 這是為了在調用異步代碼時使用我們用於編寫同步代碼的相同技術 Await 是關於使使用高延遲操作的代碼看起來像使用低延遲操作的代碼 那些高延遲操作可能在線程上,也可能在特殊用途的硬件上,他們可能會將他們的工作分解成小塊並將其放入消息隊列中,以便稍后由 UI 線程處理。 他們正在做一些事情來實現異步,但他們正在做這件事。 Await 只是讓您利用這種異步性。

另外,我認為您缺少第三種選擇。 我們這些老人——今天帶着說唱音樂的孩子應該離開我的草坪等等——還記得 1990 年代初期的 Windows 世界。 沒有多 CPU 機器,也沒有線程調度程序。 你想在同一時間運行兩個的Windows應用程序,你不得不屈服 多任務處理是合作的 操作系統告訴一個進程它可以運行,如果它行為不端,它就會使所有其他進程無法獲得服務。 它一直運行直到它讓步,並且不知何故它必須知道如何在下次操作系統將控制權交還給它時從它停止的地方開始 單線程異步代碼很像這樣,使用“await”而不是“yield”。 等待的意思是“我會記住我在這里離開的地方,讓別人跑一會;當我等待的任務完成時給我回電話,我會從我離開的地方接起。” 我認為您可以看到這如何使應用程序響應更快,就像它在 Windows 3 天中所做的那樣。

調用任何方法都意味着等待方法完成

有你缺少的鑰匙。 方法可以在其工作完成之前返回 這就是異步的本質。 一個方法返回,它返回一個任務,意思是“這項工作正在進行中;告訴我完成后要做什么”。 該方法的工作尚未完成,即使它已返回

在 await 操作符之前,您必須編寫看起來像意大利奶酪串的代碼來處理這樣一個事實,即完成我們有工作要做,但返回和完成不同步 Await 允許您編寫看起來像返回和完成是同步的代碼,而不是它們實際上是同步的。

我真的很高興有人問這個問題,因為在很長一段時間里,我也認為線程是並發所必需的。 當我第一次看到事件循環時,我認為它們是謊言。 我心想“如果這段代碼在單個線程中運行,它就不可能並發”。 請記住,這是我已經經歷了理解並發性和並行性之間差異的斗爭之后。

經過我自己的研究,我終於找到了缺失的部分: select() 具體來說,IO 多路復用,由不同名稱的各種內核實現: select()poll()epoll()kqueue() 這些是系統調用,雖然實現細節不同,但允許您傳入一組文件描述符以進行監視。 然后,您可以進行另一個調用,該調用會阻塞,直到被監視的文件描述符之一發生更改。

因此,可以等待一組 IO 事件(主事件循環),處理完成的第一個事件,然后將控制權交還給事件循環。 沖洗並重復。

這是如何工作的? 嗯,簡短的回答是它是內核和硬件級的魔法。 計算機中除了CPU之外還有很多組件,這些組件可以並行工作。 內核可以控制這些設備並直接與它們通信以接收某些信號。

這些 IO 多路復用系統調用是單線程事件循環(如 node.js 或 Tornado)的基本構建塊。 當您await一個函數時,您正在觀察某個事件(該函數的完成),然后將控制權交還給主事件循環。 當您正在觀看的事件完成時,該函數(最終)從它停止的地方開始。 允許您像這樣暫停和恢復計算的函數稱為協程

awaitasync使用任務而不是線程。

該框架有一個線程池,准備以Task對象的形式執行一些工作; 向池提交任務意味着選擇一個空閑的、已經存在的1線程來調用任務操作方法。
創建一個任務就是創建一個新對象,比創建一個新線程快得多。

給定一個Task可以附加一個Continuation ,它是一個新的Task對象,一旦線程結束就會被執行。

由於async/await使用Task ,它們不會創建新線程。


雖然中斷編程技術在每個現代操作系統中都被廣泛使用,但我認為它們與這里無關。
您可以使用aysnc/await在單個 CPU 中並行執行(實際上是交錯的)兩個CPU 綁定任務
這不能簡單地用操作系統支持排隊IORP來解釋。


上次我檢查編譯器將async方法轉換為DFA 時,工作分為幾個步驟,每個步驟以await指令結束。
await啟動它的Task並將其附加到繼續執行下一步。

作為概念示例,這里是一個偽代碼示例。
為了清楚起見,事情正在被簡化,因為我不記得所有的細節。

method:
   instr1                  
   instr2
   await task1
   instr3
   instr4
   await task2
   instr5
   return value

它變成了這樣的東西

int state = 0;

Task nextStep()
{
  switch (state)
  {
     case 0:
        instr1;
        instr2;
        state = 1;

        task1.addContinuation(nextStep());
        task1.start();

        return task1;

     case 1:
        instr3;
        instr4;
        state = 2;

        task2.addContinuation(nextStep());
        task2.start();

        return task2;

     case 2:
        instr5;
        state = 0;

        task3 = new Task();
        task3.setResult(value);
        task3.setCompleted();

        return task3;
   }
}

method:
   nextStep();

1實際上,池可以有其任務創建策略。

我不打算與 Eric Lippert 或 Lasse V. Karlsen 以及其他人競爭,我只想提請注意這個問題的另一個方面,我認為沒有明確提及。

await使用await不會使您的應用程序神奇地響應。 如果您在 UI 線程阻塞的方法中執行任何操作,它仍然會像非等待版本一樣阻塞您的 UI

您必須專門編寫可等待的方法,以便它生成一個新線程或使用諸如完成端口之類的東西(它將在當前線程中返回執行並在完成端口發出信號時調用其他內容以繼續執行)。 但這部分在其他答案中得到了很好的解釋。

這是我如何看待這一切,它在技術上可能不是超級准確,但至少對我有幫助:)。

機器上發生的處理(計算)基本上有兩種類型:

  • CPU 上發生的處理
  • 發生在其他處理器(GPU、網卡等)上的處理,我們稱之為 IO。

所以,當我們寫一段源碼的時候,編譯后,根據我們使用的對象(這點很重要),處理會是CPU bound ,或者IO bound ,其實可以綁定到兩者。

一些例子:

  • 如果我使用FileStream對象(它是一個 Stream)的 Write 方法,處理會說,1% 的 CPU 限制和 99% 的 IO 限制。
  • 如果我使用NetworkStream對象(它是一個 Stream)的 Write 方法,處理會說,1% 的 CPU 限制和 99% 的 IO 限制。
  • 如果我使用Memorystream對象(它是一個 Stream)的 Write 方法,處理將是 100% 的 CPU 限制。

所以,正如您所看到的,從面向對象程序員的角度來看,雖然我總是訪問一個Stream對象,但下面發生的事情可能在很大程度上取決於對象的最終類型。

現在,為了優化事情,如果可能和/或必要,能夠並行運行代碼有時很有用(注意我不使用異步這個詞)。

一些例子:

  • 在桌面應用程序中,我想打印文檔,但不想等待。
  • 我的 Web 服務器同時為許多客戶端提供服務,每個客戶端並行獲取他的頁面(未序列化)。

在 async / await 之前,我們基本上有兩種解決方案:

  • 線程 它相對容易使用,有 Thread 和 ThreadPool 類。 線程僅受 CPU 限制
  • “舊的” Begin/End/AsyncCallback異步編程模型。 這只是一個模型,它不會告訴您是否會受 CPU 或 IO 限制。 如果你看一下 Socket 或 FileStream 類,它是 IO 綁定的,這很酷,但我們很少使用它。

async / await 只是一種常見的編程模型,基於 Task 概念 對於受 CPU 限制的任務,它比線程或線程池更易於使用,並且比舊的 Begin/End 模型更易於使用。 然而,卧底,它“只是”兩者的超級復雜功能完整的包裝器。

所以,真正的勝利主要是 IO Bound 任務,不使用 CPU 的任務,但 async/await 仍然只是一種編程模型,它無法幫助您確定最終如何/在哪里進行處理。

這意味着它不是因為一個類有一個方法“DoSomethingAsync”返回一個任務對象,你可以假設它是 CPU 綁定的(這意味着它可能非常無用,特別是如果它沒有取消令牌參數),或 IO Bound (這意味着它可能是必須的),或兩者的結合(因為該模型非常具有病毒性,最終,結合和潛在的好處可能是超級混合的,而不是那么明顯)。

所以,回到我的例子,在 MemoryStream 上使用 async/await 執行我的寫操作將保持 CPU 限制(我可能不會從中受益),盡管我肯定會從文件和網絡流中受益。

我試着自下而上地解釋它。 也許有人覺得它有幫助。 我在那里,做了那個,重新發明了它,當在 Pascal 的 DOS 中制作簡單的游戲時(美好的舊時光......)

所以......每個事件驅動的應用程序內部都有一個事件循環,如下所示:

while (getMessage(out message)) // pseudo-code
{
   dispatchMessage(message); // pseudo-code
}

框架通常對你隱藏這個細節,但它就在那里。 getMessage 函數從事件隊列中讀取下一個事件或等待事件發生:鼠標移動、按下鍵、按下鍵、單擊等。然后 dispatchMessage 將事件分派給適當的事件處理程序。 然后等待下一個事件,依此類推,直到出現退出循環並完成應用程序的退出事件。

事件處理程序應該運行得很快,以便事件循環可以輪詢更多事件並且 UI 保持響應。 如果單擊按鈕觸發了這樣的昂貴操作,會發生什么?

void expensiveOperation()
{
    for (int i = 0; i < 1000; i++)
    {
        Thread.Sleep(10);
    }
}

好吧,UI 變得無響應,直到 10 秒操作完成,因為控件停留在函數內。 要解決此問題,您需要將任務分解為可以快速執行的小部分。 這意味着您無法在單個事件中處理整個事情。 您必須完成一小部分工作,然后將另一個事件發布到事件隊列以請求繼續。

因此,您可以將其更改為:

void expensiveOperation()
{
    doIteration(0);
}

void doIteration(int i)
{
    if (i >= 1000) return;
    Thread.Sleep(10); // Do a piece of work.
    postFunctionCallMessage(() => {doIteration(i + 1);}); // Pseudo code. 
}

在這種情況下,只有第一次迭代運行,然后它會向事件隊列發布一條消息以運行下一次迭代並返回。 我們的示例postFunctionCallMessage偽函數將“調用此函數”事件放入隊列,因此事件調度程序將在到達它時調用它。 這允許在連續運行長時間運行的工作的同時處理所有其他 GUI 事件。

只要這個長時間運行的任務在運行,它的延續事件就一直在事件隊列中。 所以你基本上發明了自己的任務調度程序。 隊列中的繼續事件是正在運行的“進程”。 實際上這就是操作系統所做的,除了繼續事件的發送和返回調度程序循環是通過操作系統注冊上下文切換代碼的​​ CPU 的定時器中斷完成的,所以你不需要關心它。 但是在這里您正在編寫自己的調度程序,因此您確實需要關心它 - 到目前為止。

因此,我們可以通過將它們分解成小塊並發送延續事件,在與 GUI 並行的單個線程中運行長時間運行的任務。 這是Task類的總體思路。 它代表一項工作,當您對其調用.ContinueWith時,您可以定義當前工作完成時要調用的函數作為下一個工作(並將其返回值傳遞給延續)。 但是將所有這些鏈接手動拆分為小塊工作是一項繁瑣的工作,並且完全弄亂了邏輯布局,因為整個后台任務代碼基本上是一個.ContinueWith一團糟。 所以這是編譯器可以幫助您的地方。 它在幕后為您完成所有這些鏈接和延續。 當您說await您告訴編譯器“停在這里,將函數的其余部分添加為延續任務”。 編譯器負責其余的工作,因此您不必這樣做。

雖然此任務片段鏈接不涉及創建線程,並且當片段很小時,它們可以在主線程的事件循環中進行調度,但實際上有一個運行任務的工作線程池。 這允許更好地利用 CPU 內核,還允許開發人員運行手動編寫的長任務(這將阻塞工作線程而不是主線程)。

總結其他答案:

Async/await 通常是為 IO 綁定任務創建的,因為通過使用它們,調用線程不需要被阻塞。 這在 UI 線程的情況下特別有用,因為我們可以確保它們在執行后台操作時保持響應(例如從遠程服務器獲取要顯示的數據)

異步不會創建它自己的線程。 調用方法的線程用於執行異步方法,直到找到可等待的。 然后,同一個線程繼續執行異步方法調用之外的其余調用方法。 請注意,在被調用的 async 方法中,從 awaitable 返回后,可以使用線程池中的線程來執行該方法的提醒——這是唯一一個單獨的線程出現的地方。

這不是直接回答問題,但我認為這是一個有趣的附加信息:

Async 和 await 本身不會創建新線程。 但是,根據您使用 async await 的位置,await 之前的同步部分可能與 await 之后的同步部分在不同的線程上運行(例如,ASP.NET 和 ASP.NET 核心的行為不同)。

在基於 UI-Thread 的應用程序(WinForms、WPF)中,您前后都在同一個線程上。 但是當你在線程池線程上使用 async away 時,等待前后的線程可能不一樣。

關於這個主題的精彩視頻

實際上, async await鏈是由 CLR 編譯器生成的狀態機。

然而async await確實使用 TPL 使用線程池來執行任務的線程。

應用程序沒有被阻塞的原因是狀態機可以決定執行、重復、檢查和再次決定哪個協程。

進一步閱讀:

async & await 產生什么?

異步等待和生成的狀態機

異步 C# 和 F# (III.):它是如何工作的? - 托馬斯·佩特里切克

編輯

好的。 看來我的闡述不正確。 但是我必須指出,狀態機是async await的重要資產。 即使您接受異步 I/O,您仍然需要一個助手來檢查操作是否完成,因此我們仍然需要一個狀態機並確定哪些例程可以一起異步執行。

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM