Async CTP와 함께하는 좀 더 쉬운 비동기 프로그램밍

- 이글은 Eric Lippert가 쓴 “Easier Asynchronous Programming with the New Visual Studio Async CTP” 글의 번역/요약본 입니다.

 

에릭이 쓴 글은 Async 자체에 대한 기술적인 글이라기 보단 왜 Async가 다른 패턴보다 나은가에 대한 글입니다. 아주 간단한 코드 snippet로 부터 글을 시작합니다.

void ServeBreakfast(Customer diner) {     var order = ObtainOrder(diner);     var ingredients = ObtainIngredients(order);     var recipe = ObtainRecipe(order);     var meal = recipe.Prepare(ingredients);     diner.Give(meal); }

위의 코드는 정말 어디서나 볼수 있는 단순한, 정말 아무런 문제가 없어 보이는 코드죠. 하지만 만약 위의 코드중 하나의 method call (ex – ObtainIngredients)가 오래 걸린다면, 그리고 ServeBreakfast가 UI Thread에서 불린다면? 붐. 모두가 windows app에서 싫어 하는 그 하얀 바탕 화면의 unresponsive app이 되는거죠. 물론 여기에 다른 이슈들을 가미 시킨다면 위의 정말 단순해 보이는 코드가 또 다른 여러가지 문제들을 야기 시킬수 있겠죠. 이를테면, 한번에 하나만의 ServeBreakfast 코드가 UI에서 작동할수 있다던지, 동시에 진행할수 있는 일을 순차적으로만 실행한다던지 말이죠. 정말 단순해 보이는 코드도 UI라는 특수한 상황에 비추어 생각을 해 보면 간단하지가 않게되는거죠.

 

위의 문제 제기후, Eric은 현재까지 존재 하는 여러 방식의 해결책을 제시하면서 각 해결책이 가지고 있는 문제점을 지적합니다.

첫번째 해결 방식은 multi-thread를 사용하는 방식입니다.

이 해결책은 동시에 여러 ServeBreakfast 코드를 실행 할수 있는 방법을 제공하지만, 그와 함께 정말 많은 문제 역시 가지고 있습니다. 이를테면, 각각의 thread가 너무 무겁다는 거죠. default로 각 스레드마다 1메가의 stack 메모리를 가지고 가니까요, thread를 사용한다고 할지라도 UI Thread처럼 thread affinity가 있는 경우는 특정 call을 위해 context를 save 시켜야 하는 일도 프로그램머가 알아서 해 줘야 하고요 (SynchronizationContext, Control.Invoke, Dispatcher), 이걸로 인해 race condition이나 dead lock 문제가 생길수 있고요. 특히 여러개의 ServeBreakfast를 동시에 실행할수 있게만 해 줄 뿐이지, 그 안에서 blocking call이 있을 경우 Thread가 waste 되는건 여전히 막을수 없죠. 이런 여러가지 문제를 복합적으로 볼때 multi-thread로 UI Thread의 반응을 좋게 하려 할 경우, 코드가 완전 복잡해 질뿐 아니라, 실제 얼마나 더 반응성이 좋아지는지는 in practice에선 확실치 않죠.

 

두번째 해결 방식은 Application.DoEvents를 사용하는 방식입니다.

이 방식은 뭐 이런식으로 사용하는건데요. 일단 봐도 코드가 좀 드러워 보입니다. 땜빵 같아 보이죠.

void ServeBreakfast(Customer diner) {     var order = ObtainOrder(diner);     Application.DoEvents();     var ingredients = ObtainIngredients(order);     Application.DoEvents();     var recipe = ObtainRecipe(order);     Application.DoEvents();     var meal = recipe.Prepare(ingredients);     Application.DoEvents();     diner.Give(meal); }

간단한 경우, 특히나 loop인 경우, DoEvent가 잘 작동하기도 합니다만, 조금만 복잡한 경우, DoEvents는 재앙의 시작이죠. 일단 맨 처음 예처럼 ObtainIngredients가 오래걸린다 해도 이 DoEvents는 아무런 도움이 되지 못하죠. ServeBreakfast 중간 중간에 또 다른 ServeBreakfast가 실행 될수 있음은 당연하고요 (reentrancy). context를 explicit하게 save하지 않고, 어쩌다 stack에 있으니까 save 되긴 합니다만, 하여간 그 문제가 있구요. DoEvents가 message queue가 빌때까지 pumping 하니까, 나중에 run한 ServeBreakfast가 항상 오리지날 보다 먼저 끝나게 되는 문제들이 발생할수 있구요. 결론은 첫번째 방식 만만찮게 이것도 UI 반응성 하나를 높이기 위해 프로그램이 불안정해지거나 혹은 많은 양의 defensive 코드를 써서 reentrancy의 불안정 문제를 없앤다 해도, 로직을 완전 복잡하게 만들어 버리는 사태가 발생할수 있죠.

 

세번째 해결 방식은 lambda를 이용한 callback 방식입니다.

이건 가장 최근에 트랜드 탄 방식인데요. 특히 람다 (C# 3.0) 와 함께 Task가 소개된 .NET 4 부터 유행한 방식이죠. 일단 단순한 작업을 할때 정말 쉽게 적은 양의 코드로 문제를 해결할수 있고, Task가 context 및 dependency 그리고 synchonization을 간편하게 관리할수 있게 해 줌으로써 패러럴 프로그램밍과 비동기 프로그램밍의 대중화에 한발 다가 설수 있게 해줬죠. 하지만 이것 역시 그 나름의 문제가 있습니다. 간단할땐 모르지만, 일단 로직이 복잡해지면, 코드를 이런식으로 짜야 하는 문제가 생기고. 또 exception이나 기타 loop, try/catch 등과 같은 language construct들과 어떤 식으로 믹스 해야할지가 분명하지 않습니다.

void ServeBreakfast(Diner diner) {     ObtainOrderAsync(diner, order =>     {         ObtainIngredientsAsync(order, ingredients =>         {             ObtainRecipeAsync(order, recipe =>             {                 recipe.PrepareAsync(ingredients, meal =>                 {                     diner.Give(meal);                 });             });         });     }); }

위의 코드를 간단하게 설명하자면, diner로 부터 order를 가져오는 함수를 실행 시킨 후, 그 함수가 끝난후 실행해야 할 코드를 lambda로 준 후, control를 OS로 넘겨줍니다. (message pumping을 하게 해준다는 말입니다.) 이건 두번째 방법의 재진입과는 조금 다릅니다. 현 callstack안에서 또 다른 message dispatching을 하는게 아니라, 현 context를 다 save 한 후에, call을 exit 해서 app의 메인 message pumping으로 control를 돌려주는겁니다. 기존 app 의 structure와 control flow가 같기 때문에 재진입을 특별히 신경쓰지 않은 기존의 코드들과 같이 사용해도 문제가 될 확율이 훨씬 적습니다. order를 가져오는 함수의 실행이 끝나면, 알아서 lambda가 실행되고, 이번엔 order로 부터 ingredients가 가져오는 함수가 끝난후 실행 되어야 할 lambda를 준 후 다시 os로 control를 돌려주는것이죠. 본문에는 말하지 않았지만, 이것 역시 UI context 문제는 그대로 있습니다. 물론 TPL이 그걸 좀 더 쉽게 관리해 주는 방법을 제공하고, 각 Task가의 dependency 관리를 제공해 주니까 개발자가 일일이 명시적으로 코딩하는것 보단 훨씬 수월하죠. 정말 원하면 Task를 이용해서 좀 flow logic을 알아보기 쉽게 코드 할수도 있지만, 여전히 위에 써 놓은 문제들과, 순차적 프로그램밍에 익숙한 대부분의 개발자에게 이질적으로 보이는 문제를 해결할수는 없습니다.

 

마지막 해결 방식이 이번에 새로 들어간 async/await 해결 방식입니다.

이 방식은 위의 3번째 해결 방식을 언어 자체에 집어 넣어 준 것이죠. 보다 자세한 기술 적인 사항은 다음 포스트에 쓰기로 하겠습니다. 여기선 간단히 요약해서 쓰면, 밑에 처럼 일반적인 순차적 프로그램밍 방식으로 코딩을 await과 같이 하면, compiler가 알아서 밑에 코드를 3번 해결책 처럼 rewrite해 주는 겁니다. 물론 동시에 현 시점의 stack과 여러 context를 같이 save 해 줘서, 다음 코드가 실행 될때, 전과 동일한 상태로 만들어 주는것이죠. 물론 여기도 문제는 있습니다. global state이 await을 사이로 바뀔수 있다는 점인데. 이건 기존의 다른 어떤 방식도 다 가지고 있는 문제이니 딱히 이 해결책 만의 문제라고 볼쑨 없죠.

async void ServeBreakfast(Diner diner) {     var order = await ObtainOrderAsync(diner);     var ingredients = await ObtainIngredientsAsync(order);     var recipe = await ObtainRecipeAsync(order);     var meal = await recipe.PrepareAsync(ingredients);     diner.Give(meal); }

하여간, 위에 코드를 보시면 알겠지만, 기존에 나와 있던 어떤 방식보다 포커스를 코드 로직 자체에 그대로 둘수 있고, control flow가 가장 명확하면서 여타 잡달스러운 문제점으로 부터 벗어날수 있는 방식입니다. 재진입의 문제도 없고, multi-thread를 사용하지 않아도 되고 (원하면 사용할수도 있고), 사용자가 직접 context와 각 코드간의 의존성을 신경쓰지 않아도 되고, 동기화도 알아서 제공해주는, 거기다 콘트롤 순서도 직관적인 제 개인적인 의견으로 가장 이상적인 비동기 프로그램밍 방식입니다.

 

Async/await의 실제 사용 방법의 기술적인 내용은 다음 포스트에서 올리도록 하겠습니다.

 

수고.