언어/C#

C# - 관리 힙과 GC (2)

tsyang 2021. 4. 3. 03:59

네이티브 리소스의 처리


네이티브 리소스

 

네이티브 리소스란 파일이나 커널 객체 같은 걸 말한다. 대부분의 타입들은 메모리만을 이용하지만 네이티브 리소스를 사용하는 애들(파일 핸들, 소켓) 같은 애들은 그렇지 않다. 이런 경우에 GC가 네이티브 리소스를 감싸고 있는 타입을 수거해 간다면 네이티브 리소스에 대한 누수가 발생하게 된다!! GC는 네이티브 리소스에 대해 알지 못하기 때문에 이에 대한 처리가 필요해 보인다.

 


Finalization

 

파일, 네트워크 연결, 소켓, 뮤텍스 같은 네이티브 리소스를 감싸고 있는 타입은 finalization이란걸 지원한다. CLR은 GC가 이런 객체를 가비지 수집하는 과정에서 Finalize 라는 메서드를 호출하여 객체가 감싸는 네이티브 리소스를 정리할 기회를 준다.

 

public sealed class SomeClass
{
    //이 메서드가 Finalize이다. (내부적으로 protected virtual)
    ~SomeClass()
    {
        //파일 핸들 닫기 등의 네이티브 리소스 정리를 수행
    }
}

위 메서드의 IL 코드를 보면 try 블록 내부에 사용자가 작성한 내용이 위치하고 finally 블록에서 base의 Finalize()를 호출한다.

 

Finalize 메서드는 가비지 수집의 완료 시점에 호출된다. Finalize에서는 객체의 필드를 사용할 수 있으므로 Finalize 메서드가 호출되기 전에 객체가 메모리에서 내려가면 안 된다. 이는 Finalize 를 지원하는 객체가 반드시 살아남아야 하며 이는 곧 다음 세대로 승격하게 됨을 의미한다. 그리고 이것은 객체의 필드를 통해 참조하는 다른 객체들도 같이 살아남고, 승격을 하게 된다는 것을 의미한다. 따라서 가능하면 Finalize를 사용하지 말아야 하며, 가능하면 finalize 객체를 정의할 때 참조 타입의 필드를 가지지 않도록 해야 한다.. (살리는건 알겠는데 세대를 증가시키는걸 예외처리 할 수는 없었던 이유는 뭘까..?)

 

또한 Finalize 메서드는 언제 수행될지 사용자가 제어를 할 수 없다. 따라서 Finalize 내부에서 다른 Finalize를 지원하는 객체를 사용하면 안 된다. 해당 객체의 finalize가 먼저 호출되어 사용할 수 없는 상태일 수 있기 때문이다. 또한 정적 메서드를 호출 할 때도 주의가 필요하다. 정적 메서드가 이미 내부적으로 finalize된 객체에 접근할 수 있기 때문이다. 

 

CLR은 Finalize 메서드를 호출하기 위해 높은 우선순위의 전용 스레드를 사용한다. 이것은 deadlock을 방지하기 위함이다. 

그 외에 Finalize를 써야 하는 상황이라면 System.Runtime.InteropServices.SafeHandle 클래스를 상속할지 강력하게 검토해 보자. GC 수행 중에 네이티브 리소스가 해제되는 것을 보장해준다. (즉, 안 쓰면 안 될수도 있다는 소리다.)

 

 

 

정리하자면 Finalize를 사용할 때에는...

  1. Finalize를 지원하는 객체에서는 가능하면 참조 필드를 가지지 않도록 해야 함.
  2. Finalize 내부에서 다른 Finalize 지원 객체를 사용하면 안 됨.
  3. 같은 이유로 정적 메서드를 사용할 때에도 주의가 필요함.
  4. SafeHandle은 여러 이점을 제공한다. 쓰는걸 검토해보자. (사실 꼭 써야 될 것 같다..)

Finalization의 내부 과정(순서)

  1. new 연산자를 사용하여 힙으로부터 메모리를 할당 받는다. 만약 객체가 Finalize를 지원하면 해당 타입의 생성자가 호출되기 전에 객체를 가리키는 포인터가 finalization List에 들어간다.
  2. GC가 가비지를 수거할 때 먼저 finalization List를 확인한다. 만약 여기에 포함된 객체가 있다면 finalization List에서 해당 항목을 제거하고 freachable Queue에 추가한다. 
  3. CLR은 Finalize를 호출하기 위해 높은 우선순위의 전용 스레드를 사용한다고 말했었다. freachable Queue가 비어있다면 이 스레드는 sleep 상태이다. 그러나 freachable Queue에 항목이 추가되면 스레드가 깨어나 Queue에서 해당 항목을 제거한 뒤 해당 객체의 Finalize 메서드를 수행한다. 
  4. 가비지 수집기가 객체를 finalization List에서 freachable Queue에 옮기면 객체는 다시 도달 가능한 상태가 된다. 즉 가비지었다가 가비지가 아니게 되며 이것을 복원되었다고 한다. 이 때 freachable Queue를 일종의 루트로 보며, 살아남았기 때문에 세대가 승격된다.
  5. 전용 스레드가 freachable Queue에서 항목을 제거하고 Finalize()를 수행하고 나면 해당 객체는 진짜 가비지가 되며 다음 가비지 수집 때 수거될 것이다.

IDisposable

  • 네이티브 리소스의 수명을 사용자가 제어할 수 있는 클래스는 IDisaposable 인터페이스를 구현하게 된다. 
  • 이 인터페이스를 구현한 타입(예 : FileStream)에서 Dispose() 메서드를 호출하면 네이티브 리소스가 정리된다. 네이티브 리소스를 해제하기 위해서 꼭 Dispose를 써야하는 것은 아니지만 Dispose를 쓰면 원하는 시점에 네이티브 리소스를 해제할 수 있다. 또한 Dispose를 호출한다고 해서 관리 힙에서 객체가 사라지는 건 아니다.
  • 메모리 반납은 오로지 가비지 수집 작업을 통해서만 일어난다. 즉, Dispose()를 호출해도 해당 타입은 여전히 사용 가능하다. 내부의 네이티브 리소스만 닫히는 것.
  • 일반적인 경우라면 Dispose()를 명시적으로 호출하지 않는게 좋다. CLR이 알아서 하기 때문이다. 그러나 만약 File.Delete()의 호출과 같이  네이티브 리소스 정리가 먼저 필요한 경우, 리소스를 정리해야 하는 위치를 정확히 알고 있으면 써야 하며 명시적으로 Dispose를 호출하는 것은 그 객체가 이제 필요 없다는 것을 알리는 효과적인 방법이긴 하다.
  • 만약 dispose 패턴을 구현한 필드를 가진 클래스는 자신도 dispose 패턴을 구현해야 한다. 

 

예외 처리를 위해서 Dispose 메서드는 Finally 안에서 쓰는 것이 강력히 권고된다. 

 

FileStream fs = new FileStream("Temp.dat", FileMode.Create);
try
{
    fs.Write(buffer);
}
finally
{
    if (fs != null)
        fs.Dispose();
}
File.Delete("Temp.dat");

 

Dispose 패턴을 위해 using이라는 구문이 있는데 위의 코드를 다음과 같이 줄여 쓸 수 있다. 당연하지만 Dispose 패턴을 구현 한 애들만 쓸 수 있다. 

using (FileStream fs = new FileStream("Temp.dat", FileMode.Create))
{
    fs.Write(buffer);
}

File.Delete("Temp.dat");

 

종속성 문제

FileStream fs = new FileStream("path.dat", FileMode.Open);
StreamWriter sw = new StreamWriter(fs); //fs를 매개변수로 받음

sw.Dispose();

위처럼 fs를 사용하는 sw가 있다. 위의 코드에서는 fs.Dispose()를 호출할 필요가 없다. StreamWriter가 내부적으로 같은 작업을 수행해주기 때문이다. 만약 fs.Dispose()를 호출한다면 이미 객체가 정리된 것을 확인한 후 아무런 작업도 수행하지 않는다. 

 

그렇다면 sw.Dispose()를 안 써주면 어떻게 될까? 언젠가 sw, fw가 가비지 수집이 되고 Finalize()가 호출 될 것이다. 그러나 Finalize는 그 호출 순서를 보장할 수 없으므로 FileStream 객체가 이미 닫힌 뒤 StreamWriter 객체가 Finalize 되면 이미 닫힌 파일에 데이터를 쓰는 것이므로 예외가 발생한다. 따라서 마이크로소프트는 StreamWriter가 Finalize를 지원하지 않도록 했으며 개발자가 명시적으로 호출할 것을 기대한다. 


그 외의 기능들

 

네이티브 리소스를 감싸는 타입은 적은 메모리를 차지하는데 비하여 네이티브 리소스는 많은 메모리를 차지 할 수 있다. GC는 네이티브 리소스에 대해서는 아는게 없으므로 메모리가 얼마나 사용되고 있는지 힌트를 준다면 가비지 수집을 더 잘 할 수 있을 것이다. 이 경우에는 AddMemoryPressure, RemoveMemoryPressure 를 사용하여 얼마나 많은 메모리가 실제로 할당되고 해제되었는지 힌트를 줄 수 있다. 

 

그 외에 사용할 수 있는 갯수가 제한된 네이티브 리소스가 있을 수 있다. 이런 경우에는 HandleCollector 클래스를 통해 GC에 몇 개를 쓰고 있는지 알려줄 수 있다. 

 

자세한 사용법은 필요할 때 알아서 찾아보자.

 

 


객체 수명의 수동 제어 - GCHandleType

GCHandle의 타입은 네 가지가 있다.

  • Weak
  • WeakTrackResurrection
  • Normal : 메모리 상에서 제거 안 됨.
  • Pinned : 제거도 안 되고 컴팩트(주소이동) 도 안 됨.

 

GCHandleType을 이용한 가비지 수집의 과정은 다음과 같다.

 

  1. 마크 단계를 수행한다. 이 때 Normal과 Pinned 항목이 참조하는 객체들은 루트로 간주하고 마크한다. 
  2. Weak 항목이 마크되어 있지 않은 객체를 참조하면 가비지를 참조하고 있는 것으로 간주하고 그 참조 값을 null로 바꾼다. (약한참조니까)
  3. finalization List를 검토하여 마크되어 있지 않다면 freachble Queue로 옮긴다. 이 때 항목은 다시 마크된다.
  4. WeakTrackResurrection 항목을 찾아 낸 뒤 마크되지 않은 객체를 참조한다면 (이 때 2번과 다르게 freachable Queue에 의해 참조되었다가 해제되었을 수 있음) 가비지를 참조한 것으로 간주, 참조 값을 null로 변경한다. 
  5. 컴팩트한다. 단 pinned 객체는 빼고

 

Normal

노말은 왜 쓰나? 주로 관리 객체를 가리키는 포인터를 네이티브 코드 쪽으로 전달할 때 쓴다. 

 

Pinned

왜쓰나? String 객체같이 Win32 함수에 그 포인터 값을 전달해야 하는 경우, 주소가 바뀌면 안되기 때문에 사용한다. String 객체는 반드시 pinned 되어있다. 

 

Weak

WeakReference<T> 클래스가 있어서 편하게 쓸 수 있다. WeakReference가 뭔지는 알거고.. 이걸 캐시 시나리오에 응용할 법 한데 (예를 들어, 일단 메모리에 올려 놓고 target이 null이 아니면 캐시 적중~ 좋은거고, null이면 그냥 원래대로 다시 메모리에 올리는거고) 이것은 객체가 메모리에서 자주 제거되게 하고 좋지 않은 영향을 미치게 한다. 그래서 보통은 강한 참조로 캐시를 유지하다가 메모리가 많이 차면 약한 참조로 바꾼다. 이것은 Win32의 GlobalMemoryStatusFx를 이용하여... MEMORYSTATUSEX 구조체의 dwMemoryLoad 값을 확인하여 이 값이 80이 넘으면 (실제 메모리의 80%를 쓰고 있따는 뜻) 강한 참조를 약한 참조로 바꿀 수 있다. (아~ 다다익램이겠구나 ~) 아무튼 그렇다고 한다.

 

그 외에 ConditionalWeakTable이라고 key, value를 쓰는 클래스도 있는데 key가 삭제되지 않는 이상 value도 메모리에서 삭제되지 않도록 하는 녀석이다. 참고할것

 

WeakTrackResurrection : 잘 안 씀

 

토나온다..

'언어 > C#' 카테고리의 다른 글

C# - 리플렉션 (Reflection)  (1) 2021.07.04
C# - CLR 호스팅과 앱도메인  (1) 2021.06.27
C# - 관리 힙과 GC (1)  (2) 2021.03.28
C# Nullable, Null 결합 연산자  (0) 2021.03.06
C# 사용자 정의 특성  (0) 2021.02.28