Skip to content

메모리 관리

  • Go는 힙에서 동적 할당이 필요한 메모리를 암묵적으로 할당한다.

    • 할당이 암묵적으로 이뤄지기 때문에 코딩이 쉽지만, 메모리 할당과 해제가 명확하지 않으니 메모리 사용량이 높아질 수 있는 부분을 놓칠 가능성이 있다.
  • Go는 참조 지향이 아닌 값 지향적 언어이다. Go 변수는 절대 객체를 참조하지 않고, 항상 객체의 전체 값을 저장한다. (포인터가 없다는 의미는 아니다.)

    • 모든 변수 선언(함수 인자, 반환 인자, 메서드 리시버 포함)은 해당 타입 전체를 할당하거나 그 포인터만 할당한다.
  • new(<type>)&<type>과 동일하며, 힙 상의 포인터 박스와 별도의 메모리 블록에 타입을 할당한다.

  • Go 할당자는 특정 스팬에 하나 혹은 여러 8KB 페이지를 포함하는 메모리 블록을 할당하여 단편화를 해결한다.

    • 각 스팬을 클래스 메모리 블록 크기에 맞게 생성된다.
    • Go 1.21에서는 67개의 크기 클래스가 있으며 32KB가 최대 크기다.
  • Go 할당자는 가상 메모리 영역에서 메모리 블록을 bin packing한다. 또한 0으로 초기화된 개인 익명 페이지와 함꼐 mmap을 사용하여 운영체제로부터 더 많은 공간을 요청한다.

  • 하지만 메모리 공간을 make로 할당받는 즉시 사용할 수 있는 메모리 공간을 받는 것은 아니다. 600MB의 바이트 슬라이스를 사용하는 예제를 살펴보자.

    b := make([]byte, 600 * 1024 * 1024)
    b[500] = 1
    b[100000] = 1
    b[104000] = 1
    for i := range b {
    b[i] = 1
    }
  1. b 변수는 []byte 슬라이스로 선언된다. 이후 등장하는 make 문은 600MB 크기의 데이터를 가진 바이트 배열을 만들어 힙을 할당하는 작업이다.
    • 이를 디버깅해보면 아래와 같은 정보를 알 수 있다.
    • 해당 슬라이스에 사용되는 세 가지 메모리 매핑에 대한 RSS는 548KB, 0KB, 120KB이다. (VSS 번호보다 훨씬 작음)
    • 전체 프로세스의 총 RSS는 21MB이며, 프로파일링에 따르면 대부분 힙의 외부에서 들어오는 것으로 나타났다.
    • 힙 크기는 600.16MB이다. (RSS가 훨씬 더 낮음에도 불구하고)
  2. 슬라이스 요소에 대한 액세스(쓰기 또는 읽기)를 시작하면 운영체제는 해당 요소를 둘러싼 실제 물리 메모리를 예약하기 시작한다.
    • 3개의 메모리 매핑 RSS는 556KB, 0KB, 180KB이다.
    • 총 RSS는 여전히 21MB이다.
    • 힙 크기는 600.16MB이다. (실제로는 더 크지만 배경이나 루틴 때문일 수 있다.)
  3. 모든 요소에 반복해서 접근한 후, b 슬라이스의 모든 페이지가 요구에 따라 피지컬 메모리에 매핑되는 것을 볼 수 있다.
    • 다음 통계가 이를 증명한다.
    • 3개의 메모리 매핑에 대한 RSS는 1.5MB, (완전히 매핑된) 598MB, 그리고 1.2MB이다.
    • 전체 프로세스의 RSS는 621.7MB를 나타낸다. (마침내 힙 사이즈와 같아졌다.)
    • 힙 크기는 600.16MB이다.

가비지 컬렉션

  • Go는 주기적으로 힙에 있는 객체를 대상으로 가비지 컬렉션을 실행한다.

  • GOGC 옵션은 가비지 컬렉터 비율을 나타낸다.

    • 기본값은 100이다.
    • 가비지 컬렉터 주기가 끝난 후 힙 크기가 n%가 될 떄 수행될 것이라는 의미다.
    • debug.SetGCPercent 함수를 사용해서 프로그래밍적으로 설정할 수도 있다.
  • GOMEMLINIT 옵션은 소프트 메모리 제한을 제어한다.

    • 기본값은 비활성화되어 있으며 설정된 메모리 제한에 가까운 경우에 가비지 콜렉터를 더 자주 실행하도록 한다.
  • runtime.GC()를 호출하여 가비지 컬렉터의 수집을 트리거할 수도 있다.

    • GC 구현은 여러 단계로 구정된다.
      1. STW(Stop the world) 이벤트를 실행하여 모든 고루틴에 Write barrier(데이터 쓰기에 대한 lock)을 주입한다.
      2. 프로세스에 제공된 CPU 용량의 25%를 사용해, 쓰고 있는 힙의 모든 객체를 표시한다.
      3. 고루틴에서 쓰기 장벽을 제거하여 표시를 종료한다.
  • Go 런타임은 가비지 컬렉터 실행시 MADV_DONTNEED 인수를 사용하여 madvise 시스템 콜을 사용한다.

    • 그렇기 떄문에 호출 프로세스의 RSS는 즉시 감소해도, 페이지는 즉시 해제되지 않고 커널이 적절한 순간까지 지연시킬 수도 있다.
  • 자세한 동작을 이해하기 위해 600MB의 바이트 슬라이스를 GC하는 예제를 살펴보자.

    b := make([]byte, 600*1024*1024)
    for i := range b {
    b[i] = 1
    }
    b[5000] = 1
    // 스코프가 금방 끝나는 경우에는 변수를 다른 객체에 대한 포인터로 대체할 때 `b = nil`과 같은 초기화가 필요없지만,
    // 수명이 긴 함수에서는 변수를 nil로 설정하여 GC 대상에 포함되도록 해주는 것이 좋다.
    b = nil
    runtime.GC()
    // Let's allocate another one, this time 300MB!
    b = make([]byte, 300*1024*1024)
    for i := range b {
    b[i] = 2
    }
  1. 큰 슬라이스를 할당하고 모든 요소에 액세스한 후의 통계는 다음과 같다.

    • 3개의 메모리 매핑에 대한 RSS는 1.5MB, 598MB, 그리고 1.2MB이다.
    • 전체 프로세스의 RSS는 621.7MB를 나타낸다.
    • 힙 크기는 600.16MB이다.
  2. b = nil로 초기화하고 GC를 수동 호출한 후의 동계는 다음과 같다.

    • 세 가지 메모리 매핑에 대한 RSS는 1.5MB, 0, 60kb이다. (중간 값이 해제되었고, VSS 값은 동일하다.)
    • 전체 프로세스의 총 RSS는 21MB이다.
    • 힙 크기는 159KB이다.
  3. 더 작은 슬라이스를 하나 더 배정하면 이전 메모리 매핑을 다시 사용한다.

    • 세 가지 메모리 매핑에 대한 RSS는 1.5MB, 300MB, 60KB다.
    • 전체 프로세스의 총 RSS는 321MB다.
    • 힙크기는 300.1KB이다.

참고