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

seunghyun·2024년 9월 17일
0

소개

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도 사용할 수 있겠구나~

profile
game client programmer

0개의 댓글