C#

C# BCL(Base Class Library) - 스레딩2

devrabbit22 2026. 3. 15. 03:37

비동기 호출

비동기 호출(asynchronous call)이란 '동기 호출(synchronous call)'과 대비되는 개념이다.

일반적으로 비동기 호출은 입출력(I/O) 장치와 연계되어 있다. 

동기 방식의 파일 읽기

//Hosts 파일을 읽어서 내용을 출력한다.
using (FileStream fs = new FileStream(@"C:\windows\system32\drivers\etc\HOSTS", FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
    byte[] buf = new byte[fs.Length];
    fs.Read(buf, 0, buf.Length);

    string txt = Encoding.UTF8.GetString(buf);
    Console.WriteLine(txt);
}

출력 결과

동기 호출 (Blocking Call)

FileStream.Read 메서드는 동기 호출(Synchronous Call)에 해당한다.

동기 호출이란 메서드가 작업을 완료할 때까지 호출한 스레드에게 제어를 반환하지 않는 방식을 의미한다.

예를 들어 FileStream.Read는 디스크에서 데이터를 읽는 I/O 작업을 수행한다.
이때 메서드를 호출한 스레드는 파일 읽기 작업이 모두 끝날 때까지 대기해야 한다.

따라서 작업이 완료되기 전까지는 다음 코드가 실행되지 않는다.

디스크 I/O가 완료될 때까지 스레드의 실행이 일시적으로 중단된다.

이러한 이유로 동기 호출은 블로킹 호출(Blocking Call) 이라고도 한다.

왜 Blocking이라고 부르는가

동기 호출에서는

  • 메서드 호출
  • 작업 수행
  • 작업 완료 후 반환

의 순서로 진행되며, 작업이 끝날 때까지 스레드의 실행이 차단(block) 된다.

더보기

메서드 호출

I/O 작업 진행

스레드 대기 (block)

작업 완료

제어 반환

스레드가 작업이 끝날 때까지 기다려야 하기 때문에 Blocking Call이라고 부른다.

동기 호출 - 파일 읽기

동기 호출의 단점을 해결하는 방법

동기 호출은 작업이 완료될 때까지 스레드가 대기 상태에 머무르게 된다.

예를 들어 파일 읽기와 같은 I/O 작업이 오래 걸리는 경우,
해당 작업이 끝날 때까지 스레드는 아무 일도 하지 못하고 기다리게 된다.

이러한 문제를 해결하기 위해 비동기 호출(Asynchronous Call) 방식이 사용된다.

비동기 호출은 작업을 시작한 후 작업이 완료될 때까지 기다리지 않고 즉시 제어를 반환한다.
따라서 호출한 스레드는 다른 작업을 계속 수행할 수 있다. 

FileStream의 비동기 메서드

FileStream은 비동기 호출을 지원하기 위해 다음과 같은 메서드 쌍을 제공한다.

동기 메서드 비동기 메서드
Read BeginRead / EndRead
Write BeginWrite / EndWrite

이 방식은 Begin / End 패턴(Asynchronous Programming Model, APM) 이라고 불린다.

동작 방식

비동기 호출은 다음과 같은 흐름으로 동작한다.

더보기

BeginRead() 호출

파일 읽기 작업 시작

즉시 제어 반환

다른 작업 수행 가능

읽기 작업 완료

EndRead() 호출하여 결과 확인

즉, 작업은 백그라운드에서 진행되고, 필요할 때 EndRead()를 호출하여 작업 결과를 얻는다.

비동기 방식의 파일 읽기 코드

using System;
using System.Collections;
using System.Collections.Generic;
using System.Net.NetworkInformation;
using System.Reflection.Metadata;
using System.Text;
namespace BCL_Study
{
    internal class StrStudy
    {
        static void Main(string[] args)
        {
            FileStream fs = new FileStream(@"C:\windows\system32\drivers\etc\HOSTS", FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 4096, true);

            FileState state = new FileState();
            state.Buffer = new byte[fs.Length];
            state.Files = fs;

            fs.BeginRead(state.Buffer, 0, state.Buffer.Length, readCompleted, state);

            //BeginRead 비동기 메서드 호출은 스레드로 곧바로 제어를 반환하기 때문에
            //이곳에서 자유롭게 다른 연산을 동시에 진행할 수 있다.
            Console.ReadLine();
            fs.Close();
        }
        //읽기 작업이 완료되면 메서드가 호출된다.
        static void readCompleted(IAsyncResult ar)
        {
            FileState state = ar.AsyncState as FileState;
            state.Files.EndRead(ar);

            string txt  = Encoding.UTF8.GetString(state.Buffer);
            Console.WriteLine(txt);
        }
    }
    class FileState
    {
        public byte[] Buffer;
        public FileStream Files;
    }
}

BeginRead의 동작 방식

BeginRead 메서드는 비동기 방식으로 파일 읽기 작업을 시작한다.

동기 메서드인 Read와 달리, BeginRead는 디스크에서 파일 데이터를 모두 읽어올 때까지 기다리지 않는다.
읽기 작업을 시작한 뒤 즉시 호출한 스레드에 제어를 반환한다.

따라서 BeginRead를 호출한 스레드는 읽기 작업이 진행되는 동안에도 다른 코드를 계속 실행할 수 있다.

 

읽기 작업 완료 후 동작

파일 읽기 작업이 완료되면 CLR은 ThreadPool에서 유휴 스레드를 하나 가져와
해당 스레드에서 콜백 메서드(예: readCompleted)를 실행한다.

즉, 작업 완료 후의 처리는 ThreadPool 스레드가 담당하게 된다.

중요한 특징

이 방식의 중요한 특징은 다음과 같다.

  • BeginRead를 호출한 원래 스레드는 전혀 차단되지 않는다.
  • 파일 읽기 작업은 백그라운드에서 진행된다.
  • 작업이 완료되면 ThreadPool 스레드가 콜백을 실행한다.

비동기 호출 - 파일 읽기

비동기 호출은 I/O 연산이 끝날 때까지 차단되지 않으므로 논블로킹 호출(non-blocking-call)이라고도 한다.

 

비동기 호출이 스레드를 직접 사용한 방식이나 ThreadPool, QueueUserWorkItem을 사용한 것과 어떤 차이가 있는가?

using System;
using System.Collections;
using System.Collections.Generic;
using System.Net.NetworkInformation;
using System.Reflection.Metadata;
using System.Text;
namespace BCL_Study
{
    internal class StrStudy
    {
        static void Main(string[] args)
        {
            ThreadPool.QueueUserWorkItem(readCompleted);

            //QueueUserWorkItem 메서드 호출은 곧바로 제어를 반환하기 때문에 
            //이곳에서 자유롭게 다른 연산을 동시에 진행할 수 있다.

            Console.ReadLine();
        }
        //읽기 작업을 스레드 풀에 대행한다.
        static void readCompleted(object state)
        {
            using (FileStream fs = new FileStream(@"C:\windows\system32\drivers\etc\HOSTS", FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
            {
                byte[] buf = new byte[fs.Length];
                fs.Read(buf, 0, buf.Length);

                string txt = Encoding.UTF8.GetString(buf);
                Console.WriteLine(txt);
            }
        }
    }
    class FileState
    {
        public byte[] Buffer;
        public FileStream Files;
    }
}

얼핏 보면 효과는 동일하지만, 읽기 작업을 동기 호출로 ThreadPool의 스레드에 대행했으므로 QueueUserWorkItem 메서드를 호출한 측의 스레드는 다른 작업을 할 수 있다. 하지만 이를 자세히 보면 분명한 차이가 있다.

동기 호출을 사용한 스레드 풀

최초의 스레드가 자유롭게 된 상황은 같지만, 스레드 풀로부터 빌려온 스레드의 사용 시간은 길어졌다.

결론

  • 일반적인 목적의 응용 프로그램에서 QueueUserWorkItem과 비교했을 때 비동기 호출로 얻는 이득은 크지 않다.
  • 이 정도의 차이가 의미 있는 경우는 동시 접속자 수가 많은 게임 서버나 웹 서버 등이 있다.

System.Delegate의 비동기 호출

일반적으로 비동기 호출은 입출력 장치와의 속도 차이에서 오는 비효율적인 스레드 사용 문제를 극복하는데 사용된다.

.NET에서는 특이하게 입출력 장치뿐만 아니라 일반 메서드에 대해서도 비동기 호출을 할 수 있는 수단을 제공하는데, Delegate가 이 역할을 한다. 즉, 메서드를 델리게이트로 연결해두면 이미 비동기 호출을 위한 기반이 마련된 것이다.

누적합을 구하는 메서드에 대한 델리게이트
단일 스레드에서 수행되는 메서드 호출

델리게이트의 비동기 호출을 위한 메서드(BeginInvoke/EndInvoke)를 사용하면 calc 인스턴스에 할당된 Calc.Cumsum 메서드의 수행을 ThreadPool의 스레드에서 실행할 수 있다.

using System;
using System.Collections;
using System.Collections.Generic;
using System.Net.NetworkInformation;
using System.Reflection.Metadata;
using System.Text;
namespace BCL_Study
{
    internal class StrStudy
    {
        public delegate long CalcMethod(int start, int end);

        static void Main(string[] args)
        {
            CalcMethod calc = new CalcMethod(Calc.Cumsum);
            //Delegate 타입의 BeginInvoke 메서드를 호출한다.
            //이 때문에 Calc.Cumsum 메서드는 ThreadPool의 스레드에서 실행된다.
            IAsyncResult ar = calc.BeginInvoke(1, 100, null, null);

            //BeginInvoke로 반환받은 IAsyncResult 타입의 AsyncWaitHandle 속성은 EventWaitHandle 타입이다.
            //AsyncWaitHandle 객체는 스레드 풀에서 실행된 Calc.Cumsum의 동작이 완료됐을 때 Signal 상태로 바뀐다.
            //따라서 아래와 같이 호출하면 Calc.Cumsum 메서드 수행이 완료될 때까지 현재 스레드를 대기시킨다.
            ar.AsyncWaitHandle.WaitOne();

            //Calc.Cumsum의 반환값을 얻기 위해 EndInvoke 메서드를 호출한다.
            //반환값이 없어도 EndInvoke는 반드시 호출하는 것을 권장한다.
            long result = calc.EndInvoke(ar);

            Console.WriteLine(result);
        }
    }
    public class Calc
    {
        public static long Cumsum(int start, int end)
        {
            long sum = 0;
            for(int i = start; i<= end; i++)
            {
                sum += i;
            }
            return sum;
        }
    }
}

ThreadPool의 스레드에서 실행되는 델리게이트

BeginInvoke와 EndInvoke의 사용이 복잡해 보일 수 있다. 하지만 동일한 기능을 Thread 타입이나 EventWaitHandle과 ThreadPool의 조합으로 구현했던 것과 비교하면 소스코드가 더 간결하다.

 

Delegate를 이용한 비동기 코드는 FileStream.BeginRead를 사용했던 방식도 지원한다.

방법도 매우 유사한데 BeginInvoke의 3번째 인자에 콜백 메서드를 지정해 주면 된다.

using System;
using System.Collections;
using System.Collections.Generic;
using System.Net.NetworkInformation;
using System.Reflection.Metadata;
using System.Text;
namespace BCL_Study
{
    internal class StrStudy
    {
        public delegate long CalcMethod(int start, int end);

        static void Main(string[] args)
        {
            CalcMethod calc = new CalcMethod(Calc.Cumsum);

            calc.BeginInvoke(1, 100, calcCompleted, calc);

            Console.ReadLine();
        }

        static void calcCompleted(IAsyncResult ar)
        {
            CalcMethod calc = ar.AsyncState as CalcMethod;
            long result = calc.EndInvoke(ar);

            Console.WriteLine(result);
        }
    }
    public class Calc
    {
        public static long Cumsum(int start, int end)
        {
            long sum = 0;
            for(int i = start; i<= end; i++)
            {
                sum += i;
            }
            return sum;
        }
    }
}

콜백 메서드를 지정한 Delegate의 비동기 호출

Begin / End 메서드 패턴의 의미

.NET BCL에서 제공되는 클래스 중
BeginXXX / EndXXX 메서드가 쌍으로 존재한다면
이는 비동기 호출 패턴(APM: Asynchronous Programming Model) 을 의미한다.

이 패턴의 특징은 다음과 같다.

  1. BeginXXX
    • 비동기 작업을 시작한다.
    • 즉시 IAsyncResult를 반환한다.
    • 호출한 스레드는 차단되지 않는다.
  2. EndXXX
    • 비동기 작업이 끝난 후 결과를 가져온다.
    • 필요한 경우 작업이 끝날 때까지 대기한다.

Delegate와의 관계

이 패턴은 Delegate 비동기 호출 방식과 구조가 동일하다.

Delegate에서도 BeginInvoke, EndInvoke 형태로 제공되며 동작 방식이 같다.

BeginInvoke → 비동기 실행
EndInvoke → 결과 반환
 

그래서 많은 .NET API들이 Delegate 비동기 호출 패턴을 기반으로 설계되었다.


Reference

시작하세요! C# 12 프로그래밍 기본 문법부터 실전 예제까지

이미지 출처

 

[C#] 비동기 호출

비동기 호출 비동기 호출(asynchronous call)이란 "동기 호출(synchronous call)"과 대비되는 개념이다. 일반적으로 비동기 호출은 입출력(I/O) 장치와 연계되어 설명될때가 많다. 예를 들어, 파일의 데이터

andjjip.tistory.com