Loading
2022. 4. 17. 23:34 - lazykuna

[Go] Golang은 어떻게 효율성을 달성하는가

오늘의 탐구주제는 Golang입니다. 그 중에서도 극한의 성능 이야기입니다.

Golang은 근래 백엔드 프로그래밍 언어로서 그 위상을 지대하게 높여가고 있습니다. 제 생각에는 아마 아래의 이유가 클 것 같은데...

  • 쉽고 직관적인 언어
    러닝 커브가 적은 것은 은근히 중요합니다. 실무까지 투입되는 엄연한 비용으로서 작용하기 때문이죠.
    심지어 코드 관리도 쉽습니다. 별도의 헤더 파일 및 함수 선언이나, 클래스의 상속 구조와 같은 피로한 부분에 에너지를 소모할 필요가 없거든요.
  • 훌륭한 패키지와 자체 제공되는 기능들
    패키지 관리자는 물론이요 언어 자체에서 테스트 기능을 지원하기도 하고 이모저모 third party tool에 의존할법한 많은 기능들을 기본 제공하고 있어 정말 편합니다.
  • 멀티테스킹 최적화
    Go의 Goroutine의 성능은 익히 알려져 있고, channel을 이용하여 손쉽게 동시성 프로그래밍이 가능한 이점은 극한의 성능을 뽑아내야하는 백엔드 개발에 있어 최상의 조건이 아닐 수 없습니다.
  • 자유로운 플랫폼
    Golang이 알아서 다 해줍니다. 크로스 플랫폼에 대해서 고민할 필요가 없습니다.

Golang은 어떻게 보면 자바와 비슷한 성질을 가지고 있습니다. 인터프리터 언어이면서 동시에 컴파일되어 빠르게 구동가능하기도 하고(자바에서는 JIT), 메모리 관리 로직 또한 GC 기반으로 상당히 유사합니다. 자바의 그러한 특성 덕분에 백엔드 터줏대감으로 오랫동안 행세하고 있었는데, Golang이 파이를 야금야금 먹을 수 있었던 것이죠.

아무튼 빠른 건 알겠는데, 가만 보다 보면 수상한 점이 좀 있습니다. 이를테면 Golang의 자유롭고 복잡한 interface{} 를 왕창 이용해도 수상할 정도로 성능 저하가 없다던가, 역사가 오래된 자바도 GC 관련해서 수많은 이슈들(Stop-the-world 등)이 있는데 메모리 관리는 어떻게 하고 있길래 얼마 되지도 않은 언어가 신뢰도가 중요한 백엔드에서 이렇게 입지를 굳힐 수 있었는지 등.

저 두 부분에 대해 찾은 내용을 일부 정리해 봤습니다. Goroutine은 다른 좋은 글들이 많아서 여기선 안 다루는 걸로 ^^...

Golang의 다형성, 그리고 성능

개인적으로 이 점이 예전부터 가장 궁금했습니다. 사실 이 글을 쓴것도 이것 때문이긴 하고 ㅎㅎ...

Go는 덕타입(대충 trait 비슷)을 통해 끝판왕급 자유로운 타입을 구현케 합니다. C++이나 Java와 같은 클래스 상속 관계를 명시할 필요 없이, 메서드만 호환 가능하면 어떤 구조체든 다 받아준다는 것인데, 덕분에 아래와 같은 코드가 가능합니다.

type Car interface {
    manufacturer() string
    HP() int
    Torque() int
}

type M550i struct {}
func (car *M550i) manufacturer() string {
    return "BMW"
}
func (car *M550i) HP() int {
    return 530
}
func (car *M550i) Torque() int {
    return 76
}

type Stinger struct {}
func (car *Stinger) manufacturer() {
    return "Hyundai"
}
func (car *Stinger) HP() int {
    return 370
}
func (car *Stinger) Torque() int {
    return 52
}

// 아무 타입이나 다 받도록 만들고 안에서 runtime에 type Assertion을 수행하도록 한 후,
// 원하는 메서드를 수행하도록 합니다.
func PrintCar(stuffs interface{}) {
    for _, stuff := range stuffs {
        if car, ok := stuff.(*Car); ok {
            fmt.Printf("%s / %d / %d\n", car.manufacturer(), car.HP(), car.Torque())
        }
    }
}

func main() {
    cars := []Cars{M550i{}, Stinger{}}
    PrintCar(cars)
}

그런데, 이러면 어떤 타입이 올지 모르는데 성능상에서 엄청나게 손해를 보지 않을까요? golang에서는 위 코드를 실행하기 앞서 아래와 같은 체크를 수행하게 될 것입니다.

  1. 비교할 오브젝트의 타입 정보를 조회합니다.
  2. 변환할 타입과 메서드를 일일이 비교합니다.
  3. 변환할 타입으로의 모든 메서드가 존재한다면, true를 리턴합니다.

이러한 작업을 매번 반복한다는 것은 메서드 수의 곱($O(n*m)$)에 비례하게 됩니다. 알고리즘상 어떤 문자열이 다른 문자열의 애너그램인지를 계산하는 것과 다를 바가 없습니다.

그럼 다른 언어에서는 어떨까요? C++ 같은 경우는 각 클래스 멤버 함수가 고유의 메모리 주소를 가지고 있습니다. 상속받은 클래스의 경우에는, 상속된 클래스의 멤버 함수가 다른 메모리를 보도록 할 수 있습니다. 이를 가능케 하도록 하기 위해 가상함수 테이블 구조를 사용합니다. 즉, “이 타입이 들어왔으면, 이 메모리 테이블을 읽어보면, 내가 실행해야 할 함수의 주소를 알 수 있어!” 라고 컴퓨터가 생각하게 됩니다.

다시 말해, 컴파일 시간에 접근해야 할 메모리 주소를 확정하고 가게 됩니다.

물론 C++에서도 비슷한 물건이 있습니다. dynamic_cast라고... 하지만 이 기능은 런타임에서 상속 관계(가상함수 테이블)를 일일이 검사하다 보니 성능이 확실히 느린 것으로 익히 알려져 있습니다.

그럼, Go도 비슷하게 성능 저하를 감수할까요? 놀랍게도 Stackoverflow에서 실험한 결과를 보면 성능 차이가 거의 없음을 알 수 있습니다.

package main

import (
    "testing"
)

type myint int64

type Inccer interface {
    inc()
}

func (i *myint) inc() {
    *i = *i + 1
}

func BenchmarkIntmethod(b *testing.B) {
    i := new(myint)
    incnIntmethod(i, b.N)
}

func BenchmarkInterface(b *testing.B) {
    i := new(myint)
    incnInterface(i, b.N)
}

func BenchmarkTypeSwitch(b *testing.B) {
    i := new(myint)
    incnSwitch(i, b.N)
}

func BenchmarkTypeAssertion(b *testing.B) {
    i := new(myint)
    incnAssertion(i, b.N)
}

func incnIntmethod(i *myint, n int) {
    for k := 0; k < n; k++ {
        i.inc()
    }
}

func incnInterface(any Inccer, n int) {
    for k := 0; k < n; k++ {
        any.inc()
    }
}

func incnSwitch(any Inccer, n int) {
    for k := 0; k < n; k++ {
        switch v := any.(type) {
        case *myint:
            v.inc()
        }
    }
}

func incnAssertion(any Inccer, n int) {
    for k := 0; k < n; k++ {
        if newint, ok := any.(*myint); ok {
            newint.inc()
        }
    }
}
> go test
BenchmarkIntmethod-16           2000000000           1.67 ns/op
BenchmarkInterface-16           1000000000           2.03 ns/op
BenchmarkTypeSwitch-16          2000000000           1.70 ns/op
BenchmarkTypeAssertion-16       2000000000           1.67 ns/op
PASS

헉스~ 보이는 것처럼, Type Assertion을 하는 것과 직접 method 호출 하는 것 사이에 거의 차이가 없는 상황입니다. 어떻게 이게 가능할까요? 마침 Russ Cox가 Golang Interface에 대해 정리해 놓은 글이 있습니다.

주) Go언어는 계속 발전하고 있어서, 당시 쓰인 글이 지금은 틀린 내용일 수 있습니다.

메서드를 가진 프로그래밍 언어들

먼저 자세히 알아보기 전에, 메서드를 가진 프로그래밍 언어들에 대해서 간단히 짚고 넘어가 보겠습니다. 크게 두 부류가 있는데,

  1. 모든 메서드 콜의 테이블(가상 함수 테이블)을 컴파일 타임에 준비
    C++, Java와 같은 언어
  2. 메서드를 동적으로 호출 + 캐시 기능
    Python, Javascript 등 Smalltalk 계열 언어

Go는 이 둘의 중간쯤입니다. 메소드 테이블도 가지고 있고, 런타임에서 캐시도 합니다.

Go에서의 메서드 테이블 캐시

예시를 봅시다.

// 1
type Stringer interface {
    String() string
}

func ToString(any interface{}) string {
    if v, ok := any.(Stringer); ok {
        return v.String()
    }
    switch v := any.(type) {
    case int:
        return strconv.Itoa(v)
    case float:
        return strconv.Ftoa(v, 'g', -1)
    }
    return "???"
}

// 2
type Binary uint64

func (i Binary) String() string {
    return strconv.Uitob64(i.Get(), 2)
}

func (i Binary) Get() uint64 {
    return uint64(i)
}

Binary라는 타입이 있고, 해당 타입은 StringGet 메서드를 가지고 있습니다. 그리고 Stringer 인터페이스는 String 메서드를 요구하고 있는데, 따라서 Binary 타입은 Stringer 인터페이스와 호환됩니다.

실제로 그렇게 캐스팅을 수행했다고 아래와 같이 가정한다면,

b := Binary(200)
s := Stringer(b)

Go는 인터페이스 변수 s를 두 개의 포인터로 표현하게 됩니다.

  • tab: 타입 정보를 가르키는 포인터
    itable 이라는 메서드 테이블을 만드는데, 안에 타입 정보와 함께 인터페이스가 호출할 수 있는 메서드 주소값을 포함하는 정보들이 담겨 있습니다.
    이 정보들은 캐시되어, Branch Pred. 및 Cache hit만 된다면 사실상 메서드를 직접 수행하는 것과 다를 바 없는 성능을 보여주게 합니다.
  • data: 데이터를 가르키는 포인터
    = Binary 객체

이렇게 itable이 만들어지면, 컴파일 된 코드 또한 간단해집니다. 캐시된 Itable을 참조하면 끝이니까요.

타입 매칭 최적화

사실 캐시가 있는 이상 타입 매칭이 아주 중요한 내용은 아니지만... 그래도 타입 매칭에서도 최적화를 하려고 노력했습니다.

원리는 간단히 두 메서드 테이블을 정렬하면 됩니다. 으레 경시문제에서 자주 보이는 알고리즘인데, 이런 데 쓰일수가 있네요. 최대 O(An+Bn) 정도의 시간복잡도지만, 보통은 인터페이스와 거의 같은 메서드를 구현해놓으니, 인터페이스 메서드 갯수만큼의 시간복잡도라고 기대할 수 있을 듯.

메모리 최적화

Go는 메모리 최적화에도 신경을 썼습니다. Common case는 아닐 경우들인데, 그래도 최대한의 효율을 보여줄 수 있도록 신경쓴 점이 인상깊습니다.

  • Direct mapping instead of Dereferencing

인터페이스가 없거나,

인터페이스의 데이터가 아주 극소량이거나,

인터페이스가 아예 비어있을 경우에는 아예 dereferencing을 하지 않고 값 자체를 그 자리에 넣습니다. 은근히 중요한 최적화인데 Go에서는 잘 해주고 있습니다.

그럼에도 불구하고 interface가 성능에 영향을 끼치는 경우

위의 온갖 최적화에도 불구하고 일부 시스템 interface가 성능에 영향을 끼치는 경우가 있다고 합니다. Phil Pearl 씨의 포스트로부터 긁어온 내용을 그대로 번역해서 써 놓습니다.


... 이번에는 다른 실험을 해봅시다. 0을 채우는 io.Reader 를 만들어서 한번 테스트를 해볼 건데요, 아래와 같이 구현해 보겠습니다.

type zeroReader struct{}
func (z zeroReader) Read(p []byte) (n int, err error) {
    for i := range p {
        p[i] = 0
    }
    return len(p), nil
}

테스트 로직 이전과(interface vs. direct call) 거의 동일합니다.

func BenchmarkInterfaceAlloc(b *testing.B) {
    var z zeroReader
    var r io.Reader
    r = z
    b.Run("via interface", func(b *testing.B) {
        b.ReportAllocs()
        for i := 0; i < b.N; i++ {
            var buf [7]byte
            r.Read(buf[:])
        }
    })
    b.Run("direct", func(b *testing.B) {
        b.ReportAllocs()
        for i := 0; i < b.N; i++ {
            var buf [7]byte
            z.Read(buf[:])
        }
    })
}

실험 결과는 의외입니다. 앞선 실험처럼 interface와 direct가 별 차이가 없을 것으로 생각되었으나, 실제로는 1 alloc 오버헤드에 20ns의 시간이 걸려서 큰 차이가 생겼습니다. 무슨 일이 벌어지고 있는 걸까요?

BenchmarkInterfaceAlloc/via_interface-8       50000000        24.5 ns/op        8 B/op         1 allocs/op
BenchmarkInterfaceAlloc/direct-8              300000000        5.52 ns/op        0 B/op         0 allocs/op

두 경우 모두에서 Read call 전에 7byte의 메모리를 할당하고 있는데, Direct call에서의 경우, 컴파일러가 Read 함수가 하는 일을 알고 있기 때문에 Escape Analysis를 수행하여 버퍼가 스택 바깥으로 나가지 않을 것을 알고 있어 추가 메모리 할당하지 않고 버퍼를 스택에 위치시킬 수 있습니다.

Escape Analysis는 컴파일러가 변수의 위치(heap or stack)를 어디에 설정할지 결정하는 과정입니다. 보통 변수의 스코프가 함수 바깥을 넘어가거나, 소유권이 변경되는지의 여부에 따라 결정됩니다. 자세한 내용은 링크 내용을 참조해 주세요!

반면, 인터페이스의 경우 Escape 여부를 컴파일 타임에 알 수 없어 (알 수 없을만 합니다, 다양한 타입이 인터페이스로 입력될 수 있기 때문에!), 컴파일러는 반드시 버퍼가 Escape 할 것이라고 가정해야 하며, 따라서 버퍼를 heap에 위치시키게 됩니다. 이 때문에 1 alloc가 발생하고, GC overhead(락 포함)로 수행 시간이 느려지게 됩니다.

어떠한 인터페이스로도 전달되는 버퍼는 반드시 Escape(heap 할당)되게 됩니다. 이 점은 상당히 실망스럽네요. 심지어 io.Reader 정의를 보면, buffer이 반드시 escape하지 않아야 한다고 쓰여 있기까지 합니다.

비슷한 경우로 json.Unmarshaller 이 있습니다. 마찬가지로 버퍼가 escape하지 않아야 한다고 명시하고 있습니다.


이런 부분에서의 문제는 직접적인 피드백도 없고 문제 발생시 찾기도 어려워서 굉장히 좋지 않지만, 아무래도 Go 언어가 쓰기 쉽게 만들어지다 보니 예견된 문제이기도 합니다. Go도 Rust처럼 디테일한 타입 시스템을 적용했다면 이런 특이한 문제가 없었을지도 모르지만, Go는 사용자보고 그렇게 이거해라 저거해라 하는 언어는 아니니까요.

Golang에서의 메모리 관리

(220423 추가 및 수정)

Golang의 메모리 관리 철학도 재미있는 점이 많습니다. 런타임(GC)과 컴파일 타임(Escape Analysis) 둘 다 관여하는 부분이 있는데 이 점들에 대해서 정리해 보았습니다.

Escape Analysis

Escape Analysis는 포인터의 동적 범위를 결정하는 작업을 의미하고, 이것의 본디 목적은 어떤 변수의 실제 스코프가 정의된 스코프(함수와 같은 중괄호 블록)를 벗어나는지를 확인하기 위해서입니다. 일단 간단한 예시로 보면,

type Ticket struct {
	Title string
	Desc string
}

func ReturnValue() Ticket {
	ticket := Ticket{
		Title: "test",
		Desc: "test",
  }
	return ticket
}

func ReturnPtr() *Ticket {
	ticket := &Ticket{
		Title: "test",
		Desc: "test",
  }
	// ** Pointer escapes!! **
	return ticket
}

첫 번째 경우에는 값을 복사합니다. 즉 ticketOuter := ReturnValue() 와 같이 사용하면 값이 복사되고, ReturnValue 안에 있는 객체의 reference는 없어지므로, ticket는 escape 하지 않습니다. 따라서 이 경우에는 Go가 값을 Stack 메모리에 할당합니다.

그러나 두번째 경우에는 포인터가 복사됩니다. ticketOuter := ReturnPtr() 과 같이 사용하면 포인터만 복사되지 값이 복사되지는 않기 때문에, ticket의 reference는 여전히 살아 있으므로 escape하다고 할 수 있습니다. 따라서 이 경우에는 Go가 값을 Heap 메모리에 할당합니다.

이게 꽤 똑똑해서, 아래와 같이 포인터를 사용하여 동적 할당을 유도하더라도 상황이 괜찮다면 스택에 값을 할당하게 만들어집니다. 아래 예시를 보면,

package main

type S struct { 
  intptr *int
}

func main() { 
  var i *int = new(int)
  *i := 10
  Test(i)
}

func Test(y *int) (s S) {
  s.intptr = y
  return s
}

다소 까다로운 경우인데도 최적화를 잘 해냈습니다... i 가 not escaping한 것을 알아챌 뿐만 아니라 Inlining까지도 자동으로 해내는 모습을 볼 수 있습니다.

./test_escape4.go:17:6: can inline refStruct
./test_escape4.go:9:6: can inline main
./test_escape4.go:14:11: inlining call to refStruct
./test_escape4.go:11:18: new(int) escapes to heap

스택 메모리에 값을 할당하는 것이 훨씬 쉽고 빠르기 때문에 최적화가 되는 핵심 요소 중 하나인데, 스택할당을 위해서는 스택 프레임 (esp) 값을 따서 가지고 오고 변경하면 끝이기 때문에 할 일이 적은 반면, 힙 할당을 위해서는 압도적으로 비싼 가용 메모리 검색 과정이 일어나야 합니다. 설령 이를 감수한다 하더라도, 단편화 문제 또한 피할수가 없습니다. 자세한 부분이 궁금하다면 IAR 사의 아티클을 참조해 볼 수 있을 것 같습니다!

실제 go run이나 go build에서 gcflags="-m" 플래그를 붙여서 escape하는지 확인해 볼 수 있습니다.

...
./test_escape.go:11:12: &Ticket{...} escapes to heap
...

사실 이건 JVM에서도 사용하던 기술이긴 했는데(How JVMs improve Application performance), 근래 Go가 사용하면서 다시 재조명받는 느낌이긴 하네요.

실험: struct의 일부분도 Escape Analysis를 하나?

이건 개인적으로 궁금했던 부분입니다. struct의 일부분을 사용해야 할 경우 golang이 어떻게 동작할까요? Memory efficiency를 중시한다면 새로운 구조체를 할당한 후 값을 복사할 것이고, Performance efficiency라면 일부 메모리 낭비를 감수하고서라도 구조체를 그대로 가져다 쓸 것입니다. 거기에 한 술 더 떠서, 아래처럼 스택 메모리에서 구조체의 일부분만을 리턴하는 경우는 어떨까요?

package main

import "fmt"

type Ticket struct {
	Title string
	Desc  string
}

type TicketExtended struct {
	Ticket
	Token string
	Date  string
}

func ReturnPtr() *Ticket {
	ticketExtended := TicketExtended{
		Ticket: Ticket{
			Title: "abcd",
			Desc:  "abcd",
		},
		Token: "1234",
		Date:  "22 May 2022 12:34:56 US/Pacific",
	}
	return &ticketExtended.Ticket
}

func main() {
	ticket := ReturnPtr()
	fmt.Printf("%+v\\n", *ticket)
}

그런데 테스트 해 본 결과가 상당히 흥미롭습니다.

> go run -gcflags="-m" test_escape3.go
# command-line-arguments
./test_escape3.go:16:6: can inline ReturnPtr
./test_escape3.go:29:21: inlining call to ReturnPtr
./test_escape3.go:30:12: inlining call to fmt.Printf
./test_escape3.go:17:2: moved to heap: ticketExtended
./test_escape3.go:30:22: *ticket escapes to heap
./test_escape3.go:30:12: []interface {}{...} does not escape
<autogenerated>:1: .this does not escape
{Title:abcd Desc:abcd}

ticketExtended 이 아예 heap으로 옮겨가고 있는 걸로 보아서, 메모리 낭비를 다소 감안한 성능 위주의 최적화를 하는 것으로 보입니다. Ticket만 값을 복사하는 것 보다는 TicketExtended 통째로 가지고 가는 방식으로 작동하는 것으로 보입니다. 재미있네요 ㅎㅎ.

  • 혹시 ticketExtended 의 시작점 포인터만 가지고 있는 거 아니야? 라는 의심이 들어서 interface{}(ticket).(*ticketExtended) 로 강제 캐스팅을 해 봤는데, 안 되네요. 내부 구조가 어떤식으로 되어 있는지 아직 좀 미스터리... ㅡㅡ;

GC

개발자가 직접 관리하는 메모리는 깨짐의 원인을 만들기 때문에, 이를 해결하고자 하는 것은 오래된 프로그래머들의 숙명일 겁니다. 이를 위해 더 엄격한 문법으로 개발자를 휘어잡거나(ex: Rust), 아니면 GC를 사용하여 알아서 메모리를 관리를 하는 방법이 있습니다.

아무튼, Go는 앞서 이야기했듯 성능 우선의 프로그램 언어라고 말씀드렸는데, 그러한 가치관이 GC에도 반영되었을까요? 이에 앞서 Go의 GC 특징부터 알아보면...

  • Non-generational GC (비세대 GC)
  • Non-compaction GC (비압축형 GC)

이 두가지의 특징이 있습니다. 하나하나씩 알아보면...

Non-generational GC

보통 세대별 GC를 쓰는 이유는 “대부분 새로 생성된 객체는 더 오래된 객체에 비해서 일찍 죽는다"라는 가정을 하기 때문입니다. 실제로도 for loop 같은데에서 생기는 임시 객체들은 빨리 사라지기 마련이죠. 그런데 Go는 왜? Why? 쓰지 않는걸까요?

이유는, 세대별 GC에서 발생하는 고질적인 문제, Write barrier에 대한 오버헤드를 허용할 수 없었기 때문입니다.

YG/OG를 따로 분리하여 세대별로 GC를 수행한다는 발상까지는 좋았으나, 위 그림처럼 YG GC(=Minor GC)를 수행하게 되면 Dangling pointer이 생길 수 있습니다. 이 때문에 OG가 참조하는 경우 Write barrier을 켜게 되고, 뭐 Card table에 기록을 하게 되고 확인을 하게 되고 어쩌구저쩌구... 아무튼 복잡합니다! Go 개발자들은 복잡한 걸 무척이나 싫어합니다. 복잡하면서 오버헤드까지 있는 걸 견디지 못해서 빼버리기로 합니다.

하지만 세대별 GC가 주는 장점은 그럼에도 강력할 수 있습니다. 따라서 Go는 아예 단기간 할당의 경우 힙에 메모리 할당하는 것을 최대한 지양하고자 했고, 이를 컴파일 타임에 이스케이프 분석(Escape Analysis)을 통해 가능케 했습니다!

전혀 다른 방식으로 훨씬 더 간단하게 문제를 해결했다는 점이 대단하네요.

Non-compaction GC

메모리 컴팩션은 메모리 단편화를 막기 위해 메모리를 재배치하는 과정입니다. 아마 GC중에서 제일 비싼 비용 중 하나일 겁니다. 그런데, Go는 과감하게 이를 포기했습니다. 그래서, 아래와 같이 GC를 하게 되면 파편화 된 흔적이 그대로 남습니다.

이러한 설계를 가능케 한 것은 tcmalloc 메모리 할당자를 도입했기 때문이라고 합니다. 큰 메모리와 작은 메모리에 대해서 다른 할당을 해서 어쩌구 하는데, 그냥 메모리 할당할때부터 파편화를 최소화하는 구조라고 볼 수 있을 듯 합니다.

이러한 설계를 통해서, Go는 STW가 작동하더라도 부담을 최소화 하는 방식으로 GC가 작동하게끔 만들어졌습니다. 파라미터 설정도 쉽고요.

그럼에도 발생하는 문제

이렇게 훌륭한 설계를 가졌지만, GC가 제대로 동작하지 않는 문제가 있을 수 있습니다. 아래는 Ravelin Tech blog 원문을 그대로 가지고 왔습니다. (좀 특이한 케이스이긴 한데, 재밌어 보여서 ㅎㅎ)


최근의 이슈였던 것 중 하나는, Go로 만든 프로세스가 시가닝 갈수록 메모리를 계속해서 차지하고 있다는 것이었습니다. 메모리 누수인 것으로 추정되었고, 그래서 문제를 찾아보기로 했죠. 일단 go heap profiler를 붙였는데, 정작 상황을 보면 메모리 누수가 되고 있는 것처럼 보이는 부분은 없어 보였습니다. 하지만 top  ps 에서 보여지는 메모리 점유율보다 훨씬 적게 표시되고 있는 것도 이상한 점이었죠. 이거 아무래도 mmap syscall를 통해 불린 heap allocation이 아닌가 의심하며 골머리를 앓기 시작했습니다. 정말로 그러한 괴상한 코드 때문에 문제가 발생한 것인지를 다시 한번 확인하기 위해, runtime.ReadMemStats 를 호출해 보았고, 다행히도 여기서는 OS에서 확인되는 만큼의 heap 사용량을 보여주고 있었습니다. 그렇다면 문제는 단 하나, Go의 GC였습니다.

그러던 중 메뉴얼로부터 재미있는 사실을 하나 알아냈는데, Heap profile은 GC 최종 결과를 보여주는 반면 ReadMemStats 는 호출 당시의 것을 보여준다는 것이었습니다. 그래서 대체 뭐가 문제였을까요? 혹시 메모리 할당량이 너무 많아 Go의 GC가 메모리 정리 주기에 뒤쳐진다면...? 극한의 상황에서는 그럴 수 있는 것으로 보입니다.

큼지막한 포인터 배열을 할당할 경우, Go의 GC는 그 포인터들 하나하나를 확인하기 위해서 CPU를 마구마구 써버릴 수 있고, 그런 경우에는 GC가 제대로 동작하지 않게 됩니다. 그러한 경우에는 두 가지 방법이 있을 텐데...

  1. 큼지막한 메모리 할당할 때는 포인터로 할당하지 마세요
  2. 아니면, mmap syscall을 이용해서 Go의 GC가 이를 추적하지 못하도록 만들도록 하세요.

실제로 12GB 가량의 포인터 배열을 만들어놓고, 동시에 힙 메모리를 잔뜩 할당하는 코드를 돌리면 메모리가 사정없이 누수가 되는 것을 확인할 수 있습니다. (직접 사이트 들어가서 예시 코드를 확인해 보세요!)

최적화된 코드 작성하기

위 사항을 알면, Go에 최적화된 코드를 작성하는 법을 알 수가 있습니다.

  • 스택에 메모리가 할당될 수 있도록 하기
    이건 C++ 쓸 때부터 내려오는 전통이죠 ㅎㅎ... Go에서는 이를 자동으로 해준다지만 그래도 알아두면 좋습니다.
  • (가능하다면) 배열 내 포인터 사용을 지양하기
    위에서처럼 거대한 포인터 배열을 사용할 경우 GC에 불필요한 부담을 줄 수 있습니다.

글을 마치며...

어떤 언어나 프레임워크의 성능을 최대한으로 사용하기 위해서는, 그 언어/프레임워크의 내부 구현을 알 필요가 있습니다. 저는 위 경우들에 대해서 Go가 성능 측면에서 어떻게 접근하고 대처하고 있는지 궁금했고, 그래서 찾아본 내용들은 제 궁금증을 많이 해소해 주었습니다.

Golang이 제공하는 자유도와 그럼에도 유지되는 성능은 놀라운 수준입니다. 백엔드 개발언어로서 신흥 강자로 도래하고 있는 이유가 다 있네요.

참조

 

'개발 > Engineering' 카테고리의 다른 글

Django는 과연 무거운가?  (0) 2022.04.23
Kafka는 어떻게 고성능을 달성하는가?  (0) 2022.04.23
Address Sanitizer과 그 원리  (0) 2022.04.09
Rolling Update  (0) 2022.03.13
빅데이터 & 분산 시스템 시대에서의 RDBMS  (0) 2022.03.12