yield and await는 .NET에서 제어 흐름을 어떻게 구현합니까?


105

yield키워드를 이해했듯이 반복기 블록 내부에서 사용하면 제어 흐름을 호출 코드로 반환하고 반복기를 다시 호출하면 중단 된 부분을 선택합니다.

또한 await호출 수신자를 기다릴뿐만 아니라 호출자에게 제어권을 반환하고 호출자 awaits가 메서드를 호출 할 때 중단 된 부분을 선택합니다 .

즉 , 스레드가 없으며 async 및 await의 "동시성"은 영리한 제어 흐름으로 인한 환상이며 세부 사항은 구문에 의해 숨겨집니다.

이제 저는 전직 어셈블리 프로그래머이고 명령어 포인터, 스택 등에 매우 익숙하며 일반적인 제어 흐름 (서브 루틴, 재귀, 루프, 분기)이 어떻게 작동하는지 알게되었습니다. 하지만이 새로운 구조는 .. 이해가 안 돼요.

이 때 await도달 할 때, 어떻게 런타임 코드의 조각 다음에 실행해야하는지 알고 있나요? 중단 된 부분에서 다시 시작할 수있는시기를 어떻게 알 수 있으며 어디에서 어떻게 기억합니까? 현재 호출 스택은 어떻게됩니까? 어떻게 든 저장됩니까? 호출하는 메서드가 다른 메서드를 호출하기 전에 다른 메서드를 호출하면 await어떨까요? 스택을 덮어 쓰지 않는 이유는 무엇입니까? 그리고 도대체 예외와 스택 해제의 경우 런타임이이 모든 과정에서 어떻게 작동할까요?

언제 yield도달하면 런타임이 물건을 집어 야하는 지점을 어떻게 추적합니까? 반복기 상태는 어떻게 보존됩니까?


4
TryRoslyn 온라인 컴파일러 에서 생성 된 코드를 볼 수 있습니다
xanatos

1
Jon Skeet의 Eduasync 기사 시리즈 를 확인할 수 있습니다 .
Leonid Vasilev

답변:


115

아래에서 구체적인 질문에 답할 것이지만, 수익을 설계하고 기다리는 방법에 대한 광범위한 기사를 읽으면 좋을 것입니다.

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/

이 기사 중 일부는 현재 구식입니다. 생성 된 코드는 여러면에서 다릅니다. 그러나 이것들은 확실히 그것이 어떻게 작동하는지에 대한 아이디어를 줄 것입니다.

또한 람다가 클로저 클래스로 생성되는 방식을 이해하지 못하는 경우 먼저 . 람다가 없으면 앞면이나 뒷면을 비동기로 만들지 않습니다.

await에 도달하면 런타임은 다음에 실행할 코드 조각을 어떻게 알 수 있습니까?

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

그게 기본입니다. Await는 멋진 반환 일뿐입니다.

중단 된 부분에서 다시 시작할 수있는시기를 어떻게 알 수 있으며 어디에서 어떻게 기억합니까?

어떻게 기다리지 않고 어떻게하나요? 메소드 foo가 메소드 bar를 호출 할 때, 어떻게 든 우리는 bar가 무엇을하든 상관없이 foo 활성화의 모든 지역을 그대로 유지하면서 foo의 중간으로 돌아가는 방법을 기억합니다.

어셈블러에서 어떻게 수행되는지 알고 있습니다. foo에 대한 활성화 레코드가 스택으로 푸시됩니다. 그것은 지역 주민들의 가치를 포함합니다. 호출 시점에서 foo의 반환 주소가 스택으로 푸시됩니다. bar가 완료되면 스택 포인터와 명령어 포인터가 필요한 위치로 재설정되고 foo는 중단 된 위치에서 계속 이동합니다.

await 의 연속은 활성화 시퀀스가 ​​스택을 형성하지 않는다는 명백한 이유로 레코드가 힙에 배치된다는 점을 제외하면 정확히 동일 합니다.

대기중인 델리게이트는 작업의 연속으로 제공합니다. (1) 다음에 실행해야하는 명령 포인터를 제공하는 조회 테이블에 대한 입력 인 숫자와 (2) 로컬 및 임시의 모든 값이 포함됩니다.

거기에 몇 가지 추가 장비가 있습니다. 예를 들어, .NET에서 try 블록의 중간으로 분기하는 것은 불법이므로 단순히 try 블록 내의 코드 주소를 테이블에 붙일 수 없습니다. 그러나 이것들은 부기 세부 사항입니다. 개념적으로 활성화 레코드는 단순히 힙으로 이동됩니다.

현재 호출 스택은 어떻게됩니까? 어떻게 든 저장됩니까?

현재 활성화 레코드의 관련 정보는 처음부터 스택에 저장되지 않습니다. 처음부터 힙에서 할당됩니다. (정식 매개 변수는 정상적으로 스택이나 레지스터에 전달 된 다음 메서드가 시작될 때 힙 위치에 복사됩니다.)

발신자의 활성화 기록은 저장되지 않습니다. 기다림은 아마 그들에게 돌아올 것입니다. 기억하십시오. 그래서 그들은 정상적으로 처리 될 것입니다.

이것은 await의 단순화 된 연속 전달 스타일과 Scheme과 같은 언어에서 볼 수있는 진정한 call-with-current-continuation 구조 간의 밀접한 차이입니다. 이러한 언어에서 호출자에게 다시 계속되는 것을 포함하여 전체 연속은 call-cc에 의해 캡처됩니다 .

호출하는 메서드가 대기하기 전에 다른 메서드를 호출하면 어떻게 될까요? 스택이 덮어 쓰여지지 않는 이유는 무엇입니까?

이러한 메서드 호출이 반환되므로 해당 활성화 레코드가 더 이상 대기 시점의 스택에 없습니다.

그리고 도대체 예외와 스택 해제의 경우 런타임이이 모든 과정에서 어떻게 작동할까요?

포착되지 않은 예외가 발생하는 경우 예외가 포착되어 태스크 내부에 저장되며 태스크 결과를 가져올 때 다시 발생합니다.

내가 전에 언급했던 모든 부기를 기억하십니까? 예외 의미론을 올바르게 얻는 것은 엄청난 고통이었습니다.

수율에 도달하면 런타임이 항목을 선택해야하는 지점을 어떻게 추적합니까? 반복기 상태는 어떻게 보존됩니까?

같은 길. 지역의 상태가 힙으로 이동되고 MoveNext다음에 호출 될 때 재개되어야 하는 명령어를 나타내는 숫자 가 지역과 함께 저장됩니다.

그리고 반복자 블록에는 예외가 올바르게 처리되는지 확인하기위한 많은 장비가 있습니다.


1
작성자의 배경 (어셈블러 등)에 대한 질문으로 인해 이러한 두 구조 모두 관리 메모리 없이는 불가능하다는 점을 언급 할 가치가 있습니다. 클로저의 수명을 조정하려고 시도하는 관리 메모리 없이는 확실히 부트 스트랩에 걸려 넘어 질 것입니다.

모든 페이지 링크를 찾을 수 없음 (404)
Digital3D

현재 모든 기사를 사용할 수 없습니다. 다시 게시 할 수 있습니까?
Michał Turczyn

1
@ MichałTurczyn : 그들은 여전히 ​​인터넷에 있습니다. Microsoft는 블로그 아카이브가있는 곳으로 계속 이동하고 있습니다. 점차적으로 내 개인 사이트로 전체를 마이그레이션하고 시간이있을 때 이러한 링크를 업데이트하려고합니다.
Eric Lippert

38

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되면 작업 awaited가 이미 완료 되었기 때문에 반환 됩니다 (예 : 동 기적으로 반환 할 수있는 경우). 메서드는 상태를 계속 이동하지만 그렇지 않으면 대기자에 대한 콜백으로 설정됩니다.

콜백을 트리거하는 항목 (예 : 비동기 I / O 완료, 스레드 완료에서 실행중인 작업)과 특정 스레드로 마샬링하거나 스레드 풀 스레드에서 실행하는 데 필요한 요구 사항은 대기자에 따라 달라집니다. , 원래 호출의 어떤 컨텍스트가 필요하거나 필요하지 않을 수 있습니다. 그것이 무엇이든간에 그 awaiter의 무언가가을 호출 MoveNext하고 다음 작업을 계속하거나 (다음 작업까지 await) 완료하고 반환하는 경우 Task구현하는 것이 완료됩니다.


시간을내어 자신의 번역을 시작 했습니까? O_O uao.
CoffeDeveloper 2017

4
@DarioOO 내가 할 수있는 첫 번째는 꽤 빠르게 할 수있는 것인데, yield그렇게함으로써 이점이있을 때 수작업 으로 번역을 많이 한 것입니다 (일반적으로 최적화로서,하지만 시작점이 컴파일러에서 생성 된 것에 가깝다는 것을 확인하고 싶습니다). 따라서 나쁜 가정을 통해 최적화되지 않는 것은 없습니다.) 두 번째는 다른 답변에서 처음 사용되었으며 당시 내 지식에 몇 가지 공백이 있었기 때문에 코드를 수동으로 디 컴파일하여 해당 답변을 제공하면서 이러한 정보를 채웠습니다.
존 한나

13

이미 여기에 많은 훌륭한 답변이 있습니다. 나는 정신적 모델을 형성하는 데 도움이 될 수있는 몇 가지 관점을 공유 할 것입니다.

첫째, async메서드는 컴파일러에 의해 여러 조각으로 나뉩니다. await표현은 골절 포인트입니다. (간단한 메서드에 대해 쉽게 생각할 수 있습니다. 루프와 예외 처리가있는 더 복잡한 메서드도 더 복잡한 상태 머신이 추가됨에 따라 분리됩니다).

둘째, await상당히 간단한 시퀀스로 번역됩니다. 나는 Lucian의 설명을 좋아 하는데, "대기 가능 항목이 이미 완료되면 결과를 얻고이 메소드를 계속 실행하십시오. 그렇지 않으면이 메소드의 상태를 저장하고 리턴하십시오". (내 async소개 에서 매우 유사한 용어를 사용합니다 ).

await에 도달하면 런타임은 다음에 실행할 코드 조각을 어떻게 알 수 있습니까?

메서드의 나머지는 해당 대기 가능 항목에 대한 콜백으로 존재합니다 (작업의 경우 이러한 콜백은 연속입니다). awaitable이 완료되면 콜백을 호출합니다.

호출 스택은 저장 및 복원 되지 않습니다 . 콜백은 직접 호출됩니다. 중복 된 I / O의 경우 스레드 풀에서 직접 호출됩니다.

이러한 콜백은 메소드를 직접 계속 실행하거나 다른 곳에서 실행되도록 예약 할 수 있습니다 (예 : await캡처 된 UI SynchronizationContext및 I / O가 스레드 풀에서 완료된 경우).

중단 된 부분에서 다시 시작할 수있는시기를 어떻게 알 수 있으며 어디에서 어떻게 기억합니까?

모두 콜백입니다. awaitable이 완료되면 콜백을 호출하고 async이미 호출 한 모든 메소드 await가 재개됩니다. 콜백은 해당 메서드의 중간으로 이동하고 범위에 해당 지역 변수가 있습니다.

콜백이되어 있지 특정 스레드를 실행, 그들은 할 수 없습니다 자신의 호출 스택을 복원해야합니다.

현재 호출 스택은 어떻게됩니까? 어떻게 든 저장됩니까? 호출하는 메서드가 대기하기 전에 다른 메서드를 호출하면 어떻게 될까요? 스택이 덮어 쓰여지지 않는 이유는 무엇입니까? 그리고 도대체 예외와 스택 해제의 경우 런타임이이 모든 과정에서 어떻게 작동할까요?

콜 스택은 애초에 저장되지 않습니다. 필요하지 않습니다.

동기 코드를 사용하면 모든 호출자를 포함하는 호출 스택으로 끝날 수 있으며 런타임은이를 사용하여 반환 할 위치를 알고 있습니다.

비동기 코드를 사용하면 작업을 완료하는 일부 I / O 작업을 기반으로하는 일련의 콜백 포인터로 끝날 async수 있습니다 async.

따라서을 A호출 B하는 동기 코드를 사용하면 호출 C스택이 다음과 같이 보일 수 있습니다.

A:B:C

비동기 코드는 콜백 (포인터)을 사용합니다.

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

수율에 도달하면 런타임이 항목을 선택해야하는 지점을 어떻게 추적합니까? 반복기 상태는 어떻게 보존됩니까?

현재는 다소 비효율적입니다. :)

다른 람다처럼 작동합니다. 변수 수명이 확장되고 참조가 스택에있는 상태 개체에 배치됩니다. 모든 심층 세부 정보에 대한 최고의 리소스는 Jon Skeet의 EduAsync 시리즈 입니다.


7

yield그리고 await모두가 흐름 제어, 완전히 다른 두 가지를 다루는 동안이다. 그래서 따로 다루겠습니다.

의 목표는 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이 어떻게 보이는지 확인하십시오. 교육적이어야합니다.

async그리고 await다른 한편으로는 완전히 다른 물고기 주전자입니다. Await는 추상적으로 동기화 기본 요소입니다. 시스템에 "이 작업이 완료 될 때까지 계속할 수 없습니다."라고 알려주는 방법입니다. 그러나 언급했듯이 항상 스레드가 관련된 것은 아닙니다.

무엇 이며 참여하는 것은 동기화 컨텍스트라고 무언가이다. 항상 하나가 매달려 있습니다. 동기화 컨텍스트의 작업은 대기중인 작업과 해당 연속 작업을 예약하는 것입니다.

이라고 await thisThing()하면 몇 가지 일이 발생합니다. 비동기 메서드에서 컴파일러는 실제로 메서드를 더 작은 청크로 잘라냅니다. 각 청크는 "대기 전"섹션과 "대기 후"(또는 연속) 섹션입니다. 때 await를 실행하는 작업이 기다리고되고, 다음 계속이 - 즉, 함수의 나머지 - 동기화 컨텍스트로 전달됩니다. 컨텍스트는 작업 예약을 처리하고 완료되면 컨텍스트는 원하는 반환 값을 전달하여 연속을 실행합니다.

동기화 컨텍스트는 일정을 예약하는 한 원하는대로 자유롭게 수행 할 수 있습니다. 스레드 풀을 사용할 수 있습니다. 작업별로 스레드를 만들 수 있습니다. 동기식으로 실행할 수 있습니다. 다른 환경 (ASP.NET 대 WPF)은 환경에 가장 적합한 것을 기반으로 다른 작업을 수행하는 다른 동기화 컨텍스트 구현을 제공합니다.

(보너스 : 무엇을하는지 궁금한 적이 .ConfigurateAwait(false)있습니까? 시스템에 현재 동기화 컨텍스트 (보통 프로젝트 유형-WPF 대 ASP.NET 기반)를 사용하지 않고 대신 스레드 풀을 사용하는 기본 컨텍스트를 사용하도록 지시하는 것입니다.

다시 말하지만, 그것은 많은 컴파일러 속임수입니다. 생성 된 코드를 보면 복잡하지만 그것이 무엇을하는지 볼 수있을 것입니다. 이러한 종류의 변환은 어렵지만 결정적이고 수학적이므로 컴파일러가 우리를 위해이를 수행하는 것이 좋습니다.

PS 기본 동기화 컨텍스트의 존재에는 한 가지 예외가 있습니다. 콘솔 앱에는 기본 동기화 컨텍스트가 없습니다. 자세한 내용은 Stephen Toub의 블로그 를 확인 하십시오. 그것은에 대한 정보를 찾을 수있는 좋은 장소 asyncawait일반한다.


1
"기본 동기화 컨텍스트를 사용하지 않고 대신 스레드 풀을 사용하는 기본 동기화 컨텍스트를 사용하도록 시스템에 지시하고 있습니다."이것이 의미하는 바를 명확히 할 수 있습니까? "사용의 기본을 기본값을 사용하지 마십시오"
Kroltan

3
죄송합니다. 용어를 혼동하여 게시물을 수정하겠습니다. 기본적으로 현재 환경에 대해 기본값을 사용하지 말고 .NET (즉, 스레드 풀)에 대해 기본값을 사용하십시오.
Chris Tavares 2017

이해에 매우 간단 할 수 있었다, 당신은 내 투표 : 도착
에산 사자 드

4

일반적으로 CIL을 살펴 보는 것이 좋지만 이러한 경우에는 엉망입니다.

이 두 언어 구조는 작동 방식이 비슷하지만 약간 다르게 구현됩니다. 기본적으로 컴파일러 마법에 대한 구문 설탕 일 뿐이며 어셈블리 수준에서 미친 / 안전하지 않은 것은 없습니다. 간단히 살펴 보겠습니다.

yield더 오래되고 단순한 문장이며 기본 상태 머신의 구문 설탕입니다. 를 반환 IEnumerable<T>하거나 IEnumerator<T>포함 할 수있는 yield메서드는 메서드를 상태 머신 팩토리로 변환합니다. 한 가지 주목해야 할 점은 yield내부 가있는 경우 호출 할 때 메서드의 코드가 실행되지 않는다는 것입니다 . 그 이유는 작성한 코드가 IEnumerator<T>.MoveNext메서드 로 옮겨져 있는 상태를 확인하고 코드의 올바른 부분을 실행하기 때문입니다. yield return x;그런 다음 유사한 것으로 변환됩니다.this.Current = x; return true;

일부 반영을 수행하면 구성된 상태 머신과 해당 필드를 쉽게 검사 할 수 있습니다 (상태 및 로컬에 대해 적어도 하나). 필드를 변경하면 재설정 할 수도 있습니다.

await형식 라이브러리에서 약간의 지원이 필요하며 약간 다르게 작동합니다. Task또는 Task<T>인수를 취한 다음 작업이 완료되면 해당 값을 얻 거나 를 통해 연속을 등록합니다 Task.GetAwaiter().OnCompleted. async/ await시스템 의 전체 구현은 설명하는 데 너무 오래 걸리지 만 그다지 신비 롭지는 않습니다. 또한 상태 머신을 만들고 OnCompleted에 계속해서 전달합니다 . 작업이 완료되면 그 결과를 계속 사용합니다. awaiter의 구현은 연속을 호출하는 방법을 결정합니다. 일반적으로 호출 스레드의 동기화 컨텍스트를 사용합니다.

모두 yieldawait그들의 occurance에 따라 상태 기계를 형성하기까지의 방법의 각 부분을 대표하는 기계의 각 지점으로, 방법을 분할해야합니다.

스택, 스레드 등과 같은 "낮은 수준"용어에서 이러한 개념에 대해 생각해서는 안됩니다. 이는 추상화이며 내부 작업에는 CLR의 지원이 필요하지 않으며 마술을 수행하는 컴파일러 일뿐입니다. 이것은 런타임을 지원하는 Lua의 코 루틴이나 단지 흑마 법인 C의 longjmp 와는 크게 다릅니다 .


5
참고 : 작업await 을 수행 할 필요가 없습니다 . 뭐든지 충분합니다. do n't가 필요하지 않은 것과 약간 비슷하며 , 어떤 것도 충분합니다. INotifyCompletion GetAwaiter()foreachIEnumerableIEnumerator GetEnumerator()
IllidanS4은 모니카 다시 원하는
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.