언어/C#

C# - 관리 힙과 GC (1)

tsyang 2021. 3. 28. 23:49

관리 힙


C#은 왜 관리 힙을 쓰나?

메모리 관리를 수동으로 해줘야 하는 C++의 경우 메모리 해제를 까먹어 메모리 누수가 발생하거나, 이미 해제한 메모리에 접근하여 메모리 손상이 발생하는 경우가 많고 이는 결국 버그나 보안 취약점으로 연결된다.

 

리소스 할당

CLR환경 하에서는 모든 객체가 관리 힙에 할당된다.

프로세스가 초기화되면,

  1. CLR은 관리 힙으로 쓸 주소 공간을 할당하고
  2. 다음 객체를 할당할 위치를 가리키는 포인터(이하 NextObjPtr)을 시작 주소를 가리키게 한다.

이 주소 영역이 가득 차면 CLR은 프로세스의 주소 공간이 사용될 때 까지 영역을 확대한다.

 

new연산자

CLR에서 new연산자는,

  1. 필요한 용량을 계산한다. (특정 타입의 필드, 상속한 타입의 필드까지) - 여기에는 type object와 sync block index까지 포함된다. 
  2. 관리 힙에 여유 공간이 있는지 확인한다. 충분하면 객체의 위치를 NextObjPtr이 가리키는 위치로 정하고 공간을 0으로 채운다. 
  3. 타입 생성자를 호출하고 객체의 참조를 반환한다.
  4. NextObjPtr은 다음 객체를 할당할 위치를 가리키도록 한다.

이 과정은 포인터에 특정 값을 더하는 작업일 뿐이므로 빠르다. 또한 관련있는 객체가 순서대로 할당될 확률이 높기 때문에 locality가 향상되고 프로세스의 워킹 셋이 줄어든다. 

 

 

가비지 수집


객체의 수명 관리

C#에서는 참조 카운팅은 안 쓴다. 참조 카운팅은 객체가 자신이 참조되는 횟수를 기록하는 필드를 가지고 있어서 이게 0이 되면 객체가 제거되는 방식이다. (C++의 스마트 포인터) 근데 이런건 순환 참조 문제가 있다. 

그래서 CLR에서는 참조 추적을 쓴다. 참조 추적은 루트로부터 연결이 가능한지를 고려한다.

 


GC수행

CLR이 GC를 수행할 때 프로세스 내의 모든 스레드를 일시 정지한다. (가비지를 검토할때 누가 쓰면 안되니까.)

그 뒤에 마크 단계(marking phase), 컴팩트 단계(compacting phase)를 수행한다.

 

 

마크단계

  1. 힙 내의 모든 객체를 돌면서 특정 비트를 0으로 설정한다. (sync block index필드에 포함됨) 이 비트가 0이라는 건 객체가 삭제될 것이라는 것이다. 즉, 모든 객체를 삭제 대상으로 설정한다.
  2. 이후 활성된 루트를 돌면서 루트가 참조중인 객체를 모두 찾은 뒤 앞에서 설정한 비트를 1로 바꾼다(마크한다). 
  3. 마크된 객체의 루트도 확인하여 참조 중인 객체의 비트를 마크한다. (이미 마크된 객체는 다시 마크하지 않으며 그 객체의 필드도 확인하지 않는다. 순환 참조로 무한루프가 생길 수 있기 때문, 사실 그럴 필요도 없고)
  4. 위 작업이 끝나면 마크된 애들은 reachable 바크 안된 애들은 unreachable이라고 하며 unreachable인 애들은 응용 프로그램의 어떤 루트도 해당 객체에 접근할 수 없음을 말한다.

컴팩트 단계

마크단계가 끝나면 GC는 컴팩트 단계를 수행한다. 컴팩트 단계에서는 살아남은 객체들을 시작 지점 쪽으로 옮겨서 연속된 메모리공간에 위치하도록 한다. 이러한 작업은 몇 가지 이점이 있다.

  1. 참조의 지역성이 증가하고 응용프로그램의 워킹 셋의 크기가 감소한다.
  2. 파편화된 주소 공간을 처리한다.

당연하겠지만 컴팩트 과정에서 CLR은 루트가 가리키는 주소를 옮겨주는 작업을 수행한다. 컴팩트가 끝나면 NextObjPtr 은 다시 살아남은 객체의 마지막으로 이동된다. 이후 CLR은 모든 스레드의 수행을 재개한다.

 


기타

  1. 대행 객체 힙에 대해서는 컴팩트 작업이 수행되지 않는다. 
  2. 정적 필드의 경우 타입이 로드된 앱도메인이 내려가기 전까지 유지되므로 가리키는 객체가 끝까지 살아남는다. 따라서 정적 필드는 가능한 사용하지 않는 것이 좋다.
  3. 디버깅할때 별도의 /debug 스위치가 필요할 수 있다. 예를 들면 Main메서드에서 2초마다 콜백을 수행하는 타이머를 만들었다. GC가 수행되면 해당 타이머는 당연히 루트에서 접근이 불가능하므로 수거가 되고 콜백이 제대로 수행이 되지 않을 것이다. 이건 해당 상황이 닥치면 알아서 찾아보도록.

 


 

세대

 최근에 생성된 객체는 더 짧은 수명을 가지고 예전에 생긴 객체는 더 오랜 수명을 가질 것이라는 합리적인 가정을 해볼 수 있다. 이런 가정으로부터 CLR은 세대를 고려한 가비지 수집기를 사용한다. CLR은 0,1,2세대를 사용하는데, 각각의 세대에는 허용된 크기가 있다. 그리고 이 크기는 CLR이 자체적으로 학습을 해가며 조정한다. 각 세대는 2,1,0 순서로 메모리의 연속적인 공간에 위치한다. 그러니 2세대에 대한 가비지 수집 작업이 가장 오래 걸릴 것이다. (컴팩트때 옮겨야 하는 공간이 많으니까..?)

 

 새롭게 생긴 객체들은 0세대에 속하게 된다. 0세대의 크기가 허용된 크기를 초과하면 0세대에 대해 가비지를 수집하게 되고 살아남은 애들은 1세대로 승격한다. 1세대도 0세대와 같은 동작이 수행된다. 2세대의 경우 더이상 승격할 수 없기 때문에 가비지가 삭제만 된다. 

 

 앞에서도 말했듯, CLR은 각 세대별 허용된 크기를 동적으로 조절한다. 예를 들어 0세대를 수집했는데 살아남는 객체가 거의 없다는 것을 GC가 알게 되면 0세대에 허용한 메모리 크기를 줄인다. 허용된 메모리 크기를 줄이는 것은 더 많은 GC를 발생시키지만 반면에 작업량도 준다. 예를들어 모든 0세대의 객체가 가비지라면 단순히 NextObjPtr의 위치만 바꿔버리면 그만이기 때문이다. 반면에 가비지를 수거했는데 살아남은 객체가 많다면 GC는 허용된 메모리 크기를 늘린다. 이렇게 되면 가비지 수집은 더 빈도가 낮게 일어나지만, 가비지 수집이 일어나면 더 많은 메모리가 반납될 것이다. 만약 한 세대에 대해서 가비지를 수집했는데 반납된 메모리가 충분하지 않다면 CLR은 OutOfMemoryException이 발생하기 전에 전체에 대해서 가비지를 수집한다. 

 


 

가비지 수집의 발생

 

가비지 수집은 다음과 같은 경우에 발생할 수 있다.

  1. 앞에서 말한 것 처럼 세대별 허용된 크기를 초과하는 경우
  2. 코드에서 명시적으로 GC.Collect()를 호출하는 경우.
  3. 운영체제가 메모리를 부족하다고 보고하는 경우.
  4. CLR이 앱도메인을 내리는 경우. 당연히 어떤 것도 루트가 되지 못할 것이기 때문에 ㅇㅇ
  5. CLR이 종료되는 경우. 

 


대형 객체

시대마다 대형 객체를 정의하는 기준은 다르겠지만 대충 최근에는 85,000바이트 이상을 대형 객체라 한다. 일반적으로 긴 문자열(XML , json같은..)이나 바이트 배열이 대형 객체인 경우가 많다.

 

CLR은 대형 객체를 조금 다르게 다루는데,

  • 대형 객체는 다른 객체(소형 객체)와 다른 프로세스 주소 공간에 할당된다.
  • 대형 객체에 대해서는 컴팩트 단계를 수행하지 않는다. 시간이 많이 걸리기 때문이다. 따라서 파편화가 발생할 수 있다. (그러나 추후에 이런 정책은 바뀔지도..?)
  • 대형 객체는 생성 즉시 2세대의 일부로 간주된다. 따라서 장시간 사용될 객체만 대형 객체로 만드는 것이 좋다. 

 


 

가비지 수집 모드

 

GC모드

CLR이 기동되면 GC 모드가 설정된다. 두 가지 GC모드가 있는데,

  • 워크스테이션 : 클라이언트에 최적화된 것으로 GC가 수행되면 모든 스레드가 멈추기 때문에 이에 따라 사용자들이 불편을 겪지 않게끔 지연시간을 최소화하기위한 모드이다. 이 모드에서 GC는 다른 응용프로그램이 컴퓨터에서 돌아간다고 가정하여 CPU를 너무 많이 쓰지 않도록 동작한다. 기본적으로 응용프로그램은 이 모드이다.
  • 서버 : 서버에 최적화된 것이다. 워크스테이션과 달리 GC를 돌릴때 모든 CPU를 사용할 수 있을것으로 기대하고 만든 모드. 

 

하위모드

추가로 동시/비동시 모드를 가지고있다. 

  • 동시모드(기본값) : 추가적인 백그라운드 스레드를 이용하여 프로그램을 수행하는 동안 마크 작업을 수행한다. 마크 작업이 끝나면 모든 스레드를 멈추고 컴팩트를 할지 말지 결정하는데, 이미 마크를 했으므로 빨리 수행될 것이다. 그러나 컴팩트 작업을 수행하지 않을 수도 있다. 메모리가 충분한 경우이다. (실제로 이를 선호하기도 한다.)
  • 비동시 : 위에서 설명했던거처럼 허용된 크기 초과 감지하면 스레드 멈추고 마크&컴팩트 하는것..

정리하자면 동시모드에서는 마크모드가 백그라운드에서 돌아가고 다끝나면 컴팩트 할지 말지 정하는거. 왜냐면 컴팩트가 사실상 가비지를 수거하는 과정이니까. 메모리가 충분하다면 할 필요가 없겠지. 또한 이러한 이유로 동시 모드에서는 더 많은 메모리를 사용하게 된다. 

 

그 외에도 GCSettings 클래스의 GCLatencyMode 속성이 있다. 다른건 뭐 알아서 필요할때 찾고, 더 볼건 LowLatency 지연 모드인데, 이걸 설정하면 비교적 시간이 많이 걸리는 2세대 수집을 수행하지 않도록 한다. 물론 이 경우에도 메모리가 부족하면 GC가 수행된다. 

 

사용 예)

GCLatencyMode prevMode = GCSettings.LatencyMode;
try
{
    GCSettings.LatencyMode = GCLatencyMode.LowLatency;
    //애니메이션 수행 등 시간에 민감한 작업들을  수행..
}
finally
{
    GCSettings.LatencyMode = prevMode; //모드 원복
}

 


강제 가비지 수집

GC.Collect()를 이용하면 강제로 가비지 수집을 할 수 있는데, 다음을 정할 수 있다.

  • 가비지를 수집할 세대
  • 모드 (Forced : 무조건 수행, Optimized : 이득이 있으면 수행, Default : 기본값, 현재는 Forced로 설정되어있음)
  • 블로킹(bool) : true면 비동시, false면 동시

 

대부분의 상황에서 Collect 메서드는 호출하지 않는게 좋다. 왜냐면 CLR이 알아서 허용된 메모리 크기 조절해가면서 최적화 하기 때문..

 

그래도 사용하는 것을 고려해볼 상황이 있는데 예를 들어 

  1. 반복될 가능성이 희박한 이벤트를 처리한 뒤 많은 가비지가 생성된 경우. 왜냐면 GC는 과거 수행 패턴을 학습하는데 이 때는 정밀하게 판단이 불가능 하기 때문이다.
  2. 응용 프로그램의 초기화 완료나 사용자가 데이터 파일을 저장한 직후에 모든 세대에 대해서 가비지를 수집하는 경우. (게임에서는 로딩하는동안 ㅎㅎ)

 


모니터링

  • RegisterForFullGCNotification등의 메서드를 이용하면 가비지 수집을 할 시기가 다가오는것을 알 수 있다.
  • 또 GC클래스의 CollectionCount나 GetTotalMemory등으로 가비지 수집이 얼마나 수행되었는지도 알 수 있는데, 만약 가비지가 너무 많이 수행된다면 알고리즘 최적화를 신경써야 할 것이므로 잘 활용하면 좋은 지침이 될 수 있다.

 

 

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

C# - CLR 호스팅과 앱도메인  (1) 2021.06.27
C# - 관리 힙과 GC (2)  (1) 2021.04.03
C# Nullable, Null 결합 연산자  (0) 2021.03.06
C# 사용자 정의 특성  (0) 2021.02.28
C# - 델리게이트  (1) 2021.02.21