Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일
 

C# - 생성한 참조 개체가 언제 GC의 정리 대상이 될까요?

참조 타입의 인스턴스는 그것을 참조하는 루트 개체가 없을 때 정리될 수 있습니다. 그리고 해당 루트 개체에는 CPU의 레지스터나 메모리의 스택 영역도 포함이 되는데요, 따라서 일반적으로는 레지스터나 스택이 개체의 GC 힙 주소를 담고 있다면 그것은 GC가 되어서는 안 됩니다.

이것을 코드로 표현해볼까요? 가령 다음과 같은 상황에서,

static void Main(string[] args)
{
    object hr = new object();
    hr.ToString(); // hr 인스턴스 사용

    Console.WriteLine("TEST");    
}

hr 개체는 Main 메서드가 종료될 때까지 GC가 되지 않을 거라고 예상할 수 있습니다. 왜냐하면, hr 변수는 스택 또는 (최적화에 의해) 레지스터에 보관될 것이고, Main 메서드가 끝날 때까지는 유지될 것이기 때문에 GC가 되진 않을 것이기 때문입니다.

그래서 간혹 개발자들은 hr 인스턴스를 사용하지 않게 되었을 때 일부러 GC가 되게끔 null 값을 지정하곤 합니다.

static void Main(string[] args)
{
    object hr = new object();
    hr.ToString(); // 마지막 hr 인스턴스 사용 지점.
    hr = null; // GC 대상이 되도록!

    Console.WriteLine("TEST");    
}

일반적인 상황이라면, 저렇게 동작이 되는 것이 맞습니다. 하지만, "JIT 컴파일러"가 수행하는 최적화에 의해 저런 가정은 올바르지 않습니다. 왜냐하면, 똑똑한 JIT 컴파일러는 hr 개체가 할당이 된 후 ToString 메서드가 호출된 다음부터 더 이상 사용을 안 하고 있는 것을 알 수 있기 때문입니다.

따라서, 저 IL 코드를 "Jitting"하는 과정에서 hr 변수가 더 이상 사용되지 않는 시점, 즉 ToString을 호출한 이후부터는 hr 개체를 GC 대상으로 판정하는 최적화를 (경우에 따라) 수행합니다.

이에 대한 재현을 다음과 같이 할 수 있습니다.

// Release 빌드

using System;

internal class Program
{
    static void Main(string[] args)
    {
        object hr = new object();
        WeakReference wr = new WeakReference(hr);

        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();

        Console.WriteLine($"Target == {wr.Target}");

        for (; ;)
        {
            Console.ReadLine();
        }
    }
}

hr 인스턴스가 할당되었지만, JIT 컴파일러는 해당 개체가 new WeakReference 호출 이후 사용되지 않는다는 것을 알 수 있어, 그 후부터 GC 대상이 되게끔 최적화를 수행합니다. 그래서 GC.Collect 호출에서 hr 개체는 해제되고 화면에는 "Target == " 문자열만 출력이 됩니다.

즉, 굳이 개발자가 "hr = null;" 코드를 넣지 않아도 되는 것입니다.

여기서 한 가지 재미있는 점은, 마지막의 "for (; ;)" 코드를 주석 처리하면,

// ...[생략]...

Console.WriteLine($"Target == {wr.Target}");

// for (; ;)
{
    Console.ReadLine();
}

그다음부터는 다시 "Target == System.Object" 출력을 얻게 됩니다. 아마도, JIT 컴파일러의 최적화 코드는, 변수 생성 후 scope을 벗어나기까지 실행되는 코드가 유의미하지 않다면 (어차피 빠르게 지나갈 수 있으므로) 굳이 변수의 수명을 계산하는 작업은 하지 않는 듯합니다.




그런데, 저게 전부가 아닙니다. 실제로 위에서 제시한 코드를 disassembly 창으로 보면 다음과 같이 나옵니다.

using System;

internal class Program
{
    static void Main(string[] args)
    {
        object hr = new object();
00007FF9101138A0  push        rbp  
00007FF9101138A1  push        r15  
00007FF9101138A3  push        r14  
00007FF9101138A5  push        r13  
00007FF9101138A7  push        r12  
00007FF9101138A9  push        rdi  
00007FF9101138AA  push        rsi  
00007FF9101138AB  push        rbx  
00007FF9101138AC  sub         rsp,98h  
00007FF9101138B3  lea         rbp,[rsp+0D0h]  
00007FF9101138BB  vxorps      xmm4,xmm4,xmm4  
00007FF9101138BF  vmovdqa     xmmword ptr [rbp-60h],xmm4  
00007FF9101138C4  vmovdqa     xmmword ptr [rbp-50h],xmm4  
00007FF9101138C9  xor         edx,edx  
00007FF9101138CB  mov         qword ptr [rbp-40h],rdx  
00007FF9101138CF  lea         rcx,[rbp-0A8h]  
00007FF9101138D6  mov         rdx,r10  
00007FF9101138D9  call        00007FF96FBD5480  
00007FF9101138DE  mov         rsi,rax  
00007FF9101138E1  mov         rcx,rsp  
00007FF9101138E4  mov         qword ptr [rbp-88h],rcx  
00007FF9101138EB  mov         rcx,rbp  
00007FF9101138EE  mov         qword ptr [rbp-78h],rcx  
00007FF9101138F2  mov         rcx,7FF9100F5678h  
00007FF9101138FC  call        CORINFO_HELP_NEWSFAST (07FF96FC8AFD0h)  
00007FF910113901  mov         rdi,rax  
        WeakReference wr = new WeakReference(hr);
00007FF910113904  mov         rcx,7FF9101E3820h  
00007FF91011390E  call        00007FF96FB5AA10  
00007FF910113913  mov         rbx,rax  
00007FF910113916  mov         rcx,rbx  
00007FF910113919  mov         rdx,rdi  
00007FF91011391C  xor         r8d,r8d  
00007FF91011391F  call        00007FF96FC7A180  
00007FF910113924  mov         qword ptr [rbp-68h],rbx  

        GC.Collect();
00007FF910113928  mov         ecx,0FFFFFFFFh  
00007FF91011392D  mov         edx,2  
00007FF910113932  mov         rax,7FF9101E3E90h  
00007FF91011393C  mov         qword ptr [rbp-98h],rax  
00007FF910113943  lea         rax,[Program.Main(System.String[])+0C3h (07FF910113963h)]  
00007FF91011394A  mov         qword ptr [rbp-80h],rax  
00007FF91011394E  lea         rax,[rbp-0A8h]  
00007FF910113955  mov         qword ptr [rsi+10h],rax  
00007FF910113959  mov         byte ptr [rsi+0Ch],0  
00007FF91011395D  call        qword ptr [7FF9101E44A8h]  
00007FF910113963  mov         byte ptr [rsi+0Ch],1  
00007FF910113967  cmp         dword ptr [7FF96FFD4A34h],0  
00007FF91011396E  je          Program.Main(System.String[])+0D6h (07FF910113976h)  
00007FF910113970  call        qword ptr [Pointer to: CORINFO_HELP_STOP_FOR_GC (07FF96FFC6398h)]  
00007FF910113976  mov         rax,qword ptr [rbp-0A0h]  
00007FF91011397D  mov         qword ptr [rsi+10h],rax  
        GC.WaitForPendingFinalizers();
00007FF910113981  mov         rax,7FF9101E4100h  
00007FF91011398B  mov         qword ptr [rbp-98h],rax  
00007FF910113992  lea         rax,[Program.Main(System.String[])+0112h (07FF9101139B2h)]  
00007FF910113999  mov         qword ptr [rbp-80h],rax  
00007FF91011399D  lea         rax,[rbp-0A8h]  
00007FF9101139A4  mov         qword ptr [rsi+10h],rax  
00007FF9101139A8  mov         byte ptr [rsi+0Ch],0  
00007FF9101139AC  call        qword ptr [7FF9101E44C0h]  
00007FF9101139B2  mov         byte ptr [rsi+0Ch],1  
00007FF9101139B6  cmp         dword ptr [7FF96FFD4A34h],0  
00007FF9101139BD  je          Program.Main(System.String[])+0125h (07FF9101139C5h)  
00007FF9101139BF  call        qword ptr [Pointer to: CORINFO_HELP_STOP_FOR_GC (07FF96FFC6398h)]  
00007FF9101139C5  mov         rcx,qword ptr [rbp-0A0h]  
00007FF9101139CC  mov         qword ptr [rsi+10h],rcx  
        GC.Collect();
00007FF9101139D0  mov         ecx,0FFFFFFFFh  
00007FF9101139D5  mov         edx,2  
00007FF9101139DA  mov         rax,7FF9101E3E90h  
00007FF9101139E4  mov         qword ptr [rbp-98h],rax  
00007FF9101139EB  lea         rax,[Program.Main(System.String[])+016Bh (07FF910113A0Bh)]  
00007FF9101139F2  mov         qword ptr [rbp-80h],rax  
00007FF9101139F6  lea         rax,[rbp-0A8h]  
00007FF9101139FD  mov         qword ptr [rsi+10h],rax  
00007FF910113A01  mov         byte ptr [rsi+0Ch],0  
00007FF910113A05  call        qword ptr [7FF9101E44A8h]  
00007FF910113A0B  mov         byte ptr [rsi+0Ch],1  
00007FF910113A0F  cmp         dword ptr [7FF96FFD4A34h],0  
00007FF910113A16  je          Program.Main(System.String[])+017Eh (07FF910113A1Eh)  
00007FF910113A18  call        qword ptr [Pointer to: CORINFO_HELP_STOP_FOR_GC (07FF96FFC6398h)]  
00007FF910113A1E  mov         rcx,qword ptr [rbp-0A0h]  
00007FF910113A25  mov         qword ptr [rsi+10h],rcx  

        Console.WriteLine($"Target == {wr.Target}");
00007FF910113A29  mov         rcx,7FF9100F4928h  
00007FF910113A33  mov         edx,5  
00007FF910113A38  call        00007FF96FC426C0  
00007FF910113A3D  mov         rcx,22518002F08h  
00007FF910113A47  mov         rcx,qword ptr [rcx]  
00007FF910113A4A  mov         edx,100h  
00007FF910113A4F  cmp         dword ptr [rcx],ecx  
00007FF910113A51  call        qword ptr [Pointer to: CLRStub[MethodDescPrestub]@7ff9101191f0 (07FF9101E6AF0h)]  
00007FF910113A57  lea         rcx,[rbp-60h]  
00007FF910113A5B  mov         qword ptr [rbp-58h],rax  
00007FF910113A5F  test        rax,rax  
00007FF910113A62  jne         Program.Main(System.String[])+01CBh (07FF910113A6Bh)  
00007FF910113A64  xor         edx,edx  
00007FF910113A66  xor         r8d,r8d  
00007FF910113A69  jmp         Program.Main(System.String[])+01D3h (07FF910113A73h)  
00007FF910113A6B  lea         rdx,[rax+10h]  
00007FF910113A6F  mov         r8d,dword ptr [rax+8]  
00007FF910113A73  add         rcx,18h  
00007FF910113A77  mov         qword ptr [rcx],rdx  
00007FF910113A7A  mov         dword ptr [rcx+8],r8d  
00007FF910113A7E  xor         ecx,ecx  
00007FF910113A80  mov         dword ptr [rbp-50h],ecx  
00007FF910113A83  mov         byte ptr [rbp-4Ch],0  
00007FF910113A87  mov         ecx,dword ptr [rbp-50h]  
00007FF910113A8A  cmp         ecx,dword ptr [rbp-40h]  
00007FF910113A8D  ja          Program.Main(System.String[])+0292h (07FF910113B32h)  
00007FF910113A93  mov         rdx,qword ptr [rbp-48h]  
00007FF910113A97  mov         r8d,dword ptr [rbp-40h]  
00007FF910113A9B  sub         r8d,ecx  
00007FF910113A9E  mov         ecx,ecx  
00007FF910113AA0  lea         rcx,[rdx+rcx*2]  
00007FF910113AA4  cmp         r8d,0Ah  
00007FF910113AA8  jb          Program.Main(System.String[])+0231h (07FF910113AD1h)  
00007FF910113AAA  mov         rdx,225180030D8h  
00007FF910113AB4  mov         rdx,qword ptr [rdx]  
00007FF910113AB7  add         rdx,0Ch  
00007FF910113ABB  mov         r8d,14h  
00007FF910113AC1  call        Method stub for: System.Buffer.Memmove(Byte ByRef, Byte ByRef, UIntPtr) (07FF91010E808h)  
00007FF910113AC6  mov         edx,dword ptr [rbp-50h]  
00007FF910113AC9  add         edx,0Ah  
00007FF910113ACC  mov         dword ptr [rbp-50h],edx  
00007FF910113ACF  jmp         Program.Main(System.String[])+0247h (07FF910113AE7h)  
00007FF910113AD1  mov         rdx,225180030D8h  
00007FF910113ADB  mov         rdx,qword ptr [rdx]  
00007FF910113ADE  lea         rcx,[rbp-60h]  
00007FF910113AE2  call        CLRStub[MethodDescPrestub]@7ff9101125f8 (07FF9101125F8h)  
00007FF910113AE7  mov         rcx,qword ptr [rbp-68h]  
00007FF910113AEB  call        00007FF96FC80CD0  
00007FF910113AF0  mov         r8,rax  
00007FF910113AF3  lea         rcx,[rbp-60h]  
00007FF910113AF7  mov         rdx,7FF9101E4618h  
00007FF910113B01  call        CLRStub[MethodDescPrestub]@7ff910112890 (07FF910112890h)  
00007FF910113B06  lea         rcx,[rbp-60h]  
00007FF910113B0A  call        CLRStub[MethodDescPrestub]@7ff910112560 (07FF910112560h)  
00007FF910113B0F  mov         rcx,rax  
00007FF910113B12  call        CLRStub[MethodDescPrestub]@7ff910112cc8 (07FF910112CC8h)  

        for (; ;)
        {
            Console.ReadLine();
00007FF910113B17  call        CLRStub[MethodDescPrestub]@7ff910112c58 (07FF910112C58h)  
00007FF910113B1C  jmp         Program.Main(System.String[])+0277h (07FF910113B17h)  
00007FF910113B1E  add         rsp,98h  
00007FF910113B25  pop         rbx  
00007FF910113B26  pop         rsi  
00007FF910113B27  pop         rdi  
00007FF910113B28  pop         r12  
00007FF910113B2A  pop         r13  
00007FF910113B2C  pop         r14  
00007FF910113B2E  pop         r15  
00007FF910113B30  pop         rbp  
00007FF910113B31  ret  

여기서, object hr 변수를 담고 있는 레지스터만을 정리하면 딱 3군데가 나옵니다.

...[생략]...
00007FF9101138FC  call        CORINFO_HELP_NEWSFAST (07FF96FC8AFD0h)  
00007FF910113901  mov         rdi,rax  
...[생략]...
mov         rdx,rdi
...[생략]...
00007FF910113B27  pop         rdi
...[생략]...

보시면, "hr" 변수의 값을 "rdi" 레지스터에 보관 후, 마지막에 함수의 스택 프레임을 정리하는 시기에 "pop rdi"를 호출하기까지 값이 변함이 없습니다.

따라서, rdi 레지스터에 해당 GC Heap의 주소를 보관하고 있기 때문에 루트(root) 역할을 하게 되고, 따라서 GC 되어서는 안 됩니다. 그런데, 어떻게 이것을 해제해 버리는 걸까요?




이에 대한 해답을 다음의 글에서 설명하고 있습니다.

When does an object become available for garbage collection?
; https://devblogs.microsoft.com/oldnewthing/20100810-00/?p=13193

위의 글에서는 아래의 예제로 설명을 시작합니다.

class SomeClass {
 ...
 string SomeMethod(string s, bool reformulate)
 {
  OtherClass o = new OtherClass(s);
  string result = Frob(o);
  if (reformulate) Reformulate();
  return result;
 }
}

OtherClass의 인스턴스가 변수 "o"에 저장이 되는데요, 이때의 변수 "o"는 결국 스택의 한 위치에 불과하고 그 스택의 위치는 Frob 메서드가 반환하는 "string result" 변수와 공유되는 것도 가능합니다. 그런 상황을 다음과 같이 어셈블리 코드로 표현하고 있는데요,

  mov ecx, esi        ; load "this" pointer into ecx register
  mov edx, [ebp-8]    ; load parameter ("o") into edx register
  call SomeClass.Frob ; call method
  mov [ebp-8], eax    ; re-use memory for "o" as "result"

따라서, 변수 "o"에 해당하는 "[ebp - 8]" 스택 위치가 이후 변수 "result"에 해당하는 값을 함께 보관하는 용도로 사용되기 때문에 GC 입장에서는 "o" 인스턴스에 대한 스택의 루트 참조가 "Frob" 메서드의 반환 후 "mov [ebp - 8], eax" 시점에 없어지기 때문에 "o" 인스턴스가 GC 대상이 될 수 있습니다.

정리해 보면, JIT 컴파일러가 (Debug가 아닌) release 빌드 환경이라면 저런 스택 메모리 사용의 최적화로 인해 "SomeMethod" 자체의 반환 시점이 아닌, 내부 코드 실행 중인 Frob 메서드의 반환 시점에 이미 "o" 인스턴스는 GC 대상이 될 수 있습니다. 여기까지는 단순한 JIT 컴파일러가 출력한 어셈블리로 인해 충분히 이해될 수 있습니다.




(하지만, 위의 동작 방식에 대한 설명도 여전히 우리가 테스트했던 예제 코드의 "rdi" 레지스터를 무시하는 것에 대한 설명이 안 됩니다.)

그런데, 해당 글에서는 이야기가 진행되면서 더 재미있는 설명을 하고 있습니다. 위의 예제 코드에서 "Frob" 메서드가 매개변수로 전달받은 "o" 개체에 대해 사용을 하지 않는다고 가정해보겠습니다. 그렇다면, "o" 개체는 Frob 메서드가 실행을 반환하기 이전에라도 GC 대상으로 포함시켜도 무방합니다.

반면, Frob 함수가 아래와 같이 o 인스턴스를 사용한다고 가정해보겠습니다.

string Frob(OtherClass o)
{
 string result = FrobColor(o.GetEffectiveColor());
 // ...[생략]...
 return result;
}

사용자가 만든 저 코드는, 어쩌면 FrobColor를 반환할 때까지는 o 개체가 GC 대상에서 제외될 수 있다고 판단할 수 있지만, 엄밀히 위의 코드는 다음과 같이 해석이 되기 때문에,

string Frob(OtherClass o)
{
 var v1 = o.GetEffectiveColor();
 string result = FrobColor(v1);
}

o 인스턴스의 사용은 FrobColor 이전에 사용이 완료되었으므로 FrobColor 수행 이전에 GC 대상으로 적합하게 됩니다. 즉, 매개변수로 넘어온 인스턴스도 메서드의 실행 중 어느 시점에는 더 이상 사용하지 않는다는 것을 계산해 넣을 수 있습니다.

심지어 GetEffectiveColor가 이렇게 구현되었다고 가정해 보겠습니다.

Color GetEffectiveColor()
{
 Color color = this.Color;
 for (OtherClass o = this.Parent; o != null; o = o.Parent) {
  color = BlendColors(color, o.Color);
 }
 return color;
}

그렇다면, 위의 코드도 "this.Parent"를 한 시점에 "this" 인스턴스의 사용은 모두 완료됐기 때문에 역시 GC 가능한 개체로 볼 수 있습니다.

결국 SomeMethod에서 생성한 "o" 개체는 Frob, GetEffectiveColor 메서드를 연이어 호출하면서 사용하고는 있지만, "SomeMethod"의 메서드 반환까지 살아 있을 필요는 없고, GetEffectiveColor의 "this.Parent"를 구한 시점에 GC 대상이 될 수 있다는, 결론을 내릴 수 있습니다.




물론, 여기까지의 설명도 여전히 "rdi"로 유지하고 있는 개체를 어떻게 GC 구성요소가 해제해 버리는가에 대한 답은 아닙니다. 기술적으로 봤을 때, GC 구성요소는 해당 개체를 해제할 수 없습니다.

하지만, ^^ 마지막에 이에 대한 해답이 나옵니다. GC 자체로는 그런 능력이 없지만, "JIT 컴파일러"와 협업을 하기 때문에 그것이 가능하다는 점입니다. 즉, 스택이나 레지스터에 등록된 특정 참조를 무시할 수 있도록 JIT 컴파일러가 구성한 메타데이터 정보의 도움을 받는다는 것입니다.

Even if the reference is left on the stack, the JIT can leave some metadata behind that tells the GC, “If you see the instruction pointer in this range, then ignore the reference in this slot since it’s a dead variable,” similar to how in unmanaged code on non-x86 platforms, metadata is used to guide structured exception handling.


결국, 우리가 만들었던 rdi 레지스터의 참조는, JIT 컴파일러가 만들어 놓은 특정 범위에 속하면 "root 개체의 자격"에서 일시적으로 제외되었던 것입니다.




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

[연관 글]






[최초 등록일: ]
[최종 수정일: 5/9/2022]

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

비밀번호

댓글 작성자
 




[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
13094정성태7/6/202219오류 유형: 816. Golang - "short write" 오류 원인
13093정성태7/5/202257.NET Framework: 2029. C# - HttpWebRequest로 localhost 접속 시 2초 이상 지연
13092정성태7/3/2022122.NET Framework: 2028. C# - HttpWebRequest의 POST 동작 방식파일 다운로드1
13091정성태7/3/202280.NET Framework: 2027. C# - IPv4, IPv6를 모두 지원하는 서버 소켓 생성 방법
13090정성태6/29/202260오류 유형: 815. PyPI에 업로드한 패키지가 반영이 안 되는 경우
13089정성태6/28/202280개발 환경 구성: 646. HOSTS 파일 변경 시 Edge 브라우저에 반영하는 방법
13088정성태6/27/202259개발 환경 구성: 645. "Developer Command Prompt for VS 2022" 명령행 환경의 폰트를 바꾸는 방법
13087정성태6/23/2022129스크립트: 41. 파이썬 - FastAPI / uvicorn 호스팅 환경에서 asyncio 사용하는 방법
13086정성태6/22/2022239.NET Framework: 2026. C# 11 - 문자열 보간 개선 2가지파일 다운로드1
13085정성태6/22/2022193.NET Framework: 2025. C# 11 - 원시 문자열 리터럴(raw string literals)파일 다운로드1
13084정성태6/21/202270개발 환경 구성: 644. Windows - 파이썬 2.7을 msi 설치 없이 구성하는 방법
13083정성태6/20/2022196.NET Framework: 2024. .NET 7에 도입된 GC의 메모리 해제에 대한 segment와 region의 차이점
13082정성태6/19/2022117.NET Framework: 2023. C# - Process의 I/O 사용량을 보여주는 GetProcessIoCounters Win32 API파일 다운로드1
13081정성태6/17/2022192.NET Framework: 2022. C# - .NET 7 Preview 5 신규 기능 - System.IO.Stream ReadExactly / ReadAtLeast파일 다운로드1
13080정성태6/17/2022129개발 환경 구성: 643. Visual Studio 2022 17.2 버전에서 C# 11 또는 .NET 7.0 preview 적용
13079정성태6/17/202296오류 유형: 814. 파이썬 - Error: The file/path provided (...) does not appear to exist
13078정성태6/16/2022188.NET Framework: 2021. WPF - UI Thread와 Render Thread파일 다운로드1
13077정성태6/15/2022116스크립트: 40. 파이썬 - postgresql 환경 구성
13075정성태6/15/2022201Linux: 50. Linux - apt와 apt-get의 차이 [2]
13074정성태6/13/2022205.NET Framework: 2020. C# - NTFS 파일에 사용자 정의 속성값 추가하는 방법파일 다운로드1
13073정성태6/12/2022244Windows: 207. Windows Server 2022에 도입된 WSL 2
13072정성태6/10/2022157Linux: 49. Linux - ls 명령어로 출력되는 디렉터리 색상 변경 방법
13071정성태6/9/2022201스크립트: 39. Python에서 cx_Oracle 환경 구성
13070정성태6/8/2022243오류 유형: 813. Windows 11에서 입력 포커스가 바뀌는 문제 [1]
13069정성태5/26/2022499.NET Framework: 2019. C# - .NET에서 제공하는 3가지 Timer 비교
[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...