C# 5.0 문법 | ThreadPool의 단점을 개선한 Task와 Async, Await로 비동기 코드 작성하기

seunghyun·2024년 9월 17일

소개

Async, Await은 5.0부터 비동기를 쉽게 제공하기 위해 등장했으며 Thread, Task를 공부하는 목적이 Async, Await를 사용해 비동기 프로그래밍을 하는 것 같다. (예를 들어서 Task는 그 자체로 홀로 사용되기 보다는 Async, Await와 함께 사용된다)

그래서 Async, Await를 사용하기 전에 Thread, ThreadPool, Task를 짚고 넘어가자!

+) Task는 Func, Action과 같은 델리게이트를 이해하고 있어야 응용 가능하다 (당연히 람다로도 실행 가능)


Task

C# 4.0 버전에서 나온 Task 클래스는 C# 1.0 부터 있었던 스레드, 스레드풀의 단점을 개선한 클래스이다. 그래서 스레드를 이해하고 있다면 Task도 이해하기 쉽고, 스레드풀을 사용할 일이 있으면 Task를 사용하는 것이 나을 수 있다.

특징 1. Task는 백그라운드 스레드이며 스레드풀을 이용한다.

using System.Threading.Tasks;
...

public class TestTask : MonoBehaviour
{
	void Start()
    {
    	Tast task = new Task(BackgroundTask);
        task.Start();
    }
    
    void BackgroundTask()
    {
    	bool isBackground = Thread.CurrentThread.IsBackground;
        bool isThreadPoolThread = Thread.CurrentThread.IsThreadPoolThread;
    }
}

특징 2. 스레드와 비슷한 기능들이 제공된다. 내부적으로도 이미 스레드풀을 사용하고 있음.

그 예로 Task.Wait() 의 경우 Thread.Join() 처럼 특정 task가 종료될 때까지 현재 task를 블록시킨다.

public class TestTask : MonoBehaviour
{
	void Start()
    {
    	ThreadPool.QueueUserWorkItem((obj)=>
        {
        	Debug.Log("스레드풀 실행");
        });
        
        Task task = new Task(() =>
        {
        	Debug.Log("Task 실행");
        });
        
        task.Start();
        task.Wait(); // Thread.Join 메서드와 비슷한 기능
    }
}

특징 3. Task가 완료된 후 수행할 작업을 지정할 수 있다.

스레드의 경우 스레드 작업이 끝난 후 이어질 작업을 지정하는 기능이 기존 C# (닷넷 프레임워크 차원) 에서 지원해주지 않는데, Task가 나오면서부터 가능해졌다.

ContinueWith() 로 가능하다.

public class TestTask : MonoBehaviour
{
	void Start()
    {
    	Task task = new Task(SleepAction);
        // Task가 완료된 후 수행할 작업 지정
        task.ContinueWith((obj) =>
        {
        	Debug.Log("Task 완료");
        });
        task.Start();
    }
    
    void SleepAction()
    {
    	Thread.Sleep(3000);
    }
}

그리고 아래 특징4의 예시 코드에 등장하는 WhenAll() 또한, 매개변수로 들어오는 Task(들)이 모두 완료될 때까지 대기 후 모든 Task가 완료되면 새로운 Task를 반환한다.

특징 4. Run() 메소드로 Task의 생성과 시작을 한번에 할 수 있다. Start()를 따로 해주지 않아도 바로 시작할 수 있다.
+) Task.Factory.StartNew() 메소드는 Run() 메소드보다 더 많은 선택 옵션이 있다. (취소 기능 등)

참고로 Run() 메소드 정의부터 살펴보면 아래와 같다.

간단한 작업 실행에 주로 사용된다. 
그래서 더 복잡한 옵션이 필요하다면 StartNew 가 추천됨.
(CancellationToken은 작업 취소 기능을 제공하는 옵션이다)
...
public static Task Run(Func<Task> function);
public static Task Run(Action action);
public static Task Run(Action action, CancellationToken cancellationToken);
...

사용 예시는 아래와 같다.

public class TestTask : MonoBehaviour
{
	private Task task1, task2, task3;
    private Stopwatch stopwatch;
    
	void Start()
    {
    	stopwatch = new Stopwatch();
        stopwatch.Start();
    
    	task1 = Task.Run(Task1Function);
        task2 = Task.Run(() =>
        {
        	Thread.Sleep(2000);
        });
        task3 = Task.WhenAll(task1, task2);
    }
    
    private void Update()
    {
    	if(task3.IsCompleted)
        {
        	stopwatch.Stop();
            Debug.Log("경과 시간 : "+stopwatch.Elapsed.TotalSeconds+"초");
        }
    }
    
    void Task1Function()
    {
    	Thread.Sleep(3000);
    }
}

특징 5. 기존 스레드, 스레프풀은 결과값을 확인하고 취합할 때 어렵다는 단점 등이 있는데, Task는 결과값 반환으로 가능성이 많아졌다.

public class TestTask : MonoBehaviour
{
	void Start()
    {
    	Task<int> task = Task.Run(Test);
        task.Wait();
        Debug.Log($"실행결과: {task.Result}");
    }
    
    int Test()
    {
    	int result = 0;
        for(int i=0; i<10; i++)
        {
        	Thread.Sleep(100);
            result = i+1;
        }
        return result;
    }
}

Async Await

C# 1.0

Async Await 를 본격적으로 알아보기 전에,
이 개념이 나온 배경을 알아보자!

메인 스레드를 대기/차단(block)시키지 않고 별도의 새로운 스레드에서 다른 작업을 처리하도록 할 수 있다.

C# 1.0 에서 비동기 실행 방법은 BeginInvoke와 EndInvoke메소드로 간단히 해볼 수 있다.

BeginInvoke를 사용하는 예를 보자. 델리게이트를 실행하는 BeginInvoke의 콜백 메소드의 매개변수이자 BeginInvoke의 반환 형식으로는 IAsyncResult이 있다.

그리고 IAsyncResult에는 AsyncState라는 오브젝트 타입이 있다. 이는 BeginInvoke의 두번째 매개변수가 될 수 있다.

public class AsyncTest : MonoBehaviour
{
	void Start()
    {
    	Action action = LongRunningOperation;
        IAsyncResult result = action.BeginInvoke(new AsyncCallback(EndOperationCallback), null);
        Debug.Log("메인스레드 진행 중");
    }
    
    void LongRunningOperation()
    {
    	Thread.Sleep(3000); 
        Debug.Log("오래 걸리는 작업 완료");
    }
    
    void EndOperationCallback(IAsyncResult asyncResult)
    {
    	Debug.Log("콜백 실행");
    }
}

파일 IO의 동기, 비동기 예시 코드를 캡쳐했다. 왼쪽이 동기, 오른쪽이 비동기이다.

비동기가 동기보다도 상당히 코드의 흐름이 복잡해보임을 알 수 있다. C# 1.0에서는 이런 식으로밖에 할 수 없었다.

C# 5.0

이 복잡함을 개선하기 위해 나온 것이 바로 Async Await 이다.

왼쪽이 동기, 오른쪽이 Async Await이다. 훨씬 간편해보인다!

사용 방법을 보기 위해 샘플 코드를 보자.

...
using System.Threading.Tasks;

public class AsyncTest : MonoBehaviour
{
	// 비동기 실행할 메서드 앞에 async 넣기
	async void Start()
    {
    	Debug.Log("메인 스레드 시작");
        
    	// 비동기로 실제로 실행할 부분 앞에 await 넣기
        // await 키워드를 만나면 블록된다
        // 즉 AsyncSum의 모든 부분이 끝날 때까지 블록된다
        // 메인스레드를 차단하지는 않지만, 코드 진행X
    	await AsyncSum(10, 20);
        
        Debug.Log("메인 스레드 진행 중");
    }
    
    async Task AsyncSum(int num1, int num2)
    {
    	// Task.Delay()는 Thread.Sleep()의 비동기 버전으로 메인스레드를 차단하지 않음
    	await Task.Delay(3000); // 3초 지연
        int num3 = num1+num2;
        Debug.Log("오래 걸리는 작업 완료");
    }
}

실행 결과는 1.0 버전과 좀 다르다. (전혀 비동기스럽지 않아서 당황했다)

메인 스레드 시작
오래 걸리는 작업 완료
메인 스레드 진행 중

여기서 알 수 있는 특징이다.

async await를 사용하면

  • 비동기로 호출되더라도 코드는 대기하고 있으므로 결과적으로는 동기로 실행하는 듯한 결과를 얻을 수 있다.
  • 장점은 메인스레드를 차단하지 않는다는 점이다.

그렇담 async 메소드의 반환타입은 뭐가 될 수 있을까!!
무조건 Task, Task<TResult> 두 가지만 가능하다.
+) 이 원칙의 예외로 void도 반환타입으로 허용했는데, event handler 때문이다.

아무말

Action부터 차곡차곡 알아야 async도 사용할 수 있겠구나~

0개의 댓글