C# async await를 이용하여 비동기를 동기처럼 구현하기 -1

jinwook4217·2020년 6월 8일
4
post-thumbnail

🤔비동기 프로그래밍이란 무엇인가?


비동기 프로그래밍의 원칙은, 오래 실행되는(또는 오래 실행될 가능성이 있는) 함수는 비동기 연산의 형태로 작성하라는 것이다. 이는, 오래 실행되는 함수를 그냥 동기적으로 작성하고, 필요하다면 새 스레드나 작업 객체에서 그런 함수를 호출함으로써 동시성을 도입하는 전통적인 접근방식과 대조된다.

그러한 전통적인 접근방식에 비한 비동기적 접근방식의 차이점은, 동시성이 함수 외부가 아니라 내부에서 시작한다는 것이다. 이로부터 다음 두 가지 장점이 비롯된다.

비동기적 접근방식의 장점

  • 스레드에 묶이지 않고도 I/O에 한정되는 동시성을 구현할 수 있다.
  • 일꾼 스레드를 다루는 코드의 양을 줄일 수 있으며, 그러면 스레드 안전성을 보장하기가 쉬워진다.

비동기 프로그램의 용도

응용 프로그램이 커짐에 따라 증가하는 복잡도를 다스리기 위해 흔히 큰 메서드를 더 작은 메서드들로 리팩터링하는데, 그러면 메서드들이 메서드들을 연쇄적으로 호출하는 '메서드들의 사슬'들이 생겨난다. 그런 사슬들은 하나의 호출 그래프를 형성한다.

전통적인 동기적 접근방식에서는, 호출 그래프에 오래 실행되는 연산이 끼어 있는 경우 UI의 반응성을 보장하려면 호출 그래프 전체를 스레드에서 실행해야 한다. 결과적으로 하나의 동시적 연산에 다수의 메서드들이 관여하게 되며(성긴 동시성), 프로그래머는 호출 그래프의 모든 메서드에 대해 스레드 안전성을 고려해야 한다.

그러나 비동기적 접근방식에서는 호출 그래프 전체를 스레드에서 실행할 필요가 없다. 그럴 필요가 있는 메서드에 대해서만 스레드를 띄우면 된다. 다른 모든 메서드는 모두 UI 스레드에서 실행할 수 있으며, 따라서 스레드 안전성이 훨씬 간단해진다. 이런 접근방식은 세밀한 동시성, 즉 작은 동시적 연산들로 이루어지며 그 실행이 UI 스레드와 개별 스레드를 오가는 형태의 동시성으로 이어진다.

C#의 비동기 함수


C# 5.0에는 asyncawait라는 키워드들이 도입되었다. 이 키워드들을 이용하면 동기적 코드와 같은 구조를 가진 비동기적 코드를 동기적 코드만큼이나 간단하게 작성할 수 있다.

await 키워드를 이용한 대기

await 키워드는 연속용 콜백의 부착을 단순화한다. 간단한 예로, 컴파일러는 다음과 같은 코드를

var 결과 = await 표현식;
문장();

기능상으로 다음에 해당하는 코드로 바꾸어서 컴파일한다.

var awaiter = 표현식.GetAwaiter();
awaiter.OnCompleted(() => {
	var 결과 = awaiter.GetResult();
	문장();
});

이해를 돕기 위해, 소수들의 개수를 세는 비동기 메서드를 살펴보자.

Task<int> GetPrimesCountAsync(int start, int count) {
	return Task.Run(() =>
		ParallelEnumerable.Range(start, count).Count(n =>
			Enumerable.Range(2, (int) Math.Sqrt(n) - 1).All(i => n % i > 0)));
}

다음은 await 키워드를 이용해서 이 메서드를 호출하는 예이다.

int result = await GetPrimesCountAsync(2, 1000000);
Debug.Log(result);

이 코드가 컴파일되려면, 이 코드를 포함한 메서드에 async 수정자를 추가해야한다.

public async void DisplayPrimesCount() {
	int result = await GetPrimesCountAsync(2, 1000000);
	Debug.Log(result);
}

async 수정자는 반환 형식이 void 이거나 Task, Task 인 메서드(그리고 람다식)에만 적용할 수 있다.

async 수정자가 지정된 메서드를 비동기 함수라고 부른다. 그런 메서드는 그 자체가 비동기적인 경우가 많기 때문이다. 앞의 비동기 메서드를 지금까지 설명한 과정에 맞게 확장하면 다음과 같은 모습이 된다.

void DisplayPrimesCount() {
	var awaiter = GetPrimesCountAsync(2, 1000000).GetAwaiter();
	awaiter.OnCompleted(() => {
		int result = awaiter.GetResult();
		Debug.Log(result);
	});
}

지금 예에서 await 표현식이 최종적으로 하나의 int 값으로 평가된다는 점을 주목하기 바란다. 이는 대기하려는 표현식이 Task 객체에 해당하며, 그 객체에 대한 GetAwaiter().GetResult() 가 int 를 돌려주기 때문이다.

비제네릭 Task 객체를 기다리는 것도 적법하다. 이 경우 해당 표현식은 void 표현식이다.

await Task.Delay(5000);
Debug.Log("5초가 지났음!");

동기적인 코드를 작성할 때처럼 코드를 짜되, 실행을 차단하는 함수를 비동기 함수로 대체하고 그 함수를 호출하는 표현식에 await 를 적용하면 된다. 일꾼 스레드에서 실행되는 코드는 GetPrimesCountAsync의 본문뿐이다. 이 덕분에 스레드 안전성이 간단해진다. 지금 예에서 문제가 될 만한 부분은 재진입, 즉 계산이 진행되는 도중에 사용자가 버튼을 다시 클릭해서 GetPrimesCountAsync에 다시 진입하려는 상황뿐이다.(이를 방지하는 한 방법은 계산 시작 시 버튼을 비활성화하는 것이다.)

비동기 함수 작성

반환 형식이 void인 메서드가 있을 때, void를 Task로 바꾸고 async를 적용하면 메서드 자체가 await를 적용할 수 있는 유용한 비동기 버전으로 변한다.

async Task PrintAnswerToLife() {  // void 대신 Task를 돌려준다.
	await Task.Delay(5000);
	int answer = 21 * 2;
	Debug.Log(answer);
}

메서드 본문에서 명시적으로 Task 객체를 돌려주지는 않음을 주목하기 바란다. 컴파일러는 Task 객체를 만들고, 메서드 실행이 완료되면(또는 미처리 예외가 발생하면) 그 작업 객체에 신호한다. 이 덕분에 비동기 호출 연쇄(비동기 함수 안에서 다른 비동기 함수를 호출하는)를 사용하기가 어렵지 않다.

async Task Go() {
	await PrintAnswerToLife();
	Debug.Log("Done");

그리고 Go의 반환 형식이 Task이므로 Go 자체도 대기 가능한(await를 적용할 수 있는) 함수이다.

Task를 돌려주는 비동기 함수

반환 형식이 void가 아닌 메서드를 비동기화할 때에는, 원래의 반환 형식이 TResult라고 할 때 반환 형식을 Task로 바꾸면 된다. 다음은 TResult가 int인 경우이다.

async Task<int> GetAnswerToLife() {
	await Task.Delay(5000);
	int answer = 21 * 2;
	return answer;  // int를 돌려주는 return이 있으므로,
}									// 메서드 반환 형식을 Task<int>로 한다.
async Task Go() {
	await PrintAnswerToLife();
	Debug.Log("Done");
}

async Task PrintAnswerToLife() {
	int answer = await GetAnswerToLife();
	Debug.Log(answer);
}

async Task<int> GetAnswerToLife() {
	await Task.Delay(5000);
	int answer = 21 * 2;
	return answer;
}

결과적으로, 우리는 원래의 PrintAnswerToLife를 두 개의 메서드로 리팩터링했다. 동기적 코드를 리팩터링할 때보다 더 어렵지는 않았음을 주목하기 바란다. 동기적 프로그래밍과의 유사성은 의도적인 것이다.

이상의 예재들에는 C#에서 비동기 함수를 설계할 때 사용하는 기본 원리들이 반영되어 있다. 정리하자면 다음과 같다.

  1. 메서드를 동기적으로 작성한다.
  2. 동기적 메서드 호출들을 비동기적 메서드 호출들로 바꾸고 await를 적용한다.
  3. '최상위' 메서드(흔히 UI 컨트롤에 대한 이벤트 처리부)를 제외한 비동기 메서드들의 반환 형식을 Task에서 Task로 바꾼다(await를 적용해서 대기할 수 있도록)

비동기 람다 표현식

통상적인 메서드, 즉 이름이 붙은 메서드를 비동기화하면 다음과 같은 형태가 된다.

async Task NamedMethod() {
	await Task.Delay(1000);
	Debug.Log("Foo");
}

이와 비슷하게, 이름이 없는 메서드도 async 기뭐드를 붙여서 비동기화할 수 있다.

Func<Task> unnamed = async () => {
	await Task.Delay(1000);
	Debug.Log("Foo");
};

이들을 비동기적으로 호출하는 구문은 동일하다.

await NamedMethod();
await unnamed();

다음은 비동기 람다식을 이벤트 처리부로 등록하는 예이다.

myButton.Click += async(sender, args) => {
	await Task.Delay(1000);
	myButton.Content = "Done";
};

비동기 람다식 역시 Task를 돌려줄 수 있다.

Func<Task<int>> unnamed = async() => {
	await Task.Delay(1000);
	return 123;
};
int answer = await unnamed();

최적화


동기적 완료

비동기 함수가 대기 이전에 반환될 수도 있다. 웹 페이지를 내려받는 연산에 캐싱을 적용하는 다음과 같은 메서드를 생각해 보자.

static Dictionary<string, string> _cache = new Dictionary<string, string>();

async Task<string> GetWebPageAsync(string uri) {
	string html;
	if (_cache.TryGetValue(uri, out html)) return html;
	return _cache[uri] = await new WebClient().DownloadStringTaskAsync(uri);
}

이 메서드는 주어진 URI가 캐시에 존재하면 대기를 수행하지 않고 즉시 결과를 반환한다. 이때 결과는 이미 신호된 작업 객체이다. 이러한 방식을 동기적 완료라고 부른다.

참고 자료


  • C# 6.0 완벽 가이드 2권 (734p ~ 759p)

C# 6.0 완벽 가이드

profile
유니티 개발을 조금씩 해왔습니다.

2개의 댓글

comment-user-thumbnail
2020년 12월 17일

와 쩐다 유니티 프로그래밍 하다가 이걸 보고 대가리 빠개질 것 같던 비동기 함수 5개를 에러없이 뚞딱 만들어 낼 수 있었습니다.

1개의 답글