Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일
 
(연관된 글이 3개 있습니다.)
(시리즈 글이 11개 있습니다.)
디버깅 기술: 6. .NET 예외 처리 정리
; https://www.sysnet.pe.kr/2/0/316

디버깅 기술: 15. First-Chance Exception
; https://www.sysnet.pe.kr/2/0/510

디버깅 기술: 16. Watson Bucket 정보를 이용한 CLR 응용 프로그램 예외 분석
; https://www.sysnet.pe.kr/2/0/595

.NET Framework: 110. WPF - 전역 예외 처리
; https://www.sysnet.pe.kr/2/0/614

디버깅 기술: 31. Windbg - Visual Studio 디버그 상태에서 종료해 버리는 응용 프로그램
; https://www.sysnet.pe.kr/2/0/957

디버깅 기술: 42. Watson Bucket 정보를 이용한 CLR 응용 프로그램 예외 분석 - (2)
; https://www.sysnet.pe.kr/2/0/1096

.NET Framework: 534. ASP.NET 응용 프로그램이 예외로 프로세스가 종료된다면?
; https://www.sysnet.pe.kr/2/0/10863

디버깅 기술: 110. 비동기 코드 실행 중 예외로 인한 ASP.NET 프로세스 비정상 종료 현상
; https://www.sysnet.pe.kr/2/0/11383

디버깅 기술: 119. windbg 분석 사례 - 종료자(Finalizer)에서 예외가 발생한 경우 비정상 종료(Crash) 발생
; https://www.sysnet.pe.kr/2/0/11732

닷넷: 2148. C# - async 유무에 따른 awaitable 메서드의 병렬 및 예외 처리
; https://www.sysnet.pe.kr/2/0/13422

닷넷: 2213. ASP.NET/Core 웹 응용 프로그램 - 2차 스레드의 예외로 인한 비정상 종료
; https://www.sysnet.pe.kr/2/0/13551




C# - async 유무에 따른 awaitable 메서드의 병렬 및 예외 처리

간단하게 비동기 함수를 다음과 같이 만들고,

namespace ConsoleApp1;

internal class Program
{
    static async Task Main(string[] args)
    {
        DateTime now = DateTime.Now;
        await DelayAsync(3);
        await DelayAsync(6);
        Console.WriteLine($"{(DateTime.Now - now).TotalSeconds}"); // 3초 + 6초 대기, 총 9초 소요
    }

    static async Task DelayAsync(int id)
    {
        await Task.Delay(id * 1000);
    }
}

호출하면, 총 9초의 시간을 지연시킵니다. 그런데, 저 2개의 비동기 호출을 (종속성이 없어) 병렬 처리하고 싶다면 어떻게 해야 할까요? 그런 경우, 간단하게는 Task.WhenAll을 사용할 수 있습니다.

static async Task Main(string[] args)
{
    DateTime now = DateTime.Now;

    Task t1 = DelayTask(3); // 3초 작업 시작
    Task t2 = DelayAsync(6); // 연이어 6초 작업 시작

    await Task.WhenAll(t1, t2); // 6초 소요

    Console.WriteLine($"{(DateTime.Now - now).TotalSeconds}");
}

하지만, WhenAll을 사용하지 않고 다음과 같이 표현하는 것도 가능합니다.

static async Task Main(string[] args)
{
    DateTime now = DateTime.Now;

    Task t1 = DelayTask(3); // 3초 작업 시작
    Task t2 = DelayAsync(6); // 연이어 6초 작업 시작

    await t1; // 3초 대기
    await t2; // 위의 3초 대기를 포함해 3초 더 대기, 총 6초 소요

    Console.WriteLine($"{(DateTime.Now - now).TotalSeconds}");
}

마찬가지로 await 순서를 바꿔도 상관없습니다.

await t2; // 6초 대기
await t1; // 위의 6초 대기 동안에 t1은 이미 종료했으므로 바로 반환

그렇다면 개별 await를 한 것과 WhenAll을 호출한 것과는 어떤 차이가 있을까요? ^^




그에 대한 차이점은 await 코드를 원칙적으로 분석하시면 됩니다.

await t1; // t1 작업 대기 후 아래의 코드 실행
await t2; 

위와 같은 상황에서 t1의 작업에서 예외가 발생하면 throw가 되므로 그다음 코드인 "await t2"를 수행하지 않게 됩니다. 이런 차이점을 try/catch로 감싸면 좀 더 명확하게 알 수 있습니다.

namespace ConsoleApp1;

internal class Program
{
    static async Task Main(string[] args)
    {
        DateTime now = DateTime.Now;

        Task t1 = DelayAsync(3);
        Task t2 = DelayAsync(6);

        try
        {
            await t1;
            await t2;
        }
        catch (Exception e)
        {
            Console.WriteLine(e);
            Console.WriteLine($"t1.Exception: {t1.Exception}");
            Console.WriteLine($"t2.Exception: {t2.Exception}");
        }

        Console.WriteLine($"{(DateTime.Now - now).TotalSeconds}");
    }

    static async Task DelayAsync(int id)
    {
        await Task.Delay(id * 1000);
        throw new InvalidOperationException($"{id} - Exception in DelayAsync");
    }
}

/* 실행 결과
System.InvalidOperationException: 3 - Exception in DelayAsync
   at ConsoleApp1.Program.DelayAsync(Int32 id) in C:\temp\ConsoleApp1\Program.cs:line 71
   at ConsoleApp1.Program.Main(String[] args) in C:\temp\ConsoleApp1\Program.cs:line 17
t1.Exception: System.AggregateException: One or more errors occurred. (3 - Exception in DelayAsync)
 ---> System.InvalidOperationException: 3 - Exception in DelayAsync
   at ConsoleApp1.Program.DelayAsync(Int32 id) in C:\temp\ConsoleApp1\Program.cs:line 71
   at ConsoleApp1.Program.Main(String[] args) in C:\temp\ConsoleApp1\Program.cs:line 17
   --- End of inner exception stack trace ---
t2.Exception:
3.0295292
*/

위의 결과를 보면 await t1에서의 예외로 인해 3초 만에 실행이 종료됩니다. 이와 함께 "Exception e"는 "await t1"의 예외로 설정되고, 개별 t1.Exception은 System.AggregateException 타입으로 예외 정보를 갖게 됩니다.

그런 반면 t2.Exception은 비어 있는데요, 그렇다고 저 정보가 실행이 안 되었기 때문에 빈 것은 아닙니다. 애당초 "Task t2 = DelayAsync(6);" 코드를 호출하는 단계에서 비동기 작업은 시작했으므로, 별도로 이를 확인하기 위해 다음과 같이 Sleep을 주면,

try
{
    await t1;
    await t2;
}
catch (Exception e)
{
    Console.WriteLine(e);
    Console.WriteLine($"t1.Exception: {t1.Exception}");
    Console.WriteLine($"t2.Exception: {t2.Exception}"); // 이 시점에는 작업이 진행되지 않아 비어 있지만,
}

Thread.Sleep(3000);
Console.WriteLine($"t2.Exception(2nd): {t2.Exception}"); // 이 시점에는 6초 대기가 끝나 예외 정보가 채워져 있음.

/* 출력 결과
...[생략]...

t2.Exception:
t2.Exception(2nd): System.AggregateException: One or more errors occurred. (6 - Exception in DelayAsync)
 ---> System.InvalidOperationException: 6 - Exception in DelayAsync
   at ConsoleApp1.Program.DelayAsync(Int32 id) in C:\temp\ConsoleApp1\Program.cs:line 71
   --- End of inner exception stack trace ---

보는 바와 같이 두 번째 출력에서 t2의 Exception 필드에 AggregateException 예외 정보가 채워져 있는 것을 확인할 수 있습니다.

반면 위의 코드를 Task.WhenAll로 바꾸면,

Task t1 = DelayAsync(3);
Task t2 = DelayAsync(6);

try
{
    await Task.WhenAll(t1, t2); // (3초가 아닌) 6초 대기 후,
}
catch (Exception e)
{
    Console.WriteLine(t1.Exception);
    Console.WriteLine(t2.Exception);
}

/* 출력 결과:
System.AggregateException: One or more errors occurred. (3 - Exception in DelayAsync)
 ---> System.InvalidOperationException: 3 - Exception in DelayAsync
   at ConsoleApp1.Program.DelayAsync(Int32 id) in C:\temp\ConsoleApp1\Program.cs:line 71
   at ConsoleApp1.Program.Main(String[] args) in C:\temp\ConsoleApp1\Program.cs:line 50
   --- End of inner exception stack trace ---
System.AggregateException: One or more errors occurred. (6 - Exception in DelayAsync)
 ---> System.InvalidOperationException: 6 - Exception in DelayAsync
   at ConsoleApp1.Program.DelayAsync(Int32 id) in C:\temp\ConsoleApp1\Program.cs:line 71
   --- End of inner exception stack trace ---
*/

모든 비동기 작업이 끝난 후에 처리를 이어가므로, 각각의 Task에 대한 예외 정보가 모두 기록돼 있습니다. 차이점이 눈에 들어오시죠? ^^ 따라서 굳이 Task.WhenAll을 개별 await으로 바꿔 쓰고 싶다면 이런 식으로 처리할 수는 있습니다.

// Task WhenAll의 코드를 직접 구성하는 경우

try
{
    Exception? e1 = null, e2 = null;

    try
    {
        await t1;
    }
    catch (Exception e)
    {
        e1 = e;
    }

    try
    {
        await t2;
    }
    catch (Exception e)
    {
        e2 = e;
    }

    if (e1 != null)
    {
        throw e1;
    }

    if (e2 != null)
    {
        throw e2;
    }
}
catch (Exception e)
{
    Console.WriteLine($"t1.Exception: {t1.Exception}");
    Console.WriteLine($"t2.Exception: {t2.Exception}");
}

음... 그냥 차라리 Task.WhenAll 쓰는 것이 더 낫겠습니다.




지난 글에서,

C# - 비동기 메서드의 async 예약어 유무에 따른 차이
; https://www.sysnet.pe.kr/2/0/13421

awaitable 메서드를 정의하는 2가지 방식을 설명했는데요, 이게 예외랑 역이면 약간 혼란스러울 수 있습니다. 예를 들어, 위에서 적용한 방식대로 다음과 같이 코딩을 하면,

namespace ConsoleApp1;

internal class Program
{
    static async Task Main(string[] args)
    {
        DateTime now = DateTime.Now;

        Task t1 = DelayTask(3);
        Task t2 = DelayAsync(6);

        try
        {
            await Task.WhenAll(t1, t2);
        }
        catch (Exception e)
        {
            Console.WriteLine(t1.Exception);
            Console.WriteLine(t2.Exception);
        }

        Console.WriteLine($"{(DateTime.Now - now).TotalSeconds}");
    }

    static Task DelayTask(int id)
    {
        Task task = Task.Delay(3000);
        throw new ApplicationException($"{id} - Exception in DelayTask");
        return task;
    }

    static async Task DelayAsync(int id)
    {
        await Task.Delay(id * 1000);
        throw new InvalidOperationException($"{id} - Exception in DelayAsync");
    }
}

/* 출력 결과:
Unhandled exception. System.ApplicationException: 3 - Exception in DelayTask
   at ConsoleApp1.Program.DelayTask(Int32 id) in C:\temp\ConsoleApp2\Program.cs:line 31
   at ConsoleApp1.Program.Main(String[] args) in C:\temp\ConsoleApp2\Program.cs:line 12
   at ConsoleApp1.Program.<Main>(String[] args)
*/

이번에는 3초의 대기조차 없이 곧바로 System.ApplicationException 예외가 발생하면서 (try/catch를 했는데도) 비정상 종료를 하게 됩니다. 그도 그럴 것이, DelayTask를 호출하는 시점에 내부 코드는 Task.Delay를 곧바로 실행한 후 "throw ..."를 하기 때문입니다.

따라서 try의 시점을 앞당겨야 하는데,

Task t1;
Task t2;

try
{
    t1 = DelayTask(3);
    t2 = DelayAsync(6);

    await Task.WhenAll(t1, t2);
}
catch (Exception e)
{
    Console.WriteLine(t1.Exception);
    Console.WriteLine(t2.Exception);
}

이와 상관없이 DelayTask에서 await 처리를 하지 않은 체 예외가 발생했으므로 t2까지 가지 못하고 예외가 발생한다는 점에는 변함이 없습니다.

어찌 보면 DelayTask의 내부 구현 코드를 감안했을 때 이것이 당연하다고 할 수 있지만, 호출하는 입장에서라면 완전히 다른 문제가 됩니다. 즉, 동일한 awaitable 메서드임에도 불구하고 내부 구현에 따라 외부의 예외 처리를 달리해야 한다는 것은 분명 문제라고 볼 수 있습니다. 이런 문제를 피하려면 대상 awaitable 메서드가 단순히 Task를 반환하는 유형인지, async가 붙은 유형인지 확인해야 하는 절차를 거쳐야 합니다. 게다가 이것 역시 "인터페이스"처럼 계약 관계가 되므로 async를 부여했던 메서드를 나중에 Task로 간략화하는 것에 주의해야 한다는 문제도 함께 있습니다.

결국, 지난 글에서는 효율상 할 수만 있다면 Task를 바로 반환하는 것을 권장했지만, 외부에서의 일관성 있는 예외 처리까지 감안한다면 가능한 async로 통일하는 것이 나을 수도 있습니다.

여러분의 선택은 어떤가요? ^^ DelayTask 유형처럼 만들 수 있는 상황에서 DelayAsync 유형처럼 만드실 건가요? ^^




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]

[연관 글]






[최초 등록일: ]
[최종 수정일: 11/20/2023]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 



2024-08-30 09시17분
Understanding .NET stack traces - A guide for developers
; https://blog.elmah.io/understanding-net-stack-traces-a-guide-for-developers/
정성태

... 61  62  63  64  65  66  67  68  69  70  71  72  73  [74]  75  ...
NoWriterDateCnt.TitleFile(s)
12086정성태12/20/201920915디버깅 기술: 144. windbg - Marshal.FreeHGlobal에서 발생한 덤프 분석 사례
12085정성태12/20/201918887오류 유형: 586. iisreset - The data is invalid. (2147942413, 8007000d) 오류 발생 - 두 번째 이야기 [1]
12084정성태12/19/201919318디버깅 기술: 143. windbg/sos - Hashtable의 buckets 배열 내용을 모두 덤프하는 방법 (do_hashtable.py) [1]
12083정성태12/17/201922295Linux: 27. linux - lldb를 이용한 .NET Core 응용 프로그램의 메모리 덤프 분석 방법 [2]
12082정성태12/17/201920555오류 유형: 585. lsof: WARNING: can't stat() fuse.gvfsd-fuse file system
12081정성태12/16/201922411개발 환경 구성: 465. 로컬 PC에서 개발 중인 ASP.NET Core 웹 응용 프로그램을 다른 PC에서도 접근하는 방법 [5]
12080정성태12/16/201919573.NET Framework: 870. C# - 프로세스의 모든 핸들을 열람
12079정성태12/13/201921460오류 유형: 584. 원격 데스크톱(rdp) 환경에서 다중 또는 고용량 파일 복사 시 "Unspecified error" 오류 발생
12078정성태12/13/201921245Linux: 26. .NET Core 응용 프로그램을 위한 메모리 덤프 방법 [3]
12077정성태12/13/201920348Linux: 25. 자주 실행할 명령어 또는 초기 환경을 "~/.bashrc" 파일에 등록
12076정성태12/12/201918868디버깅 기술: 142. Linux - lldb 환경에서 sos 확장 명령어를 이용한 닷넷 프로세스 디버깅 - 배포 방법에 따른 차이
12075정성태12/11/201919669디버깅 기술: 141. Linux - lldb 환경에서 sos 확장 명령어를 이용한 닷넷 프로세스 디버깅
12074정성태12/10/201919343디버깅 기술: 140. windbg/Visual Studio - 값이 변경된 경우를 위한 정지점(BP) 설정(Data Breakpoint)
12073정성태12/10/201920875Linux: 24. Linux/C# - 실행 파일이 아닌 스크립트 형식의 명령어를 Process.Start로 실행하는 방법
12072정성태12/9/201917672오류 유형: 583. iisreset 수행 시 "No such interface supported" 오류
12071정성태12/9/201921185오류 유형: 582. 리눅스 디스크 공간 부족 및 safemode 부팅 방법
12070정성태12/9/201923114오류 유형: 581. resize2fs: Bad magic number in super-block while trying to open /dev/.../root
12069정성태12/2/201919492디버깅 기술: 139. windbg - x64 덤프 분석 시 메서드의 인자 또는 로컬 변수의 값을 확인하는 방법
12068정성태11/28/201928161디버깅 기술: 138. windbg와 Win32 API로 알아보는 Windows Heap 정보 분석 [3]파일 다운로드2
12067정성태11/27/201919567디버깅 기술: 137. 실제 사례를 통해 Debug Diagnostics 도구가 생성한 닷넷 웹 응용 프로그램의 성능 장애 보고서 설명 [1]파일 다운로드1
12066정성태11/27/201919236디버깅 기술: 136. windbg - C# PInvoke 호출 시 마샬링을 담당하는 함수 분석 - OracleCommand.ExecuteReader에서 OpsSql.Prepare2 PInvoke 호출 분석
12065정성태11/25/201917540디버깅 기술: 135. windbg - C# PInvoke 호출 시 마샬링을 담당하는 함수 분석파일 다운로드1
12064정성태11/25/201920464오류 유형: 580. HTTP Error 500.0/500.33 - ANCM In-Process Handler Load Failure
12063정성태11/21/201919390디버깅 기술: 134. windbg - RtlReportCriticalFailure로부터 parameters 정보 찾는 방법
12062정성태11/21/201918891디버깅 기술: 133. windbg - CoTaskMemFree/FreeCoTaskMem에서 발생한 덤프 분석 사례 - 두 번째 이야기
12061정성태11/20/201919329Windows: 167. CoTaskMemAlloc/CoTaskMemFree과 윈도우 Heap의 관계
... 61  62  63  64  65  66  67  68  69  70  71  72  73  [74]  75  ...