c# - .NET में नियंत्रण के प्रवाह और उपज का इंतजार कैसे करें?




asynchronous async-await (4)

आम तौर पर, मैं सीआईएल को देख कर पुनर्मिलन करूंगा, लेकिन इन के मामले में, यह एक गड़बड़ है।

ये दो भाषा निर्माण कार्य करने में समान हैं, लेकिन इसे थोड़ा अलग तरीके से लागू किया गया है। असल में, यह एक संकलक जादू के लिए सिर्फ एक सिंथेटिक चीनी है, विधानसभा स्तर पर कुछ भी पागल / असुरक्षित नहीं है। आइए उन्हें संक्षेप में देखें।

yield एक पुराना और सरल कथन है, और यह एक बुनियादी राज्य मशीन के लिए एक कृत्रिम चीनी है। IEnumerable<T> या IEnumerator<T> लौटाने वाली एक विधि में एक yield हो सकती है, जो फिर विधि को एक राज्य मशीन कारखाने में बदल देती है। एक बात जो आपको ध्यान देनी चाहिए वह यह है कि इस विधि में कोई भी कोड उस समय नहीं चलाया जाता है जब आप इसे कॉल करते हैं, अगर अंदर कोई yield है। कारण यह है कि आपके द्वारा लिखा गया कोड IEnumerator<T>.MoveNext विधि में अनुवादित किया जाता है, जो उस राज्य की जाँच करता है जो कोड का सही भाग चलाता है। yield return x; तो यह कुछ करने के लिए परिवर्तित कर रहा है। this.Current = x; return true; this.Current = x; return true;

यदि आप कुछ प्रतिबिंब करते हैं, तो आप आसानी से निर्मित राज्य मशीन और उसके खेतों (कम से कम एक राज्य के लिए और स्थानीय लोगों के लिए) का निरीक्षण कर सकते हैं। यदि आप फ़ील्ड बदलते हैं तो आप इसे रीसेट भी कर सकते हैं।

await लिए लाइब्रेरी से थोड़े समर्थन की आवश्यकता होती है, और कुछ अलग तरीके से काम करता है। यह Task या Task<T> तर्क लेता है, फिर या तो कार्य पूरा होने पर इसके मूल्य का परिणाम होता है, या टास्क के माध्यम से एक निरंतरता का पंजीकरण करता है Task.GetAwaiter().OnCompleted async / await प्रणाली का पूर्ण कार्यान्वयन समझाने में बहुत लंबा लगेगा, लेकिन यह भी उतना रहस्यमय नहीं है। यह एक राज्य मशीन भी बनाता है और इसे OnCompleted की निरंतरता के साथ पास करता है। यदि कार्य पूरा हो गया है, तो यह निरंतरता में अपने परिणाम का उपयोग करता है। वेटर का कार्यान्वयन यह तय करता है कि निरंतरता को कैसे लागू किया जाए। आमतौर पर यह कॉलिंग थ्रेड के सिंक्रनाइज़ेशन संदर्भ का उपयोग करता है।

yield और await दोनों को विधि के प्रत्येक भाग का प्रतिनिधित्व करने वाली मशीन की प्रत्येक शाखा के साथ, एक राज्य मशीन बनाने के लिए उनके प्रदूषण के आधार पर विधि को विभाजित करने के लिए है।

आपको इन अवधारणाओं के बारे में "निचले स्तर" की शर्तों जैसे कि ढेर, धागे आदि के बारे में नहीं सोचना चाहिए। ये सार हैं, और उनके आंतरिक कामकाज को सीएलआर से किसी भी समर्थन की आवश्यकता नहीं है, यह सिर्फ संकलक है जो जादू करता है। यह लुआ के कोरटाइन से बेतहाशा अलग है, जिसमें रनटाइम का समर्थन है, या सी का लॉन्जंप , जो सिर्फ काला जादू है।

जैसा कि मैं yield कीवर्ड को समझता हूं, अगर एक इटरेटर ब्लॉक के अंदर से उपयोग किया जाता है, तो यह कॉलिंग कोड पर नियंत्रण का प्रवाह लौटाता है, और जब इटरेटर को फिर से कॉल किया जाता है, तो यह वहीं छोड़ता है जहां इसे छोड़ा गया था।

इसके अलावा, await न केवल कैली के लिए इंतजार कर रहा है, बल्कि यह कॉलर को नियंत्रण लौटाता है, केवल यह लेने के लिए कि कॉलर ने विधि का awaits , जहां इसे छोड़ दिया।

दूसरे शब्दों में - कोई धागा नहीं है , और async और इंतजार की "संगामिति" नियंत्रण के चतुर प्रवाह के कारण एक भ्रम है, जिसका विवरण वाक्यविन्यास द्वारा छुपाया गया है।

अब, मैं एक पूर्व असेंबली प्रोग्रामर हूं और मैं इंस्ट्रक्शन पॉइंटर्स, स्टैक आदि से बहुत परिचित हूं और मुझे लगता है कि कैसे सामान्य फ्लो ऑफ कंट्रोल (सबरूटीन, रिकर्सन, लूप, ब्रांच) काम करते हैं। लेकिन ये नए निर्माण - मैं उन्हें नहीं मिला।

जब कोई await जाती है, तो रनटाइम को कैसे पता चलता है कि आगे किस कोड को निष्पादित करना चाहिए? यह कैसे पता चलता है कि यह फिर से शुरू हो सकता है जहां इसे छोड़ दिया गया था, और यह कैसे याद करता है कि कहां है? वर्तमान कॉल स्टैक का क्या होता है, क्या यह किसी तरह से सहेजा जाता है? क्या होगा यदि कॉलिंग विधि s await से पहले अन्य विधि कॉल करती है - तो स्टैक ओवरराइट क्यों नहीं होता है? और कैसे पृथ्वी पर रनटाइम अपवाद और स्टैक के मामले में इस सब के माध्यम से अपना काम करेगा?

जब yield जाती है, तो रनटाइम उस बिंदु का ट्रैक कैसे रखता है जहां चीजों को उठाया जाना चाहिए? इटरेटर स्थिति कैसे संरक्षित है?


पहले से ही यहाँ एक महान उत्तर हैं; मैं सिर्फ कुछ दृष्टिकोण साझा करने जा रहा हूं जो मानसिक मॉडल बनाने में मदद कर सकते हैं।

सबसे पहले, एक async विधि संकलक द्वारा कई टुकड़ों में टूट जाती है; await भाव भंगुर बिंदु हैं। (यह सरल तरीकों के लिए गर्भ धारण करना आसान है; लूप और अपवाद हैंडलिंग के साथ अधिक जटिल विधियां भी टूट जाती हैं, एक अधिक जटिल राज्य मशीन के अतिरिक्त के साथ)।

दूसरा, await का काफी सरल अनुक्रम में अनुवाद किया गया है; मुझे लुसियन का वर्णन पसंद है, जो शब्दों में बहुत अधिक है "यदि प्रतीक्षा योग्य पहले से ही पूरा हो गया है, तो परिणाम प्राप्त करें और इस पद्धति को जारी रखें; अन्यथा, इस पद्धति की स्थिति को बचाएं और वापस लौटें"। (मैं अपने async परिचय में बहुत समान शब्दावली का उपयोग करता हूं)।

जब कोई प्रतीक्षा हो जाती है, तो रनटाइम को कैसे पता चलता है कि आगे किस कोड को निष्पादित करना चाहिए?

शेष विधि उस आवेग के लिए कॉलबैक के रूप में मौजूद है (कार्यों के मामले में, ये कॉलबैक निरंतर हैं)। जब प्रतीक्षा पूरी हो जाती है, तो यह अपने कॉलबैक को आमंत्रित करता है।

ध्यान दें कि कॉल स्टैक सहेजा नहीं गया है और पुनर्स्थापित नहीं किया गया है; कॉलबैक को सीधे लागू किया जाता है। ओवरलैप्ड I / O के मामले में, वे सीधे थ्रेड पूल से मंगवाए जाते हैं।

वे कॉलबैक विधि को सीधे निष्पादित करना जारी रख सकते हैं, या वे इसे कहीं और चलाने के लिए शेड्यूल कर सकते हैं (उदाहरण के लिए, यदि await ने UI SynchronizationContext await कैप्चर किया और थ्रेड पूल पर I / O पूरा हो गया)।

यह कैसे पता चलता है कि यह फिर से शुरू हो सकता है जहां इसे छोड़ दिया गया था, और यह कैसे याद करता है कि कहां है?

यह सब सिर्फ कॉलबैक है। जब कोई प्रतीक्षा पूरी हो जाती है, तो वह अपने कॉलबैक को आमंत्रित करता है, और कोई भी async विधि जो पहले से ही ed का await कर रही थी वह फिर से शुरू हो जाती है। कॉलबैक उस विधि के बीच में कूदता है और इसके स्थानीय चर दायरे में होते हैं।

कॉलबैक एक विशेष थ्रेड नहीं हैं, और उनके कॉलस्टैक को पुनर्स्थापित नहीं किया गया है।

वर्तमान कॉल स्टैक का क्या होता है, क्या यह किसी तरह से सहेजा जाता है? क्या होगा यदि कॉल करने की विधि प्रतीक्षा करने से पहले अन्य विधि कॉल करती है - तो स्टैक ओवरराइट क्यों नहीं किया जाता है? और कैसे पृथ्वी पर रनटाइम अपवाद और स्टैक के मामले में इस सब के माध्यम से अपना काम करेगा?

कॉलस्टैक को पहले स्थान पर सहेजा नहीं गया है; यह आवश्यक नहीं है।

सिंक्रोनस कोड के साथ, आप एक कॉल स्टैक के साथ समाप्त हो सकते हैं जिसमें आपके सभी कॉलर्स शामिल हैं, और रनटाइम जानता है कि उस का उपयोग करके कहां लौटना है।

एसिंक्रोनस कोड के साथ, आप कॉलबैक पॉइंटर्स के एक समूह के साथ समाप्त हो सकते हैं - कुछ I / O ऑपरेशन में निहित जो अपने कार्य को पूरा करता है, जो कि एक async विधि को फिर से शुरू कर सकता है जो अपने कार्य को पूरा करता है, जो एक async विधि को फिर से शुरू कर सकता है जो अपना कार्य समाप्त करता है, आदि। ।

तो, सिंक्रोनस कोड A कॉलिंग B कॉलिंग C , आपका कॉलस्टैक इस तरह दिख सकता है:

A:B:C

जबकि अतुल्यकालिक कोड कॉलबैक (पॉइंटर्स) का उपयोग करता है:

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

जब पैदावार हो जाती है, तो रनटाइम उस बिंदु का ट्रैक कैसे रखता है जहां चीजों को उठाया जाना चाहिए? इटरेटर स्थिति कैसे संरक्षित है?

वर्तमान में, बल्कि अक्षम रूप से। :)

यह किसी भी अन्य लैम्ब्डा की तरह काम करता है - चर जीवनकाल को बढ़ाया जाता है और संदर्भ को एक राज्य वस्तु में रखा जाता है जो स्टैक पर रहता है। सभी गहरे स्तर के विवरणों के लिए सबसे अच्छा संसाधन जॉन स्कीट की एडुआएस्क्यू सीरीज़ है


yield और await , जबकि दोनों प्रवाह नियंत्रण के साथ काम कर रहे हैं, दो पूरी तरह से अलग चीजें हैं। इसलिए मैं उनसे अलग से निपटूंगा।

yield का लक्ष्य आलसी दृश्यों का निर्माण करना आसान बनाना है। जब आप इसमें एक yield कथन के साथ एक एन्यूमरेटर-लूप लिखते हैं, तो संकलक एक नया कोड उत्पन्न करता है जिसे आप नहीं देखते हैं। हुड के तहत, यह वास्तव में एक नया वर्ग उत्पन्न करता है। वर्ग में सदस्य होते हैं जो लूप की स्थिति को ट्रैक करते हैं, और IEnumerable का एक कार्यान्वयन ताकि हर बार जब आप MoveNext कॉल MoveNext यह उस लूप के माध्यम से एक बार और कदम उठाएं। तो जब आप इस तरह से एक फॉरेक्स लूप करते हैं:

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

उत्पन्न कोड कुछ इस तरह दिखता है:

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

Mything.items () के कार्यान्वयन के अंदर राज्य-मशीन कोड का एक गुच्छा है जो लूप के एक "स्टेप" करेगा और फिर वापस आ जाएगा। इसलिए जब आप इसे एक साधारण लूप जैसे स्रोत में लिखते हैं, तो हुड के नीचे यह एक साधारण लूप नहीं है। तो संकलक प्रवंचना। यदि आप स्वयं को देखना चाहते हैं, तो ILDASM या ILSpy या इसी तरह के टूल को बाहर निकालें और देखें कि उत्पन्न IL कैसा दिखता है। यह शिक्षाप्रद होना चाहिए।

दूसरी ओर async और await , मछली की एक पूरी अन्य केतली हैं। इंतजार है, अमूर्त में, एक तुल्यकालन आदिम। यह सिस्टम को यह बताने का एक तरीका है "मैं तब तक जारी नहीं रख सकता जब तक यह पूरा नहीं हो जाता।" लेकिन, जैसा कि आपने उल्लेख किया है, हमेशा एक थ्रेड शामिल नहीं होता है।

क्या शामिल है कुछ को एक सिंक्रनाइज़ेशन संदर्भ कहा जाता है। वहाँ हमेशा एक चारों ओर लटका रहता है। उनके लिए सिंक्रनाइज़ेशन संदर्भ का काम उन कार्यों को शेड्यूल करना है, जिन पर इंतजार किया जा रहा है और उनकी निरंतरता है।

जब आप कहते हैं कि इस await thisThing() , तो कुछ बातें होती हैं। एक async विधि में, संकलक वास्तव में इस विधि को छोटे विखंडू में काटता है, प्रत्येक chunk "प्रतीक्षा से पहले" खंड और "प्रतीक्षा के बाद" (या निरंतरता) अनुभाग में होता है। जब वेट निष्पादित होता है, तो कार्य का इंतजार किया जा रहा है, और निम्नलिखित निरंतरता - दूसरे शब्दों में, फ़ंक्शन के बाकी - को सिंक्रनाइज़ेशन संदर्भ में पारित किया जाता है। संदर्भ कार्य को शेड्यूल करने का ध्यान रखता है, और जब यह संदर्भ समाप्त हो जाता है, तो निरंतरता को चलाता है, जो भी रिटर्न वैल्यू चाहता है उसे पास करता है।

जब तक वह सामान को शेड्यूल करता है तब तक सिंक संदर्भ जो कुछ भी करना चाहता है वह करने के लिए स्वतंत्र है। यह थ्रेड पूल का उपयोग कर सकता है। यह प्रति कार्य एक थ्रेड बना सकता है। यह उन्हें सिंक्रोनाइज़ कर सकता है। विभिन्न वातावरण (ASP.NET बनाम WPF) विभिन्न सिंक संदर्भ कार्यान्वयन प्रदान करते हैं जो कि उनके वातावरण के लिए सबसे अच्छा है के आधार पर अलग-अलग चीजें करते हैं।

(बोनस: कभी सोचा है कि .ConfigurateAwait(false) क्या करता है? यह सिस्टम को वर्तमान सिंक संदर्भ का उपयोग नहीं करने के लिए कह रहा है (आमतौर पर उदाहरण के लिए आपके प्रोजेक्ट प्रकार - WPF बनाम ASP.NET पर आधारित) और इसके बजाय डिफ़ॉल्ट का उपयोग करें, जो उपयोग करता है धागा पूल)।

तो फिर, यह कंपाइलर ट्रिक का एक बहुत कुछ है। यदि आप जनरेट किए गए कोड को देखते हैं, लेकिन आपको यह देखना चाहिए कि यह क्या कर रहा है। इस प्रकार के परिवर्तन कठिन हैं, लेकिन निर्धारक और गणितीय हैं, यही कारण है कि यह महान है कि संकलक उन्हें हमारे लिए कर रहा है।

PS डिफ़ॉल्ट सिंक संदर्भों के अस्तित्व में एक अपवाद है - कंसोल ऐप्स में डिफ़ॉल्ट सिंक संदर्भ नहीं है। अधिक जानकारी के लिए स्टीफन टूब के ब्लॉग की जाँच करें। यह async पर जानकारी के लिए और सामान्य रूप से await करने के लिए एक शानदार जगह है।


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 जाता है। सुरक्षित जाँच की जाती है, तो यह true क्योंकि टास्क का await एड पहले से ही पूरा हो चुका है (उदाहरण के मामले जहां यह समकालिक रूप से वापस आ सकता है) तब विधि राज्यों के माध्यम से चलती रहती है, लेकिन अन्यथा यह वेटर को कॉलबैक के रूप में स्थापित करता है।

बस इसके साथ जो होता है वह वेटर पर निर्भर करता है, कॉलबैक को ट्रिगर करने के संदर्भ में (जैसे async I / O पूरा होने पर, थ्रेड पूरा करने पर चलने वाला कार्य) और किसी थ्रेड पर थ्रेडिंग के लिए या किसी थ्रेड थ्रेड पर चलने के लिए क्या आवश्यकताएँ हैं। , क्या मूल कॉल से संदर्भ की आवश्यकता हो सकती है या नहीं और इतने पर। जो कुछ भी है, हालांकि उस वेटर में कुछ भी मूवनेट में जाएगा और यह या तो काम के अगले टुकड़े (अगले await ) के साथ जारी रहेगा या खत्म हो जाएगा और जिस स्थिति में यह Task कर रहा है वह पूरा हो जाएगा।





async-await