C# 6.0 완벽 가이드/ 객체 처분과 쓰레기 수거

  • 객체 중에는 열린 파일이나 자물쇠(lock), 운영체제 핸들, 비관리(unmanaged) 객체 같은 자원들을 해제하는 명시적인 해체(tear-down) 코드가 필요한 객체들이 있다. .NET의 어법에서 그런 작업을 처분(disposal)이라고 부른다.
    • .NET Framework는 객체 처분 기능을 지원하기 위해 IDisposable이라는 인터페이스를 제공한다.
  • 처분은 쓰레기 수거(garbage collection, GC)와는 다른 연산이다. 보통의 경우 처분은 프로그래머가 명시적으로 수행하지만, 쓰레기 수거는 런타임이 자동으로 수행해준다.
    • 다른 말로 하면 프로그래머는 파일 핸들이나 자물쇠, 운영체제 자원들의 해제를 신경 쓰고, CLR은 그런 자원들이 차지하던 메모리의 해제를 신경 쓴다.

IDisposable, Dispose, Close

  • .NET Framework는 해체 수단이 필요한 형식을 위해 다음과 같은 특별한 인터페이스를 제공한다.
public interface IDisposable
{
  void Dispose();
}

  • C#의 using 문은 이 IDisposable을 구현하는 객체에 대해 Dispose 메서드를 try/finally 블록을 이용해서 호출하는 코드를 단축 표기하는 수단이라 할 수 있다. 예컨대 C# 컴파일러는 다음 코드를
using (FileStream fs = new FileStream("myFile.txt", FileMode.Open))
{
  // ...
}
  • 다음과 같이 바꾸어서 컴파일한다.
FileStream fs = new FileStream("myFile.txt", FileMode.Open);
try
{
  // ...
}
finally
{
  if (fs != null) ((IDisposable)fs).Dispose();
}
  • finally 블록 덕분에, 예외가 발생하거나 기타 이유로 코드가 try 블록을 일찍 벗어날 때도 Dispose 메서드가 반드시 호출된다.
  • 간단한 시나리오에서는 그냥 IDisposable을 상속해서 Dispose를 구현하기만 하면 처분 가능(disposal) 형식이 된다.
sealed class Demo : IDisposable
{
  public void Dispose()
  {
    // 마무리/해체 작업을 수행한다.
  }
}

표준 처분 의미론

  • .NET Framework이 정의하는 형식들의 처분 논리는 ‘사실상의 표준’이라고 할 수 있는 특정한 규칙들을 따른다. 그 규칙들이 .NET Framework나 C# 언어의 명세에 포함되어 있는 것은 아니다. 그 규칙들은 단지 소비자들을 위한 일관된 프로토콜을 정의하기 위한 것일 뿐이다. 그 규칙들은 다음과 같다.
    • 일단 처분된 객체는 다시 살릴 수 없다. 다시 활성화하는 것이 불가능하며, 객체의 메서드(Dispose 이외의)나 속성을 호출하면 ObjectDisposedException 예외가 발생한다.
    • 한 객체에 대해 Dispose를 여러 번 호출해도 오류가 발생하지 않는다.
    • 처분 가능 객체 x가 처분 가능 객체 y를 소유한 경우, x의 Dispose 메서드는 자동으로 y의 Dispose를 호출한다. (호출하지 말라고 지시하지 않았다면)

Close와 Stop

  • 어떤 형식은 Dispose 외에 Close라는 메서드도 정의한다. 이 Close 메서드의 의미론에 관해 .NET Framework가 철저하게 일관적이지는 않지만, 거의 모든 경우 Close 메서드는 다음 둘 중 하나를 수행한다.
    • Dispose와 정확히 동일한 기능
    • Dispose 기능의 일부
  • 후자의 예는 IDbConnection이다. Close를 호출해서 연결을 닫은 후에 Open으로 다시 여는 것이 가능하다. 그러나 Dispose로 처분한 연결을 다시 살리지는 못한다.
    • ShowDialog 메서드로 띄운 Form에 대해 Close를 호출하면 그냥 차잉 화면에서 사라질 뿐이지만, Dispose를 호출하면 창의 자원들이 해제된다.
  • 어떤 클래스는 Stop 메서드를 정의한다 (예컨대 Timer나 HttpListener 클래스) Stop 메서드도 Dispose처럼 비관리 자원들을 해제할 수 있지만, Dispose와는 달리 Start 메서드로 객체의 작동을 다시 시작하는 것이 가능하다.
  • WinRT(Windows Runtime)에서는 Close가 Dispose와 같은 것으로 간주된다. 실제로 WinRT 런타임은 Close라는 이름의 메서드들을 Dispose라는 이름의 메서드로 투영하는데, 이는 해당 형식들을 using 문에서 사용할 수 있게 하기 위한 것이다.

Dispose를 호출해야 할 때

  • 거의 모든 경우에서는 “의심스럽다면 처분하라”라는 규칙을 따르는 것이 안전하다.
  • 비관리 자원 핸들을 감싼 객체는 거의 항상 처분이 필요하다. 그래야 그 핸들들을 해제할 수 있기 때문이다. Windows Forms 컨트롤들이나 파일/네트워크 스트림, 네트워크 소켓, GDI+의 펜/브러시/비트맵 등이 그러한 예이다.
    • 반대로 어떤 형식이 처분 가능한 형식이면, 그 형식은 직접적으로든 간접적으로든 비관리 핸들을 참조할 가능성이 크다. (항상 그런 것은 아니라 해도) 이는 비관리 핸들이 객체의 ‘바깥 세상’에 있는 운영체제 자원들이나 네트워크 연결, 데이터베이스 자물쇠 등으로 가는 관문 역할을 하기 때문이다.
    • 따라서 그런 핸들을 참조하는 객체를 제대로 폐기하지 않으면 객체의 바깥 세상에서 문제가 생길 가능성이 있다.
  • 그러나 객체를 처분하지 말아야 할 상황도 있다. 그런 상황들은 크게 다음 세 범주로 나뉜다.
    • 현재 코드가 객체를 ‘소유’하지 않을 때, 즉 정적 필드나 속성을 통해서 공유 객체를 얻었을 때
    • 객체의 Dispose 메서드가 현재 상황에 맞지 않는 작업을 수행할 때
    • 객체의 설계 차원에서 Dispose 메서드가 꼭 필요한 것이 아닐 때, 그리고 그 객체를 처분하려면 프로그램이 쓸데없이 복잡해질 때
  • 첫 범주는 드물다.
    • 주된 예는 System.Drawing 이름공간의 형식들이다. 정적 필드나 속성을 통해서 얻은 GDI+ 객체는 절대로 처분하면 안 된다. 왜냐면 같은 인스턴스를 응용 프로그램의 수명 전체에서 사용하기 때문이다.
    • 그러나 생성자를 통해서 얻은 인스턴스는 처분하는 것이 바람직하다. 정적 메서드를 통해서 얻은 인스턴스들도 마찬가지다.
  • 둘째 범주는 좀 더 흔하다. 좋은 예는 System.IO 이름공간과 System.Data 이름공간에 있는 다음과 같은 형식들이다.
형식 처분의 기능 처분하지 말아야 할 때
MemoryStream 이후의 입출력이 금지된다. 나중에 스트림을 읽거나 써야 할 때
StreamReader, StreamWriter 판독기/기록기의 내용을 배출하고(flush), 바탕 스트림을 닫는다. 바탕 스트림을 계속 열어두고 싶을 때(그런 경우 StreamWriter를 다 사용했다면 Dispose 대신 Flush를 호출해야 한다)
IDbConnection 데이터베이스 연결을 해제하고 연결 문자열을 지운다. 나중에 Open으로 다시 열고자 할 때(그런 경우에는 Dispose 대신 Close를 호출해야 한ㄷ)
DataContext (LINQ to SQL) 이후의 사용이 금지된다. 게으르게 평가되는 질의가 문맥에 연결되어 있을 가능성이 있을 때

 

  • MemoryStream의 Dispose 메서드는 개체만 비활성화할 뿐, 어떤 중요한 마무리 작업을 수행하지는 않는다. MemoryStream은 비관리 핸들이나 그와 비슷한 종류의 자원을 전혀 소유하지 않기 때문이다.
  • 셋째 범주에는 System.ComponentModel 이름공간의 WebClient, StringReader, StringWriter, BackgroundWorker 클래스가 포함된다.
    • 이 형식들은 단지 기반 클래스가 처분 가능이라서 처분 가능 형식이 된 것일 뿐, 어떤 본질적인 마무리 작업이 필요한 것은 아니다.
    • 한 메서드에서 이런 형식의 인스턴스를 생성해서 그 메서드 안에서만 사용하는 경우라면 그 부분을 using으로 감싸는 것이 크게 번거로운 일은 아닐 것이다.
    • 그러나 객체의 수명이 한 메서드 범위를 넘어서 지속된다면 그 객체가 더 이상 쓰이지 않는지 점검해서 적절히 처분하는 코드가 쓸데 없이 복잡해진다. 그런 경우라면 객체의 처분을 무시해도 무방하다.

명시적 선택 기반 처분

  • 커스텀 형식을 작성할 때 IDisposable을 구현하면 그 형식을 C#의 using 문에 사용할 수 있다는 장점이 생긴다. 그런데 그런 장점을 얻으려고 IDisposable을 비본질적인 작업에까지 확장하는 우를 범할 수 있다. 다음 예를 보자.
public sealed class HouseManager : IDisposable
{
  public void Dispose()
  {
    CheckTheMail();
  }
  ...
}
  • 여기에 깔린 의도는 원한다면 클래스 소비자가 비본질적인 정리 작업을 생략할 수 있게 한다는 것이다. 그런 경우 소비자는 Dispose를 호출하지 않으면 된다. 그러나 그러려면 소비자가 HouseManager의 Dispose 메서드의 구현 세부사항을 알아야 한다. 또한 나중에 본질적인 정리 작업이 Dispose에 추가되면 그러한 의도가 무산된다.
public void Dispose()
{
  CheckTheMail();  // 비본질적
  LockTheHouse();  // 본질적
}
  • 이 문제의 해결책은 다음과 같은 명시적 선택(opt-in) 기반 처분 패턴이다.
public sealed class HouseManager : IDisposable
{
  public readonly bool CheckMailOnDispose;

  public HouseManager (bool checkMailOnDispose)
  {
    CheckMailOnDispose = checkMailOnDispose;
  }

  public void Dispose()
  {
    if (CheckMailOnDispose) CheckTheMail();
    LockTheHouse();
  }
  ...
}
  • 이제 소비자는 고민없이 Dispose를 호출하면 된다. 따라서 코드가 간단해지고, 특별한 문서화나 반영 기능을 동원할 필요가 없다.
    • 실제 이 패턴은 .NET Framework의 System.ID.Compression에 있는 DeflateStream 클래스에 쓰였다.

처분시 필드 비우기

  • 일반적으로 Dispose 메서드에서 객체의 필드들을 비울 필요는 없다. 그러나 객체의 수명 도중 내부적으로 등록한 이벤트들의 구독을 해제하는 것은 좋은 습관이다.
    • 그런 이벤트들의 구독을 해제하면 원치 않은 이벤트 통지를 받는 일이 없게 되며, 객체가 여전히 살아 있다고 쓰레기 수거기가 오해하는 일도 피할 수 있다.
    • Dispose 메서드 자체는 메모리(관리되는 메모리)의 해제를 유발하지 않는다. 메모리 해제는 오직 쓰레기 수거 과정에서만 일어난다.
  • 또한 객체가 처분되었음을 뜻하는 필드를 두고 Dispose에서 그 필드를 적절히 설정하는 것도 좋은 방법이다. 그러면 소비자가 처분된 객체에 대해 멤버 함수를 호출하려 할 때 ObjectDisposedException을 던질 수 있다.
    • 더 나아가 다음처럼 누구나 읽을 수 있는 자동 속성을 두는 것도 좋다.
public bool IsDisposed { get; private set; }
  • 더 나아가서, 객체 자신의 이벤트 처리부들을 Dispose 메서드에서 비우는 것도 바람직하다. 이것이 필수는 아니지만, 이렇게 하면 처분 도중 또는 이후에 이벤트들이 발동할 가능성이 없어진다.
  • 종종 객체가 암호화 키 같은 고가의 비밀을 담기도 한다. 그런 경우에는 처분 도중에 그런 필드의 내용을 비우는 것이 합당하다(특권이 적은 어셈블리나 악성 코드에 그런 자료가 노출되지 않도록).
    • System.Security.Cryptography의 SymmetricAlgorithm 클래스가 실제로 그렇게 한다. 처분 시 이 클래스는 암호화 키를 담고 있는 바이트 배열에 대해 Array.Clear를 호출한다.

자동 쓰레기 수거

  • 객체에 커스텀 정리 작업을 위한 Dispose 메서드가 필요하든 그렇지 않든, 언젠가는 객체가 힙에 차지하고 있는 메모리를 해제해야 할 시점이 온다. CLR은 자동적인 쓰레기 수거(GC)를 통해서 그런 해제 작업을 완전히 자동으로 처리한다. 관리되는 메모리를 프로그래머가 손수 해제해야 할 일은 없다. 예컨대 다음 메서드를 생각해 보자.
public void Test()
{
  byte[] myArray = new byte[1000];
  ...
}
  • Test 메서드가 실행되면 메모리 힙에 1000개의 바이트를 담는 배열 하나가 할당된다. 그 배열을 참조하는 지역 변수 myArray는 지역 변수 스택에 저장된다.
    • 메서드 실행이 끝나면 이 지역 변수 myArray가 범위를 벗어나게 되며, 그러면 메모리 힙의 배열을 참조하는 변수는 더 이상 없는 상태가 된다. 더 이상 참조되지 않는 배열, 즉 ‘버림받은’ 배열은 쓰레기 수거의 대상이 된다.
    • 최적화가 비활성화된 디버깅 모드에서, 지역 변수가 참조하는 객체의 수명은 코드 블록의 끝까지 연장된다. 이는 디버깅 편의를 위해서다. 그렇게 수명이 연장되지 않으면, 객체가 더 이상 쓰이지 않게 되는 가장 이른 시점부터 쓰레기 수거의 대상이 된다.
  • 그런데 객체가 버림받는다고 그 즉시 쓰레기 수거가 일어나는 것은 아니다. 청소 대행 업체가 수행하는 실제 쓰레기 수거와 마찬가지로 .NET Framework의 쓰레기 수거는 주기적으로 일어난다. 단, 어떤 고정된 일정을 따르지는 않는다.
    • CLR은 가용 메모리 양과 할당된 메모리 양, 지난번 수거 이후 흐른 시간 등등 여러 가지 요인을 고려해서 수거 시점을 결정한다. 간단히 말해서 객체가 버림받은 시점과 해당 메모리가 실제로 해제되는 시점 사이의 지연 시간은 예측할 수 없다. 몇 나노초일 수도 있고 며칠일 수도 있다.
    • 쓰레기 수거기가 한 번의 수거 주기에서 모든 쓰레기를 수거하지는 않는다. 메모리 관리자는 객체들을 세대(generation)별로 분류하며, 쓰레기 수거기는 젊은 세대(최근 할당된 객체들)를 늙은 세대(오랫동안 살아 있는 객체들)보다 더 자주 수거한다.

쓰레기 수거와 메모리 소비

  • 쓰레기 수거기는 쓰레기 수거에 쓰이는 시간과 응용 프로그램의 메모리 소비량(작업 집합(working set)의 크기) 사이의 균형을 맞추려고 노력한다. 이 때문에 응용 프로그램이 실제로 필요한 것보다 더 많은 메모리를 소비할 수 있다. 커다란 임시 배열들을 생성하는 경우에 특히 그렇다.
  • 프로세스의 메모리 소비량은 ‘Windows 작업 관리자’나 ‘리소스 모니터’로 파악할 수 있으며, 프로그램 안에서는 성능 카운터(performance counter)를 질의해서 알아낼 수 있다.
// 이 형식들은 System.Diagnostics에 있음
string procName = Process.GetCurrentProcess().ProcessName;

using (PerformanceCount pc = new PerformanceCounter("Process", "Private Bytes", procName))
  Console.WriteLine(pc.NextValue());
  • 이 질의는 전용 작업 집합(private working set)을 조회한다. 전용 작업 집합은 프로그램의 메모리 소비에 관한 최선의 전반적인 지표를 제공한다. 특히 이 수치에는 CLR이 내부적으로 해제한, 그리고 운영체제가 요구한다면(다른 프로세스들에 할당하기 위해) 기꺼이 넘겨줄 메모리의 용량이 제외되어 있다.

뿌리 참조

  • 꽃이나 나무의 뿌리처럼, 뿌리 참조는 객체를 살아 있게 한다. 뿌리 객체가 직접적으로든 간접적으로든 참조하지 않는 객체는 쓰레기 수거 대상이 된다. 뿌리 객체로 간주되는 요소는 다음과 같다.
    • 실행 중인 메서드(또는 그 메서드의 호출 스택에 있는 모든 메서드)의 지역 변수나 매개변수
    • 정적 변수
    • 종료(finalization) 준비가 된 객체들이 담긴 대기열에 있는 객체
  • 삭제된 객체의 코드가 실행될 수는 없으므로, 만일 어떤 메서드(인스턴스 메서드)가 실행될 여지가 조금이라도 있으려면 해당 객체가 어떤 방식으로든 뿌리부터 참조되어야 한다.
  • 서로를 순환적으로 참조하는 일단의 객체들은 뿌리 참조 없이 죽은 것으로 간주된다. 다른 말로 하면 뿌리 객체로부터 화살표(참조 관계)들을 따라 접근할 수 없는 객체는 도달 불가능(unreachable) 객체이며, 따라서 쓰레기 수거의 대상이 된다.

쓰레기 수거와 WinRT

  • WinRT는 자동 쓰레기 수거가 아니라 COM의 참조 계수(reference counting) 메커니즘을 이용해서 메모리를 해제한다. 그렇긴 하지만 C#에서 인스턴스화한 WinRT 객체들의 수명은 CLR의 쓰레기 수거기가 관리한다.
    • 이를 위해 CLR은 내부적으로 런타임 호출 가능 래퍼(runtime callable wrapper)라고 부르는 객체를 생성해서 COM 객체로의 접근을 중재한다)

종료자

  • 객체에 종료자(finalizer)가 있으면 객체의 메모리가 해제되기 전에 종료자가 호출된다. 종료자의 선언 구문은 생성자의 선언과 비슷하나 이름 앞에 ~ 기호가 붙는다.
class Test
{
  ~Test()
  {
    // 종료자 논리 코드...
  }
}
  • 선언이 생성자의 것과 비슷하긴 하지만, 종료자는 public이나 static으로 선언할 수 없고, 매개변수를 받을 수 없으며, 기반 클래스를 호출할 수 없다.
  • 종료자 메커니즘이 가능한 것은 한 번의 쓰레기 수거가 여러 단계로 수행되기 때문이다.
    • 쓰레기 수거기는 우선 쓰이지 않는, 따라서 삭제할 객체들을 식별한다. 그중 종료자가 없는 객체들은 즉시 삭제한다.
    • 종료자가 있는 객체들은 특별한 대기열에 넣는다. 대기열에 있는 아직 종료자가 실행되지 않은 객체는 여전히 살아 있는 것으로 간주된다.
  • 그런 객체들을 모두 대기열에 넣으면 한 번의 쓰레기 수거 주기가 끝나서 프로그램의 실행이 재개된다. 그와 함께 종료자 스레드가 만들어진다.
    • 이 스레드는 프로그램과 병렬로 실행되면서, 특별한 대기열에서 객체들을 뽑아서 종료자 메서드를 호출한다. 아직 종료자가 실행되지 않은 객체는 여전히 살아 있는 것으로 간주된다. 즉 대기열 자체가 하나의 뿌리 객체 역할을 한다.
    • 그러나 대기열에서 뽑혀서 종료자 메서드가 실행되면 객체는 버림받은 상태가 되며, 다음번 쓰레기 수거(그 객체가 속한 세대에 대한)에서 삭제된다.
  • 종료자가 유용한 상황이 있긴 하지만, 다음과 같은 문제점들을 반드시 염두에 두어야 한다.
    • 종료자 떄문에 메모리 할당과 쓰레기 수거가 느려진다(객체들의 종료자 존재 여부와 실행 여부를 쓰레기 수거기가 추적해야 하므로)
    • 종료자는 객체와 그 객체가 참조하는 모든 객체의 수명을 필요 이상으로 늘린다. (실제 삭제가 다음번 쓰레기 수거로 미루어지므로)
    • 일단 객체들에 대해 그 종료자들이 호출되는 순서를 예측할 수 없다.
    • 객체의 종료자가 호출되는 시점을 프로그래머가 거의 제어할 수 없다.
    • 종료자 안에서 코드 실해잉 차단되면 다른 객체들의 종료자가 호출되지 못한다.
    • 응용 프로그램이 정상적으로 깔끔하게 종료되지 않으면 종료자들이 호출되지 않을 수 있다.
  • 정리하자면 종료자는 변호사와 비슷하다. 변호사가 필요한 때도 있긴 하지만 대체로 우리는 꼭 필요한 상황이 아니면 변호사를 구하려 하지 않는다. 만일 변호사에 일을 맡긴다면 변호사가 해주는 일이 무엇인지 100% 이해할 필요가 있다.
  • 다음은 종료자 구현시 따를만한 지침 몇가지 이다.
    • 종료자의 실행이 빨리 끝나게 하라
    • 종료자 안에서 코드 실행이 차단되는 일이 없게 하라
    • 종료자 안에서 다른 종료 가능 객체를 참조하지 말라
    • 예외를 던지지 말라
  • 객체를 생성하는 도중에 예외가 발생해도 그 객체의 종료자가 호출될 수 있다. 따라서 종료자를 작성할 때는 객체의 필드들이 모두 정확히 초기화되었다고 가정하지 않는 것이 중요하다.

종료자에서 Dispose 호출

  • 종료자와 관련해서 흔히 쓰이는 패턴은 종료자 안에서 Dispose를 호출하는 것이다. 이 패턴은 객체의 정리 작업이 그리 급하지 않을 때 그리고 Dispose를 호출해서 정리 작업을 촉진하는 것이 꼭 필요해서라기보다는 일종의 최적화를 위한 것일 때 적합하다.
  • 이 패턴을 적용하면 자원 해제와 메모리 해제의 결합도가 강해진다는 점을 기억하기 바란다. 자원 자체가 메모리가 아닌 한, 자원 해제와 메모리 해제는 서로 다른 관심사일 수 있다. 또한 이 패턴은 종료자 스레드의 부담을 증가한다.
    • 소비다가 Dispose 호출을 까먹는 경우의 대비책으로 이 패턴을 사용할 수도 있다. 그런 목적으로 이 패턴을 사용할 때는 프로그래머가 나중에라도 버그를 고칠 수 있도록 Dispose 호출 누락 사실을 로그에 기록하는 것이 바람직하다.
class Test : IDisposable
{
  public void Dispose()  // virtual 아님
  {
    Dispose(true);
    GC.SuppressFinalize(this);  / 종료자의 실행을 방지한다.
  }

  protected virtual void Dispose(bool disposing)
  {
    if (disposing)
    {
      // 이 인스턴스가 소유한 객체들에 대해 Dispose를 호출한다.
      // 여기서는 다른 종료 객체들을 참조해도 된다.
      // ...
    }
      // 이 객체가 (이 객체만) 소유하고 있는 비관리 자원들을 해제한다.
      // ...
  }

  ~Test()
  {
    Dispose(false);
  }
}
  • Dispose에는 bool disposing 플래그를 받는 중복적재 버전이 있다. 매개변수 없는 버전은 virtual로 선언되지 않으며, true를 인수로 해서 개선된 버전을 호출할 뿐이다.
  • 실제 처분 논리는 개선된 버전에 들어 있다. 개선된 버전은 protected 이자 virtual이다. 파생 클래스만의 처분 논리를 추가하려면 이 버전을 재정의하는 것이 안전한 방법이다.
    • disposing 플래그는 Dispose 메서드가 ‘제대로’ 호출되었는지(true) 아니면 Dispose 호출 누락의 ‘마지막 대비책’으로서 종료자가 호출한 것인지(false)를 나타낸다.
    • 일반적인 원칙은 만일 disposing이 false이면 종료자가 이 메서드를 호출한 것이므로 종료자가 참조하는 다른 객체들을 참조하지 말아야 한다는 것이다(그런 객체들은 이미 종료 처리가 되어서 예측할 수 없는 상태에 있을 수 있으므로)
    • 이 원칙을 따른다면 종료자에서 호출한 Dispose 안에서 할 수 있는 일이 상당히 많이 사라진다. 다음은 disposing이 false인 ‘마지막 대비책’ 모드에서도 여전히 할 수 있는 작업 두 가지 이다.
      • 운영체제 자원들(이를테면 P/Invoke로 Win32 API를 호출해서 얻은)에 대한 모든 직접 참조를 해제한다.
      • 생성시 만든 임시 파일을 삭제한다.
  • 이런 작업이 안정적으로 수행되게 하려면, 예외를 던질 수 있는 모든 코드를 try/catch 블록으로 감쌀 필요가 있다. 그리고 발생한 예외는 가능하면 로그에 기록하는 것이 바람직하다. 단 로깅 작업 자체도 최대한 간단하고 안정적이어야 한다.
  • 앞의 매개변수 없는 Dispose 메서드에서 GC.SuppressFinalize를 호출한다는 점을 주목하기 바란다. 이 호출은 나중에 쓰레기 수거기가 이 객체를 거둬 갈 때 종료자가 실행되지 않게 하는 효과를 낸다.
    • 엄밀히 말해서 이런 처리가 꼭 필요하지는 않다. 원칙적으로 Dispose는 여러 번 되풀이해서 호출되어도 문제를 일으키지 않아야 하기 때문이다.
    • 그러나 이렇게 처리를 해주면 (그리고 객체가 참조하는 다른 객체들이) 한 번의 쓰레기 수거로 수거되므로 성능이 향상된다.

객체 되살리기

  • 종료자가 살아 있는 객체를 수정해서, 그 객체가 어떤 죽어가는 객체를 참조하게 되었다고 하자. 다음번 쓰레기 수거 주기에서 CLR은 이전에 죽어 가던 객체가 더 이상 버림받지 않은 상태임을 알게 된다. 결과적으로 그 객체는 더 이상 수거 대상이 아니다.
    • 이를 객체의 소생(resurrection, 또는 회생) 또는 되살리기라고 부른다. 객체의 소생은 고급주제에 속한다.
  • 예를 들어 임시 파일을 관리하는 클래스를 작성한다고 하자. 그 클래스의 인스턴스가 수거될 때 종료자에서 임시 파일을 삭제하는 것이 바람직하다. 다음은 이를 간단히 구현해 본 것이다.
public class TempFileRef
{
  public readonly string FilePath;
  public TempFileRef (string filePath) { FilePath = filePath; }

  ~TempFileRef() { File.Delete(FilePath); }
}
  • 그러나 이 구현에는 버그가 있다. File.Delete가 예외를 던질 수 있기 때문이다. 그러면 응용 프로그램 전체가 강제로 종료된다. (또한 다른 종료자들은 실행 기회를 잃는다)
    • 빈 catch 절을 이용해서 예외를 그냥 “삼켜버릴” 수도 있지만, 그러면 어떤 문제가 발생했는지 알 수 없게 된다. 본격적인 오류 보고 API를 호출하는 것도 마땅치 않다.
    • 그러자면 종료자 스레드의 부담이 커져서 다른 객체들의 쓰레기 수거에 방해가 될 것이기 때문이다. 종료자에서 실행하는 작업은 간단하고 안정적이며 빨라야 한다.
  • 더 나은 방법은 다음처럼 객체를 정적 컬렉션에 추가해서 객체를 되살리는 것이다.
public class TempFileRef
{
  static ConcurrentQueue<TempFileRef> _failedDeletions = new ConcurrentQueue<TempFileRef>();

  public readonly string FilePath;
  public Exception DeletionError { get; private set; }

  public TempFileRef (string filePath) { FilePath = filePath; }

  ~TempFileRef()
  {
    try { File.Delete(FilePath); }
    catch (Exception ex)
    {
      DeletionError = ex;
      _failedDeletions.Enqueue(this);  // 객체 되살리기
    }
  }
}
  • 정적 _failedDeletions에 객체를 추가하면 객체에 대한 ‘뿌리 참조’가 생긴다. 따라서 객체는 나중에 컬렉션에서 제거되기 전까지는 살아남게 된다.
  • ConcurrentQueue<T>는 Queue<T>의 스레드 안전 버전으로, System.Collections.Concurrent에 정의되어 있다. 스레드에 안전한 컬렉션을 사용하는 이유는 두 가지이다.
    • 첫째로 CLR은 다수의 종료자를 여러 스레드에서 병렬로 실행할 수 있다. 따라서 종료자가 정적 컬렉션 같은 공유 상태에 접근한다면, 그런 종료자를 가진 두 객체가 함께 종료되는 상황을 반드시 고려해야 한다.
    • 둘째로 파일 삭제 실패 상황을 해결하기 위해 언젠가는 _failedDeletions에서 객체를 뽑아야 하는데, 그런 연산 역시 스레드에 안전한 방식으로 수행해야 한다. 같은 시기에 다른 종료자가 다른 객체를 같은 컬렉션에 집어 넣고 있을 수도 있다.

GC.ReRegisterForFinalize 메서드

  • 되살아난 객체의 종료자는 다시 호출되지 않는다. 다시 호출되게 하려면 명시적으로 GC.ReRegisterForFinalize를 호출해야 한다.
  • 다음 예를 보자. 이 종료자는 임시 파일을 삭제하되 삭제가 실패하면 객체를 다시 종료 대상으로 등록한다(다음번 쓰레기 수거에서 다시 시도할 수 있도록)
public class TempFileRef
{
  public readonly string FilePath;
  int _deleteAttempt;

  public TempFileRef (string filePath) { FilePath = filePath; }

  ~TempFileRef()
  {
    try { File.Delete(FilePath); }
    catch
    {
      if (_deleteAttemp++ < 3) GC.ReRegisterForFinalize(this);
    }
  }
}
  • 단 삭제 실패가 3회 이상이면 종료자는 파일 삭제를 조용히 포기한다. 이를 이전 예의 방법과 결합해서 좀 더 개선할 수도 있을 것이다. 즉, 삭제가 세 번 실패하면 그때부터는 객체를 _failedDeletions에 추가한다.
  • 종료자 메서드에서 ReRegisterForFinalize를 단 한 번만 호출하도록 하는 것이 중요하다. 만일 두 번 호출하면 객체가 두 번 등록되어서 종료 처리가 두 번 더 일어난다.

쓰레기 수거기의 작동 방식

  • 표준 CLR의 쓰레기 수거기는 세대별(generation) 표시 후 압축(mark-and-compact) 쓰레기 수거 알고리즘을 사용한다. 그러한 쓰레기 수거기는 관리되는 힙에 할당된 객체들의 메모리를 자동으로 관리한다.
    • 이런 종류의 쓰레기 수거기를 추적식(tracing) 쓰레기 수거기라고 부르는데, 이는 이런 쓰레기 수거기가 객체에 대한 모든 접근을 일일이 감시하는 것이 아니라 가끔 깨어나서는 관리되는 힙에 저장된 객체들의 그래프를 추적해서 수거 대상 객체들을 찾아 수거하기 때문이다.
  • 쓰레기 수거기는 메모리가 일정량 이상 할당된 후 추가로 메모리가 할당될 때, 또는 응용 프로그램의 메모리 사용량을 줄여야 할 필요가 있는 그 외의 상황에서 쓰레기 수거를 실행한다.
    • 또한 코드에서 System.GC.Collect를 호출해서 쓰레기 수거를 명시적으로 강제할 수도 있다.
    • 쓰레기 수거가 진행되는 동안에는 모든 스레드가 실행을 멈출 수 있다.
  • 더 이상 쓰이지 않는 객체 중 종료자가 없는 것들은 즉시 폐기된다. 종료자가 있는 것들은 특별한 대기열에 추가되며, 쓰레기 수거가 완료된 후 종료자 스레드에서 그 객체들을 처리한다. 그 객체들은 해당 객체의 세대에 대한 다음번 쓰레기 수거에서 수거 대상이 된다(그 사이에 되살아나지 않는 한)
  • 수거되지 않고 남은 ‘활성(live)’ 객체들은 힙의 시작 위치 쪽으로 이동한다(압축). 결과적으로 추가적인 객체들을 위한 공간이 힙에 생긴다.
    • 이러한 압축의 목적은 두 가지이다. 하나는 메모리 단편화(fragmentation)를 방지하는 것이고, 또 하나는 CLR이 새 객체를 할당할 때 아주 간단한 전략을 사용할 수 있게 하는 것이다.
    • 아주 간단한 전략이란 항상 힙의 끝에서 메모리를 할당한다는 것이다. 그러면 가용 메모리 조각들의 목록을 관리하는 복잡한 작업(시간이 많이 소비될 수도 있는)을 피할 수 있다.
  • 쓰레기를 모두 수거한 후에도 새 객체를 할당할 메모리가 모자라면, 그리고 운영체제가 프로그램에 추가적인 메모리를 제공할 수도 없는 상황이면 OutOfMemoryException 예외가 발생한다.

최적화 기법

세대별 수거

  • CLR 쓰레기 수거기의 가장 중요한 최적화 기법은 세대별(generational) 수거이다. 이 기법은 비록 다수의 객체는 할당되고 얼마 지나지 않아 해제되지만 그보다 더 오래 살아 있는 객체들도 존재한다는, 그리고 그런 객체들은 가끔만 수거 대상 여부를 점검해도 충분하다는 점을 활용한다.
  • 기본적으로 쓰레기 수거기는 관리되는 힙을 3가지 세대로 나눈다.
    • 방금 할당된 객체들은 Gen0(0세대)에 속하고, 한 번의 쓰레기 수거에서 살아 남은 객체들은 Gen1(1세대), 그 외의 객체들은 Gen2(2세대)에 속한다. Gen0과 Gen1을 단명(ephemeral) 세대라고 부른다.
  • CLR은 힙의 Gen0 구역을 비교적 작게 유지한다(전형적인 크기는 몇백 KB에서 몇 MB 정도; 최대 크기는 64비트 워크스테이션 CLR의 경우 256MB). Gen0 구역이 다 차면 쓰레기 수거기는 Gen0 수거를 실행한다. 이 쓰레기 수거는 비교적 자주 발생한다.
    • 쓰레기 수거기는 Gen1에 대해서도 비슷한 힙 메모리 공간 상한을 적용한다(Gen1은 Gen2에 대한 버퍼 역할을 한다) 따라서 Gen1의 수거도 비교적 빠르게, 그리고 자주 실행된다.
    • 그러나 Gen2를 포함하는 전체 쓰레기 수거(full GC)는 시간이 훨씬 오래 걸리므로 덜 자주 일어난다.
    • 아래 그림은 전체 수거의 효과가 나와있다.

  • 이해를 돕기 위해 아주 대략적인 수치들만 제시한다면, Gen0 수거에는 1ms 미만의 시간이 걸린다. 따라서 전형적인 응용 프로그램에서는 쓰레기 수거에 의한 지연을 눈치채기 어려울 정도이다.
    • 그러나 전체 수거는 그보다 길다. 객체들의 그래프가 큰 프로그램이라면 100ms 이상 걸릴 수 있다. 물론 구체적인 수치에는 수많은 요인이 영향을 미치며, 응용 프로그램에 따라 그 차이가 클 수 있다.
    • 특히 Gen0, Gen1과는 달리 그 크기가 무제한인 Gen2의 수거에 걸리는 시간은 특히나 차이가 크다.
  • 정리하자면 수명이 짧은 객체는 GC의 효율이 아주 높다. 다음 메서드가 생성하는 StringBuilder 객체들은 거의 확실히 빠른 Gen0에서 수거된다.
string Foo()
{
  var sb1 = new StringBuilder("test");
  sb1.Append("...");
  var sb2 = new StringBuilder("test");
  sb2.Append(sb1.ToString());
  return sb2.ToString();
}

LOH

  • 쓰레기 수거기는 특정 크기(현재는 85,000바이트) 이상의 객체들을 개별적인 힙에 저장한다. 그 힙을 LOH(large object heap; 큰 객체 힙 또는 대형 객체 힙)라고 부른다.
    • 이 덕분에 과도한 Gen0 수거가 방지된다. LOG가 없다면 일련의 16MB짜리 객체들을 할당할 때마다 Gen0 수거가 일어날 것이다.
  • 기본적으로 LOH는 압축되지 않는다. 쓰레기 수거 도중 커다란 메모리 블록을 이동하는 것은 비용이 너무 크기 때문이다. LOH가 압축되지 않는다는 사실로부터 다음 두 가지 결과가 비롯된다.
    • 메모리 관리자가 그냥 객체를 힙의 끝에 할당하는 간단한 전략을 사용할 수 없으므로 메모리 할당이 더 느릴 수 있다. 메모리 관리자는 중간에 빈틈이 있는지 점검해야 하며, 그러려면 가용 메모리 블록들의 연결 목록을 관리해야 한다.
    • LOH에서는 단편화가 일어난다. 즉 객체를 해제하면 힙 중간에 구멍이 생길 수 있으며, 나중에 그 구멍을 채우기 어려울 수 있다. 예컨대 86,000바이트짜리 객체가 남긴 구멍은 오직 85,000에서 86,000바이트 사이의 객체로만 메울 수 있다(그 구멍 바로 옆에 또 다른 구멍이 생겨서 합쳐지지 않는 한)
  • 이 때문에 문제가 생기는 경우라면 다음번 수거 시 LOH를 압축하라고 쓰레기 수거기에 요청할 수도 있다. 다음이 그러한 코드이다.
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;

동시적 배경 수거

  • 쓰레기 수거기가 GC를 수행하는 동안에는 프로그램의 실행 스레드들이 차단된다. Gen0이나 Gen1의 경우에는 수거 과정 전체에서 실행이 차단된다.
    • 그러나 Gen2 수거에서는 사정이 좀 다르다. 잠재적으로 오랫동안 응용 프로그램이 멈추는 것은 바람직하지 않으므로, 쓰레기 수거기는 Gen2 수거 도중에도 스레드의 실행을 허용한다. 이러한 최적화는 CLR의 워크스테이션 버전에만 적용된다.
    • Windows 데스크톱 버전들에(그리고 모든 Windows 버전에서 독립형(standalone) 응용 프로그램 실행에) 쓰이는 것이 그 CLR 버전이다.
    • 이런 최적화를 워크스테이션 버전에만 적용하는 이유는 사용자 인터페이스가 없는 서버 응용 프로그램은 쓰레기 수거 때문에 실행이 잠시 지연되어도 큰 문제가 되지 않는다는 것이다.
  • 실행 지연을 완화하기 위해 서버 CLR은 GC 수행시 모든 가능한 코어를 활용한다. 따라서 8코어 서버는 전체 GC가 워크스테이션보다 몇 배 빨리 실행될 수 있다. 실제로 서버의 쓰레기 수거기는 잠복지연을 최소화하기 보다는 처리량을 최대화하도록 조율된다.
  • 워크스테이션 최적화를 예전에는 동시 수거(concurrent collection)라고 불렀다. 그러나 CLR 4.0부터는 동시 수거 기법을 폐기하고 배경 수거(background collection)라는 기법을 사용한다.
    • 동시 수거 시절에는 Gen2 수거가 실행되는 도중에 Gen0 구역이 모두 소비되는 경우 더 이상 수거가 동시적으로 진행되지 않는다는 한계가 있었지만, 배경 수거에는 그런 한계가 없다. 아주 간단하게 말하면 CLR 4.0부터는 메모리를 잇달아 할당하는 응용 프로그램의 반응성이 예전보다 낫다.

GC 알림(서버 CLR)

  • 서버 버전의 CLR은 전체 GC를 수행하기 직전에 그 사실을 프로그램에 통지할 수 있다. 이는 서버 팜(server farm) 구성을 위한 것이다.
    • 이런 시나리오를 생각하면 된다. 한 서버에서 쓰레기 수거가 시작되려 하면, 그 서버에게 오는 요청들을 다른 서버들에게 보낸다. 그런 다음 재빨리 GC를 수행하고 다시 요청들을 받기 시작한다.
  • GC 알림을 받으려면 GC.RegisterForFullGCNotification을 호출한다. 그런 다음에는 개별 스레드를 띄워서 먼저 GC.WaitForFullGCApproach를 호출한다. 이 메서드가 GCNotificationStatus를 돌려주었다면 곧 수거가 시작된다는 뜻이다.
    • 그러면 요청들이 다른 서버들에게 가도록 설정하고 코드에서 직접 GC를 강제한다. 그런다음 GC.WaitForFullGCComplete를 호출한다. 이 메서드가 반환되었다면 GC가 끝난 것이다. 그러면 다시 요청들을 받기 시작한다. 이제 GC.WaitForFullGCApproach 호출부터 이 과정을 반복한다.

쓰레기 수거 강제 실행

  • 언제라도 GC.Collect를 호출해서 GC를 강제로 실행할 수 있다. 인수 없이 GC.Collect를 호출하면 전체 GC가 실행된다.
    • 정수 값을 넣어서 호출하면 그 값까지의 세대들만 수거된다. 예컨대 GC.Collect(0)를 호출하면 빠른 Gen0 수거만 일어난다.
  • 일반적으로 수거 시점을 쓰레기 수거기가 판단하게 하면 최상의 성능이 나온다. GC를 직접 강제하면 Gen0 객체들이 Gen1로(그리고 Gen1 객체들이 Gen2로) 불필요하게 승격되어서 성능이 나빠질 수 있다.
    • 또한 강제 실행은 응용 프로그램이 실행되는 동안 성능 최적화를 위해 쓰레기 수거기가 각 세대의 상한을 동적으로 조정하는 자체 조율(self-tuning) 능력에 방해가 될 수도 있다.
  • 그러나 예외도 있다. 가장 흔한 경우는 응용 프로그램이 잠시 수면에 빠질 때이다.
    • 좋은 예가 매일 특정 시간에 어떤 작업을 수행하는 Windows 서비스이다. 그런 응용 프로그램은 이를테면 System.Timers.Timer를 이용해서 24시간마다 특정 활동을 수행할 것이다. 그 활동을 마치면 약 24시간 동안은 아무런 코드도 실행하지 않는다.
    • 따라서 그 서비스는 활동을 위해 사용한 메모리를 이후 약 24시간 동안 계속해서 차지하게 된다. 해결책은 일일 활동을 완료한 즉시 GC.Collect를 호출하는 것이다.
  • 종료자들 때문에 수거가 지연된 객체들이 확실히 수거되게 하려면 수거를 강제하고 WaitForPendingFinalizers를 호출한 후 또다시 수거를 강제하면 된다.
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
  • 이를 루프로 여러 번 되풀이하기도 한다. 종료자가 실행되면 또 다른 객체들이 해제되어서 종료 처리 대기열에 들어갈 수 있기 때문이다.
  • 종료자가 있는 클래스를 시험해 보는 경우도 GC.Collect를 호출하는 것이 바람직한 또 다른 예이다.

쓰레기 수거기의 조율

  • 정적 GCSettings.LatencyMode 속성은 쓰레기 수거기가 잠복지연(latency)과 전반적인 효율성 사이의 균형을 잡는 방식에 영향을 미친다.
    • 이 속성을 기본값인 Interactive 대신 LowLatency로 설정하면 CLR은 더 빨리 실행할 수 있는 수거들을 선호하게 된다.(대신 수거가 더 자주 일어난다)
    • 응용 프로그램이 실시간 사건들에 아주 빠르게 반응해야 한다면 이 설정이 유용하다.
  • .NET Framework 4.6부터는 GC.TryStartNoGCRegion을 호출해서 쓰레기 수거기의 활동을 일시적으로 정지할 수도 있다. GC.EndNoGCRegion을 호출하면 쓰레기 수거기의 활동이 재개된다.

메모리 압력

  • CLR은 컴퓨터에 있는 총 메모리 용량을 비롯한 여러 요인에 기초해서 수거 실행 시점을 결정한다. CLR은 관리되는 메모리만 추적하므로, 만일 프로그램이 비관리 메모리를 할당한다면, 응용 프로그램이 실제보다 더 적은 메모리를 사용한다고 CLR이 오해할 여지가 있다.
    • 이 문제를 완화하는 한 가지 방법은 프로그램이 할당한 비관리 메모리의 양을 GC.AddMemoryPressure 메서드를 이용해서 CLR에게 귀띔하는 것이다. 그러면 CLR은 그 수치도 고려해서 GC 실행 여부를 결정한다. 보통의 방식으로 되돌리려면(비관리 메모리를 해제한 후에) GC.RemoveMemoryPressure를 호출하면 된다.

관리되는 메모리 누수

  • C++처럼 비관리 코드를 생성하는 언어로 프로그램을 짤 때는 더 이상 쓰이지 않는 객체의 메모리가 확실히 해제되게 만드는데 신경을 써야 한다. 그렇게 하지 않으면 메모리 누수(memory leak)가 발생한다.
    • 그러나 관리되는 코드의 세계에서는 CLR의 자동 쓰레기 수거 시스템 덕분에 그런 종류의 오류가 발생할 수 없다.
  • 그렇긴 하지만 크고 복잡한 .NET 응용 프로그램에서는 동일한 증상이 비교적 완화된 형태로 나타나서, 결국에는 메모리 누수가 있는 비관리 코드에서와 같은 결과, 즉 응용 프로그램이 메모리를 점점 더 많이 소비하다가 급기야는 재시작하는 결과를 빚어질 수 있다.
    • 한 가지 다행한 점은, 대체로 관리되는 메모리의 누수는 진단하고 방지하기가 더 쉽다는 점이다.
  • 관리되는 메모리의 누수 현상은 미사용 참조 또는 잊힌 참조 때문에 미사용 객체들이 죽지 않고 계속 살아 있어서 발생한다.
    • 흔히 문제가 되는 것은 이벤트 처리부들이다. 이들은 대상 객체에 대한 참조를 유지한다(대상이 정적 메서드가 아닌 한). 예컨대 다음 클래스들을 생각해 보자.
class Host
{
  public event EventHandler Click;
}

class Client
{
  Host _host;
  public Client (Host host)
  {
    _host = host;
    _host.Click += HostClicked;
  }

  void HostClicked (object sender, EventArgs e) { ... }
}
  • 다음은 Client의 인스턴스를 1000개 생성하는 메서드가 있는 시험용 클래스이다.
class Test
{
  static Host _host = new Host();

  public static void CreateClients()
  {
    Clinet[] clients = Enumerable.Range(0, 1000).Select(i => new Client(_host)).ToArray();
    // ...
  }
}
  • 아마도 독자는 CreateClients 메서드의 실행이 끝나면 1000개의 Client 객체들이 수거 대상이 되리라고 예상할 것이다. 그러나 모든 Client 객체에는 뿌리 참조가 존재한다.
    • _host 객체의 Click 이벤트는 모든 Client 인스턴스를 참조한다. Click 이벤트가 발동하지 않거나, HostClicked 메서드가 뭔가 주의를 끄는 일을 하지 않는다면 이 사실을 프로그래머가 알아채지 못할 위험이 있다.
  • 이 문제의 해결책 하나는 Client가 IDisposable을 구현해서 다음과 같이 Dispose 메서드에서 이벤트 처리부들을 해제하는 것이다.
public void Dispose() { _host.Click -= HostClicked; }
  • Client 인스턴스들을 다 사용한 후에는 다음과 같은 코드를 이용해서 모두 처분하면 된다.
Array.ForEach(clients, c => c.Dispose());
  • WPF의 경우에는 자료 바인딩도 메모리 누수의 흔한 원인이다.

타이머

  • 잊힌 타이머도 메모리 누수의 원인이 된다. 누수 상황은 타이머의 종류에 따라 두 종류로 나뉜다.
    • 우선 System.Timers 이름공간에 있는 타이머를 살펴보자. 다음 예에서 Foo 클래스의 생성자는 타이머 하나를 생성해서 매초 tmr_Elapsed 메서드를 호출하게 한다.
using System.Timers;

class Foo
{
  Timer _timer;

  Foo()
  {
    _timer = new System.Timers.Timer { Interval = 1000 };
    _timer.Elapsed += tmr_Elapsed;
    _timer.Start();
  }

  void tmr_Elapsed (object sender, ElapsedEventArgs e) { ... }
}
  • 안타깝게도 이 Foo의 인스턴스들은 절대로 수거되지 않는다. .NET Framework가 활성 타이머들에 대해 Elapsed 이벤트를 발동하려면 그 타이머들에 대한 참조를 계속 유지해야 하기 때문이다. 결과적으로
    • .NET Framework 때문에 Foo의 _timer 멤버가 계속 살아남고
    • _timer의 tmr_Elapsed 이벤트 처리부 때문에 Foo 인스턴스가 계속 살아남는다.
  • Timer가 IDisposable을 구현한다는 점을 알면 해결책이 명백해진다. 타이머 객체를 처분하면 .NET Framework는 더 이상 그 객체에 대한 참조를 유지하지 않는다.
class Foo : IDisposable
{
  ...
  public void Dispose() { _timer.Dispose(); }
}
  • IDisposable 구현에 관한 좋은 지침 하나. 만일 독자의 클래스의 어떤 필드에 IDisposable를 구현하는 형식의 객체를 배정한다면, 독자의 클래스 역시 IDisposable를 구현하는 것이 바람직하다.
  • WPF와 Windows Forms의 타이머들도 방금 논의한 참조 유지 문제와 관련해서 System.Timers의 타이머와 정확히 동일한 방식으로 작동한다.
  • 그러나 System.Threading 이름공간의 타이머는 특별하다. .NET Framework는 활성 스레드 타이머에 대한 참조를 유지하지 않는다. 대신 콜백 대리자들을 직접 참조한다.
    • 이는 소비자가 까먹고 스레드 타이머를 직접 처분하지 않았다면 종료자에서 자동으로 타이머를 중지하고 처분한다는 뜻이다. 다음 예를 보자.
static void Main()
{
  var tmr = new System.Threading.Timer (TimerTick, null, 1000, 1000);
  GC.Collect();
  System.Threading.Thread.Sleep(10000);
}

static void TimerTick (object notUsed) { Console.WriteLine("tick"); }
  • 만일 이 예제 코드를 ‘릴리스’ 모드로 컴파일해서 실행하면, 타이머는 한 번 발동될 기회도 없이 수거, 종료된다. 이 문제 역시 타이머를 다 사용한 후 처분하면 해결 된다.
using (var tmr = new System.Threading.Timer (TimerTick, null, 1000, 1000))
{
  GC.Collect();
  System.Threading.Thread.Sleep(10000);
}
  • using 블록 끝의 암묵적인 tmr.Dispose 호출 덕분에 CLR은 그 블록이 끝나기 전까지는 tmr 변수가 여전히 ‘사용 중’임을 알게 되며 따라서 수거 대상에서 제외한다. 모순적이게도 이 Dispose 호출은 객체를 더 오래 살게 한다.

메모리 누수 진단

  • 관리되는 메모리의 누수를 피하는 가장 쉬운 방법은 애초에 응용 프로그램을 작성하는 동안 능동적으로 메모리 소비량을 감시하는 것이다. 프로그램의 객체들이 현재 사용하고 있는 메모리양을 얻는 방법은 다음과 같다(true 인수는 먼저 수거를 실행하라고 쓰레기 수거기에 알려주는 역할을 한다.)
long memoryUsed = GC.GetTotalMemory(true);
  • 검사 주도적 개발(test-driven development, TDD)을 실천하는 독자라면, 메모리가 예상대로 재확보되었는지에 대한 단언(assertion)이 있는 단위 검사(unit test)들을 사용하는 것도 한 방법이다. 그런 단언이 실패한다면, 최근 변경한 코드만 점검해 보면 된다.
  • 이미 작성된 커다란 응용 프로그램에서 메모리 누수가 있다면, windbg.exe 도구가 누수 지점을 찾는데 도움이 된다. 또한 Microsoft의 CLR Profiler나 SciTech의 Memory Profiler, Red Gate의 ANTS Memory Profiler처럼 좀 더 사용하기 편한 그래픽 도구들도 있다.
  • CLR 자체도 자원 감시에 도움이 되는 다양한 Windows WMI 카운터들을 제공한다.

약한 참조

  • 객체를 사랑 있게 만드는 문제와 관련해서 객체에 대한 참조를 GC가 ‘볼 수 없게’ 만드는 것이 유용할 때가 있다. 그런 참조를 약한 참조(weak reference)라고 부르고 System.WeakReference로 구현한다.
  • 약한 참조를 만드는 방법은 다음과 같다. 대상 객체를 인수로 해서 WeakReference 인스턴스를 생성하면 된다.
var sb = new StringBuilder("시험용");
var weak = new WeakReference(sb);
Console.WriteLine(weak.Target);  // 시험용
  • 대상 객체를 하나 이상의 약한 참조들만 참조한다면 쓰레기 수거기는 대상 객체를 아무도 참조하지 않는다고 생각하고 수거 대상으로 간주한다. 대상 객체가 수거되면 WeakReference의 Target 속성이 널이 된다.
var weak = new WeakReference(new StringBuilder("약한 참조"));
Console.WriteLine(weak.Target);  // 약한 참조
GC.Collect();
Console.WriteLine(weak.Target);  // (출력 없음)
  • 대상 객체가 널이 아닌지 점검하는 시점과 실제로 사용하는 시점 사이에 대상 객체가 수거될 수도 있다. 그런 일을 방지하려면 대상 객체를 다음처럼 지역 변수에 배정하면 된다.
var weak = new WeakReference(new StringBuilder("weak"));
var sb = (StringBuilder) weak.Target;
if (sb != null) { /* sb로 뭔가 수행 */ }
  • 일단 지역 변수에 대상 객체를 배정하면 ‘강한 뿌리 참조’가 생긴다. 따라서 그 변수가 유효한 범위에 있는 한 객체는 수거되지 않는다.
  • 다음의 Widget 클래스는 생성된 모든 인스턴스를 약한 참조를 이용해서 관리한다. 약한 참조를 이용하므로 그 객체들이 수거대상에서 제외되어서 필요 이상으로 오래 살아 남는 문제가 발생하지 않는다.
class Widget
{
  static List<WeakReference> _allWidgets = new List<WeakReference>();
  public readonly string Name;

  public Widget(string name)
  {
    Name = name;
    _allWidgets.Add(new WeakReference(this));
  }

  public static void ListAllWidgets()
  {
    foreach (WeakReference weak in _allWidgets)
    {
      Widget w = (Widget)weak.Target;
      if (w != null) Console.WriteLine(w.Name);
    }
  }
}
  • 이런 시스템의 유일한 문제점은 시간이 흐르면서 정적 목록이 계속 길어지고 대상이 널인 약한 참조들이 누적된다는 것이다. 따라서 어떤 방식으로든 목록을 정리하는 전략을 구현할 필요가 있다.

약한 참조와 캐싱

  • WeakReference의 한 가지 용도는 커다란 객체 그래프를 캐싱하는 것이다. 그런 전략을 이용하면 메모리를 많이 사용하는 자료를 잠시 동안 캐싱할 때 메모리를 추가로 과도하게 소비할 필요가 없다.
_weakCache = new WeakReference(...);  // _weakCache는 클래스의 한 필드
...
var cache = _weakCache.Target;
if (cache == null) { /* 캐시를 재생성해서 _weakCache에 배정 */ }
  • 그러나 실무에서 이 전략이 대단히 효과적이지는 않다. 왜냐면 GC가 언제 발생할지, 어떤 세대에 대한 GC가 실행될지를 프로그램이 거의 제어할 수 없기 때문이다.
    • 특히 캐시가 Gen0에 남아 있다면 수 밀리초 이내에 수거될 수 있다(또한 꼭 메모리가 부족할 때만 GC가 일어나는 것은 아님을 기억해야 한다. 정상적인 메모리 조건에서도 GC가 주기적으로 실행된다)
    • 따라서 적어도 일단은 강한 참조들을 유지하는 것으로 시작해서 시간이 지남에 따라 그것들을 약한 참조로 바꾸는 방식의 2수준 캐시를 구현할 필요가 있다.

약한 참조와 이벤트

  • 이벤트 때문에 관리되는 메모리의 누수가 발생할 수 있음을 앞서 보았는데, 약한 참조를 이용하여 이를 해결할 수도 있다.
  • 대상 메서드에 대한 약한 참조만 유지하는 대리자가 있다고 하자. 그런 대리자만으로는 대상의 생명이 유지되지 않는다. 대상을 강하게 참조하는 다른 뿌리 객체가 있어야 대상이 살아남는다.
    • 그렇긴 하지만 대상을 쓰레기 수거기가 수거하기로 결정한 시점과 실제로 GC가 실행되는 시점 사이에서 이벤트가 발동해서 대상이 호출되는 일은 여전히 가능하다.
    • 따라서 약한 참조를 이용한 누수 문제 해결책이 효과적이려면 코드가 그런 상황도 견고하게 처리할 수 있어야 한다.
    • 그렇다는 가정하에서 다음은 이벤트로 인한 메모리 누수를 방지하는 약한 대리자 클래스의 구현 예이다.
public class WeakDelegate where TDelegate : class
{
	class MethodTarget
	{
		public readonly WeakReference Reference;
		public readonly MethodInfo Method;

		public MethodTarget(Delegate d)
		{
			Reference = new WeakReference(d.Target);
			Method = d.Method;
		}
	}

	List _targets = new List();

	public WeakDelegate()
	{
		if (!typeof(TDelegate).IsSubclassOf(typeof(Delegate)))
			throw new InvalidOperationException("TDelegate는 대리자 형식이어야 함");
	}

	public void Combine(TDelegate target)
	{
		if (taget == null) return;

		foreach(Delegate d in (target as Delegate).GetInvocationsList())
			_targets.Add(new MethodTarget(d));
	}

	public void Remove(TDelegate target)
	{
		if (taget == null) return;
		foreach(Delegate d in (target as Delegate).GetInvocationsList())
		{
			MethodTarget mt = _targets.Find(w => Equals(d.Target, (w.Reference?.Target) && Equals(d.Method.MethodHandle, w.Method.MethodHandle)));

			if (mt != null) _target.Remove(mt);

		}
	}

	public TDelegate Target
	{
		get
		{
			var deadRefs = new List();

			foreach(MethodTarget mt in _targets.ToArray())
			{
				WeakReference wr = mt.Reference;

				// 대상이 정적 메서드이거나 활성 인스턴스 메서드인가?
				if (wr == null || wr.Target != null)
				{
					var newDelegate = Delegate.CreateDelegate(typeof(TDelegate), wr?.Target, mt.Method);
					combinedTarget = Delegate.Combine(combinedTarget, newDelegate);
				}
				else
					_target.Remove(mt);
			}

			return combinedTarget as TDelegate;
		}
		set
		{
			_target.Clear();
			Combine(value);
		}
	}
}
  • 이 코드는 C#과 CLR의 흥미로운 사실 몇 가지를 보여준다.
    • 첫쨰로 생성자에서 TDelegate가 대리자 형식인지 점검한다는 점을 주목하기 바란다. 이러한 점검은 C#의 한계 때문이다. C#은 다음과 같은 형식 제약을 허용하지 않는다. C#은 System.Delegate를 그런 제약이 적용되지 않는 특별한 형식으로 간주하기 때문이다.
... where TDelegate : Delegate  // 컴파일러는 이를 허용하지 않음
  • 대신 반드시 클래스 제약을 선택하고 실행시점에서 생성자 안에서 형식을 점검해야 한다.
  • Combine 메서드와 Remove 메서드에서는 target에서 Delegate로의 참조 변환을 수행하는데, 이때 통상적인 캐스팅 연산자 대산 as 연산자를 사용한다.
    • 이 경우 C#의 중의성 때문에 캐스팅 연산자를 허용하지 않는다. C# 컴파일러로서는 이 형식 매개변수에 대한 캐스팅 연산자가 참조 변환을 뜻하는지 아니면 커스텀 변환을 뜻하는지 알 수 없다.
  • 그런 다음에는 GetInvocationList를 호출한다. 이는 이 메서드들을 다중 캐스팅 대리자, 즉 대상 메서드가 여러 개인 대리자가 호출할 수도 있기 때문이다.
  • Target 속성에서는 약한 참조 중 대상이 살아 있는 것들을 모아서 하나의 다중 캐스팅 대리자를 만든다. 나머지 참조들(대상이 죽은)은 목록에서 제거한다. 이 덕분에 _targets 목록이 무한정 길어지지 않는다. (Combine 메서드에서도 그런 처리를 해주면 좋을 것이다. 더 나아가서 스레드 안전성을 위해 잠금 처리를 추가한다면 더욱 좋을 것이다)
  • 이 클래스는 약한 참조가 아예 없는 대리자로도 작동한다. 이는 대상이 정적 메서드인 경우에 해당한다.
    • 다음은 이 대리자를 이용해서 이벤트를 구현하는 예이다.
public class Foo
{
	WeakDelegate _click = new WeakDelegate();

	public event EventHandler Click
	{
		add { _click.Combine(value); }
		remove { _click.Remove(value); }
	}

	protected virtual void OnClick (EventArgs e) => _click.Target?.Invoke(this, e);
}
[ssba]

The author

지성을 추구하는 디자이너/ suyeongpark@abyne.com

댓글 남기기

This site uses Akismet to reduce spam. Learn how your comment data is processed.