Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일

(시리즈 글이 4개 있습니다.)
.NET Framework: 2008. C# - async/await 그리고 스레드 (1) MyTask로 재현
; https://www.sysnet.pe.kr/2/0/13055

.NET Framework: 2009. C# - async/await 그리고 스레드 (2) MyTask의 호출 흐름
; https://www.sysnet.pe.kr/2/0/13056

.NET Framework: 2012. C# - async/await 그리고 스레드 (3) Task.Delay 재현
; https://www.sysnet.pe.kr/2/0/13060

.NET Framework: 2014. C# - async/await 그리고 스레드 (4) 비동기 I/O 재현
; https://www.sysnet.pe.kr/2/0/13062




C# - async/await 그리고 스레드 (2) MyTask의 호출 흐름

자, 그럼 지난번 글에 이어서, 왜 "Thread.Sleep(16)"을 두었는지 설명해 보겠습니다. 우선, Task.Run을 사용한 예제에서 Sleep 코드를 제거해볼까요?

using System;

internal class Program
{
    static async Task Main(string[] args)
    {
        Console.WriteLine($"1단계: threadid == {Thread.CurrentThread.ManagedThreadId}");
        await DoAsync();
        Console.WriteLine($"2단계: threadid == {Thread.CurrentThread.ManagedThreadId}");
    }

    private static Task DoAsync()
    {
        return Task.Run(
            () =>
            {
                Console.WriteLine($"DoAsync called! (threadid == {Thread.CurrentThread.ManagedThreadId})");
            });
    }
}

위의 코드를 수행하면 보통 다음과 같은 출력을 볼 수 있습니다.

1단계: threadid == 1
DoAsync called! (threadid == 7)
2단계: threadid == 7

하지만 가끔씩, 이런 식의 출력도 나옵니다.

1단계: threadid == 1
DoAsync called! (threadid == 6)
2단계: threadid == 7

아니, Task.Run 스레드가 await 이후의 코드를 수행한다면서요? 그런데, 위의 결과는 (스레드 풀에 있던 것으로 짐작되는) 별도의 스레드가 await 이후의 코드를 실행하고 있습니다. 이유가 뭘까요?




이것을 밝혀내려면 (물론, Task.cs 소스 코드를 분석해도 되지만) 우리가 만든 MyTask의 소스 코드에서 마찬가지로 Thread.Sleep을 제거해 봐야 합니다.

using System.Runtime.CompilerServices;

internal class Program
{
    static async Task Main(string[] args)
    {
        Console.WriteLine($"1단계: threadid == {Thread.CurrentThread.ManagedThreadId}");
        await new MyDummy().DoAsync();
        Console.WriteLine($"2단계: threadid == {Thread.CurrentThread.ManagedThreadId}");
    }
}

class MyDummy
{
    public MyTask DoAsync()
    {
        MyTask _task = new MyTask(() =>
        {
            Console.WriteLine($"DoAsync called! (threadid == {Thread.CurrentThread.ManagedThreadId})");
        });

        return _task;
    }
}

public class MyTask
{
    Action? _continuation;

    bool _completed = false;
    public bool Completed => _completed;

    public MyTask(Action action)
    {
        ParameterizedThreadStart taskAction = (arg) =>
        {
            action();
            this.SetComplete();
        };

        Thread t = new Thread(taskAction);
        t.Start();
    }

    public MyTaskAwaiter GetAwaiter()
    {
        MyTaskAwaiter ta = new MyTaskAwaiter(this);
        return ta;
    }

    public void SetComplete()
    {
        _completed = true;

        if (_continuation != null)
        {
            _continuation();
        }
    }

    private void SetContinuation(Action continuation)
    {
        _continuation = continuation;
    }

    public struct MyTaskAwaiter : ICriticalNotifyCompletion, INotifyCompletion
    {
        MyTask _task;

        public MyTaskAwaiter(MyTask task)
        {
            _task = task;
        }

        public bool IsCompleted
        {
            get { return _task.Completed; }
        }

        public void GetResult()
        {
        }

        public void OnCompleted(Action continuation)
        {
        }

        public void UnsafeOnCompleted(Action continuation)
        {
            _task.SetContinuation(continuation);
        }
    }
}

이렇게 바꾸고 실행하면, 이제는 다음과 같이 await 이후의 코드가 실행되지 않는 것을 볼 수 있습니다.

1단계: threadid == 1
DoAsync called! (threadid == 7)

무엇이 문제일까요? 간단합니다. _continuation을 호출하지 못하는 상황이 발생하기 때문입니다.

public MyTask(Action action)
{
    ParameterizedThreadStart taskAction = (arg) =>
    {
        action();
        this.SetComplete();
    };

    Thread t = new Thread(taskAction);
    t.Start();
}

public void SetComplete()
{
    _completed = true;

    if (_continuation != null)
    {
        _continuation();
    }
}

위의 동작과 함께 원래의 await 코드가 다음과 같이 실행되는 형식이라는 것을 감안하면,

Task task = new MyDummy().DoAsync();
if (task.IsCompleted == true)
{
    // "분할 2" 코드 실행
}
else
{
    task.GetAwaiter().UnsafeOnCompleted(...[분할 2] 코드...);
}

Thread.Start 실행은 되었지만 아직 SetComplete를 호출하기 전이라고 가정해 보겠습니다. 그럼, if (task.IsCompleted == true)는 아니기 때문에 "분할 2" 코드는 실행되지 않고, 향후 IsCompleted가 true인 상황에서 실행되도록 UnsafeOnCompleted를 통해 보관하는 else 절을 타게 됩니다.

하지만, else 절까지 이동했지만 그사이 SetComplete의 코드가 실행되는 경우라면 어떨까요? 그럼 _completed = true가 되지만 아직 _continuation이 들어온 것은 아니므로 SetComplete 메서드에서는 실행이 안 될 것입니다. 결국 "분할 2" 코드는 어디에서도 실행이 안 된 것입니다.

따라서, 저런 식의 상황을 만들지 않기 위해 일부러 Thread.Sleep(16) 정도의 시간을 주면 우리가 원하는 대로 항상 스레드가 _continuation 코드를 실행할 수 있었던 것입니다.




(확실하게 분석한 것은 아니지만) 닷넷의 Task는 이런 문제를 해결하기 위해 실행되지 않는 UnsafeOnCompleted의 action 코드를 스레드 풀의 스레드가 실행하는 대비책을 마련해두었습니다.

이를 이용해 우리가 만든 MyTask의 동작을 다음과 같이 변경할 수 있습니다.

public class MyTask
{
    // ...[생략]...

    bool _executed = false;

    public void SetComplete()
    {
        _completed = true;

        _executed = _continuation != null;

        if (_executed == true)
        {
            _continuation!();
        }
    }

    private void SetContinuation(Action continuation)
    {
        _continuation = continuation;

        if (_completed == true && _executed == false)
        {
            ThreadPool.QueueUserWorkItem((obj) =>
           {
               _continuation();
           });
        }
    }

    // ...[생략]...
}

이렇게 하면, SetComplete 시점에 _continuation을 실행할 수 없어도 (UnsafeOnCompleted를 경유한) SetContinuation에서 스레드 풀에 넣어 두는 작업을 한 번 거치기 때문에 이런 실행 결과를 보입니다.

1단계: threadid == 1
DoAsync called! (threadid == 7)
2단계: threadid == 8




유의할 점은, 제가 이 글에서 구현하는 MyTask와 MyTaskAwaiter는 임의로 재현을 위해 코드를 만든 것일 뿐 이 동작이 BCL의 Task, TaskAwaiter와 정확히 같지는 않습니다. 그냥 전체적인 흐름이 그런 식으로 된다는 정도로 이해하시면 되겠습니다.

(첨부 파일은 이 글의 예제 코드를 포함합니다.)




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]







[최초 등록일: ]
[최종 수정일: 9/3/2023]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 



2022-05-13 12시18분
[한예지] 선생님, 코드에서 arg 매개변수를 사용하지 않는 것 같은데
MyTask 생성자에서 ParameterizedThreadStart 델리게이트를 사용하신 이유를 알 수 있을까요?
[guest]
2022-05-13 09시00분
아... 이유 없습니다. 습관적으로 스레드 생성자에 그걸 사용해서 그렇게 된 것입니다. ^^; (당연히 arg를 사용하지 않으면 ThreadStart 델리게이트를 사용해도 무방합니다.)
정성태

... 46  47  48  49  50  51  52  [53]  54  55  56  57  58  59  60  ...
NoWriterDateCnt.TitleFile(s)
12324정성태9/11/202010822개발 환경 구성: 514. smigdeploy.exe를 이용한 Windows Server 2016에서 2019로 마이그레이션 방법
12323정성태9/11/20209728오류 유형: 649. Copy Database Wizard - The job failed. Check the event log on the destination server for details.
12322정성태9/11/202010936개발 환경 구성: 513. Azure VM의 RDP 접속 위치 제한 [1]
12321정성태9/11/20208995오류 유형: 648. netsh http add urlacl - Error: 183 Cannot create a file when that file already exists.
12320정성태9/11/202010155개발 환경 구성: 512. RDP(원격 데스크톱) 접속 시 비밀 번호를 한 번 더 입력해야 하는 경우
12319정성태9/10/20209952오류 유형: 647. smigdeploy.exe를 Windows Server 2016에서 실행할 때 .NET Framework 미설치 오류 발생
12318정성태9/9/20209385오류 유형: 646. OpenVPN - "TAP-Windows Adapter V9" 어댑터의 "Network cable unplugged" 현상
12317정성태9/9/202011728개발 환경 구성: 511. Beats용 Kibana 기본 대시 보드 구성 방법
12316정성태9/8/202010131디버깅 기술: 170. WinDbg Preview 버전부터 닷넷 코어 3.0 이후의 메모리 덤프에 대해 sos.dll 자동 로드
12315정성태9/7/202012450개발 환경 구성: 510. Logstash - FileBeat을 이용한 IIS 로그 처리 [2]
12314정성태9/7/202011085오류 유형: 645. IIS HTTPERR - Timer_MinBytesPerSecond, Timer_ConnectionIdle 로그
12313정성태9/6/202012181개발 환경 구성: 509. Logstash - 사용자 정의 grok 패턴 추가를 이용한 IIS 로그 처리
12312정성태9/5/202016050개발 환경 구성: 508. Logstash 기본 사용법 [2]
12311정성태9/4/202011302.NET Framework: 937. C# - 간단하게 만들어 보는 리눅스의 nc(netcat), json_pp 프로그램 [1]
12310정성태9/3/202010576오류 유형: 644. Windows could not start the Elasticsearch 7.9.0 (elasticsearch-service-x64) service on Local Computer.
12309정성태9/3/202010320개발 환경 구성: 507. Elasticsearch 6.6부터 기본 추가된 한글 형태소 분석기 노리(nori) 사용법
12308정성태9/2/202011599개발 환경 구성: 506. Windows - 단일 머신에서 단일 바이너리로 여러 개의 ElasticSearch 노드를 실행하는 방법
12307정성태9/2/202012335오류 유형: 643. curl - json_parse_exception / Invalid UTF-8 start byte
12306정성태9/1/202010477오류 유형: 642. SQL Server 시작 오류 - error code 10013
12305정성태9/1/202011403Windows: 172. "Administered port exclusions"이 아닌 포트 범위 항목을 삭제하는 방법
12304정성태8/31/202010334개발 환경 구성: 505. 윈도우 - (네트워크 어댑터의 우선순위로 인한) 열거되는 IP 주소 순서를 조정하는 방법
12303정성태8/30/202010536개발 환경 구성: 504. ETW - 닷넷 프레임워크 기반의 응용 프로그램을 위한 명령행 도구 etrace 소개
12302정성태8/30/202010408.NET Framework: 936. C# - ETW 관련 Win32 API 사용 예제 코드 (5) - Private Logger파일 다운로드1
12301정성태8/30/202010735오류 유형: 641. error MSB4044: The "Fody.WeavingTask" task was not given a value for the required parameter "IntermediateDir".
12300정성태8/29/202010112.NET Framework: 935. C# - ETW 관련 Win32 API 사용 예제 코드 (4) CLR ETW Consumer파일 다운로드1
12299정성태8/27/202011042.NET Framework: 934. C# - ETW 관련 Win32 API 사용 예제 코드 (3) ETW Consumer 구현파일 다운로드1
... 46  47  48  49  50  51  52  [53]  54  55  56  57  58  59  60  ...