신입 개발자가 작성한 글입니다. 잘못된 정보가 있을 수 있습니다. 알려주신다면 적극 수정하도록 하겠습니다.
2023/08/16 기준 비동기에 대한 새로운 정보를 얻게 되어 비동기에 대해 더 공부하는 중입니다.
해당 공부 이후 새로 글을 작성하겠습니다.
앞으로 글의 서두에 이 문장을 꼭 달아야겠습니다. 지금의 제 머릿속에는 잘못된 정보가 굉장히 많다는 것을 거듭 깨닫습니다 🥲
우선, 제가 이 글과 이 글에서 찾던 비동기 입력은 이 세상에 없었습니다. 비동기의 의미를 잘못 알고 있었던 것입니다.
비동기 프로그래밍의 의미
동기식 프로그래밍의 단점
동기식 프로그래밍에서는 한 작업이 끝날 때까지 다음 작업을 시작하지 못하므로, 작업이 끝날 때까지 대기해야 하는 상황이 발생합니다. 이는 처리량과 응답 시간을 떨어뜨리는 원인이 됩니다.
비동기 프로그래밍의 장점
동기식 프로그래밍의 단점을 보완하여, 어떤 작업이 끝날 때까지 기다리지 않는 것이 비동기 프로그래밍입니다. 이에 대한 자세한 설명은 쉽게 찾을 수 있으므로 생략하도록 하겠습니다.
비동기 오해의 시작 : await
위와 같은 설명 때문에, 저는 비동기식 프로그래밍을 하면 해당 일이 완료될 때까지 일을 하는 곳과 무엇인가 끊어진다고 생각했습니다. 하지만 코드를 보고 있자면 의문이 끊이지 않았습니다.
var data = await LoadAsync();
💭 await ? 웨이트? LoadAsync 다 될때까지 기다리는데? 기다리는데 어떻게 비동기이지? 다 될때까지 기다리는데... 동기 아니야?
내가 생각하던 비동기 입력은 없었다!
제가 오해하고 있던 부분은 비동기 프로그래밍 자체가 아닌 '비동기 입력'이었습니다. 비동기로 입력을 요청하는 메서드인 ReadAsync() 등을 사용하거나 비동기 함수 내부에서 동기식 입력을 요청하면, 입력이 들어올 때까지 스레드를 점유하지 않을 것이라 생각한 것입니다. 하지만, 현실은 달랐습니다. 입력을 요청하는 메서드를 실행하면, 스레드는 언제 입력이 들어오나 오매불망 기다리고 있어야만 합니다. 물론 그 동안 스레드를 점유한 채로 말입니다.
테스트 1
위의 이야기는 아래 코드를 실행하여 확인할 수 있습니다. 스레드가 2개 뿐이므로, 아무리 비동기로 함수를 실행한덜 Func는 두 번밖에 실행되지 않습니다. 알아서 입력이 없으면 자리를 비켜 주고, 스케쥴링을 할 줄 알았는데, 그렇지가 않습니다.
public async static Task Main()
{
ThreadPool.SetMinThreads(2, 2);
ThreadPool.SetMaxThreads(2, 2); // 스레드 개수를 2개로 제한
for (int i = 0; i < 3; i++) // 비동기 함수를 3번 호출
{
Task.Run(() => Func());
}
Console.ReadKey();
}
static async Task Func()
{
Console.WriteLine("Async Func Start");
Console.ReadKey(); // 함수 내부에서는 무한 대기
}
현재 이해한 비동기의 개념
비동기 함수는 Task, Task<T> 자료형으로만 제작합니다. void 도 async로 선언할 수 있지만, 이는 어쩔 수 없는 이유가 있었다고 합니다. (*시작하세요! C# 프로그래밍 689pg에 나와 있음) Task로 실행된 비동기 함수는 여러 스레드를 넘나들며 높은 자유도로 실행됩니다.
Task 의 결과물을 받는 방법
Task는 '작업'을 의미하고, 작업의 결과물이지는 않습니다. 따라서 Task를 통해 만들어진 산출물을 출력하려면 아래 방법 중 한 가지를 이용해야 합니다. (더 있을 수 있습니다. 제가 파악한 세 가지 방법입니다.)
- await Task
- Task.Run
- Task.Result
먼저, 비동기 함수를 선언하겠습니다.
public static async Task<int> FuncAsync()
{
return 77;
}
1. await Task
await Task() 형태로 호출하면 Task가 다 끝날 때까지 기다린 후 산출물을 리턴합니다.
public static async Task Main()
{
var result = await Func();
Console.WriteLine(result);
}
2. Task.Run
Task.Run(Task) 형태로 호출하면 Task를 실행할 뿐, 결과물을 리턴하지는 않습니다. Task.Run은 Task를 리턴합니다. 여기서 결과를 출력하기 위해서는 Task.Result 또는 await를 사용해야 합니다.
public static async Task Main()
{
var task = Task.Run(FuncAsync);
Console.WriteLine(task);
var result = await task;
Console.WriteLine(result);
}
3. Task.Result
.Result 를 사용하면 Task.Run 등을 통해 실행된 Task가 다 끝날 때까지 대기한 후, 결과물을 출력할 수 있습니다.
public static async Task Main()
{
var task = FuncAsync();
var result = task.Result;
Console.WriteLine(result);
}
Task.Result와 await Task 의 차이점
이 둘의 차이점은 await Task는 await 가 호출되는 시점에 문맥 교환이 일어나고, Task.Result는 Task가 완료될 때까지 스레드를 점거한다는 것입니다.
문맥 교환이란 ?
비동기 호출에서 아주 핵심적인 개념이 바로 문맥 교환입니다. 문맥 교환을 하게 되면 현재 하던 일을 잠시 저장하고, 다른 일을 하고 돌아 올 수 있습니다. 이 저장에 걸리는 시간은 크게 신경 쓰지 않아도 괜찮습니다. 문맥 교환에 걸리는 시간은 1~1000 마이크로 초인데, 1000 마이크로 초는 0.001초 입니다.
이는 아래 Task.Delay와 Thread.Sleep의 차이를 통해 와 닿게 이해하실 수 있습니다.
Task.Delay 와 Thread.Sleep의 차이점
두 방법 모두 일정 시간을 기다리는 방법입니다. 하지만 약간의 차이점이 있습니다. 바로 Task.Delay 는 비동기로 딜레이를 주고, Thread.Sleep 는 해당 스레드를 일정 시간동안 잠재우는 방식으로 딜레이를 준다는 점입니다.
Task.Result와 await Task 의 차이점 이해
다시 Task.Result 와 await Task 로 돌아오겠습니다. Task.Result 를 실행하면 Thread.Sleep 처럼 스레드가 Task의 결과가 나올 때까지 잠에 들고, 아무도 사용할 수 없습니다. 반면, await Task를 호출하게 되면 해당 Task로 문맥 교환이 일어나게 되고, 현재 실행되던 Task는 Task 큐에 줄을 서게 됩니다.
이렇게, 하나의 Task는 몇 부분에 걸쳐서 쪼개져 실행될 수 있습니다. 쪼개진 Task들은 Task Queue에 들어가서 스레드에 자리가 나는 대로 순서대로 실행됩니다. 이것이 바로 비동기의 정체였던 것입니다.
테스트 2
아래 코드를 실행해 보며 비동기에 대해 마지막으로 이해해 보도록 하겠습니다.
public async static Task Main()
{
ThreadPool.SetMinThreads(1, 1);
ThreadPool.SetMaxThreads(2, 1);
var t1 = Task.Run(Work1Async);
var t2 = Task.Run(Work1Async);
await Task.Delay(1_000);
var t = Task.Run(Work3);
await t1;
await t2;
await t;
}
public static async Task Work1Async()
{
Console.WriteLine("Work1Async Start");
await Work2Async();
Console.WriteLine("Work1Async End");
}
public static async Task Work2Async()
{
Console.WriteLine("Work2Async Start");
await Task.Delay(1_200);
Console.WriteLine("Work2Async End");
}
public static Task Work3()
{
Console.WriteLine("Work3 Start");
var a = Console.ReadKey(); // 이게 지금 비동기로 갔어 사라졌어
return Task.CompletedTask;
}
코드를 간략하게 설명하자면 이렇습니다.
1. 스레드를 나눠 사용하는 것을 보기 위해 스레스 풀의 워커 스레드의 수는 2개로 제한했습니다.
2. Work1은 Work2를 실행하는데, Work2는 실행된 후 비동기로 1.2초를 대기합니다.
3. 두 개의 Work1가 실행되고, 1.2초를 기다리는 사이, Work3가 시작되어 스레드 하나를 점거합니다.
4. 이러한 상황에서 두 개의 Work1,2가 어떻게 실행되는지 확인합니다.
1. 첫 번째 Work1이 실행되었습니다. 위치는 97237 스레드입니다. 스레드풀에는 이미 97239 스레드도 발급되어 있네요. 2개의 스레드가 모두 발행된 상태입니다.
2. 두 번째 Work1이 실행되었습니다. 위치는 97239 스레드입니다.
3. 첫 번째 Work2가 실행되었습니다. 위치는 97237 스레드입니다.
아래 그림처럼 Work2를 실행하기 전의 Work1은 진행사항을 PCB에 기록하고 Work2로 문맥 교환이 일어납니다.
* 아래 그림에서 Task Stack은 설명을 위해 그린 자료입니다. 실제로 존재하는지는 모릅니다. (아마 있지 않을까요? 아니면 콜백으로 구현하나)
4. 두 번째 Work2가 실행되었습니다. 위치는 97239 스레드입니다.
그리고 Work2 내부에 선언한 Task.Delay가 작동하기 시작합니다.
5. Work3가 실행되었습니다. 위치는 97239 스레드입니다. 이제 이 스레드는 키 입력을 기다리며 Work3에 의해 점거됩니다.
6. 1.2초 대기를 완료한 하나의 Work2가 끝났습니다. 스레드의 위치는 105260 …? 갑자기 어디서 나타난 스레드인가요? 97237 스레드가 사라졌습니다… 이거 왜 그런지 아시는 분이 계시다면 제보 부탁드립니다 ! 이를 재현하기 위해 여러 번 디버깅을 다시 해 보았는데, 이런 현상은 발생하지 않았습니다. 모쪼록 설명을 돕기 위해 A 스레드, B 스레드로 설명하겠습니다.
A 스레드는 여전히 Work3이 점거하고 있습니다.
7. 두 번째 Work2가 끝났습니다. 스레드의 위치는 105260 입니다.
8. 두 개의 Work1이 끝났습니다. 스레드의 위치는 두 번 모두 105260 입니다.
9. 프로그램이 종료되었습니다. 출력도 정상적으로 완료되었습니다.
내가 비동기에 그토록 매달렸던 이유
저는 서버에서 스레드 하나당 하나의 클라이언트가 할당되는 게 싫었습니다. 클라이언트가 컴퓨터의 처리 속도만큼 빠르게 채팅을 할 수 있는 건 아니므로 분명 유휴시간이 생길텐데, 그 시간동안 아무 의미 없이 스레드가 낭비되고 있는 것이 참을 수 없었습니다 ... 이러한 문제점은 서버를 Event Driven 형식으로 설계하면 해결할 수 있다고 합니다. 하지만 아직 C#의 Event에 대해 완전히 이해하지 못했습니다 😂 모르는 것이 너무 많습니다. 다음번에 꼭 이 부분도 다뤄보고 싶습니다.
2023/08/14 추가 : Socket.ReadAsync 를 실행하자, 스레드를 반납하고 비동기 함수를 실행하는 것을 확인했습니다. 제가 찾던 완전히 스레드를 반납하고 입력을 감지했을 때 깨어나는 비동기(이하 완전 비동기라고 하겠습니다.)의 비밀이 여기에 있을지도 모르겠습니다!
2023/08/16 추가 : Microsoft 개발자가 작성한 글 (https://devblogs.microsoft.com/dotnet/how-async-await-really-works/
) 에서 비밀의 단서를 찾았습니다. 콜백 메서드를 이용해서 완전 비동기를 구현할 수 있을 것 같습니다.
'Server > C# 비동기와의 전쟁' 카테고리의 다른 글
Task.Run vs await (0) | 2023.11.03 |
---|---|
C# 이벤트 (LUA와 비교) (0) | 2023.06.09 |
[추가필요] 스레드 간 리소스 공유 (0) | 2023.06.02 |
스레드 종료를 기다리는 법 (Join, EventWaitHandle) (0) | 2023.05.22 |