Go의 메모리 할당

이영민·2021년 12월 9일
2

Go의 메모리 할당 종류

Stack 할당

스택의 할당과 해제는 CPU 명령어 각각 하나씩으로 끝나기 때문에 가벼움.
변수의 수명과 메모리 사용량이 컴파일 시에 확정될 수 있는 경우에만 사용됨.
Go의 스택 메모리는 계속 증가하는 동적 메모리 풀임

  • 일정한 크기를 갖는 타 언어보다 메모리 효율성이 높고, 재귀 호출 때문에 스택 메모리가 고갈되지도 않음.

Heap 할당

실행 시 malloc 호출로 동적으로 메모리를 할당하고, 이후 GC가 할당된 개체를 참조되지 않게 되었는지 주기적으로 검사할 필요가 있기 때문에 상당히 무거움.

이스케이프 분석(탈출 검사, escape analysis)

사용자가 직접 스택에 할당할지 힙에 할당할지 정하지 않고 컴파일러가 선택함.
기본적인 아이디어: 가비지 컬렉션 작업을 컴파일 시 할 수 있는 부분을 하는 것.
컴파일러가 코드 영역세 걸쳐 변수의 범위를 추적하여 수명이 특정 범위로 한정될 수 있거나 메모리 크기가 컴파일 시에 확정될 경우 스택에 할당하고, 확정할 수 없는 경우 힙에 할당.

  • ex: 함수 내부에서 선언되었으나 해당 포인터를 함수 바깥으로 return할 경우.
    • C 등에서는 함수 내부에서 선언된 변수는 함수가 종료되면 사라지기 때문에 dangling 오류가 발생.
    • 인스턴스 자체를 return할 경우 복사된 인스턴스가 return될 것이기 때문에 다른 얘기.(Go는 Pass By Value)

스펙이 정해져 있지 않기 때문에, 이전에는 heap에 할당되던 게 컴파일러가 발전하면서 stack에 할당하게 될 수도 있음.

포인터를 피하는 것이 좋은 이유

포인터는 스택 할당의 저해 요인!
함수의 인수나 메소드 리시버도 값을 복사하는 게 대부분 더 가벼움.

  • Go는 Duff's devices[1]라는 기법을 사용하여 메모리 복사 등의 작업에 매우 효율적인 어셈블러 코드를 생성함.

포인터의 디레퍼런스 때는 nil 체크도 해야 하므로 그만큼 처리가 늘어남.
포인터를 사용하지 않고 값을 복사하는 것이 메모리 지역성을 높여 CPU 캐시 적중률이 오름.

  • 캐시 라인에 포함된 개체의 복사는 단일 포인터의 복사와 거의 같을 만큼 가벼움.

포인터를 포함하지 않는 메모리 영역은 GC가 검사를 생략할 수 있음.

  • 반대로 포인터가 있는 메모리 영역은 포인터 참조를 스캔해야 하므로 메모리 상에 흩어진 영역을 계속 로드하게 됨.
    • 처리도 무겁고, 로드로 인해 CPU 캐시에서 다른 데이터를 쫓아내 CPU 캐시 적중률도 나빠짐.

포인터를 사용하는 경우: 소유권을 나타내는 경우와 값을 변경 가능하게 하는 경우.

주의해야 할 heap 할당

슬라이스와 문자열

슬라이스는 크기가 동적으로, 컴파일 시에는 결정되지 않으므로 백스토어(슬라이스 내의 포인터가 참조하는 곳) 배열이 힙에 할당됨.
문자열도 바이트 슬라이스이므로 마찬가지.
배열을 사용할 경우 배열은 크기가 고정되므로 스택에 할당.

  • 필요한 크기의 최대 값을 알고, 스택에서도 문제 없는 크기라면 배열을 지역변수로 선언해서 사용하는 게 좋음.
    Append 사용 시 백스토어 크기가 부족해서 확장할 경우 확장 시 할당되는 백스토어도 힙에 할당됨.
    슬라이스를 받는 함수에 배열을 전달할 때는 a[:]와 같이 Slice expressions를 사용하면 됨.

time.Time

시간대 정보를 포인터로 가짐.
time.Time을 계속 갖고 있는 것보다 unix time 정수를 갖고 있는 게 GC에 좋음.(time.Time은 힙에 할당되므로)

문자열이나 슬라이스를 반환하는 함수

반환 문자열을 byte 슬라이스에 추가하고 싶은 경우, string으로 반환해서 byte 슬라이스에 추가하기 보다 byte 슬라이스를 받고, 함수 안에서 추가 후 다시 해당 byte 슬라이스를 반환하는 게 좋음.

  • byte 슬라이스 -> 문자열 등으로 변환할 경우 추가적으로 힙에 할당되는 듯.
  • 이 경우, 함수를 사용하는 사용자가 미리 필요한 만큼 메모리를 할당할 수도 있음.(슬라이스의 cap 지정)
func (t Time) Format (layout string) string
func (t Time) AppendFormat (b [] byte, layout string) [] byte 

strconv의 Itoa와 FormatFloat 등은 가능하다면 AppendInt와 AppendFloat를 사용하는 게 좋음.

인터페이스 사용

인터페이스의 메소드 호출은 구조체의 메소드 호출보다 무거움.
인터페이스를 저장하는 변수는 실제 구현된 구조체에 대한 포인터.
실행이 잦은 구간은 인터페이스를 사용하지 않고 heap 할당이 발생하지 않도록 할 수 있음.

  • 다만 인터페이스를 통한 확장성을 잃게 되므로 Trade-off.

함수 리터럴 사용

함수 리터럴을 생성해서 반환할 경우 함수 포인터가 반환되므로 해당 함수 리터럴은 heap에 할당.
함수 리터럴 내부에서 외부 변수에 접근할 경우 해당 변수를 내부 상태로 가져옴.(Closure 개념)

  • 값 복사가 아닌 인스턴스 참조(포인터)로 가져옴.
  • 때문에 해당 변수는 heap에 할당됨.

sync.Pool 사용

임시 인스턴스를 반복 사용하는 경우 sync.Pool도 성능 향상에 도움이 됨.

new vs make

new

메모리를 할당하되 초기화는 하지 않음.

  • Zeroed Storage: 메모리 할당 후 zero value를 설정한 다음 해당 인스턴스에 대한 포인터 반환.
m := new(MyType)
m := &MyType{}

위 두 코드는 같음.

make

메모리 할당과 더불어 미리 사용할 준비가 되도록 초기화도 함께 진행.
포인터를 반환하지 않음.
슬라이스, 맵, 채널에 사용 가능.

  • 슬라이스, 맵, 채널에 make를 사용하지 않으면 nil을 제로값으로 가짐.
  • make를 사용해야 배열처럼 특정 사이즈로 각 타입 제로값이 채워짐.

[1] Duff's Device

Switch문과 Loop unrolling 기법으로 메모리 복사를 최적화한 기법.

일반 복사

void copy_byte( char* dst, char* src, int cnt )
{
    while ( cnt-- > 0 )
        *dst++ = *src++;
}

Duff's Device 복사

void copy_duff( char* dst, char* src, int cnt )
{
    int repeat = ( cnt + 7 ) / 8;
 
    switch ( cnt % 8 )
    {
    case 0: do { *dst++ = *src++;
    case 7:      *dst++ = *src++;
    case 6:      *dst++ = *src++;
    case 5:      *dst++ = *src++;
    case 4:      *dst++ = *src++;
    case 3:      *dst++ = *src++;
    case 2:      *dst++ = *src++;
    case 1:      *dst++ = *src++;
            } while ( --repeat > 0 );
    }
}

위 코드가 정상적으로 작동하는 이유
1. switch 구문의 느슨한 명세. case 라벨은 다른 어떤 구문 앞에 prefix 형태로 존재해도 문법적으로 유효함.
2. C 언어에서 loop의 중간 부분으로 jump 할 수 있음.

즉 다음과 같이 동작함.

switch ( cnt % 8 )
{
case 0: goto label0;
case 1: goto label1;
case 2: goto label2;
case 3: goto label3;
case 4: goto label4;
case 5: goto label5;
case 6: goto label6;
case 7: goto label7;
}
    while ( --repeat > 0 )
    {
label0:
        *dst++ = *src++;
label7: 
        *dst++ = *src++;
label6:
        *dst++ = *src++;
label5:
        *dst++ = *src++;
label4:
        *dst++ = *src++;
label3:
        *dst++ = *src++;
label2:
        *dst++ = *src++;
label1:
        *dst++ = *src++;
    }
  1. 순환할 횟수를 count / 8의 올림으로 계산.
  2. 맨 처음 순환 시 8번 전부 계산하지 않고 8로 나눈 나머지만큼만 계산 수행.
  3. 나머지 순환 동안은 8번 전부 계산함으로서 최종적으로 count만큼 계산 수행.

이를 통해 비교 횟수를 count / 8로 줄일 수 있음.

참고자료

읽어볼 기사

profile
중니어 개발자

0개의 댓글