Thread 04. Task, Async, Await

목표 : Task.Run + async/await + return + Task.Delay 조합
1. 비동기 프로그래밍
CPU(중앙처리장치)는 메모리 접근, 데이터 입출력, 네트워크 통신, 화면 렌더링 제어 등 수많은 작업을 수행한다.
그런데 CPU가 디스크나 네트워크처럼 속도가 느린 작업이 끝날 때까지 그대로 기다린다면??
멈춰진 고속 연산 장치 CPU의 성능이 낭비된다.
이를 방지하기 위해 비동기 프로그래밍 기법을 사용한다.
비동기 프로그래밍은 CPU가 입출력 완료를 기다리느라 멈춰 있지 않고,
그 시간 동안 다른 연산을 처리하도록 만든다.
입출력이 끝나면 알림(콜백, 이벤트 등)을 통해 해당 작업을 다시 이어갈 수 있어,
CPU 자원을 효율적으로 활용할 수 있다.
2. C# 에서 비동기 프로그래밍
C# 에서 Thread, ThreadPool 기반 비동기 프로그래밍 기술로 시작했지만,
Task 기반, async / await 기반의 비동기 구현 개념만 이해하면 비동기 로직을 손쉽게 구현할 수 있다.
3. Task 기반 비동기 프로그래밍
: Task 기반 비동기 패턴 (Task-based asynchronous pattern)
A. Task 등장 배경
: ThreadPool 한계점
- 스레드가 재사용되므로 개발자가 직접 이름을 지정하거나 세부 제어를 할 수 없다.
- Thread.Join 같은 제어 메서드를 사용할 수 없어, 프로세스 흐름을 세밀하게 통제하기 어렵다.
- ThreadPool의 스레드는 기본적으로 백그라운드 스레드이므로,
- 메인 스레드가 종료되면 아직 실행 중인 작업이 중단될 수 있다.
- QueueUserWorkItem 방식은 리턴값을 직접 제공하지 않으므로,
- 작업 결과를 받으려면 별도의 동기화나 Task API를 활용해야 한다.
- ThreadPool은 내부적으로 최대 스레드 수가 제한되어 있어,
남은 자원이 없을 경우 새로운 작업은 대기열에서 차례를 기다려야 한다.
B. 동작 방식
- Task 클래스는 기본적으로 ThreadPool 기반의 스케줄러에 등록되어 실행된다.
- ThreadPool은 CPU 코어 수와 부하 상황에 따라 스레드 수를 자동으로 조정하여 처리 효율을 높인다.
- Task는 작업 상태(진행 중, 완료, 취소, 실패 등)를 확인할 수 있으며,
- Wait, ContinueWith, await 등을 통해 비동기 흐름을 제어할 수 있다.
또한 CancellationToken을 이용해 취소 요청을 지원하고, 실행 중 발생한 예외도 적절히 처리할 수 있다. - 특히 Task<TResult>를 사용하면 비동기 작업의 결과를 손쉽게 돌려받을 수 있다.
3. Task 기반 비동기 프로그래밍 예제
A. 구현 동작 리스트
- 커피 1 잔을 준비한다.
- 팬을 데운 다음 계란 2 개를 볶는다.
- 베이컨 3 조각을 볶는다.
- 빵 2 조각을 토스트기에 굽는다.
- 토스트에 버터와 잼을 바른다.
- 오렌지 주스 1 잔을 붓는다.
B. 동기적으로 실행의 경우
- 각각의 아침 식사를 만들기 위한 작업을 하나씩 진행한다.
C. 비동기적으로 실행의 경우
- 한 사람(또는 스레드)이 첫 번째 작업이 완료되기 전에
다음 작업을 시작하여 비동기적으로 아침 식사를 만들 수 있습니다. - 계란을 볶기 위해 팬을 데우기 시작하자마자
+ 베이컨을 볶을 수도 있습니다. - 빵을 토스터에 넣어 놓고
+ 오렌지 주스를 준비할 수 있습니다.
3.1 동기적 실행 코드
: 하나의 작업이 완료될때까지 다른 작업을 진행 할 수 없다.
using System;
using System.Threading.Tasks;
namespace AsyncBreakfast
{
#region 0) 데모용 마커 클래스(표현용 타입)
internal class Bacon { }
internal class Coffee { }
internal class Egg { }
internal class Juice { }
internal class Toast { }
#endregion
class Program
{
#region 1) 진입점(Main) — 전체 흐름(동기/순차)
static void Main(string[] args)
{
// (데모) 시작 전 대기: 실제 환경에서는 제거 가능
Task.Delay(2000).Wait();
Console.WriteLine("=== 아침 식사 준비 시작(동기적/순차 실행) ===\n");
// 1) 커피
Coffee cup = PourCoffee();
Console.WriteLine("@ 커피가 준비되었습니다.\n");
// 2) 계란후라이(2개)
Egg eggs = FryEggs(2);
Console.WriteLine("@ 계란 후라이가 준비되었습니다.\n");
// 3) 베이컨(3조각)
Bacon bacon = FryBacon(3);
Console.WriteLine("@ 베이컨이 준비되었습니다.\n");
// 4) 토스트(2조각) + 버터/잼 바르기
Toast toast = ToastBread(2);
ApplyButter(toast);
ApplyJam(toast);
Console.WriteLine("@ 토스트가 준비되었습니다.\n");
// 5) 오렌지 주스
Juice oj = PourOJ();
Console.WriteLine("@ 오렌지 주스가 준비되었습니다.\n");
Console.WriteLine("=== 아침 식사 준비 작업이 모두 끝났습니다! ===");
}
#endregion
#region 2) 주스(즉시 완료)
private static Juice PourOJ()
{
Console.WriteLine("[주스] 오렌지 주스를 잔에 붓습니다.");
return new Juice();
}
#endregion
#region 3) 토스트(굽기/버터/잼)
private static void ApplyJam(Toast toast) =>
Console.WriteLine("[토스트] 잼을 바릅니다.");
private static void ApplyButter(Toast toast) =>
Console.WriteLine("[토스트] 버터를 바릅니다.");
private static Toast ToastBread(int slices)
{
for (int i = 1; i <= slices; i++)
Console.WriteLine($"[토스트] {i}번째 빵을 토스터기에 넣습니다.");
Console.WriteLine("[토스트] 굽기 시작...");
Task.Delay(3000).Wait(); // 동기(블로킹) 지연 시뮬레이션
Console.WriteLine("[토스트] 토스트를 꺼냅니다.");
return new Toast();
}
#endregion
#region 4) 베이컨(블로킹 시뮬)
private static Bacon FryBacon(int slices)
{
Console.WriteLine($"[베이컨] 팬에 베이컨 {slices}조각을 올립니다.");
Console.WriteLine("[베이컨] 한 면을 굽는 중...");
Task.Delay(3000).Wait();
for (int i = 1; i <= slices; i++)
Console.WriteLine($"[베이컨] {i}번째 조각을 뒤집습니다.");
Console.WriteLine("[베이컨] 다른 면을 굽는 중...");
Task.Delay(3000).Wait();
Console.WriteLine("[베이컨] 접시에 베이컨을 담습니다.");
return new Bacon();
}
#endregion
#region 5) 계란(블로킹 시뮬)
private static Egg FryEggs(int howMany)
{
Console.WriteLine("[계란] 팬을 예열합니다...");
Task.Delay(3000).Wait();
Console.WriteLine($"[계란] 계란 {howMany}개를 깹니다.");
Console.WriteLine("[계란] 굽는 중...");
Task.Delay(3000).Wait();
Console.WriteLine("[계란] 접시에 계란후라이를 담습니다.");
return new Egg();
}
#endregion
#region 6) 커피(즉시 완료)
private static Coffee PourCoffee()
{
Console.WriteLine("[커피] 커피를 잔에 붓습니다.");
return new Coffee();
}
#endregion
}
}
3.2 비동기적 실행 코드로 변환
: 하나의 작업이 진행중에 다른 작업을 진행 할 수 있다.


using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace AsyncBreakfast
{
#region 0) 데모용 마커 클래스(표현용 타입)
internal class Bacon { }
internal class Coffee { }
internal class Egg { }
internal class Juice { }
internal class Toast { }
#endregion
class Program
{
// 프로그램 시작 시각 기록용
private static DateTime StartTime;
#region 1) 진입점(Main) — 전체 흐름(비동기/병행)
static async Task Main(string[] args)
{
StartTime = DateTime.Now; // 시작 시각 기록
Console.WriteLine($"=== 아침 식사 준비 시작(비동기/병행 실행) ===");
Console.WriteLine($"[시작 시각] {StartTime:HH:mm:ss.fff}\n");
// 1) 즉시 가능한 작업은 바로 수행
Coffee cup = PourCoffee();
Log("@ 커피가 준비되었습니다.\n");
// 2) 오래 걸리는 작업은 비동기로 병행 시작
var eggsTask = FryEggsAsync(2);
var baconTask = FryBaconAsync(3);
var toastTask = MakeToastWithButterAndJamAsync(2);
// 3) 완료되는 순서대로 알림(WhenAny)
var running = new List<Task> { eggsTask, baconTask, toastTask };
while (running.Count > 0)
{
Task finished = await Task.WhenAny(running);
if (finished == eggsTask)
Log("@ 계란 후라이가 준비되었습니다.");
else if (finished == baconTask)
Log("@ 베이컨이 준비되었습니다.");
else if (finished == toastTask)
Log("@ 토스트가 준비되었습니다.");
await finished; // 예외 전파/정상 종료 보장
running.Remove(finished);
}
// 4) 마지막으로 주스
Juice oj = PourOJ();
Log("@ 오렌지 주스가 준비되었습니다.\n");
Log("=== 아침 식사 준비 작업이 모두 끝났습니다! ===");
}
#endregion
#region 2) 고수준 조립(토스트 → 버터/잼)
static async Task<Toast> MakeToastWithButterAndJamAsync(int count)
{
var toast = await ToastBreadAsync(count);
ApplyButter(toast);
ApplyJam(toast);
return toast;
}
#endregion
#region 3) 동기 즉시 작업(커피/주스/토스트 바르기)
private static Coffee PourCoffee()
{
Log("[커피] 커피를 잔에 붓습니다.");
return new Coffee();
}
private static Juice PourOJ()
{
Log("[주스] 오렌지 주스를 잔에 붓습니다.");
return new Juice();
}
private static void ApplyButter(Toast toast) =>
Log("[토스트] 버터를 바릅니다.");
private static void ApplyJam(Toast toast) =>
Log("[토스트] 잼을 바릅니다.");
#endregion
#region 4) 비동기 작업(토스트/베이컨/계란)
private static async Task<Toast> ToastBreadAsync(int slices)
{
for (int i = 1; i <= slices; i++)
Log($"[토스트] {i}번째 빵을 토스터기에 넣습니다.");
Log("[토스트] 굽기 시작...");
await Task.Delay(3000); // 비동기 지연
Log("[토스트] 토스트를 꺼냅니다.");
return new Toast();
}
private static async Task<Bacon> FryBaconAsync(int slices)
{
Log($"[베이컨] 팬에 베이컨 {slices}조각을 올립니다.");
Log("[베이컨] 한 면을 굽는 중...");
await Task.Delay(3000);
for (int i = 1; i <= slices; i++)
Log($"[베이컨] {i}번째 조각을 뒤집습니다.");
Log("[베이컨] 다른 면을 굽는 중...");
await Task.Delay(3000);
Log("[베이컨] 접시에 베이컨을 담습니다.");
return new Bacon();
}
private static async Task<Egg> FryEggsAsync(int howMany)
{
Log("[계란] 팬을 예열합니다...");
await Task.Delay(3000);
Log($"[계란] 계란 {howMany}개를 깹니다.");
Log("[계란] 굽는 중...");
await Task.Delay(3000);
Log("[계란] 접시에 계란후라이를 담습니다.");
return new Egg();
}
#endregion
#region 5) 공용 로그 헬퍼(시작 시각/현재 시각/경과 시간)
private static void Log(string message)
{
DateTime now = DateTime.Now;
double elapsedMs = (now - StartTime).TotalMilliseconds;
Console.WriteLine($"[{now:HH:mm:ss.fff}] (+{elapsedMs,6:0}ms) {message}");
}
#endregion
}
}
Task.Run(...)
: 일반적인 비동기 작업 실행 권장 방법
namespace TPLTest
{
using System;
using System.Threading.Tasks;
class Program
{
static void Main(string[] args)
{
// Task.Run을 이용하여 스레드 생성과 동시에 실행
Task.Run(() => Run(null));
Task.Run(() => Run("1st"));
Task.Run(() => Run("2nd"));
// 아무 키나 누르면 종료
Console.WriteLine("아무 키나 누르면 종료합니다...");
Console.ReadLine();
}
static void Run(object data)
{
Console.WriteLine(data == null ? "NULL" : data);
}
}
}
Task.Factory.StartNew( );
: 쓰레드를 생성과 동시에 실행하는 방법(특수한 경우)
namespace TPLTest
{
using System;
using System.Threading.Tasks;
class Program
{
static void Main(string[] args)
{
// Task.Factory를 이용하여 스레드 생성과 시작
Task.Factory.StartNew(new Action<object>(Run), null);
Task.Factory.StartNew(new Action<object>(Run), "1st");
Task.Factory.StartNew(Run, "2nd");
// 아무 키나 누르면 종료
Console.WriteLine("Press Any key...");
Console.ReadLine();
}
static void Run(object data)
{
Console.WriteLine(data == null ? "NULL" : data);
}
}
}
Task<T>
: 비동기 작업에서 리턴 타입을 가질 수 있습니다.
using System;
using System.Threading.Tasks;
namespace TaskReturnValueExample
{
class Program
{
static async Task Main(string[] args)
{
Console.WriteLine("=== Task<T> 리턴값 학습 예제 ===");
// A) Task<int> 생성 : 문자열 길이를 구하는 작업
Task<int> lengthTask = Task.Run(() => GetLength("Hello World"));
// B) Task<string> 생성 : 문자열을 대문자로 변환하는 작업
Task<string> upperTask = Task.Run(() => ToUpper("async programming"));
// [~] 메인 스레드에서 다른 작업 흉내내기
Console.WriteLine("~ 메인 스레드에서 다른 작업 실행 중...\n");
// B.1) 결과 가져오기 방법 B - await (비동기, 권장)
string upper = await upperTask; // 완료 전이라면 비동기 대기
Console.WriteLine($"B [await] 대문자로 변환 = {upper}");
// A.1) 결과 가져오기 방법 A - Result (동기 블로킹)
int length = lengthTask.Result; // 완료 전이라면 끝날 때까지 대기
Console.WriteLine($"A [Result 속성] 문자열 길이 = {length}");
Console.WriteLine("\n=== 프로그램 종료 ===");
}
// 문자열 길이 반환
static int GetLength(string input)
{
Console.WriteLine("A 문자열 길이 계산 중...");
Task.Delay(2000).Wait(); // 시간 지연 흉내
return input?.Length ?? 0;
}
// 문자열을 대문자로 변환
static string ToUpper(string input)
{
Console.WriteLine("B 문자열 대문자 변환 중...");
Task.Delay(3000).Wait(); // 시간 지연 흉내
return input?.ToUpper() ?? string.Empty;
}
}
}
A.1) Result (동기 블로킹)
- 뜻: "Task가 끝날 때까지 여기서 멈추고 기다려라"
- 동작: 현재 실행 중인 스레드(Main 스레드 포함)를 그대로 붙잡고(Task가 끝날 때까지 점유) 기다립니다.
- 결과: Task가 끝나면 결과를 반환.
- 문제점:
- UI 앱(WPF, WinForms)에서는 화면이 멈춤(“응답 없음”).
- ASP.NET 같은 환경에서는 **데드락(교착 상태)**이 발생할 수 있음.
B.1) await (비동기, 권장)
- 뜻: “Task가 끝날 때까지 기다리지만, 스레드를 점유하지 말고 놀고 있던 다른 일 해라”
- 동작: 현재 스레드(Main 스레드 등)를 반납 >> 다른 요청 처리 가능.
- 결과: Task가 끝나면 원래 위치로 돌아와서 결과를 이어서 사용.
- 장점:
- UI 앱에서도 화면이 멈추지 않고 계속 반응함.
- ASP.NET 서버에서는 스레드를 놀리지 않고 효율적으로 요청을 처리 가능.
동작 비교
- A_Task 실행 시작
- GetLength("Hello World") 실행 >> 2초 대기 후 문자열 길이 반환.
- A_Task 는 2초 후에 완료 상태로 바뀌는 경우
- B_Task 실행 시작
- ToUpper("async programming") 실행 >> 3초 대기 후 대문자 반환.
- 2_Task는 3초 후에 완료 상태로 바뀌는 경우
- await B_Task 도달
- B_Task가 아직 끝나지 않았으면 현재 메서드(Main) 실행을 잠시 멈추고,
B_Task가 끝날 때(3초 후)까지 비동기적으로 대기. - 이 시점에서 A_Task는 이미 실행 중이고, 2초 후 완료될 예정.
- 중요한 점: await B_Task는 A_Task 의 완료 여부와 무관하게 B_Task만 기다립니다.
- B_Task가 아직 끝나지 않았으면 현재 메서드(Main) 실행을 잠시 멈추고,
- A_Task 완료 후 실행 재개
- B_Task가 끝나서 리턴 값이 준비되면, await 다음 줄부터 실행이 이어집니다.
- 이제 A_Task.Result 실행 → 만약 A_Task가 이미 끝났다면(2초 후 완료), 즉시 결과 반환.
- 만약 A_Task가 아직 끝나지 않았다면, Result는 끝날 때까지 동기 블로킹.
Task의 ThreadPool 갯수 제한
Task는 기본적으로 ThreadPool을 이용하기에 ThreadPool 처럼 Thread 갯수 제한이 가능하다.
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Generic;
namespace ThreadTest
{
// 스레드로 전달 될 파라미터 클래스
class Node
{
// 콘솔 출력시 사용되는 텍스트
public string? Text { get; set; }
// 반복문 횟수
public int Count { get; set; }
// Sleep의 시간틱
public int Tick { get; set; }
}
class Program
{
static void Main(string[] args)
{
// ThreadPool의 최소 쓰레드는 0, 최대 쓰레드는 2개로 설정
ThreadPool.SetMinThreads(0, 0);
ThreadPool.SetMaxThreads(2, 2);
// 리스트에 Task를 관리
var list = new List<Task<int>>();
// Task에 사용될 람다식 입력 값은 object 반환값은 int형
var func = new Func<object, int>((x) =>
{
// object를 Node타입으로 강제 캐스팅
var node = (Node)x;
// 설정된 반복문의 횟수만큼 값을 더하고, 시간 지연
int sum = 0;
for (int i = 0; i <= node.Count; i++)
{
sum += i;
Console.WriteLine(node.Text + " = " + i);
Thread.Sleep(node.Tick);
}
// 콘솔 출력
Console.WriteLine("Completed " + node.Text);
// 결과값 리턴
return sum;
});
// 리스트에 Task 작업 추가, 스레드풀의 제한된 갯수만큼 동작
list.Add(new Task<int>(func, new Node { Text = "A", Count = 5, Tick = 1000 }));
list.Add(new Task<int>(func, new Node { Text = "B", Count = 5, Tick = 10 }));
list.Add(new Task<int>(func, new Node { Text = "C", Count = 5, Tick = 500 }));
list.Add(new Task<int>(func, new Node { Text = "D", Count = 5, Tick = 300 }));
list.Add(new Task<int>(func, new Node { Text = "E", Count = 5, Tick = 200 }));
// list에 넣은 Task를 전부 실행하고, 전부 종료될 때까지 대기
list.ForEach(x => x.Start());
list.ForEach(x => x.Wait());
// 스레드에서 더해진 합을 출력
//lock를 사용하지 않아도 각 스레드 결과 값을 사용
Console.WriteLine("Sum = " + list.Sum(x => x.Result));
// 아무 키나 누르면 종료
Console.WriteLine("Press Any key...");
Console.ReadLine();
}
}
}
Task는 별도의 스레드를 생성 가능하다.
using System;
using System.Threading;
using System.Threading.Tasks;
public class Program
{
static void MainThread(object? obj)
{
for (int i = 0; i < 5; i++) Console.WriteLine("Create Thread");
}
static void Main(string[] args)
{
// 쓰레드를 최소 1개 최대 3개까지밖에 못빌려주도록 제한
ThreadPool.SetMinThreads(1, 1);
ThreadPool.SetMaxThreads(3, 3);
// 스레드 풀에서 스레드를 3개 가져와 무한 실행
for (int i = 0; i < 3; i++)
{
// Task의 LongRunning 옵션 사용
Task t = new Task(() => { while (true) ; }, TaskCreationOptions.LongRunning);
t.Start();
}
// Task도 기본적으로 ThreadPool에서 돌아가지만
// LongRunning 옵션을 생성해 둔 것은 별도의 스레드를 생성한다.
ThreadPool.QueueUserWorkItem(MainThread);
ThreadPool.QueueUserWorkItem(MainThread);
// 아무 키나 누르면 종료
Console.WriteLine("Press Any key...");
Console.ReadLine();
}
}
async & await

참고
- async 한정자만 붙인다고 자동으로 비동기가 되지 않습니다.
비동기 API를 await할 때, 해당 await가 아직 완료되지 않았으면 그 시점에서 호출자에게 제어가 반환됩니다. - await는 “병렬 실행”을 만드는 키워드가 아닙니다.
await는 비동기 완료를 “논블로킹”으로 기다리는 문법입니다.
병렬/동시 진행은 여러 작업을 시작(예: Task.Run 여러 개, 또는 여러 비동기 호출을 발행)해야 생깁니다. - async void는 이벤트 핸들러에서만 쓰세요. (예외 전파/대기 불가 문제)
일반 메서드는 async Task/async Task<T>로 작성합니다. - Thread.Sleep/Task.Delay().Wait() 같은 블로킹은 피하고, await Task.Delay(...)를 사용하세요.
예제 1) await "논블로킹 대기"라는 감각 익히기
using System;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
Console.WriteLine("=== 예제 1: await은 논블로킹 대기 ===");
// 비동기 작업 시작 (메시지 5회, 1초 간격)
Task printTask = PrintNumbersAsync("비동기", 5, 1000);
// 동시에 메인 루프도 진행 (메시지 5회, 500ms 간격)
for (int i = 1; i <= 5; i++)
{
Console.WriteLine($"[메인] 실행 {i}");
await Task.Delay(500); // 메인도 논블로킹 대기
}
// 비동기 작업 완료 대기
await printTask;
Console.WriteLine("=== 예제 1 완료 ===");
}
// 메시지를 n번 출력(간격: intervalMs)
static async Task PrintNumbersAsync(string tag, int count, int intervalMs)
{
for (int i = 1; i <= count; i++)
{
Console.WriteLine($"[{tag}___] _{i}");
await Task.Delay(intervalMs); // 논블로킹 대기
}
}
}
예제 2) 파라미터/리턴값 있는 작업을 여러 개 실행하고 합계 집계
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Example
{
// 작업 파라미터를 담는 클래스
class Node
{
public string Text { get; set; } = "";
public int Count { get; set; } // 반복 횟수
public int IntervalMs { get; set; } // 출력 간격(ms)
}
class Program
{
static async Task Main()
{
Console.WriteLine("=== 예제 2: 여러 Task<T> 실행 후 결과 집계 ===");
// 여러 개의 비동기 작업 발행
var tasks = new List<Task<int>>
{
RunAsync(new Node { Text = "A", Count = 5, IntervalMs = 1000 }),
RunAsync(new Node { Text = "B", Count = 5, IntervalMs = 100 }),
RunAsync(new Node { Text = "C", Count = 5, IntervalMs = 500 }),
RunAsync(new Node { Text = "D", Count = 5, IntervalMs = 300 }),
RunAsync(new Node { Text = "E", Count = 5, IntervalMs = 200 }),
};
// 모두 완료될 때까지 논블로킹 대기
int[] results = await Task.WhenAll(tasks);
// 각 작업의 합을 최종 집계
int total = results.Sum();
Console.WriteLine($"합계(Sum) = {total}");
Console.WriteLine("=== 예제 2 완료 ===");
}
// Node 파라미터를 받아 합계를 계산해서 반환하는 비동기 작업
static async Task<int> RunAsync(Node node)
{
int sum = 0;
for (int i = 0; i <= node.Count; i++)
{
sum += i;
Console.WriteLine($"{node.Text} = {i}");
await Task.Delay(node.IntervalMs); // 논블로킹 대기
}
Console.WriteLine($"Completed {node.Text}");
return sum; // Task<int>의 결과
}
}
}