(번역글) .NET Internals Cookbook Part 1 - Exceptions, filters and corrupted processes
이번 글은 다음의 시리즈에 대한 번역 글입니다. ^^ (참고로, 1:1 식으로 번역하지 않았습니다.)
.NET Internals Cookbook Part 1 - Exceptions, filters and corrupted processes
; https://blog.adamfurmanek.pl/2019/02/16/net-internals-cookbook-part-1/
글의 내용이 너무 좋아서 ^^ 많은 분들에게 알리고 싶어 원 저자의 허락을 받아 게시하는 것인데, 혹시 내용이 틀린 부분이 있다면 덧글 부탁드립니다.
(
첨부 파일은 이 글의 예제 프로젝트를 포함합니다.)
1. System.Exception으로부터 상속받지 않은 객체를 throw로 던지면 어떻게 될까요?
C#의 경우에는 가능하지 않지만 C++/CLI와 같은 언어에서는 System.Exception 객체로부터 상속받지 않은 객체도 throw하는 것이 가능합니다. 예를 들어, integer, string, byte 등의 인스턴스를 직접 throw할 수 있는데 CLR은 이것을 일반 예외와 구분하기 위해
System.Runtime.CompilerServices.RuntimeWrappedException이라는 예외를 따로 제공합니다.
하지만 초기 .NET 1.x 버전에서는 그와 같은 (System.Exception 상속 이외의) 객체들이 RuntimeWrappedException 예외로 처리되지 않고 조건이 없는 catch 문으로만 다룰 수 있었습니다. 그래서 그 당시에는 다음과 같은 식의 예외 처리가 의미가 있었습니다.
// .NET 1.x
try
{
}
catch (Exception e) // System.Exception 및 그의 상속 객체들
{
}
catch // C++/CLI 등의 언어에서 throw할 수 있는 System.Exception을 상속하지 않은 객체들
{
}
위의 코드는 C#의 경우 .NET 2.0부터 컴파일 경고가 발생하는데,
warning CS1058: A previous catch clause already catches all exceptions. All non-exceptions thrown will be wrapped in a System.Runtime.CompilerServices.RuntimeWrappedException.
마지막 catch 문이 절대 실행되지 않기 때문입니다. 즉, .NET 2.0부터는 다음과 같이 마이그레이션을 해야 합니다.
// .NET 2.0 ~ (now)
try
{
}
catch (RuntimeWrappedException e) // C++/CLI 등의 언어에서 throw할 수 있는 System.Exception을 상속하지 않은 객체들
{
}
catch (Exception) // System.Exception 및 그의 상속 객체들
{
}
만약 호환을 위해 .NET 1.x과 같이 처리하고 싶다면 RuntimeCompatibilityAttribute 특성을 설정해야 합니다.
[assembly: RuntimeCompatibilityAttribute(WrapNonExceptionThrows = false)]
실제로 테스트를 해볼까요? ^^
다음의 C/C++ CLI 코드가 있을 때,
public ref class MyTest
{
public:
void DoMethod()
{
throw 5;
}
void DoMethod2()
{
throw gcnew System::Int32(5);
}
};
C#에서 이 코드를 다음과 같이 처리해 보면,
using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
[assembly: RuntimeCompatibilityAttribute(WrapNonExceptionThrows = true)]
namespace ConsoleApp1
{
class Program
{
static void Main(string[] args)
{
MyTest my = new MyTest();
Test(() => my.DoMethod());
Test(() => my.DoMethod2());
}
private static void Test(Action action)
{
try
{
action();
}
catch (RuntimeWrappedException e1) // C++/CLI 등의 언어에서 throw할 수 있는 System.Exception을 상속하지 않은 관리 객체들
{
Console.WriteLine(e1.ToString());
}
catch (SEHException e2) // C++/CLI 등의 언어에서 throw할 수 있는 System.Exception을 상속하지 않은 비-관리 객체들
{
Console.WriteLine(e2.ToString());
}
catch (Exception e3) // System.Exception 및 그의 상속 객체들
{
Console.WriteLine(e3.ToString());
}
catch
{
Console.WriteLine("untyped catch");
}
}
}
}
WrapNonExceptionThrows = true인 실행 결과와,
throw 5
System.Runtime.InteropServices.SEHException (0x80004005): External component has thrown an exception.
at _CxxThrowException(Void* , _s__ThrowInfo* )
at MyTest.DoMethod() in F:\...\ConsoleApp1\Dll1\pch.h:line 19
at ConsoleApp1.Program.<>c__DisplayClass0_0.<Main>b__0() in F:\...\ConsoleApp1\ConsoleApp1\Program.cs:line 15
at ConsoleApp1.Program.Test(Action action) in F:\...\ConsoleApp1\ConsoleApp1\Program.cs:line 23
throw gcnew System::Int32(5)
System.Runtime.CompilerServices.RuntimeWrappedException: An object that does not derive from System.Exception has been wrapped in a RuntimeWrappedException.
at MyTest.DoMethod2() in F:\...\ConsoleApp1\Dll1\pch.h:line 23
at ConsoleApp1.Program.<>c__DisplayClass0_0.<Main>b__1() in F:\...\ConsoleApp1\ConsoleApp1\Program.cs:line 16
at ConsoleApp1.Program.Test(Action action) in F:\...\ConsoleApp1\ConsoleApp1\Program.cs:line 23
WrapNonExceptionThrows = false인 결과가 다릅니다.
throw 5
System.Runtime.InteropServices.SEHException (0x80004005): External component has thrown an exception.
at _CxxThrowException(Void* , _s__ThrowInfo* )
at MyTest.DoMethod() in F:\...\ConsoleApp1\Dll1\pch.h:line 19
at ConsoleApp1.Program.<>c__DisplayClass0_0.<Main>b__0() in F:\...\ConsoleApp1\ConsoleApp1\Program.cs:line 15
at ConsoleApp1.Program.Test(Action action) in F:\...\ConsoleApp1\ConsoleApp1\Program.cs:line 23
throw gcnew System::Int32(5)
untyped catch
2. ThreadAbortException을 무시할 수 있을까요?
ThreadAbortException 예외는 잡을 수는 있지만 기본적으로 무시하진 않습니다. 만약 무시하고 싶다면 명시적으로 Thread.ResetAbort() 메서드를 호출해야 합니다. 다시 말하면, Thread.Abort를 호출했다고 해서 언제나 해당 스레드가 종료된다고 보장할 수는 없습니다.
또 한가지 재미있는 점은, Thread.Abort를 호출하면 스레드의 나머지 부분은 실행되지 않지만 finally 구문에 작성한 코드는 여전히 실행된다는 점입니다. 결국 대상 스레드가 finally 절의 코드를 수행 중이라면 Thread.Abort는 그것조차도 막을 수 없다는 것입니다. 실제로 CER (Constrained Execution Regions) 처리 시 반드시 수행되어야 하는 보호 코드를 finally 블록에 넣어야 하는 것에는 이런 이유도 있습니다.
참고로,
Thread.Abort의 내부 구현은 다음과 같은 식입니다.
- OS 스레드를 중지(Suspend)시키고,
- 스레드 중지가 요청되었음을 알리는 메타데이터 비트를 설정 후,
- 해당 스레드의 APC 큐에 작업을 추가하고,
- OS 스레드를 다시 시작(Resume)
따라서 Thread.Abort가 호출되면 스레드는 잠시 멈췄다가 다시 실행이 되는데, APC 큐의 작업은 이후 스레드가 alertable 상태로 진입할 때 실행됩니다. 그때 위의 2번 작업에서 수행한 비트 설정 유무를 체크해서 실질적으로 스레드 종료 작업을 밟게 되는데, 만약 이 과정에서 스레드가 자발적인 alertable 상태로 진입하지 않으면 CLR은 스레드의 IP 레지스터를 강제로 설정하는 방식으로 결국 APC 작업이 수행되도록 합니다.
3. AccessViolationException 유의 예외를 잡을 수 있을까요?
그와 같은 예외를 잡으려면 해당 메서드에 대해
HandleProcessCorruptedStateExceptionsAttribute 특성을 지정해야 합니다. 예를 들어 볼까요? ^^
using System;
using System.Runtime.ExceptionServices;
using System.Runtime.InteropServices;
namespace ConsoleApp1
{
class Program
{
static void Main(string[] args)
{
MyMethod();
}
public static int MyMethod()
{
try
{
Marshal.Copy(new byte[] { 42 }, 0, (IntPtr)1000, 1);
}
catch (Exception e)
{
System.Console.WriteLine("Catched: " + e.ToString());
return 1;
}
return 0;
}
}
}
위의 예제는, System.AccessViolationException 예외가 발생하지만 catch 문의 코드가 실행되지 않습니다. 반면 HandleProcessCorruptedStateExceptions 특성을 MyMethod에 다음과 같이 지정하면,
[HandleProcessCorruptedStateExceptions]
public static int MyMethod()
{
// ...[생략]...
}
이번에는 MyMethod 안에 있는 catch 문의 코드가 실행됩니다. 위의 상황은 .NET 2.0부터 바뀐 것이고 .NET 1.x 시절에는 무조건 catch 문으로 AV 예외를 잡을 수 있었습니다. 만약 .NET 2.0에서도 HandleProcessCorruptedStateExceptions 특성 없이 AV 예외를 잡고 싶다면 app.config에 다음과 같은 설정을 하면 됩니다.
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<runtime>
<legacyCorruptedStateExceptionsPolicy enabled="true" />
</runtime>
</configuration>
이렇게 바꾸고 HandleProcessCorruptedStateExceptions 특성을 주석 처리해도 catch 문의 코드가 실행되는 걸 확인할 수 있습니다. 그런데 사실 .NET 1.x에서는
System.AccessViolationException 타입도 없었습니다. 원래는 .NET 1.x의 경우 AV 예외를 System.NullReferenceException으로 처리했는데요, 이것도 .NET 2.0 이상에서 하위 호환을 위해 1.x처럼 AV 예외를 null 참조 예외로 바꿀 수 있도록 옵션이 제공됩니다.
<legacyNullReferenceExceptionPolicy enabled="true" />
따라서, legacyCorruptedStateExceptionsPolicy, legacyNullReferenceExceptionPolicy 옵션이 모두 true라면 HandleProcessCorruptedStateExceptions 특성이 없어도 AV 예외가 .NET 1.x와 동일하게 System.NullReferenceException으로 잡히게 됩니다.
4. finally 블록의 코드가 실행되지 않거나, finally가 중첩된 경우 특정 finally만 실행되는 경우도 있나요?
두 가지 상황 모두 발생할 수 있습니다.
우선 첫 번째 상황의 경우, 예를 들어 응용 프로그램을 작업 관리자 등을 이용해 강제 종료를 하면 finally 코드가 수행되지 않는 것을 확인할 수 있습니다. 또는, 코드로 테스트해 보려면 Environment.FailFast 메서드로 재현할 수 있습니다.
using System;
using System.Threading;
class Program
{
static void Main(string[] args)
{
try
{
Thread.Sleep(1000 * 5);
Environment.FailFast("FailFast called!");
}
finally
{
Console.WriteLine("Finally");
}
}
}
이 프로그램은 5초 후에 Environment.FailFast가 호출되지만 "Finally" 문자열 출력을 볼 수 없습니다.
두 번째 상황의 경우는 이전에 설명한 HandleProcessCorruptedStateExceptions의 도움을 받아 재현할 수 있습니다. 가령 다음과 같이 try/finally 블록이 있는 경우,
private static void Method1()
{
try
{
Method2();
}
finally
{
Console.WriteLine("Method 1");
}
}
private static void Method2()
{
try
{
Method3();
}
finally
{
Console.WriteLine("Method 2");
}
}
private static void Method3()
{
throw new ApplicationException("TEST");
}
Method3에서의 예외로 인해 Method2의 finally와 Method1의 finally가 차례대로 실행됩니다. 반면, Method3에서 System.AccessViolationException이 발생하는 상황에서 Method1에만 HandleProcessCorruptedStateExceptions 특성을 지정했다면,
[HandleProcessCorruptedStateExceptions]
private static void Method1()
{
try
{
Method2();
}
finally
{
Console.WriteLine("Method 1");
}
}
private static void Method2()
{
try
{
Method3();
}
finally
{
Console.WriteLine("Method 2");
}
}
private static void Method3()
{
Marshal.Copy(new byte[] { 42 }, 0, (IntPtr)1000, 1);
}
이번에는 화면에 Method1의 finally 블록만 실행되는 것을 확인할 수 있습니다.
5. fault란?
C#에서 지원하는 예외 처리 블록은 try, catch, finally로 3가지가 있습니다. 반면 IL 수준으로 내려가면 fault 블록을 하나 더 정의할 수 있는데 이는 예외가 발생하는 경우에만 실행됩니다. (finally의 경우 예외 유무에 상관없이 실행되는 것과 비교됩니다.)
따라서 IL 코드로 다음과 같이 작성해 ilasm.exe로 컴파일하면 fault 영역을 테스트할 수 있습니다.
// Metadata version: v4.0.30319
.assembly extern mscorlib
{
.publickeytoken = (B7 7A 5C 56 19 34 E0 89 ) // .z\V.4..
.ver 4:0:0:0
}
.assembly 'bacccde7-828a-4616-94e8-da4332f65171'
{
.hash algorithm 0x00008004
.ver 0:0:0:0
}
.module 'bacccde7-828a-4616-94e8-da4332f65171.dll'
// MVID: {63E2991D-2F64-4FE2-8CF3-0A1E1F6A1FD1}
.imagebase 0x10000000
.file alignment 0x00000200
.stackreserve 0x00100000
.subsystem 0x0003 // WINDOWS_CUI
.corflags 0x00000001 // ILONLY
// Image base: 0x01280000
.class public auto ansi beforefieldinit Program
extends [mscorlib]System.Object
{
.method public hidebysig static void Main() cil managed
{
//
.entrypoint
.maxstack 1
.language '{3F5162F8-07C6-11D3-9053-00C04FA302A1}', '{994B45C4-E6E9-11D2-903F-00C04FA302A1}', '{5A869D0B-6611-11D3-BD2A-0000F80849BD}'
.line 6,6 : 2,3 ''
IL_0000: nop
.line 7,7 : 6,7 ''
.try
{
IL_0001: nop
.line 9,9 : 3,4 ''
IL_0002: nop
IL_0003: leave.s IL_0013
.line 9,9 : 11,12 ''
} // end .try
fault
{
IL_0005: nop
.line 10,10 : 4,52 ''
IL_0006: ldstr "This will not be executed."
IL_000b: call void [mscorlib]System.Console::WriteLine(string)
IL_0010: nop
.line 11,11 : 3,4 ''
IL_0011: nop
IL_0012: endfinally
.line 16707566,16707566 : 0,0 ''
} // end handler
IL_0013: nop
.line 13,13 : 6,7 ''
.try
{
IL_0014: nop
.line 14,14 : 4,42 ''
IL_0015: ldstr "Some exception"
IL_001a: newobj instance void [mscorlib]System.Exception::.ctor(string)
IL_001f: throw
.line 15,15 : 11,12 ''
} // end .try
fault
{
IL_0020: nop
.line 16,16 : 4,44 ''
IL_0021: ldstr "You will see this."
IL_0026: call void [mscorlib]System.Console::WriteLine(string)
IL_002b: nop
.line 17,17 : 3,4 ''
IL_002c: nop
IL_002d: endfinally
} // end handler
} // end of method Program::Main
.method public hidebysig specialname rtspecialname
instance void .ctor() cil managed
{
//
.maxstack 8
IL_0000: ldarg.0
IL_0001: call instance void [mscorlib]System.Object::.ctor()
IL_0006: nop
IL_0007: ret
} // end of method Program::.ctor
} // end of class Program
6. 예외 필터(exception filter)란?
예외를 바로 처리하는 대신에, 해당 예외의 조건을 미리 검사한 후 처리할 수 있는 구문이 바로 "예외 필터"입니다. 다른 시리즈의 글(
Part 2 - Handling and rethrowing exceptions in C#)에서 예외 처리 구조에 대해 언급했지만 필터의 경우 두 단계로 이뤄진 예외 처리 구조에서 첫 번째 시점에 실행된다는 차이점이 있습니다.
그렇다면 이것이 기존 구문의 조합으로 가능한 간편 표기법(syntax sugar)일까요? 이를 위해 다음의 코드를 빌드하고,
using System;
public class Program
{
public static void Main()
{
try
{
}
catch (Exception e) when (e.Message == "Message") // C# 6.0부터 예외 필터 지원
{
}
}
}
IL 코드로 보면,
.try
{
IL_0001: nop
.line 9,9 : 3,4 ''
IL_0002: nop
IL_0003: leave.s IL_002e
.line 16707566,16707566 : 0,0 ''
} // end .try
filter
{
IL_0005: isinst [mscorlib]System.Exception
IL_000a: dup
IL_000b: brtrue.s IL_0011
IL_000d: pop
IL_000e: ldc.i4.0
IL_000f: br.s IL_0027
IL_0011: stloc.0
.line 10,10 : 22,51 ''
IL_0012: ldloc.0
IL_0013: callvirt instance string [mscorlib]System.Exception::get_Message()
IL_0018: ldstr "Message"
IL_001d: call bool [mscorlib]System.String::op_Equality(string,
string)
IL_0022: stloc.1
.line 16707566,16707566 : 0,0 ''
IL_0023: ldloc.1
IL_0024: ldc.i4.0
IL_0025: cgt.un
IL_0027: endfilter
.line 16707566,16707566 : 0,0 ''
} // end filter
{ // handler
IL_0029: pop
.line 10,10 : 51,52 ''
IL_002a: nop
.line 11,11 : 3,4 ''
IL_002b: nop
IL_002c: leave.s IL_002e
.line 12,12 : 2,3 ''
} // end handler
별도의 filter 영역이 지정된 것을 볼 수 있습니다. 즉, 간단하게 또 다른 C# 코드로 엮어낼 수 있는 syntax sugar 구현이 아닙니다.
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]