비동기 호출
비동기 호출(asynchronous call)이란 "동기 호출(synchronous call)"과 대비되는 개념이다. 일반적으로 비동기 호출은 입출력(I/O) 장치와 연계되어 설명될때가 많다.
예를 들어, 파일의 데이터를 읽는 작업을 보자.
using System;
using System.IO;
using System.Text;
namespace async_test_del
{
class Program
{
static void Main(string[] args)
{
// Hosts 파일을 읽어서 내용을 출력.
using(FileStream fs = new FileStream(@"C:파일경로", 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);
}
}
}
}
여기서 FileStream.Read 메서드는 동기호출에 속한다. 즉, Read 메서드는 디스크의 파일로부터 데이터를 모두 읽기 전까진 제어를 반환하지 않는다. 이 때문에 다른 말로 동기 호출을 블로킹 호출(blocking call)이라고도 한다. 아래 이미지에서도 볼 수 있듯이 메서드를 호출한 다음 디스크 I/O가 완료될 때까지는 실행이 차단(block)되므로 그런 이름이 붙은 것이다.
쉽게 말하면 느린 디스크 I/O가 끝날 때까지 스레드는 아무 일도 못한다는 것이고, 이는 곧 CPU가 일을 하지 않고 놀게 된다는 것을 의미한다.
이런 동기 호출의 단점을 해결하기 위해 비동기 호출이 제공된다. FileStream은 비동기 호출을 위해 Read/Write 메서드에 대해 각각 BeginRead/EndRead, BeginWrite/EndWrite 메서드를 쌍으로 제공한다.
아래 예제에서 동기 호출을 비동기 호출로 바꾸면 다음과 같다.
using System;
using System.IO;
using System.Text;
namespace async_test_del
{
class FileState
{
public byte[] Buffer;
public FileStream File;
}
class Program
{
static void Main(string[] args)
{
FileStream fs = new FileStream(@"C:파일경로", FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
FileState state = new FileState();
state.Buffer = new byte[fs.Length];
state.File = fs;
fs.BeginRead(state.Buffer, 0, state.Buffer.Length, readCompleted, state);
// BeginRead 비동기 메서드 호출은 스레드로 곧바로 제어를 반환함.
// 따라서 이곳에서 자유롭게 다른 연산을 동시에 진행할 수 있게된다.
Console.ReadLine();
fs.Close(); // 파일스트림 닫기(종료)
}
// 읽기 작업이 완료되면 메서드가 호출된다.
private static void readCompleted(IAsyncResult ar)
{
FileState state = ar.AsyncState as FileState;
state.File.EndRead(ar);
string txt = Encoding.UTF8.GetString(state.Buffer);
Console.WriteLine(txt);
}
}
}
BeginRead 메서드는 디스크로부터 파일 데이터를 읽어낼 때까지 기다리지 않고 곧바로 스레드에 제어를 반환한다. 따라서 스레드는 이후의 코드를 끊김 없이 실행할 수 있다. 일을 더하기 원하는 우리의 바램대로 CPU가 쉬지않고 다른 일을 할 수 있께 된 것이다. 그리고 읽기 작업이 완료되면 CLR은 TreadPool로부터 유휴 스레드를 하나 얻어와 그 스레드에 readCompleted 메서드의 실행을 맡긴다. 여기서 중요한 것은 BeginRead를 호출한 스레드를 전혀 방해하지 않는다는 점이다.
비동기 호출은 I/O 연산이 끝날 때까지 차단되지 않으므로 논블로킹 호출(non-blocking call)이라고도 한다.
그런데 비동기 호출이 그동안 배웠던 스레드를 직접 사용한 방식이나 ThreadPool.QueueUserWorkItem을 사용한 것과는 어떤 차이가 있을지 알아보자.
위에서 작성한 예제에서 스레드 풀을 직접 사용하는 예제로 바꾸면 다음과 같다.
using System;
using System.IO;
using System.Text;
using System.Threading;
namespace async_test2_del
{
class Program
{
static void Main(string[] args)
{
ThreadPool.QueueUserWorkItem(readComplted);
// QueueUserWorkItem 메서드 호출은 곧바로 제어를 반환함.
// 따라서 이곳에서 자유롭게 다른 연산을 자유롭게 진행할 수 있다.
Console.ReadLine();
}
// 읽기 작업을 스레드 풀에 대행한다.
private static void readComplted(object state)
{
using (FileStream fs = new FileStream(@"C:\파일경로\", 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);
}
}
}
}
얼핏 보면 효과는 동일하지만, 읽기 작업을 동기 호출로 ThreadPool의 스레드에 대행했으므로 QueueUserWorkItem 메서드를 호출한 측의 스레드는 다른 작업을 할 수 있다. 하지만 이를 자세히 보면 분명한 차이가 있다.
최초의 스레드가 자유롭게 된 상황은 같지만, 스레드 풀로부터 빌려온 스레드의 사용 시간이 비동기 호출 - 파일 읽기 이미지와 비교해 길어졌다. 그런데 이게 큰 의미가 있을지 생각해볼 필요가 있다.
결론은 일반적인 목적인 응용 프로그램에서 QueueUserWorkItem과 비교했을때 비동기 호출로 얻는 이득은 크지는 않다. 이 정도의 차이가 의미가 있는 경우는 동시 접속자 수가 많은 게임 서버나 웹 서버 등이 있다. 우리가하는 프로그램은 이런 부류에 속하지는 않으므로 개념만 잡고 넘어가도록 하자.
System.Delegate의 비동기 호출
일반적으로 비동기 호출은 입출력 장치와의 속도 차이에서 오는 비효율적인 스레드 사용 문제를 극복하는데 사용된다. 그런데 닷넷에서는 특이하게도 입출력 장치 뿐만 아니라 일반 메서드에 대해서도 비동기 호출을 할 수 있는 수단을 제공하는데, 다름 아닌 델리게이트가 그런 역할을 한다. 즉, 메서드를 델리게이트로 연결해 두면 이미 비동기 호출을 위한 기반이 마련된 것이다 다름없다.
예를 들어 누적합을 구하는 메서드에 대한 델리게이트를 예제를 작성해보자.
using System;
namespace async_delegate_text_del
{
public class Calc
{
public static long Cumsum(int start, int end)
{
long sum = 0;
for(int i = start; i <= end; i++)
{
sum += i;
}
Console.Write("{0} ~ {1}의 합 : ", start, end);
return sum;
}
}
class Program
{
public delegate long CalcMethod(int start, int end); // 델리게이트 선언
static void Main(string[] args)
{
CalcMethod calc = new CalcMethod(Calc.Cumsum);
long result = calc(1, 100); // 1~100까지 합 구하기
Console.WriteLine(result);
}
}
}
위 코드에서 calc 델리게이트 수행은 당연히 현재의 스레드에서 수행된다.
하지만 델리게이트의 비동기 호출을 위한 메서드(BeginInvoke / EndInvoke) 를 사용하면 calc 인스턴스에 할당된 Calc.Cumsum 메서드의 수행을 ThreadPool의 스레드에서 실행할 수 있다. 다음은 이러한 사용법을 보여준다.
using System;
namespace async_delegate_text_del
{
public class Calc
{
public static long Cumsum(int start, int end)
{
long sum = 0;
for(int i = start; i <= end; i++)
{
sum += i;
}
Console.Write("{0} ~ {1}의 합 : ", start, end);
return sum;
}
}
class Program
{
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);
}
}
}
얼핏 보면 BeginInvoke와 EndInvoke의 사용이 복잡해 보일 수 있는데, 동일한 기능을 Thread 타입이나 EventWaitHandle과 ThreadPool의 조합으로 구현했던 것과 비교하면 소스코드가 좀 더 간결해 보이는 것을 알 수 있다.
Delegate를 이용한 비동기 코드는 FileStream.BeginRead를 사용했던 예제와 같은 방식도 지원한다.
방법도 유사한데 BeginInvoke의 3번째 인자에 콜백 메서드를 지정해주면 된다.
using System;
namespace async_delegate_text2_del
{
public delegate long CalcMethod(int start, int end);
public class Calc
{
public static long Cumsum(int start, int end)
{
long sum = 0;
for (int i = start; i <= end; i++)
{
sum += i;
}
Console.Write("{0} ~ {1}의 합 : ", start, end);
return sum;
}
}
class Program
{
static void Main(string[] args)
{
CalcMethod calc = new CalcMethod(Calc.Cumsum);
calc.BeginInvoke(1, 100, calcCompleted, calc); // BeginInvoke 메서드 사용
Console.ReadLine();
}
static void calcCompleted(IAsyncResult ar)
{
CalcMethod calc = ar.AsyncState as CalcMethod;
long result = calc.EndInvoke(ar);
Console.WriteLine(result);
}
}
}
닷넷 BCL에서 제공되는 모든 비동기 호출은 Delegate의 비동기 호출과 유사한 방식으로 구현돼 있다.
따라서 BCL 클래스중에 Begin/End 접두사가 있는 메서드가 함께 제공된다면 비동기 호출을 의미하는 것이며, 그에 대한 사용법은 이번에 배운 Delegate의 사용법에 준한다.
'개발자과정준비 > C#' 카테고리의 다른 글
[C#] 스레딩(threading) (0) | 2021.10.21 |
---|---|
[C#] 문자열(String) 처리 - 2 (0) | 2020.09.27 |
[C#] 문자열(String) 처리 - 1 (0) | 2020.09.26 |
[C#] 시간(DateTime, TimeSpan, Diagnostics.Stopwatch) (0) | 2020.09.19 |
[C#] LiNQ (0) | 2020.08.29 |