본문 바로가기
프로그래밍/C#

C# 비동기 프로그래밍으로 반응성 개선하기

by bantomak 2024. 4. 11.

반응형 응용 프로그램 개발

.NET 프레임워크 발표 당시에는 순차적으로 실행되는 프로그램 흐름을 따랐다. 이런 실행 흐름은 다음 작업을 실행하기 전에 무조건 현재 진행 중인 작업이 끝나야 한다는 단점을 갖는다. 이로 인해 프로그램이 멈춘 것처럼 보이는 등 사용자에게 좋지 않은 인상을 주게 된다.

 

.NET 프레임워크는 이 문제점을 해소하려는 목적으로 운영체제가 독자적으로 스케줄링할 수 있는 최소 실행 단위인 스레드를 도입했다. 비동기(asynchronous) 프로그래밍이란 독립된 스레드가 특정 작업을 처리하게 함으로써 원래의 스레드가 멈추는 것을 막고 다른 작업들을 처리할 수 있게 하는 것이다.

 

동기식 프로그램 실행

먼저 모든 작업을 동기적으로 수행하는 프로그램을 만들어보는 것부터 시작하자.

 

static void Main(string[] args)
{
    SynchronousPrecess();
}

public static void SynchronousPrecess()
{
    Stopwatch sw = Stopwatch.StartNew();
    Console.WriteLine("Start synchronous process now...");

    int iResult = RunSyncronousProcess();
    Console.WriteLine($"iResult = {iResult}");

    Console.WriteLine($"Total Time = {sw.ElapsedMilliseconds / 1000} second(s)!");
}

public static int RunSyncronousProcess()
{
    int iReturn = 0;
    iReturn += LongProcess1();
    iReturn += LongProcess2();

    return iReturn;
}

public static int LongProcess1()
{
    Thread.Sleep(5000);
    return 5;
}

public static int LongProcess2()
{
    Thread.Sleep(7000);
    return 7;
}

 

 

LongProcess1()과 LongProcess2()는 서로 독립적이며 각각 특정한 처리 시간이 걸린다. 예제에서는 이들 메서드를 동기식으로 실행하므로 두 메서드를 실행하는데 총 12초가 필요한데, LongProcess1()을 처리하는데 5초, LongProcess2()을 처리하는데 7초가 필요하다.

 

스레드 사용하기

다음과 같이 앞 코드에서 일부를 리팩토링하고 스레드(thread)를 추가하면 반응성을 개선할 수 있다.

static void Main(string[] args)
{
    AsynchronousProcess();
}

public static void AsynchronousProcess()
{
    Stopwatch sw = Stopwatch.StartNew();
    Console.WriteLine("Start asynchronous process now...");

    int iResult = RunAsynchronousProcess();
    Console.WriteLine($"iResult = {iResult}");

    Console.WriteLine($"Total Time = {sw.ElapsedMilliseconds / 1000} second(s)!");
}

public static int RunAsynchronousProcess()
{
    int iResult = 0;
    // LongProcess1()을 위한 스레드 생성
    Thread thread = new Thread(
        () => iResult = LongProcess1());

    thread.Start();

    int iResult2 = LongProcess2();

    thread.Join();

    return iResult + iResult2;
}

 

 

RunSynchronousProcess()보다 RunAsynchronousProcess()가 더 빠르다는 것을 알 수 있다. 또, LongProcess1() 메서드를 실행하기 위해 새로운 스레드를 만드는데, 이 스레드는 Start() 메서드를 호출할 때까지 시작되지 않는다.

 

스레드가 시작되면 LongProcess2()와 같은 다른 작업을 실행할 수 있다. 이 작업이 끝나면 스레드를 이용해서 시작했던 작업이 끝났는지 대기하기 위해 스레드 인스턴스에서 제공하는 Join() 메서드를 호출할 수 있다.

 

Join() 메서드는 스레드의 실행이 끝날 때까지 현재 스레드를 차단하며, 대기하던 스레드 작업이 끝나면 Join() 메서드가 반환하면서 현재 스레드에 대한 차단이 해제된다.

 

스레드 풀을 이용한 스레드 생성

앞서 살펴본 것처럼 스레드를 직접 만드는 방법 외에 System.Threading.ThreadPool 클래스를 이용해서 몇 개의 스레드를 미리 만들어두고 사용하는 것도 가능하다. 이 클래스는 스레드 풀의 스레드를 이용하고자 할 때 사용한다. 스레드 풀을 이용할 때는 주로 QueueUserWorkItem() 메서드를 이용하는데, 이 메서드는 스레드 풀의 큐에 실행 요청을 추가하여, 스레드 풀 내에 사용할 수 있는 스레드가 있다면 큐에 있는 요청이 즉시 실행된다. 

 

static void Main(string[] args)
{
    ThreadPoolProcess();
}

public static void ThreadPoolProcess()
{
    Stopwatch sw = Stopwatch.StartNew();
    Console.WriteLine("Start ThreadPool process now...");

    int iResult = RunInThreadPool();
    Console.WriteLine($"iResult = {iResult}");

    Console.WriteLine($"Total Time = {sw.ElapsedMilliseconds / 1000} second(s)!");
}

public static int RunInThreadPool()
{
    int iResult = 0;
    // LongProcess1()을 쓰레드 풀에 있는 유휴 상태의 스레드에 할당
    ThreadPool.QueueUserWorkItem((t) => iResult = LongProcess1());

    int iResult2 = LongProcess2();

    return iResult + iResult2;
}

 

 

긴 처리 시간을 필요로 하는 작업을 처리하기 위해 새로 스레드를 만드는 대신, QueueUserWorkItem() 메서드를 이용하면 스레드 풀이 관리하는 큐에 새 작업 항목을 추가할 수 있다. 이렇게 스레드 풀에 추가한 작업이 처리되는 유형은 세 가지 정도가 있다.

 

  • 스레드 풀에 사용 가능한 유휴 상태의 스레드가 한 개 이상 있어서 작업을 즉시 실행한다.
  • 사용 가능한 유휴 스레드는 없지만 스레드 개수가 MaxThreads 속성 값에 도달하지 않아 스레드 풀이 새로 스레드를 만들고, 이 스레드로 하여금 해당 작업을 즉시 처리하게 한다.
  • 유휴 스레드도 없고 스레드 풀의 전체 스레드 개수가 MaxThreads 속성 값에 도달한 상황이라면 사용 가능한 스레드가 생길 때까지 작업은 대기한다.

댓글