C# - async/await 그리고 스레드 (3) Task.Delay 재현
지난 글(1, 2)을 통해, 기본적인 비동기 호출에 대한 동작 방식을 우리가 직접 MyTask를 구현하면서 알아봤습니다.
자, 그럼 이해를 돕기 위해 또 하나의 실습을 해볼까요? 적당한 걸로, 기존의 Task.Delay와 같은 기능을 구현해 보겠습니다. Task.Delay는 Thread.Sleep의 awaitable 버전인데요, 다음과 같은 식으로 사용합니다.
using System;
internal class Program
{
static async Task Main(string[] args)
{
Console.WriteLine($"[{DateTime.Now}] 1단계: threadid == {Thread.CurrentThread.ManagedThreadId}");
await Task.Delay(2000); // 2초의 비동기 sleep 후, 아래의 코드 실행
Console.WriteLine($"[{DateTime.Now}] 2단계: threadid == {Thread.CurrentThread.ManagedThreadId}");
}
}
/* 출력 결과
[2022-05-11 오후 10:12:12] 1단계: threadid == 1
[2022-05-11 오후 10:12:14] 2단계: threadid == 4
*/
그런데,
Sleep은 Win32 API로 제공은 되지만 엄밀히 비동기 버전의 SleepAsync API는 없습니다. 그렇다면 Task.Delay는 도대체 어떻게 구현을 한 것이고, await 이후의 코드는 어떤 스레드에서 실행이 되는 것일까요?
이를 알아보기 위해 우리가 만든 MyTask에도 Delay 기능을 넣어보겠습니다. 여기서 구현의 핵심은, (누누이 말했지만) await 이후로 분리된 코드를 실행할 스레드가 있어야 (
혹은 없다면 동기 호출) 한다는 점입니다.
자, 그럼 간단하게는 (기존 소스 코드에서) 이런 식으로 부가 코드를 넣어 구현하는 것이 가능합니다.
internal class Program
{
static async Task Main(string[] args)
{
Console.WriteLine($"1단계: threadid == {Thread.CurrentThread.ManagedThreadId}");
await MyTask.Delay(1000);
Console.WriteLine($"2단계: threadid == {Thread.CurrentThread.ManagedThreadId}");
}
}
public class MyTask
{
// ...[생략]...
public static MyTask Delay(int milliSeconds)
{
Action action = () =>
{
Thread.Sleep(milliSeconds);
};
return new MyTask(action);
}
}
/* 출력 결과
[2022-05-11 오후 10:14:24] 1단계: threadid == 1
[2022-05-11 오후 10:14:26] 2단계: threadid == 9
*/
비록, 구현은 했지만 한 가지 아쉬운 점이 있습니다. 바로 Sleep 시간 동안
(MyTask 내에서 새로 생성하는) 스레드 하나가 점유된다는 것입니다. Sleep이 비동기 I/O 호출을 지원하지도 않으므로 얼핏 보면 저게 최선일 듯한데요, 다행히 방법이 있습니다. 바로
System.Threading.Timer를 이용해 스레드 점유를 없애고 CLR ThreadPool을 활용하는 것입니다.
실제로 이 방법은 Task.Delay가 이용하고 있는데요, 우리도 다음과 같이 구현할 수 있습니다.
internal class Program
{
static async Task Main(string[] args)
{
Console.WriteLine($"[{DateTime.Now}] 1단계: threadid == {Thread.CurrentThread.ManagedThreadId}");
await MyTask.DelayAsync(2000);
Console.WriteLine($"[{DateTime.Now}] 2단계: threadid == {Thread.CurrentThread.ManagedThreadId}");
}
}
public class DelayTask : MyTask
{
System.Threading.Timer _timer;
public DelayTask(int milliSeconds)
{
_timer = new Timer(expiredCallback, null, milliSeconds, 0);
}
private void expiredCallback(object? state)
{
_timer.Dispose();
this.SetComplete();
}
}
public class MyTask
{
// ...[생략]...
public MyTask() { }
// ...[생략]...
public static DelayTask DelayAsync(int milliSeconds)
{
return new DelayTask(milliSeconds);
}
// ...[생략]...
}
/* 실행 결과 */
[2022-05-11 오후 10:14:39] 1단계: threadid == 1
[2022-05-11 오후 10:14:41] 2단계: threadid == 4
간단하죠? ^^ 스레드 걱정을 할 필요가 없는 이유는, Timer 타입의 생성자에 전달하는 callback 메서드를 (지정된 시간 이후에) CLR ThreadPool로부터 선택된 스레드가 실행해 주기 때문입니다. 그리고, 자연스럽게 그 과정에서 MyTask.SetComplete의 호출로 인해
await 이후의 "분할 2" 코드까지 실행이 됩니다.
(
첨부 파일은 이 글의 예제 코드를 포함합니다.)
자, 이렇게 해서 (
다음 편에서 다룰) 비동기 I/O를 제외하고는 기존의 Task에서 제공하던 Delay나 Task.Run을 이용한 비동기 호출 방식을 다뤄봤습니다. 보는 바와 같이, 마법은 없습니다. 단지 C# 컴파일러가 분리한 await 이후의 코드를 1) 사용자가 제공하든, 2) CLR ThreadPool에서 제공하든, 3) 설령 그것이 동기 또는 비동기이든 특정 스레드로 하여금 실행하도록 만들어야 한다는 사실에는 변함이 없습니다.
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]