[C#] 비동기 프로그래밍: Task.Run과 바로 호출의 차이점

2023. 10. 24. 22:36C#

오랜만에 글 한편 쓴다

 

오늘은 C# 비동기 프로그래밍의 핵심 개념 중 하나인 Task.Run의 사용과 그냥 메서드를 직접 호출하는 방법의 차이점에 대해 이야기해볼려고 하는데 이거 존나 중요하니까 잘 보도록 하자

public COMPortExplorerVM()
{
    Task.Run(async () => await MonitorComportStatusChangesAsync());
    B();
}
public COMPortExplorerVM()
{
    MonitorComportStatusChangesAsync();
    B();
}

위 두 코드는 어떤 차이점이 있을까?

아참 MonitorComportStatusChangesAsync() 의 구현부는 아래와 같다고 가정하자.

public async Task MonitorComportStatusChangesAsync()
{
    AA();
    BB();
    CC();
    await LongIOAsync();
    DD();
}

곰곰히 생각해보고 모르겠으면 아래를 보도록 하고 존나 쉽네 싶으면 걍 여자랑 놀러가면 되겠다

 

 

 

 

 

1. 실행 쓰레드의 차이

첫 번째 코드에서 Task.Run을 사용하면 MonitorComportStatusChangesAsync 메서드는 별도의 쓰레드(대부분의 경우 ThreadPool의 쓰레드)에서 실행된다. 그러므로 UI 쓰레드와는 별개의 쓰레드에서 비동기 작업이 실행되므로 UI 쓰레드의 차단을 피할 수 있다

반면에 두 번째 코드에서는 MonitorComportStatusChangesAsync 메서드가 현재 실행 중인 쓰레드(예: UI 쓰레드)에서 바로 실행되므로. 이 경우 await 키워드를 만나기 전까지 현재 쓰레드에서 실행된다.

 

즉 요약하면 새로운 쓰레드에서 실행되느냐 현재 쓰레드에서 실행되느냐 차이인데, 아직 중요한 부분 안 끝났으니 좀 더 읽어보도록 하자

 

2. 동작의 흐름

public COMPortExplorerVM()
{
    MonitorComportStatusChangesAsync();
    B();
}

두번째 코드에 대해 좀 더 자세히 알아보자

두번째 코드는 현재 실행 중인 쓰레드에서 바로 실행된다고 했는데 실행흐름이 어떻게 되는지 한번 알아보면

우선 B() 보다 MonitorComportStatusChangesAsync() 가 먼저 오므로 MonitorComportStatusChangesAsync()가 먼저 실행되는건 당연할거다

 

그럼 MonitorComportStatusChangesAsync() 내부로 가보면

public async Task MonitorComportStatusChangesAsync()
{
    AA();
    BB();
    CC();
    await LongIOAsync();
    DD();
}

이런 식으로 되어 있는데 이제 여기서 한번 생각해보자 CPU가 코드를 실행할때 전체 실행 순서는 어떻게 될까?

 

글 길게 쓰기 귀찮으니까 답을 알려주면 await LongIOAsync() 를 만나기 전까지는 그냥 쭉쭉 실행된다. 그리고 await LongIOAsync()를 만나면  이 시점에서 비동기 실행작업(LongIOAsync)이 완료될때까지 메서드 실행을 중단하고 제어권을 호출자에게 반환한다

그리고 이후 비동기 실행 작업이 마무리 되면 돌아와서 DD() 를 실행하게 된다

 

그럼 호출자가 누구냐?

COMPortExplorerVM 요놈이다 아 함수이름이 왜 저따구냐고? 그건 내가 사용하던 코드에서 따온거니까 따지지 말고 그냥 보도록 하자

즉 await LongIOAsync() 를 만나면 즉시 제어권은 호출자에게 건너가고 호출자에서 await가 없으니 그 다음 코드가 바로 실행된다.

 

이제 CPU가 실행하는 코드 순서를 정리해 보면 아래와 같다.

public async Task MonitorComportStatusChangesAsync()
{
    AA(); // 2
    BB(); // 3
    CC(); // 4
    await LongIOAsync(); // 5
    DD(); // 7  요놈은 바깥의 B() 실행 후에 실행된다
}
public COMPortExplorerVM()
{
    MonitorComportStatusChangesAsync(); // 1
    B(); // 6 요놈의 실행순서를 잘 보도록 하자
}

 

자 그럼 이제 마지막으로 아래 코드는?

public COMPortExplorerVM()
{
    Task.Run(async () => await MonitorComportStatusChangesAsync());
    B();
}

 

이건 뭐 쉽지 닷넷이 관리해주는 쓰레드 풀 에서 놀고있는 백수 쓰레드 한개를 징집해와서 MonitorComportStatusChangesAsync() 라고 명시된 일을 하라고 착취하는거다.

 

그러니 당연히 MonitorComportStatusChangesAsync() 와 B() 는 거의 동시에 실행되게 된다 듀얼코어 이상의 환경에서는 일정시간동안은 컨텍스트스위칭없이 말그대로 동시에 돌아갈수도 있고

 

요약하면

쓰레드풀에서 징집해온 놀고있던 쓰레드 : MonitorComportStatusChangesAsync() 에 배정되서 착취됨

현재 쓰레드 : B() 에 배정되서 착취됨

 

이후의 일을 좀 얘기해보면 그러다가 MonitorComportStatusChangesAsync() 함수 내부에서 await 를 만나는순간 제어권이 호출자로 넘어가고 호출자에서도 await를 만나게 되는데 이는 최상위 호출자이므로 더이상 갈곳이 없으므로 쓰레드는 휴가받고 자신의 고향 (쓰레드풀)으로 돌아간다.

즉 아래와 같다 보면 된다.

 

 

하지만 얼마지나지않아 이와 관련없는 전혀 다른 일 (다른 함수) 로 징집될테고 거서 또 죽도록 노동하다가 await 만나면 await 체인을 따라 최상위 호출자까지 제어권 반환하면서 실행하다 해산 그리고 또 다른일로 징집 이게 반복된다 보면된다. 이게 닷넷 쓰레드의 삶이며 숙명이다.

 

(여서 중요한건 await 만나서 await 체인을 따라 최상위 호출자까지 거슬러 올라간 후 지금까지 일해왔던 쓰레드가 해산될 경우 이후에 비동기 작업이 끝나고 배정된 쓰레드는 이전의 쓰레드와는 다른 쓰레드일 수도 있단거다, 아니 그럴 가능성이 더 크다)

 

 

어쨋든 결론은 이 글 본 이후부터라도 쓰레드 쓸일 있으면 new Thread 쓰지말고 닷넷 정부가 관리해주는 쓰레드 풀에서 백수 쓰레드를 요청 (Task.Run) 하자

 

글쓰기 귀찮다 대강 알아들어라