簡體   English   中英

如何在.NET中產生並等待實現控制流?

[英]How do yield and await implement flow of control in .NET?

據我了解yield關鍵字,如果從迭代器塊內部使用它,它將控制流返回到調用代碼,並且當再次調用迭代器時,它將從中斷的地方開始。

同樣, await不僅等待被調用方,而且將控制權返回給調用方,僅在調用方awaits該方法時才從中斷處接管。

換句話說,沒有線程 ,異步和等待的“並發性”是由聰明的控制流引起的錯覺,其細節被語法隱藏了。

現在,我是一名前匯編程序員,並且對指令指針,堆棧等非常熟悉,並且了解了正常的控制流程(子例程,遞歸,循環,分支)的工作方式。 但是這些新結構-我不明白。

當到達await狀態時,運行時如何知道下一步應執行什么代碼? 它如何知道何時可以從上次中斷的地方恢復,以及如何記住在哪里? 當前調用堆棧發生了什么,是否以某種方式保存了它? 如果調用方法在await之前進行其他方法調用怎么辦-為什么堆棧不會被覆蓋? 在異常和堆棧展開的情況下,運行時到底將如何處理所有這些問題?

當達到yield時,運行時如何跟蹤應該拾取的點? 迭代器狀態如何保存?

我將在下面回答您的特定問題,但是您可能會很容易閱讀我關於如何設計產量和等待時間的大量文章。

https://blogs.msdn.microsoft.com/ericlippert/tag/continuation-passing-style/

https://blogs.msdn.microsoft.com/ericlippert/tag/iterators/

https://blogs.msdn.microsoft.com/ericlippert/tag/async/

這些文章中有些已經過時了。 生成的代碼在很多方面都不同。 但是,這些肯定會讓您了解其工作原理。

另外,如果您不了解lambda如何作為閉包類生成,請首先了解。 如果您沒有lambda,那么您就不會做出異步的事情。

當到達等待狀態時,運行時如何知道下一步應執行什么代碼?

await生成為:

if (the task is not completed)
  assign a delegate which executes the remainder of the method as the continuation of the task
  return to the caller
else
  execute the remainder of the method now

基本上就是這樣。 等待只是幻想的回報。

它如何知道何時可以從上次中斷的地方恢復,以及如何記住在哪里?

好吧,您如何在等待的情況下做到這一點? 當方法foo調用方法bar時,無論如何執行bar,我們都以某種方式記得如何回到foo的中間,而激活foo的所有本地語言都保持不變。

您知道在匯編程序中是如何完成的。 foo的激活記錄被壓入堆棧; 它包含本地人的值。 在調用時,將foo中的返回地址壓入堆棧。 完成bar之后,堆棧指針和指令指針將重置為所需的位置,而foo將從其中斷處繼續運行。

等待的繼續是完全相同的,除了將記錄放到堆上是出於明顯的原因,即激活序列沒有形成堆棧

等待任務繼續執行的委托包含(1)一個數字,該數字是查找表的輸入,該表提供了下一步需要執行的指令指針,以及(2)所有locals和temparies的值。

那里還有一些其他裝備; 例如,在.NET中,分支到try塊的中間是非法的,因此您不能簡單地將try塊內的代碼地址粘貼到表中。 但是這些是簿記細節。 從概念上講,激活記錄只是移動到堆上。

當前調用堆棧發生了什么,是否以某種方式保存了它?

當前激活記錄中的相關信息永遠不會放在棧上。 它是從一開始就從堆中分配的。 (嗯,形式參數通常在堆棧上或寄存器中傳遞,然后在方法開始時復制到堆位置。)

呼叫者的激活記錄未保存; 請記住,等待可能會回到他們身邊,所以他們將得到正常處理。

請注意,這是簡化的繼續等待傳遞樣式與您在諸如Scheme之類的語言中看到的真正的當前循環調用結構之間的緊密區別。 在這些語言中,整個延續(包括回到呼叫者的延續)由call-cc捕獲。

如果調用方法在等待之前進行其他方法調用,怎么辦?為什么堆棧不被覆蓋?

這些方法調用返回,因此在等待時它們的激活記錄不再在堆棧上。

在異常和堆棧展開的情況下,運行時到底將如何處理所有這些問題?

如果發生未捕獲的異常,則捕獲該異常並將其存儲在任務中,並在獲取任務的結果時將其重新拋出。

還記得我之前提到的所有簿記嗎? 讓我告訴你,正確設置異常語義是一個巨大的痛苦。

當達到產量時,運行時如何跟蹤應該拾取的點? 迭代器狀態如何保存?

同樣的方式。 當地人的狀態被移到堆上,一個數字表示MoveNext在下一次被MoveNext時應繼續MoveNext的指令,並與本地人一起存儲。

同樣,在迭代器塊中還有很多工具可以確保正確處理異常。

yield是兩者中比較容易的,因此讓我們對其進行研究。

說我們有:

public IEnumerable<int> CountToTen()
{
  for (int i = 1; i <= 10; ++i)
  {
    yield return i;
  }
}

這被編譯了一下 ,如果我們要這樣寫:

// Deliberately use name that isn't valid C# to not clash with anything
private class <CountToTen> : IEnumerator<int>, IEnumerable<int>
{
    private int _i;
    private int _current;
    private int _state;
    private int _initialThreadId = CurrentManagedThreadId;

    public IEnumerator<CountToTen> GetEnumerator()
    {
        // Use self if never ran and same thread (so safe)
        // otherwise create a new object.
        if (_state != 0 || _initialThreadId != CurrentManagedThreadId)
        {
            return new <CountToTen>();
        }

        _state = 1;
        return this;
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

    public int Current => _current;

    object IEnumerator.Current => Current;

    public bool MoveNext()
    {
        switch(_state)
        {
            case 1:
                _i = 1;
                _current = i;
                _state = 2;
                return true;
            case 2:
                ++_i;
                if (_i <= 10)
                {
                    _current = _i;
                    return true;
                }
                break;
        }
        _state = -1;
        return false;
    }

    public void Dispose()
    {
      // if the yield-using method had a `using` it would
      // be translated into something happening here.
    }

    public void Reset()
    {
        throw new NotSupportedException();
    }
}

因此,效率不如IEnumerable<int>IEnumerator<int>的手寫實現(例如,在這種情況下,我們可能不會浪費擁有單獨的_state_i_current ),但還不錯(重復使用的技巧)如果可以安全地這樣做(而不是創建新對象),那么它本身就是很好的選擇),並且可以擴展以處理非常復雜的使用yield方法。

當然,因為

foreach(var a in b)
{
  DoSomething(a);
}

是相同的:

using(var en = b.GetEnumerator())
{
  while(en.MoveNext())
  {
     var a = en.Current;
     DoSomething(a);
  }
}

然后,重復調用生成的MoveNext()

async情況幾乎是相同的原理,但是有一些額外的復雜性。 重用另一個答案代碼中的示例,例如:

private async Task LoopAsync()
{
    int count = 0;
    while(count < 5)
    {
       await SomeNetworkCallAsync();
       count++;
    }
}

產生如下代碼:

private struct LoopAsyncStateMachine : IAsyncStateMachine
{
  public int _state;
  public AsyncTaskMethodBuilder _builder;
  public TestAsync _this;
  public int _count;
  private TaskAwaiter _awaiter;
  void IAsyncStateMachine.MoveNext()
  {
    try
    {
      if (_state != 0)
      {
        _count = 0;
        goto afterSetup;
      }
      TaskAwaiter awaiter = _awaiter;
      _awaiter = default(TaskAwaiter);
      _state = -1;
    loopBack:
      awaiter.GetResult();
      awaiter = default(TaskAwaiter);
      _count++;
    afterSetup:
      if (_count < 5)
      {
        awaiter = _this.SomeNetworkCallAsync().GetAwaiter();
        if (!awaiter.IsCompleted)
        {
          _state = 0;
          _awaiter = awaiter;
          _builder.AwaitUnsafeOnCompleted<TaskAwaiter, TestAsync.LoopAsyncStateMachine>(ref awaiter, ref this);
          return;
        }
        goto loopBack;
      }
      _state = -2;
      _builder.SetResult();
    }
    catch (Exception exception)
    {
      _state = -2;
      _builder.SetException(exception);
      return;
    }
  }
  [DebuggerHidden]
  void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine param0)
  {
    _builder.SetStateMachine(param0);
  }
}

public Task LoopAsync()
{
  LoopAsyncStateMachine stateMachine = new LoopAsyncStateMachine();
  stateMachine._this = this;
  AsyncTaskMethodBuilder builder = AsyncTaskMethodBuilder.Create();
  stateMachine._builder = builder;
  stateMachine._state = -1;
  builder.Start(ref stateMachine);
  return builder.Task;
}

它比較復雜,但是基本原理非常相似。 最主要的復雜之處在於現在正在使用GetAwaiter() 如果檢查了awaiter.IsCompleted任何時間,則由於await的任務已經完成(例如,它可以同步返回的情況),它會返回true ,然后該方法將繼續遍歷狀態,否則它將自身設置為對等待者的回調。

究竟發生什么取決於等待者,包括觸發回調的原因(例如異步I / O完成,在線程上運行的任務完成)以及對編組到特定線程或在線程池線程上運行有什么要求,可能需要也可能不需要原始調用的上下文,依此類推。 無論該等待者中有什么東西,都將調用MoveNext ,它將繼續進行下一個工作(直到下一個await ),或者完成並返回,在這種情況下,它正在實現的Task會完成。

這里已經有很多不錯的答案; 我將分享一些有助於形成心理模型的觀點。

首先,編譯器將async方法分為幾部分; await表達式是斷裂點。 (對於簡單的方法,這是很容易想到的;帶有循環和異常處理的更復雜的方法也可以通過添加更復雜的狀態機來分解)。

其次, await被轉換為一個相當簡單的序列。 我喜歡Lucian的描述 ,它的描述幾乎是“如果等待已完成,則獲取結果並繼續執行此方法;否則,保存此方法的狀態並返回”。 (我在async介紹中使用了非常相似的術語)。

當到達等待狀態時,運行時如何知道下一步應執行什么代碼?

該方法的其余部分作為該可等待事件的回調存在(對於任務,這些回調是連續的)。 當awaitable完成時,它將調用其回調。

請注意, 不會保存和恢復調用堆棧。 回調直接被調用。 如果有重疊的I / O,則直接從線程池中調用它們。

這些回調可以繼續直接執行該方法,或者可以調度該方法在其他地方運行(例如,如果await捕獲了UI SynchronizationContext並且在線程池上完成了I / O)。

它如何知道何時可以從上次中斷的地方恢復,以及如何記住在哪里?

全部都是回調。 當一個awaitable完成時,它會調用其回調,並且所有已經await它的async方法都將恢復。 回調跳轉到該方法的中間,並在范圍內具有其局部變量。

回調運行特定線程,並且它們沒有恢復其調用棧。

當前調用堆棧發生了什么,是否以某種方式保存了它? 如果調用方法在等待之前進行其他方法調用,怎么辦?為什么堆棧不被覆蓋? 在異常和堆棧展開的情況下,運行時到底將如何處理所有這些問題?

調用棧不會首先保存; 沒必要

使用同步代碼,您可以得到包括所有調用方的調用堆棧,並且運行時知道使用該調用返回的位置。

在那完成其任務的一些I / O操作扎根,它可以恢復一個-與異步代碼,你可以用一堆回調指針最終async是完成其任務的方法,它可以恢復一個async的是完成其任務的方法,等等。

因此,使用同步代碼A調用B調用C ,您的調用堆棧可能如下所示:

A:B:C

而異步代碼使用回調(指針):

A <- B <- C <- (I/O operation)

當達到產量時,運行時如何跟蹤應該拾取的點? 迭代器狀態如何保存?

目前,效率很低。 :)

它像其他任何lambda一樣工作-延長了變量生存期,並將引用放置在駐留在堆棧中的狀態對象中。 有關所有深入細節的最佳資源是Jon Skeet的EduAsync系列

yieldawait在處理流量控制的同時,是兩個完全不同的事物。 因此,我將分別解決它們。

yield的目標是使構建延遲序列更容易。 當您編寫其中包含yield語句的枚舉器循環時,編譯器會生成大量看不見的新代碼。 實際上,它實際上產生了一個全新的類。 該類包含跟蹤循環狀態的成員以及IEnumerable的實現,這樣,每次調用MoveNext它將再次遍歷該循環。 因此,當您執行如下所示的foreach循環時:

foreach(var item in mything.items()) {
    dosomething(item);
}

生成的代碼如下所示:

var i = mything.items();
while(i.MoveNext()) {
    dosomething(i.Current);
}

在mything.items()實現的內部是一堆狀態機代碼,它們將執行循環的“一步”,然后返回。 因此,盡管您像簡單循環一樣在源代碼中編寫代碼,但實際上並不是一個簡單的循環。 因此,編譯器很棘手。 如果想看自己,請拉出ILDASM或ILSpy或類似工具,然后查看生成的IL的外觀。 應該具有啟發性。

另一方面, asyncawait則是另一回事。 抽象來說,等待是一個同步原語。 這是一種告訴系統“在完成此操作之前我無法繼續”的方法。 但是,正如您指出的,並不總是涉及線程。

涉及的是所謂的同步上下文。 總是有一個閑逛。 他們同步上下文的工作是安排正在等待的任務及其繼續。

當您說await thisThing() ,會發生一些事情。 在異步方法中,編譯器實際上將方法分成較小的塊,每個塊都是“ await afore”之前的部分和“ await await”之后(或繼續)的部分。 當執行等待時,正在等待的任務以及隨后的繼續操作(換句話說,該函數的其余部分)將傳遞到同步上下文。 上下文負責計划任務,完成后,上下文將繼續運行,並傳遞所需的任何返回值。

只要安排了內容,同步上下文就可以隨意執行任何所需的操作。 它可以使用線程池。 它可以為每個任務創建一個線程。 它可以同步運行它們。 不同的環境(ASP.NET與WPF)提供了不同的同步上下文實現,這些實現基於適合其環境的最佳方法執行不同的操作。

(獎金:曾經想知道.ConfigurateAwait(false)是什么?它告訴系統不要使用當前的同步上下文(通常基於您的項目類型-例如WPF與ASP.NET),而是使用默認的上下文,該默認上下文使用線程池)。

同樣,這也是很多編譯器的難題。 如果您看一下生成的代碼,它很復雜,但是您應該能夠看到它在做什么。 這類轉換很困難,但是是確定性的和數學的,這就是為什么編譯器為我們完成這些轉換真是太好了。

PS:默認同步上下文存在一個例外-控制台應用程序沒有默認同步上下文。 查看Stephen Toub的博客以獲取更多信息。 在一般情況下,這是一個尋找async信息並await的好地方。

通常,我建議您查看CIL,但在這種情況下,情況很糟。

這兩種語言的結構在工作上相似,但實現方式略有不同。 基本上,這只是編譯器魔術的語法糖,在匯編級別沒有瘋狂/不安全的事情。 讓我們簡要地看一下它們。

yield是一個更古老,更簡單的陳述,它是基本狀態機的語法糖。 返回IEnumerable<T>IEnumerator<T>可能包含yield ,然后將其轉換為狀態機工廠。 您應該注意的一件事是,如果內部有一個yield ,那么在調用該方法時該方法中沒有代碼會運行。 原因是您編寫的代碼已轉移到IEnumerator<T>.MoveNext方法中,該方法檢查其所在的狀態並運行代碼的正確部分。 yield return x; 然后將其轉換為類似於此的值this.Current = x; return true; this.Current = x; return true;

如果進行了一些反思,則可以輕松地檢查構造的狀態機及其字段(狀態和本地至少一個)。 如果更改字段,甚至可以重置它。

await需要類型庫的一點支持,並且工作方式有所不同。 它使用TaskTask<T>參數,然后如果任務完成則返回其值,或者通過Task.GetAwaiter().OnCompleted注冊繼續。 async / await系統的完整實現將花費很長時間來解釋,但這也不是那么神秘。 它還創建一個狀態機,並將其沿繼續傳遞到OnCompleted 如果任務已完成,那么它將在繼續中使用其結果。 等待者的實現決定了如何調用延續。 通常,它使用調用線程的同步上下文。

yieldawait都必須根據它們的出現將方法拆分為一個狀態機,狀態機的每個分支代表方法的每個部分。

您不應該以棧,線程等“低級”術語來考慮這些概念。它們是抽象的,它們的內部工作不需要CLR的任何支持,只是編譯器發揮了作用。 這與Lua的協程有很大的不同,后者確實具有運行時的支持,或者C的longjmp ,這只是黑魔法。

暫無
暫無

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

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