|
WebClient로 비동기 I/O 사용
Q: System.IO.File 클래스에서 데이터 읽기 및 쓰기를 위한 정적 도우미 메서드인 ReadAllText, ReadAllBytes, WriteAllText, WriteAllBytes 등을 아주 유용하게 사용하고 있습니다.
그런데 이러한 메서드는 동기적입니다. 이러한 메서드도 내부적으로 비동기 I/O를 사용하도록 비동기적인 방식으로 사용할 수 있으면 좋겠습니다. System.IO.FileStream 클래스는 비동기 I/O를 지원하는 것으로 아는데 File 클래스는 어떻습니까?
A: File의 메서드는 동기 작업만 지원합니다.
그러나 질문에서 말한 것과 같은 비동기 메서드를 구현하기 위한 기능도 분명히 있습니다. 이 칼럼에서는 이를 위한 두 가지 방법을 설명합니다. 먼저 두 가지 중 더 복잡하면서 더 효과적인 방법부터 살펴보겠습니다. 우선 실제 구현하려는 API를 정의하는 작업부터 시작합니다.
질문에서 언급한 원래의 용법은 다음과 같습니다. public static byte [] ReadAllBytes(string path);
public static string ReadAllText(string path); public static void WriteAllBytes(string path, byte [] bytes); public static void WriteAllText(string path, string contents); 이러한 메서드의 비동기 버전이 필요한데, 이를 다음과 같이 정의합니다.
public static void ReadAllBytesAsync(
string path, Action<byte[]> success, Action<Exception> failure); public static void ReadAllTextAsync( string path, Action<string> success, Action<Exception> failure); public static void WriteAllBytesAsync( string path, byte[] bytes, Action success, Action<Exception> failure); public static void WriteAllTextAsync( string path, string contents, Action success, Action<Exception> failure); 원래의 용법과 매우 비슷합니다.
Read* 메서드는 동기적으로 데이터를 반환하는 대신 성공적인 실행과 예외적인 실행을 위한 두 개의 대리자를 받습니다.
전자의 대리자에는 읽은 데이터가 전달되며 후자의 대리자에는 결과 예외가 전달됩니다.
Write* 메서드 역시 두 개의 대리자를 받지만 예상 출력이 없으므로 성공 대리자에는 매개 변수가 없습니다(원래의 Write* 메서드는 void를 반환함). 좀더 복잡한 방법은 FileStream이 파생되는 System.IO.Stream 클래스에서 제공하는 APM(비동기 프로그래밍 모델) 패턴을 사용하여 직접 구현하는 것입니다.
필자는 2008년 3월호 MSDN® Magazine에 실린 칼럼(msdn.microsoft.com/magazine/cc337900)에서 각 Stream의 APM 구현을 사용하여 하나의 Stream에서 다른 Stream으로 비동기적으로 복사하는 CopyStreamToStream 메서드를 구현하는 방법을 소개했습니다.
그림 1에 이 메서드가 있습니다.
이 메서드를 다시 사용해 보겠습니다.
구현은 이전에 소개했던 것에 비해 약간 간소화되고 변경된 상태입니다.
예를 들어 System.ComponentModel.AsyncOperationManager 및 AsyncOperation을 사용하는 부분이 제거되었습니다.
이 부분은 이 칼럼에서 구현되는 보다 상위 수준의 API로 대신합니다.
그 이유에 대해서는 조금 뒤에 살펴보겠습니다.
그림 1 비동기 스트림 복사
public static void CopyStreamToStream(
Stream source, Stream destination, Action<Exception> completed) { byte[] buffer = new byte[0x1000]; if (completed == null) completed = delegate {}; AsyncCallback rc = null; rc = readResult => { try { int read = source.EndRead(readResult); if (read > 0) { destination.BeginWrite(buffer, 0, read, writeResult => { try { destination.EndWrite(writeResult); source.BeginRead( buffer, 0, buffer.Length, rc, null); } catch (Exception exc) { completed(exc); } }, null); } else completed(null); } catch (Exception exc) { completed(exc); } }; source.BeginRead(buffer, 0, buffer.Length, rc, null); } 이러한 구현은 이러한 비동기 메서드를 구현하는 데 있어 가장 어려운 측면입니다.
여기에서는 CopyStreamToStream을 토대로 두 개의 도우미 메서드를 구현합니다.
하나는 비동기적으로 파일에서 바이트를 읽는 메서드, 다른 하나는 비동기적으로 바이트를 쓰는 메서드입니다.
이 두 메서드가 작업의 대부분을 처리합니다(그림 2 참조). 그림 2 비동기 도우미 메서드
private static void ReadAllBytesAsyncInternal(string path,
Action<byte[]> success, Action<Exception> failure) { var input = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 0x1000, true); var output = new MemoryStream((int)input.Length); CopyStreamToStream(input, output, e => { byte [] bytes = e == null ? output.GetBuffer() : null; output.Close(); input.Close(); if (e != null) failure(e); else success(bytes); }); } private static void WriteAllBytesAsyncInternal( string path, byte[] bytes, Action success, Action<Exception> failure) { var input = new MemoryStream(bytes); var output = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 0x1000, true); CopyStreamToStream(input, output, e => { input.Close(); output.Close(); if (e != null) failure(e); else success(); }); } ReadAllBytesAsyncInternal은 기본 스트림을 비동기적으로 다루기 위한 입력 FileStream과 파일에서 읽은 바이트를 저장하기 위한 MemoryStream을 만듭니다.
그런 다음 CopyStreamToStream 메서드를 사용하여 FileStream에서 MemoryStream으로 모든 데이터를 비동기적으로 복사합니다.
작업이 완료되면 스트림이 닫힙니다.
예외가 발생하면 실패 대리자가 호출되며 그렇지 않으면 파일에서 읽은 데이터를 MemoryStream으로 제공하고 성공 대리자가 호출됩니다. WriteAllBytesAsyncInternal도 매우 비슷합니다.
여기에서는 제공된 바이트 배열을 래핑하는 입력 MemoryStream이 만들어지며 마찬가지로 비동기 I/O를 지원하는 출력 FileStream이 만들어집니다.
ReadAllBytesAsyncInternal과 마찬가지로 작업이 완료되면 스트림이 닫히고 예외가 발생하면 실패 대리자가 호출됩니다.
이제 앞서 본 각 공용 용법을 구현하려면 그림 1과 그림 2에서 만든 메서드에 약간의 코드만 더 추가하면 됩니다.
이러한 코드는 그림 3에 나와 있습니다.
그림 3 공용 메서드 구현
public static void ReadAllBytesAsync(
string path, Action<byte[]> success, Action<Exception> failure) { AsyncOperation asyncOp = AsyncOperationManager.CreateOperation(null); ReadAllBytesAsyncInternal(path, bytes => asyncOp.Post(delegate { success(bytes); }, null), exception => asyncOp.Post( delegate { failure(exception); }, null)); } public static void ReadAllTextAsync( string path, Action<string> success, Action<Exception> failure) { AsyncOperation asyncOp = AsyncOperationManager.CreateOperation(null); ReadAllBytesAsyncInternal(path, bytes => { string text; using (var ms = new MemoryStream(bytes)) text = new StreamReader(ms).ReadToEnd(); asyncOp.Post(delegate { success(text); }, null); }, exception => asyncOp.Post( delegate { failure(exception); }, null)); } public static void WriteAllBytesAsync( string path, byte[] bytes, Action success, Action<Exception> failure) { AsyncOperation asyncOp = AsyncOperationManager.CreateOperation(null); WriteAllBytesAsyncInternal(path, bytes, () => asyncOp.Post(delegate {success(); }, null), exception => asyncOp.Post( delegate { failure(exception); }, null)); } public static void WriteAllTextAsync( string path, string contents, Action success, Action<Exception> failure) { AsyncOperation asyncOp = AsyncOperationManager.CreateOperation(null); ThreadPool.QueueUserWorkItem(delegate { var bytes = Encoding.UTF8.GetBytes(contents); WriteAllBytesAsyncInternal(path, bytes, () => asyncOp.Post(delegate {success(); }, null), exception => asyncOp.Post( delegate { failure(exception); }, null)); }); } 이러한 메서드의 대부분은 그림 2에서 본 내부 구현을 둘러싸는 간단한 래퍼입니다.
그러나 몇 가지 흥미로운 부분도 있습니다.
첫째, 필자는 앞서 CopyStreamToStream에서 AsyncOperationManager 지원을 제거했다고 이야기했습니다.
AsyncOperation 자체는 System.Threading.SynchronizationContext를 둘러싸는 래퍼이며 AsyncOperationManager.CreateOperation에 대한 호출에서 캡처된 기본 SynchronizationContext를 사용하여 생성 시점의 동기화 컨텍스트에 맞는 방식으로 대리자를 포스트백합니다.
예를 들어 Windows® Forms 응용 프로그램의 UI 스레드에서 SynchronizationContext.Current는 UI 스레드에 대한 대리자 콜백 마샬링을 지원하는 WindowsFormsSynchronizationContext를 반환할 가능성이 높습니다.
따라서 UI 스레드에서 AsyncOperationManager.CreateOperation이 호출되는 경우 결과 AsyncOperation의 Post 메서드를 사용하면 제공된 대리자가 UI 스레드로 마샬링되고 실행됩니다. 그러나 필자는 UI 스레드에서 수행되는 작업을 최소화하려고 합니다.
이제 ReadAllTextAsync 메서드를 살펴보겠습니다.
파일에서 데이터를 로드하는 작업이 완료되면 읽은 바이트를 문자열로 변환하려고 합니다.
쓰기 가능한 StringStream 클래스가 있고 MemoryStream이 아니라 이 클래스의 인스턴스를 CopyStreamToStream에 전달할 수 있다면 이 작업을 더 효과적으로 수행할 수 있을 것입니다.
이 변환은 UI 스레드에 원하는 수준 이상의 비용 소비를 유발할 수 있습니다.
따라서 필자는 UI로 포스트백하기 전에 변환을 수행하려고 합니다.
그러나 원래 CopyStreamToStream 구현에서 완료 대리자는 캡처된 SynchronizationContext에서 실행되므로 변환을 수행하려는 시점에는 이미 UI 스레드에 와 있는 상태입니다.
그 대신 필자는 AsyncOperation 작업을 이러한 각 외부 메서드로 가져왔습니다.
이렇게 하면 실제 계산 작업을 완료할 때까지 Post 호출을 지연시킬 수 있습니다.
구현에서 다른 흥미로운 부분은 WriteAllTextAsync에 있습니다.
WriteAllTextAsync는 비동기 작업이 시작되기 전까지는 반환되지 않습니다.
그러나 이를 위해 WriteAllBytesAsyncInternal을 호출하려면 먼저 제공된 텍스트 문자열을 바이트 배열로 변환해야 합니다.
따라서 호출 스레드를 차단하는 대신 작업 항목을 ThreadPool 큐에 넣습니다.
이 작업 항목은 문자열에서 바이트로의 변환을 수행한 다음 내부 복사를 시작합니다.
최종적인 코드는 엄청난 양은 아니지만 간단하지도 않습니다.
아마 파일 데이터를 읽고 쓰는 이러한 일반적인 비동기 패턴이 Microsoft® .NET Framework에 이미 포함되어 있다면 좋겠다는 생각이 들 것입니다.
특히 이러한 패턴을 기반으로 할 수 있는 다른 일들(예: 데이터를 읽거나 쓰는 동안의 진행 알림)을 고려하면 그런 생각이 더욱 간절히 듭니다.
사실 이러한 기능은 이미 있습니다.
다만 그 위치가 의외인데, 바로 System.Net입니다.
System.Net.WebClient 클래스는 다양한 용도에서 매우 유용합니다.
이 클래스의 이름, 그리고 System.Net 네임스페이스라는 위치를 감안하면 대부분의 사람들이 이 클래스를 HTTP 서버에서 파일 다운로드하거나 FTP 사이트로 파일을 업로드하는 등의 웹 관련 작업만을 위한 클래스로 생각하는 것도 무리는 아닙니다.
그러나 WebClient는 WebRequest 및 WebResponse 위에서 추상화되며, 이는 이러한 형식의 구체적 구현을 만들기 위한 플러그형 팩터리 모델을 지원합니다.
HTTP URL로 WebRequest.Create를 호출하면 HttpWebRequest의 인스턴스가 반환되며 FTP URL로 호출하면 FtpWebRequest의 인스턴스가 반환됩니다.
WebClient는 내부적으로 WebRequest와 WebResponse를 사용하여 여러 가지 유용한 기능을 구현합니다.
다음은 관련된 몇 가지 메서드 샘플입니다.
public void DownloadDataAsync(Uri address);
public event DownloadDataCompletedEventHandler DownloadDataCompleted; public void DownloadStringAsync(Uri address); public event DownloadStringCompletedEventHandler DownloadStringCompleted; public void UploadDataAsync(Uri address, byte[] data); public event UploadDataCompletedEventHandler UploadDataCompleted; public void UploadStringAsync(Uri address, string data); public event UploadStringCompletedEventHandler UploadStringCompleted; 이제 다운로드할 데이터의 URL을 제공하여 DownloadDataAsync를 호출하면 비동기적으로 다운로드 작업이 수행되며, 작업이 완료되면 DownloadDataCompleted 이벤트가 발생합니다.
마찬가지로 텍스트 문자열과 이 문자열을 업로드할 위치를 지정하여 UploadStringAsync를 호출하면 비동기적으로 업로드 작업이 수행되며, 작업이 완료되면 UploadStringCompleted 이벤트가 발생합니다.
깔끔하고 간단합니다. 여기에서 반가운 소식은 .NET Framework에 기본 제공되는 WebRequest 공급자 중에 FileWebRequest가 있다는 점입니다(이에 대응하는 FileWebResponse도 포함).
이를 사용하여 다음과 같이 코드를 작성할 수 있습니다.
WebRequest wr = Webrequest.Create(@"file://C:\test.txt");
이 코드를 통해 일반적인 방식으로 WebRequest를 사용할 수 있습니다.
단, 이 WebRequest는 웹 사이트에 있는 파일이 아니라 디스크에 있는 파일을 대상으로 합니다.
이를 WebRequest의 WebClient 사용과 결합한다면 아마 필자의 의도를 짐작할 수 있을 것입니다.
즉, WebClient를 사용하여 비동기 방식으로 파일을 읽고 쓸 수 있습니다! 앞의 읽기와 쓰기 메서드를 구현하려면 그림 4에서 볼 수 있듯이 메서드별로 몇 줄의 코드만 추가하면 됩니다.
사실 이 시점에서는 이러한 래퍼가 필요한지에 대한 의문이 듭니다.
그저 완성된 API 용법을 WebClient가 동일한 작업을 위해 제공하는 용법에 맞추기 위한 코드가 전체 코드의 대부분을 차지하기 때문입니다.
WebClient는 내부적으로 AsyncOperationManager를 사용하여 콜백이 올바른 컨텍스트에서 수행되도록 하고 WebClient의 비동기 메서드(WebClient는 이러한 메서드의 동기 버전도 제공함)가 사용될 때 FileWebRequest/FileWebResponse가 비동기 I/O를 사용하도록 합니다.
그림 4 WebClient를 사용하여 파일 읽기 및 쓰기
public static void ReadAllBytesAsync(
string path, Action<byte[]> success, Action<Exception> failure) { var wc = new WebClient(); wc.DownloadDataCompleted += (sender, e) => { if (e.Error != null) failure(e.Error); else success(e.Result); }; wc.DownloadDataAsync(new Uri("file://" + path)); } public static void ReadAllTextAsync( string path, Action<string> success, Action<Exception> failure) { var wc = new WebClient(); wc.DownloadStringCompleted += (sender, e) => { if (e.Error != null) failure(e.Error); else success(e.Result); }; wc.DownloadStringAsync(new Uri("file://" + path)); } public static void WriteAllBytesAsync( string path, byte [] bytes, Action success, Action<Exception> failure) { var wc = new WebClient(); wc.UploadDataCompleted += (sender, e) => { if (e.Error != null) failure(e.Error); else success(); }; wc.UploadDataAsync(new Uri("file://" + path), bytes); } public static void WriteAllTextAsync( string path, string contents, Action success, Action<Exception> failure) { var wc = new WebClient(); wc.UploadStringCompleted += (sender, e) => { if (e.Error != null) failure(e.Error); else success(); }; wc.UploadStringAsync(new Uri("file://" + path), contents); } WebClient에서 더 멋진 부분은 이를 기반으로 다른 유용한 추가 기능도 제공한다는 점입니다.
특히 진행 추적에 대한 지원이 기본 제공됩니다.
다음과 같은 두 가지의 관련 이벤트가 제공됩니다.
public event
DownloadProgressChangedEventHandler DownloadProgressChanged; public event UploadProgressChangedEventHandler UploadProgressChanged; 이러한 이벤트는 각각 프로세스 내 다운로드 또는 업로드와 관련하여 제공할 진행 업데이트가 있을 때 발생합니다.
또한 이러한 이벤트를 위한 처리기에 제공되는 이벤트 인수는 진행 보고에 유용한 정보를 제공합니다.
예를 들어 다음과 같은 DownloadProgressChangedEventArgs 형식이 있습니다. public class DownloadProgressChangedEventArgs :
ProgressChangedEventArgs { public long BytesReceived { get; } public long TotalBytesToReceive { get; } /* from the base ProgressChangedEventArgs type public int ProgressPercentage { get; } public object UserState { get; } */ } 이러한 기능을 사용하면 GUI 응용 프로그램에 비동기 I/O를 매끄럽게 통합할 수 있습니다.
작업에 앞서 디스크에서 메모리로 대용량 파일을 로드해야 하는 Windows Forms 응용 프로그램이 있다고 가정해 보십시오.
파일을 비동기적으로 로드하면서 진행률 표시줄을 업데이트하는 진행 알림을 제공하고 완료되면 이를 알릴 수 있습니다. private void button1_Click(object sender, EventArgs e) {
WebClient wc = new WebClient(); wc.DownloadDataCompleted += (s, de) => { _fileData = de.Result; MessageBox.Show("File loaded"); }; wc.DownloadProgressChanged += (s, de) => progressBar1.Value = de.ProgressPercentage; wc.DownloadDataAsync(new Uri(@"file://c:\largeFile.dat")); } private byte [] _fileData; 이러한 API는 상당히 훌륭하지만 아쉽게도 중요한 문제가 있습니다.
FileWebRequest/FileWebResponse는 내부적으로 비동기 I/O를 사용하지만 구현 역시 어떠한 이유에서든 콜백 알림을 위해 작업자 ThreadPool을 사용하므로 비동기 I/O가 완료되기를 기다리는 동안 풀에서 이러한 스레드가 차단되는 경우가 있습니다.
이것이 코드 결함인지 아니면 그저 성능 저하 문제인지에 대해서는 논란의 여지가 있습니다.
결국 이 WebClient 기법을 사용하여 동시에 많은 수의 파일을 비동기적으로 읽거나 쓰려고 시도하면 ThreadPool의 스레드 수로 인해 성능이 크게 저하됩니다. ThreadPool에서 스레드를 차단하는 것은 일반적으로 바람직하지 않습니다.
이렇게 하면 ThreadPool이 새로운 스레드를 주입해야 하기 때문입니다.
이 경우 리소스가 추가로 소비될 뿐만 아니라 ThreadPool이 시간 지연을 통해 주입 속도를 조절하면서 응용 프로그램도 느려지게 됩니다.
결국 동시에 처리하는 파일 수가 적은 경우에는 비동기적으로 파일을 읽고 쓰기 위한 WebClient 방식이 유용하겠지만 FileWebRequest/FileWebResponse가 수정되어 이러한 문제가 해결되기 전까지는 칼럼 앞부분에서 소개한 직접 코딩 방식을 사용하는 것이 바람직합니다.
다른 해결책은 제대로 작동하는 WebRequest/WebResponse 구현을 직접 만들어 제공하고 이를 WebClient에 등록하는 것입니다.
Stephen에게 질문이나 의견이 있으면 netqa@microsoft.com으로 보내시기 바랍니다.
Stephen Toub은 Microsoft Parallel Computing Platform 팀의 수석 프로그램 관리자이며, MSDN Magazine의 기고 편집자이기도 합니다.
### 출처 : MSDN Magazine / 저작권자 : Microsoft Corporation ###
|
'MSDN' 카테고리의 다른 글
| #S# [MSDN] 헛된 결과가 될까 두려워하지 말고 시도하라 (0) | 2009/07/03 |
|---|---|
| #S# [MSDN][SQL Server] 새로운 SQL 잘라내기 공격 및 대처 방법 (0) | 2009/06/29 |
| #S# [MSDN][.NET] WebClient로 비동기 I/O 사용 (0) | 2009/06/26 |
| #S# [MSDN] 단순히 보기 좋은 그림 그 이상 (0) | 2009/06/21 |
| #S# [MSDN] 언어 혁명의 선구자 Bjarne Stroustrup (0) | 2009/06/21 |
| #S# [MSDN][ASP.NET/AJAX] ScriptManager를 통해 웹 응용 프로그램에서 AJAX 사용 (0) | 2009/06/21 |
TAG .net,
APM,
c#,
Class Library,
FIle,
FileStream,
IO,
MSDN,
Programming,
WebClient,
WebRequest,
WebResponse


