내용 보기

작성자

관리자 (IP : 172.17.0.1)

날짜

2020-07-10 05:32

제목

[C#] async/await에 대한 "There Is No Thread"


커뮤니티에 다음과 같은 글이 있군요.

스레드풀이 없는상태로 Task, Async, Await를 사용하면 
; http://lab.gamecodi.com/board/zboard.php?id=GAMECODILAB_QnA_etc&page=1&page_num=35&select_arrange=last_comment&desc=desc&sn=off&ss=on&sc=on&keyword=&no=4676&category=

답글에 보면, 다음의 원문 글을 인용하면서 "스레드 따로 사용하지 않습니다."라고 합니다.

There Is No Thread
; http://blog.stephencleary.com/2013/11/there-is-no-thread.html

그리곤 다시 한번 저 글을 인용하면서 아래의 글을 썼습니다.

C# Async/Await 의 진실 
; http://www.gamecodi.com/board/zboard-id-GAMECODI_Talkdev-no-4470-z-20.htm

물론, await 자체는 스레드를 따로 사용하지 않는 것이 맞습니다. 하지만, await 자체보다는 이후의 분리된 코드들에 대한 처리를 고려했을 때 무조건 "스레드 따로 사용하지 않습니다."라고 하는 것은 오해의 소지가 있습니다.

그런 의미에서 ^^ "There Is No Thread" 글의 의미를 파헤쳐 보겠습니다.




시작하기에 앞서, C#의 async/await 키워드에 대한 정리부터 하겠습니다. 제 책(시작하세요! C# 6.0 프로그래밍)의 667페이지에도 나오지만 async 키워드는 await 키워드 때문에 나온 부가적인 예약어입니다. 예를 들어, C# 4.0까지의 문법으로 작성한 다음과 같은 코드가 있을 때,

void func()
{
    int await = 5;
}

이를 await 예약어가 추가된 C# 5.0으로 빌드하면 예약어를 식별자에 사용했으므로 오류가 발생하게 됩니다. 그래서 이러한 문제를 해결하기 위해 async 예약어를 (원래는 필요 없을 텐데도) 추가적으로 도입을 한 것입니다. 덕분에 위의 func 코드는 C# 5.0에서도 정상적으로 빌드가 가능합니다. 즉, await 예약어가 C# 5.0으로 하여금 식별자가 아닌 예약어로써 취급하라는 정보가 바로 async 예약어인 것입니다. 따라서 다음과 같이 빌드하면 이제는 오류가 발생합니다.

async void func()
{
    int await = 5; // 컴파일 오류 Error	CS4003	'await' cannot be used as an identifier within an async method or lambda expression
}

이에 기반을 둬서, "C# Async/Await 의 진실" 글에 글쓴이가 인용한 Q&A 글을 봐야 합니다.

질문: Does the “async” keyword cause the invocation of a method to queue to the ThreadPool? To create a new thread? To launch a rocket ship to Mars?

답변: No. No. And no. See the previous questions. The “async” keyword indicates to the compiler that “await” may be used inside of the method, such that the method may suspend at an await point and have its execution resumed asynchronously when the awaited instance completes. This is why the compiler issues a warning if there are no “awaits” inside of a method marked as “async”.


위의 질문 답변을 명확하게 하고 싶다면 "async"라는 예약어를 C# 언어 개발자들이 "enable_await"라고 이름 지었다고 가정하면 편합니다. (사실, 원래부터 그렇게 지었으면 오해가 덜 했을 것입니다. 또는 C# 1.0부터 await을 제공했다면 async 예약어는 없었을 것입니다.) 이런 가정 아래 다시 질문/답변을 해석해 보면 더 쉽게 이해가 됩니다.

질문: Does the “enable_await” keyword cause the invocation of a method to queue to the ThreadPool? To create a new thread? To launch a rocket ship to Mars?

답변: No. No. And no. See the previous questions. The “enable_await” keyword indicates to the compiler that “await” may be used inside of the method, such that the method may suspend at an await point and have its execution resumed asynchronously when the awaited instance completes. This is why the compiler issues a warning if there are no “awaits” inside of a method marked as “enable_await”.


그러니까, async 예약어가 부여되었다고 해서 해당 메서드("async void func")가 "create a new thread" 하거나 ThreadPool에 들어가는 것이 아닙니다. 하지만 그렇다고 해서 await 예약어까지 스레드와 상관없다는 의미는 아닙니다.

자, 그럼 논란의 중심이 되었던 글로 가볼까요?

There Is No Thread
; http://blog.stephencleary.com/2013/11/there-is-no-thread.html

위의 이야기는 장치에 비동기 쓰기 작업을 하는 코드로 시작합니다.

private async void Button_Click(object sender, RoutedEventArgs e)
{
  byte[] data = ...
  await myDevice.WriteAsync(data, 0, data.Length);
}

그러면서 질문을 던집니다.

We already know that the UI thread is not blocked during the await. Question: Is there another thread that must sacrifice itself on the Altar of Blocking so that the UI thread may live?


그리곤, 장치에 대한 비동기 작업이 이뤄졌을 때의 호출 순서를 Device Driver까지 내려가면서 설명합니다. 위의 내용을 다음의 실제 사례 코드로 설명을 해보겠습니다.

async void ReadFile()
{
    string filePath = typeof(Program).Assembly.Location;
    FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
    byte[] buf = new byte[1024];
    await fs.ReadAsync(buf, 0, buf.Length);
}

ReadAsync 코드 호출은 비동기로 이뤄지지만 그 ReadAsync 자체의 호출은 "async void ReadFile" 메서드를 호출하는 스레드에서 이뤄집니다. 이후 Win32 비동기(Overlapped I/O) 절차를 따르는 ReadFile 메서드가 호출되고 이후부터는 "async void ReadFile" 메서드를 호출했던 스레드는 더 이상 I/O에 관여하지 않고 벗어나게 됩니다. 나머지 작업은 이제 커널 단에서 이뤄지는 데 I/O Request Packet(IRP)가 구성된 후 Device Driver 단을 따라 흐르게 되고 최종적으로 Device Driver는 Read 신호를 "하드 디스크"에 전송하고 끝을 냅니다.

여기까지 봤을 때, async/await로 인해 별도의 스레드가 관여하고 있는 것은 없습니다. 이에 대한 설명을 "There Is No Thread" 글에서는 다음과 같이 하고 있는 것입니다.

The write operation is now “in flight”. How many threads are processing it?

None.

There is no device driver thread, OS thread, BCL thread, or thread pool thread that is processing that write operation. There is no thread.


그런 다음, 하드 디스크의 읽기 작업이 완료되면 인터럽트를 발생하게 되고, CPU는 다시 Device Driver로 하여금 해당 인터럽트를 처리하도록 합니다. 그렇게 물리 장치에서 읽은 데이터를 처리한 다음 Device Driver는 Win32 Overlapped I/O에서 정의한 핸들을 Signaled 상태로 만듭니다. 만약 await 이후 코드가 없다면 Signaled 되었을 때 별다르게 할 일이 없으므로 결국 async/await은 별도의 스레드 관여 없이 끝나게 됩니다.

"There Is No Thread" 글의 저자가 말하고 싶었던 것은 명확합니다. async/await 자체로는 스레드 관여가 없다는 것입니다.




하지만, 이것을 오해해서 await의 사용 이후의 코드까지 스레드 관여가 없다고 생각하면 안 됩니다. 가령, 다음과 같이 await 이후의 코드를 작성해 보면,

async void ReadFile()
{
    string filePath = typeof(Program).Assembly.Location;
    FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
    byte[] buf = new byte[1024];
    await fs.ReadAsync(buf, 0, buf.Length);

    fs.Dispose();
    Console.WriteLine("I/O Done");
}

Read 작업의 완료로 인해 "Signaled" 상태가 되었을 때 IOCP 기능을 내장한 ThreadPool은 유휴 스레드를 하나 할당받아 fs.Dispose, Console.WriteLine 메서드의 호출 코드를 수행합니다. 이것은 테스트 코드로도 간단하게 확인할 수 있습니다.

static void Main(string[] args)
{
    ThreadPool.SetMaxThreads(4, 4); // 4개로 제한

    for (int i = 0; i < 4; i++)  // 미리 4개의 스레드 풀의 유휴 스레드를 소진하고
    {
        ThreadPool.QueueUserWorkItem((arg) =>
        {
            Console.WriteLine("WorkerThread: " + arg);
            Thread.Sleep(1000 * 5);  // 5초 동안 대기하므로 그동안에는 스레드 풀의 유휴 스레드가 없음!

        }, i);
    }

    Thread.Sleep(1000);

    ReadFile();

    Console.ReadLine();
}

static async void ReadFile()
{
    string filePath = typeof(Program).Assembly.Location;
    FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
    byte[] buf = new byte[1024];
    await fs.ReadAsync(buf, 0, buf.Length);

    fs.Dispose(); // 이후의 코드는 스레드 풀의 유휴 스레드에서 실행되는 데 5초 동안은 여유 스레드가 없으므로 
                  // 실행되지 않고 블록킹이 됨.
    Console.WriteLine("Done");
}

위의 코드를 보면, 스레드 풀의 여유 스레드를 모두 사용하고 있는 바람에 await 이후의 분리된 코드가 비동기 I/O 수행이 완료된 후에도 수행하지 않고 대기하게 됩니다. 그리곤 약 5초 후에 ThreadPool.QueueUserWorkItem의 작업들이 풀리면서 fs.Dispose, Console.WriteLine 메서드가 비로소 호출이 됩니다.

이 역시 "There Is No Thread" 글의 저자는 다음과 같이 언급하고 있습니다.

So, we see that there was no thread while the request was in flight. When the request completed, various threads were “borrowed” or had work briefly queued to them.





당연한 이야기지만, 이 때문에 I/O 작업이 아닌 비동기 처리는 반드시 스레드를 수반하게 되어 있습니다. 가령, 사용자 코드를 다음과 같이 비동기 처리할 수 있습니다.

async void func()
{
    await Task.Run((Func<int>)userWork);
}

int userWork()
{
    Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
    return 5;
}

위와 같이 하게 되면, 운영체제의 I/O 작업이 아닌 순수 사용자 코드를 실행하는 것이므로 CPU가 일을 해야 하고 당연히 그 대상은 스레드가 됩니다. 물론, 저 코드를 보면 await이 스레드를 생성한 것은 아니고 Task.Run의 호출로 된 것입니다. 하지만, 여기서도 await의 진정한 가치는 await 이후의 분리된 코드라는 점을 감안해야 합니다. 즉, 위와 같이 await을 사용하는 것은 아무 의미가 없고 차라리 다음과 같이 하는 것과 다를 바가 없습니다.

void func()
{
    Task.Run((Func<int>)userWork);
}

int userWork()
{
    Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
    return 5;
}

위와 같은 상황이라면 async/await 예약어가 나올 필요가 없는 것입니다. 애당초 await의 매력은 다음과 같이 코드를 작성할 수 있다는 데에 있습니다.

async void func()
{
    int result = await Task.Run((Func<int>)userWork);
    Console.WriteLine(result);
}

int userWork()
{
    Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
    return 5;
}

그리고, 개발자들의 관심은 int result = ..., Console.WriteLine(result)의 작업이 어디서 이뤄지느냐에 있는 것입니다. 대개의 경우, 그 작업에는 별도의 스레드가 반드시 관여하게 됩니다. (그런데, 왜 "대개의 경우"일까요? 그 이유가 잠시 후에 밝혀집니다.)




그런데, "There Is No Thread" 글에 보면 다음과 같은 설명이 나옵니다.

The task has captured the UI context, so it does not resume the async method directly on the thread pool thread. Instead, it queues the continuation of that method onto the UI context, and the UI thread will resume executing that method when it gets around to it.


엄밀히 말하자면, await 이후의 작업을 기본적으로 ThreadPool의 유휴 스레드에 무조건 맡기는 것은 아닙니다. 그전에, 해당 작업을 위한 호출 스레드에 SynchronizationContext 환경이 있는지를 보고, 제공되고 있다면 SynchronizationContext와 연관된 스레드에 await 이후의 작업을 맡기는 것입니다. SynchronizationContext가 제공되는 대표적인 환경이 바로 User Interface를 갖는 WinForm이나 WPF입니다. 각각 WindowsFormsSynchronizationContext, DispatcherSynchronizationContext라는 SynchronizationContext 환경을 제공합니다.

이것이 왜 필요하냐면?

예를 들면, UI를 갖는 프로그램에서 버튼 클릭에 대해 다음과 같이 반응하는 코드를 작성했다고 가정하겠습니다.

private async void Button_Click(object sender, RoutedEventArgs e)
{
    string text = await ReadTextFile();
    txtContents.Text = text;
}

보는 바와 같이, await 이후의 코드에 User Interface(위의 경우 txtContents 텍스트 컨트롤)와 상호 연동하는 코드가 있습니다. 잘 알려진 데로, UI 요소의 접근은 반드시 그 UI를 생성한 스레드에서만 해야 합니다. 하지만, 위와 같이 await으로 하게 되면 "txtContents.Text = text"라는 코드가 ThreadPool의 유휴 스레드에서 담당하게 됩니다. 따라서 오류로 연결되는데요.

마이크로소프트는 클라이언트 개발자들의 편의를 위해 이런 경우에도 자연스럽게 await 사용이 될 수 있도록 WindowsFormsSynchronizationContext를 Windows Forms의 응용 프로그램에 기본 제공을 합니다. 덕분에 await 사용 시 WindowsFormsSynchronizationContext와 연관된 스레드(즉, UI 스레드)를 기억하게 되고 이후 비동기 작업이 완료되었을 때 "txtContents.Text = text"에 대한 코드 실행을 UI 스레드에서 하도록 넘겨주는 것입니다.

이렇게 되면, await은 결국 아무런 스레드 생성 없이 작업을 모두 완료한 것이나 다름없습니다. 반면, 이렇게 편리한 기능임에도 불편함이 아주 없는 것은 아닙니다. UI 스레드와의 상호 연동을 숨겼기 때문에 이런 구조를 모르는 개발자들은 자칫 응용 프로그램이 멈추는 문제를 유발하곤 합니다.

async/await 사용시 hang 문제가 발생하는 경우
; https://www.sysnet.pe.kr/2/0/1541

async/await 사용시 hang 문제가 발생하는 경우 - 두번째 이야기
; https://www.sysnet.pe.kr/2/0/10801

어쨌든, 이렇게 (결과적으로 봤을 때) await에는 스레드가 관여하기도 하고 안 하기도 합니다. 역시 이에 대해서도 "There Is No Thread" 글에 보면 다음과 같이 언급하고 있습니다.

The idea that “there must be a thread somewhere processing the asynchronous operation” is not the truth.

must라는 표현을 쓴 이유가 있습니다. must라고 썼을 때 "is not the truth"는 맞습니다. 하지만 "may"라고 했으면 위의 문장은 "is the truth"로 끝났을 것입니다.




"There Is No Thread" 글은 정말 좋은 글입니다. 자칫 간과하기 쉬운 await의 비동기 I/O 코드 실행이 어떤 식으로 흘러간다는 것을 아주 잘 설명해 주고 있습니다.


출처1

https://www.sysnet.pe.kr/Default.aspx?mode=2&sub=0&pageno=4&detail=1&wid=11129

출처2