Go
고친 사람 github-actions[bot]
Go 는 Google 이 큰 서버 프로그램을 짜려고 만든 프로그래밍 언어입니다. 코드를 미리 컴파일해서 서버에 올릴 실행 파일 하나로 내놓습니다. 여러 일을 동시에 돌리는 장치를 언어가 직접 품고 있습니다.
쉽고 빠른 이해
Go 는 서버 프로그램을 실행 파일 하나로 만들어 주는 언어입니다. Java 처럼 서버에 가상 머신을 먼저 깔지 않아도 그 파일만 올리면 돕니다.
Google 에서 큰 서버 코드는 한 번 빌드하는 데 수십 분이 걸렸습니다. 여러 흐름을 동시에 다루는 코드는 어렵다는 평을 오래 들었습니다. 그래서 빌드가 짧고 동시 처리를 언어가 맡는 언어를 새로 만들었습니다.
- 함수 앞에
go를 붙이면 그 함수가 고루틴이라는 작은 실행 단위로 따로 돕니다 - 실행 파일 안에 함께 든 라이브러리가 고루틴 여럿을 운영체제 스레드 몇 개에 나눠 돌립니다
- 고루틴끼리는 채널이라는 통로로 값을 넘기며 순서를 맞춥니다
대가도 있습니다. 메모리를 가비지 컬렉터가 거두느라 계산 시간을 나눠 씁니다. 실행 파일은 C 로 만든 것보다 큽니다. 예외가 없어서 오류는 반환값으로 받아 직접 확인합니다.
Discord 의 한 서비스는 가비지 컬렉션이 부르는 지연 때문에 Go 를 떠났습니다.
상세
Go 가 어떤 물건인지는 여섯 특징으로 드러납니다. 실행 파일과 런타임 · 고루틴 · 채널 · 가비지 컬렉션 · 오류 · 인터페이스 순서로 봅니다. 견주는 상대는 백엔드 개발자에게 익숙한 Java 입니다.
Go 는 Google 에서 만든 정적 타입 컴파일 언어입니다. 정적 타입이란 변수와 식의 타입이 컴파일할 때 정해진다는 뜻입니다. 범용 언어이지만 시스템 프로그래밍을 염두에 두고 설계했습니다. 동시 실행은 언어가 직접 지원합니다. 오픈 소스 프로젝트입니다. Google 도 공개 저장소의 코드를 가져다 씁니다.
언어 이름은 Go 입니다. golang 이라는 별칭은 공식 웹사이트 주소가 처음에 golang.org 였던 데서 왔습니다. 소셜 미디어 해시태그가 #golang 인 것처럼 꼬리표로는 이 별칭을 많이 씁니다.
실행 파일과 그 안에 든 런타임
Go 컴파일러는 소스 코드를 실행하기 전에 미리 기계어로 옮깁니다. 기계어는 CPU(Central Processing Unit, 중앙 처리 장치)가 바로 읽는 명령입니다. 일부 다른 구현은 JavaScript 나 WebAssembly 로 옮기기도 합니다.
Java 는 사정이 다릅니다. Java 컴파일러가 내놓는 바이트코드는 CPU 가 아니라 JVM(Java Virtual Machine, 자바 가상 머신)이 읽는 중간 명령입니다. 그래서 서버에 JVM 이 먼저 깔려 있어야 합니다.
Go 에도 런타임이 있습니다. 다만 Go 에서 런타임은 가상 머신이 아니라 라이브러리 하나의 이름입니다. 가비지 컬렉션 · 동시 실행 · 스택 관리처럼 언어가 기대는 기능을 이 라이브러리가 구현합니다. C 의 표준 라이브러리인 libc 와 비슷한 위치입니다. 모든 Go 프로그램에 들어갑니다.
Go 팀이 내는 기본 도구 묶음을 gc 툴체인이라고 부릅니다. 이 툴체인의 링커는 기본으로 정적 링크를 합니다. 정적 링크는 프로그램이 쓰는 라이브러리를 실행 파일 안에 미리 합쳐 넣는 방식입니다. 그래서 모든 Go 실행 파일에 런타임이 들어 있습니다.
런타임 옆에는 타입 정보도 들어갑니다. 실행 중의 타입 검사와 리플렉션이 이 정보를 씁니다. 리플렉션은 프로그램이 실행 중에 자기 값의 타입을 들여다보는 기능입니다. 프로그램이 터질 때 찍는 호출 내역, 곧 스택 추적도 이 정보를 씁니다.
flowchart TD
subgraph E["Go 실행 파일 하나"]
A["내 코드 · 기계어"]
B["가져다 쓴 라이브러리"]
C["런타임 · 가비지 컬렉션 · 동시 실행 · 스택 관리"]
D["타입 정보 · 디버깅 정보"]
end
E --> S["서버에 올리면 가상 머신 없이 돈다"]
그림은 실행 파일 하나에 무엇이 함께 들어가는지를 보입니다. 서버에 따로 깔 것이 없는 대신 파일이
커집니다. Linux 에서 gcc 로 정적 링크한 C 의 「hello, world」는 printf 구현까지 넣어 약 750 kB
입니다. 같은 일을 fmt.Printf 로 하는 Go 프로그램은 몇 메가바이트입니다. 더 많은 런타임 기능과
타입 · 디버깅 정보가 들어간 크기입니다.
링크할 때 -ldflags=-w 를 주면 디버깅 정보가 빠집니다. 그 밖의 기능은 잃지 않고 파일 크기를
많이 줄입니다.
고루틴
한 프로그램 안에서 여러 일을 겹쳐 진행하는 것을 동시성이라고 합니다. 서버가 요청 여러 개를 한꺼번에 처리하려면 동시성이 있어야 합니다. 보통은 운영체제가 주는 스레드로 이것을 얻습니다.
Go 는 여기에 고루틴을 둡니다. 고루틴은 같은 주소 공간에서 다른 고루틴과 동시에 실행되는
함수입니다. 주소 공간은 한 프로그램이 쓰는 메모리 범위라서, 고루틴끼리 같은 변수를 볼 수 있습니다.
함수를 부를 때 앞에 go 를 붙이면 그 함수가 새 고루틴으로 돕니다.
고루틴이 싼 까닭은 스택에 있습니다. 스택은 함수를 부를 때마다 지역 변수와 돌아갈 곳을 쌓아 두는 메모리 구역입니다. 새 고루틴은 몇 킬로바이트짜리 스택으로 시작합니다. 대개 그것으로 충분합니다. 모자라면 런타임이 스택을 자동으로 늘리고 줄입니다.
이 방식이 함수 호출마다 더하는 CPU 비용은 평균 값싼 명령 세 개 정도입니다. 그래서 한 주소 공간에 고루틴을 수십만 개 만드는 것이 현실적입니다. 고루틴이 그냥 스레드였다면 그보다 훨씬 적은 수에서 시스템 자원이 바닥납니다.
고루틴은 운영체제 스레드와 일대일로 붙지 않습니다. 런타임이 고루틴 여럿을 스레드 몇 개 위에서 나눠 돌립니다.
flowchart TD
subgraph G["고루틴 — 수가 많다"]
G1["고루틴 1"]
G2["고루틴 2"]
G3["고루틴 3"]
G4["고루틴 4"]
end
R["Go 런타임"]
subgraph T["운영체제 스레드 — 수가 적다"]
T1["스레드 1"]
T2["스레드 2"]
end
G1 --> R
G2 --> R
G3 --> R
G4 --> R
R --> T1
R --> T2
그림은 고루틴 넷이 스레드 둘 위에서 도는 모습입니다. 어느 고루틴을 어느 스레드에서 돌릴지는 런타임이 정합니다.
시스템 호출은 파일 읽기나 네트워크 수신처럼 프로그램이 운영체제에 일을 맡기는 호출입니다. 한 고루틴이 막히는 시스템 호출을 부르면 그 스레드는 호출이 끝날 때까지 멈춥니다. 그러면 런타임이 그 스레드에 있던 다른 고루틴을 돌 수 있는 스레드로 옮깁니다. 코드를 쓰는 사람은 이 옮김을 보지 않습니다.
채널
고루틴끼리 값을 넘길 때는 채널을 씁니다. 채널은 한 고루틴이 값을 넣고 다른 고루틴이 꺼내는 통로입니다.
동시성과 다중 스레드 프로그래밍은 오래전부터 어렵다는 평을 얻었습니다. 그 평의 원인으로는 둘이 꼽힙니다. pthreads 같은 복잡한 설계, 그리고 뮤텍스 · 조건 변수 · 메모리 배리어 같은 낮은 수준의 세부에 무게가 너무 쏠린 것입니다. 채널처럼 한 단계 높은 장치를 두면 밑에 뮤텍스가 그대로 있어도 코드는 훨씬 단순해집니다.
이 설계는 CSP(Communicating Sequential Processes)에서 왔습니다. CSP 는 Hoare 가 세운 동시성 모델입니다. Erlang 과 Occam 도 이 계열입니다. Go 는 그 가운데 채널을 일급 객체로 다루는 갈래를 따랐습니다. 일급 객체란 변수에 담고 함수 인자로 넘길 수 있는 값이라는 뜻입니다.
여러 흐름이 같은 변수를 동시에 고치면 값이 엉킵니다. 이것을 데이터 경쟁이라고 합니다. 흔히는 뮤텍스로 잠가서 한 번에 한 흐름만 그 변수를 만지게 합니다.
채널 방식은 공유할 값을 채널로 넘깁니다. 그러면 어느 순간에도 그 값을 가진 고루틴이 하나뿐입니다. 이 방식을 따르는 한 데이터 경쟁은 생기지 않습니다. Go 는 이것을 「메모리를 공유해서 통신하지 말고, 통신해서 메모리를 공유하라」라는 구호로 줄여 부릅니다.
이 방식도 끝까지 밀 일은 아닙니다. 참조 횟수를 세는 정수 하나라면 뮤텍스로 감싸는 쪽이 맞을 수 있습니다. 큰 줄기에서 채널로 접근을 다스리면 맞게 도는 프로그램을 알아보기 쉽게 쓸 수 있다는 것이 Go 의 입장입니다.
채널은 언제 기다리게 하나로 두 종류가 갈립니다.
| 채널 | 보내는 쪽이 멈추는 때 | 받는 쪽이 멈추는 때 |
|---|---|---|
| 버퍼 없음 | 받는 쪽이 값을 받을 때까지 | 받을 값이 올 때까지 |
| 버퍼 있음 | 값이 버퍼에 복사될 때까지. 버퍼가 차 있으면 누가 하나 꺼낼 때까지 | 받을 값이 올 때까지 |
버퍼 없는 채널은 값을 주고받는 일과 두 고루틴의 순서를 맞추는 일을 한 번에 합니다.
sequenceDiagram
participant 보내는 고루틴
participant 채널
participant 받는 고루틴
보내는 고루틴->>채널: 값을 넣는다
Note over 보내는 고루틴: 받는 쪽이 받을 때까지 멈춘다
받는 고루틴->>채널: 값을 꺼낸다
채널-->>받는 고루틴: 값을 건넨다
Note over 보내는 고루틴,받는 고루틴: 두 고루틴이 서로 어디까지 왔는지 안다
가비지 컬렉션
Go 에는 할당한 메모리를 직접 푸는 연산이 없습니다. 메모리는 가비지 컬렉터를 거쳐서만 돌아갑니다. 가비지 컬렉터는 더 쓰이지 않는 메모리를 찾아 돌려받는 런타임의 부품입니다. 이렇게 정한 까닭은 아래 「포기한 것」 절이 다룹니다.
지금 구현은 마크 앤 스윕 방식입니다. 아직 쓰이는 객체에 표시를 합니다. 표시가 없는 객체는 쓸어 담습니다. 기계에 코어가 여럿이면 컬렉터는 본 프로그램과 다른 코어에서 나란히 돕니다. 코어는 CPU 안에서 명령을 처리하는 단위입니다.
힙은 실행 중에 할당한 객체가 사는 메모리 구역입니다. Go 의 컬렉터는 일 대부분을 프로그램과 함께 돌립니다. 프로그램 전체를 세우는 멈춤(stop-the-world)은 짧게만 둡니다. 그 멈춤의 길이가 힙 크기에 비례하지 않게 했습니다. 최근 몇 해의 작업으로 멈춤은 큰 힙에서도 1밀리초 아래로 줄어든 경우가 많습니다.
오류는 반환값
Java 는 실패를 예외로 던지고 try-catch 로 받습니다. Go 에는 오류 처리에 딸린 제어 구조가
없습니다. 대신 함수가 값을 여러 개 돌려줄 수 있어서, 정상 결과 옆에 오류 값을 하나 더 돌려줍니다.
오류 값의 타입은 error 라는 내장 인터페이스입니다. 문자열을 돌려주는 메서드 하나만 요구합니다.
type error interface {
Error() string
}
파일을 여는 os.Open 이 그 보기입니다. 실패하면 빈 포인터만 돌려주지 않고, 무엇이 잘못됐는지 적은
error 값을 같이 돌려줍니다. 부른 쪽은 이 값이 비었는지 보고 다음을 정합니다.
정말 더 갈 수 없는 상황에는 내장 함수 panic 을 씁니다. 슬라이스 범위를 벗어난 인덱스처럼 실행
중에 나는 오류도 panic 을 일으킵니다. defer 는 함수가 끝날 때 실행하도록 미뤄 두는 문장입니다.
panic 이 나면 지금 함수가 바로 멈춥니다. 그리고 그 고루틴의 호출 스택을 거꾸로 풀며
올라갑니다. 올라가는 길에 defer 로 미뤄 둔 함수를 차례로 실행합니다. 스택 꼭대기까지 풀리면
프로그램이 죽습니다.
recover 는 이 풀림을 멈추고 panic 에 넘긴 값을 돌려받는 내장 함수입니다. 풀리는 동안 도는 코드는
defer 로 미룬 함수뿐이라 recover 도 그 안에서만 쓸모가 있습니다. 서버에서 실패한 고루틴 하나를
다른 고루틴은 살려 둔 채 끝낼 때 씁니다.
flowchart TD
P["panic 이 난다"] --> S["지금 함수가 멈춘다"]
S --> D["미뤄 둔 defer 함수를 실행한다"]
D --> Q{"defer 함수 안에서 recover 를 불렀나"}
Q -->|예| C["풀림이 멈추고 그 값을 돌려받는다"]
Q -->|아니오| U{"고루틴 스택 꼭대기인가"}
U -->|아니오| S2["부른 함수로 올라간다"]
S2 --> S
U -->|예| X["프로그램이 죽는다"]
선언 없이 맞춰지는 인터페이스
인터페이스는 「이런 메서드를 가진 타입」에 이름을 붙인 약속입니다. Java 클래스는 implements 로
어느 인터페이스를 따르는지 미리 선언합니다. Go 에는 그 선언이 없습니다. 어떤 타입이 인터페이스가
요구하는 메서드를 다 가지면, 그것만으로 그 인터페이스를 만족합니다.
표준 라이브러리의 io.Writer 가 대표입니다. 메서드 Write 하나만 요구합니다.
type Writer interface {
Write(p []byte) (n int, err error)
}
Write 는 p 의 바이트를 밑에 있는 데이터 흐름에 씁니다. 그리고 쓴 바이트 수와, 쓰기를 도중에
멈추게 한 오류를 돌려줍니다. 이 모양의 Write 를 가진 타입이면 무엇이든 Writer 로 쓰입니다.
그래서 fmt.Fprintf 는 파일만이 아니라 어떤 출력으로든 서식을 맞춰 찍습니다. 표준 라이브러리의
bufio 패키지도 파일 입출력과 완전히 떨어져 설 수 있습니다. 둘 다 받는 것이 Writer 하나라서,
그 뒤가 파일인지 아닌지 몰라도 되기 때문입니다.
이렇게 메서드 모양만 맞으면 통하는 방식을 구조적 타이핑이라고 부릅니다. 원래 타입에 손대지 않고 나중에 인터페이스를 새로 세울 수 있습니다. 테스트용 인터페이스를 뒤에 붙이는 것도 이렇게 합니다.
포기한 것
Go 는 프로그래밍의 편함 · 컴파일 속도 · 개념끼리 겹치지 않음 · 동시성과 가비지 컬렉션 지원을 염두에 두고 설계했습니다. Go 에 없는 기능은 언어에 안 맞거나, 컴파일 속도나 설계의 명료함을 해치거나, 프로그램이 기계 위에서 도는 기본 틀을 너무 어렵게 만들어서 빠졌을 수 있습니다. 아래는 Java 개발자가 먼저 부딪히는 셋입니다. 예외 · 타입 계층 · 손으로 푸는 메모리 해제입니다.
예외
Go 에는 try-catch-finally 같은 예외 처리 구조가 없습니다. 예외를 제어 구조에 묶으면 코드가
꼬인다고 Go 팀은 봅니다. 파일을 못 여는 것처럼 흔한 오류까지 「예외적인 일」로 분류하게 부추기는
경향이 있다는 것도 이유입니다.
대신 얻은 것은 보통의 반환값으로 흐르는 오류입니다. 함수가 값을 여러 개 돌려주므로 반환값 하나에 결과와 오류를 겹쳐 싣지 않아도 됩니다.
정말 예외적인 상황을 위한 panic 과 recover 는 남겼습니다. 다만 일부러 투박하게 만들었습니다.
그래서 드물게 씁니다. Java 라이브러리가 예외를 쓰는 식으로 표준 라이브러리에 녹아 있지도 않습니다.
타입 계층
Go 에는 타입 상속이 없습니다. 객체 지향 언어는 흔히 타입 사이의 관계를 따지는 데 품이 많이 듭니다. 그런데 그 관계는 대개 자동으로 끌어낼 수 있다고 봅니다. 그래서 두 타입이 관계있다고 미리 선언하게 하는 대신, 메서드가 맞으면 인터페이스를 저절로 만족하게 했습니다.
| 안 하기로 한 것 | 대신 얻은 것 |
|---|---|
| 클래스 계층과 상속 선언 | 한 타입이 여러 인터페이스를 한꺼번에 만족한다. 전통적인 다중 상속의 복잡함은 없다 |
| 타입과 인터페이스 사이의 명시적 관계 | 관리하거나 따질 타입 계층이 없다. 원래 타입에 표시하지 않고 인터페이스를 나중에 더한다 |
| 클래스 안에만 붙는 메서드 | 구조체가 아닌 값에도 메서드를 붙인다. 내장 정수 같은 값도 된다 |
상속 대신 한 타입을 다른 타입 안에 끼워 넣는 임베딩이 있습니다. 서브클래싱과 비슷한 효과를 내지만 같지는 않습니다. 이 암묵적인 타입 의존 방식에는 익숙해지는 시간이 듭니다.
손으로 푸는 메모리 해제
Go 는 객체의 수명을 가비지 컬렉션 하나로만 다룹니다. 메모리를 손으로 관리하는 C 에서는 그 일이 개발 시간을 크게 먹을 수 있습니다. 고약한 버그의 원인이 되는 일도 잦습니다. 이를 돕는 장치를 둔 C++ 이나 Rust 도 그 장치가 소프트웨어 설계에 큰 영향을 줄 수 있습니다. 그 나름의 프로그래밍 부담을 더하는 일도 잦습니다.
동시성 코드에서는 더 어렵습니다. 객체가 스레드 사이를 오가면 언제 풀어도 안전한지 보장하기 번거롭습니다. 가비지 컬렉션이 있으면 이 문제를 프로그램마다 풀지 않고 런타임에서 한 번 풉니다. 함수끼리 메모리를 누가 풀지 약속하지 않아도 되므로 인터페이스도 단순해집니다.
대가는 셋입니다. 수집이 늘 얹는 부담 · 수집이 끼어들며 생기는 지연 · 구현의 복잡함입니다. 이득은 주로 프로그래머가 봅니다. 비용은 대부분 언어를 구현하는 쪽이 진다는 것이 Go 쪽 판단입니다. 프로그램을 돌리는 쪽에서는 아래 값으로 드러납니다.
| 비용 | 내용 |
|---|---|
| 처리량 | 프로그램과 함께 수집하는 설계는 같은 조건의 stop-the-world 수집기보다 처리량이 낮은 경우가 많다 |
| CPU 몫 | 마크 단계 동안 컬렉터가 CPU 자원의 25% 를 가져간다 |
| 할당이 잦을 때 | 사용자 고루틴이 컬렉터의 일을 거든다 |
| 짧은 멈춤 | 마크와 스윕 단계가 바뀔 때 프로그램 전체가 잠깐 선다 |
예시
Go 를 서비스에 쓴 곳은 넷을 봅니다. Uber 와 Cloudflare 는 Go 를 고른 이유를 스스로 밝혔습니다. Docker 는 Go 로 짰다는 사실만 있습니다. Discord 는 Go 로 짠 서비스를 다른 언어로 옮겼습니다.
Uber 지오펜스 조회 서비스
Uber 는 2015년 초 지오펜스 조회 하나만 하는 마이크로서비스를 Go 로 지었습니다. 지오펜스는 지도 위에 그은 구역 경계입니다. 조회는 한 지점이 어느 구역 안에 드는지 찾는 일입니다. 한 해 뒤 이 서비스는 Uber 가 운영하는 수백 개 서비스 가운데 초당 질의 수(QPS, Queries Per Second)가 가장 많았습니다.
당시 그 팀의 주 언어는 Node.js 였습니다. 그쪽 경험도 더 많았습니다. 그래도 Go 를 고른 이유는 셋입니다.
| 요구 | 내용 |
|---|---|
| 높은 처리량과 낮은 지연 | 모바일 앱의 모든 요청마다 조회한다. 초당 수십만 건을 99번째 백분위 100밀리초 안에 답해야 한다 |
| CPU 를 많이 쓰는 계산 | 한 점이 다각형 안에 드는지 가리는 계산이다. Node.js 는 입출력 위주인 다른 서비스에는 아주 잘 맞지만, 인터프리터 방식에 동적 타입이라 이 용도에는 최적이 아니었다 |
| 조회를 막지 않는 백그라운드 갱신 | 메모리 속 지오펜스를 여러 데이터 소스에서 계속 새로 읽어야 한다. 단일 스레드인 Node.js 에서는 JSON(JavaScript Object Notation) 파싱 같은 갱신이 CPU 를 오래 잡을 수 있다. 그러면 응답 시간이 튄다. Go 는 고루틴이 여러 코어에서 돌아 갱신과 조회를 나란히 한다 |
99번째 백분위 100밀리초는 요청 100개 가운데 99개가 100밀리초 안에 끝난다는 뜻입니다.
결과도 값으로 남겼습니다. 2015년 마지막 날 밤 중국 밖 트래픽을 받는 주 데이터센터 한 곳에서 가장 많을 때 17만 QPS 를 기계 40대가 CPU 사용률 35% 로 처리했습니다. 응답 시간은 95번째 백분위 5밀리초 미만, 99번째 백분위 50밀리초 미만이었습니다. 처음부터 가동률은 99.99% 였습니다. 멈춘 일은 초보적인 프로그래밍 실수와 서드파티 라이브러리의 파일 디스크립터 누수 때문이었습니다. Go 런타임 문제는 없었습니다.
Uber 쪽 경험으로는 C++ · Java · Node.js 개발자가 Go 를 배우는 데 대개 며칠이면 됐습니다.
Cloudflare Railgun
2012년 당시 Cloudflare 는 핵심 서비스를 전부 C 나 C++ 로 짰습니다. Railgun 은 달랐습니다. 약 4,000줄의 Go 입니다. 그중 주석이 아닌 코드가 약 3,000줄입니다.
Railgun 클라이언트 하나는 Cloudflare 데이터센터에서 오는 콘텐츠 요청을 많이 받아 인터넷 연결 위로 묶어 보내야 합니다. 일 자체가 동시성이 높아서 Go 를 골랐습니다. 고루틴이 묶인 인터넷 연결을 맡습니다. 원본 웹 서버에서 콘텐츠를 가져와 nginx 에 돌려주는 연결도 맡습니다. 고루틴과 채널을 많이 씁니다.
고른 이유가 하나 더 있습니다. 고객에게 나눠 줄 실행 파일이 하나로 나옵니다. 복잡한 의존성 사슬이나 공유 라이브러리 배치를 걱정할 필요가 없습니다. 위 「상세」의 정적 링크가 배포에서 이렇게 드러납니다.
Docker
Docker 는 Go 로 짰습니다. 기능을 내는 데 Linux 커널의 여러 기능을 씁니다. Go 를 고른 이유는 자료에 없습니다(미확인).
Discord Read States — Go 를 떠난 서비스
Discord 의 Read States 서비스는 Go 로 짜여 있었습니다. 대부분은 빨리 답했지만 몇 분마다 지연이 크게 튀었습니다. 조사한 Discord 팀은 원인을 Go 의 메모리 모델과 가비지 컬렉터로 짚었습니다. 지연과 CPU 사용이 대략 2분마다 함께 튀었습니다.
Discord 팀은 Go 소스를 읽고 이렇게 결론지었습니다. 힙이 얼마나 늘었든 Go 는 적어도 2분에 한 번 가비지 컬렉션을 강제로 돌린다는 것입니다. 이 2분 주기는 Go 쪽 문서로는 확인하지 못했습니다(미확인).
Go 에는 새로 할당한 양이 지난 수집 뒤 살아남은 양의 몇 퍼센트에 이르면 수집을 시작하는 설정이
있습니다(아래 「운영」의 GOGC). 수집을 더 자주 돌려 튐을 줄이려고, 팀은 실행 중에 이 퍼센트를
바꾸는 엔드포인트를 만들었습니다. 어떻게 바꿔도 그대로였습니다. 수집을 앞당길 만큼 메모리를 빨리
할당하지 않았기 때문입니다. 튐이 큰 까닭은
버릴 메모리가 많아서가 아니었습니다. 컬렉터가 서비스의 LRU(Least Recently Used) 캐시 전체를 훑어야
했기 때문입니다. LRU 캐시는 가장 오래 안 쓴 항목부터 버리는 캐시입니다. 팀은 캐시를 작게 여럿으로
나눴고, 끝내 2019년 5월에 Rust 로 옮겼습니다.
이 이야기는 Go 1.9.2 에서 잰 것입니다. 1.8 · 1.9 · 1.10 도 써 봤지만 나아지지 않았다고 합니다. 2020년 2월 글이라 지금 판의 가비지 컬렉터와는 다를 수 있습니다.
배경
Go 가 설계될 무렵 Google 에서 서버를 짜는 언어는 주로 C++ 과 Java 였습니다. 서버 프로그램은
수천만 줄이었습니다. 수백에서 수천 명이 매일 고쳤습니다. 특히 빌드가 길었습니다. C++ 은
#include 로 헤더 파일을 끌어옵니다. 그래서 프로그램 하나를 빌드하며 헤더 파일 수백 개를 수만
번씩 열어 읽기도 했습니다. 2007년 Google 의 한 주요 C++ 프로그램을 재 보니 아래와 같았습니다.
| 항목 | 값 |
|---|---|
| 소스 파일 | 약 2,000개 |
| 이어 붙인 크기 | 4.2메가바이트 |
#include 를 펼친 뒤 컴파일러에 들어간 입력 |
8기가바이트 넘게. 소스 1바이트마다 2,000바이트 |
| 분산 빌드 시스템으로 빌드한 시간 | 45분. 앞 세대 분산 빌드 시스템에서 잰 값이다. 같은 프로그램의 2012년 판은 27분이다 |
언어 선택도 막혀 있었습니다. 효율적인 컴파일 · 효율적인 실행 · 쉬운 프로그래밍을 한 주류 언어에서 다 얻을 수 없었습니다. 그래서 일부 개발자는 Python · JavaScript 같은 동적 타입 언어로 옮기며 안전성과 효율을 내줬습니다. 게다가 C · C++ · Java 는 다중 코어와 네트워크가 흔해지기 전에 설계된 언어였습니다. 필요한 것은 동적 언어처럼 쓰기 쉬운 언어였습니다. 동시에 정적 타입 컴파일 언어만큼 효율적이고 안전해야 했습니다. 네트워크와 다중 코어에 맞고, 큰 실행 파일도 컴퓨터 한 대에서 몇 초 안에 빌드돼야 했습니다.
이 목표에서 계층 대신 조합으로 짓는 타입 시스템 · 동시성과 가비지 컬렉션 지원 · 의존성을 빠듯하게 못 박는 규칙이 나왔습니다. 쓰지 않는 변수나 import 가 있으면 컴파일을 거부하는 것이 그 규칙의 한 보기입니다. 쓰지 않는 import 는 컴파일을 느리게 합니다. 그 느려짐은 코드와 사람이 쌓일수록 커집니다. 눈앞의 편함을 내주고 빌드 속도와 프로그램의 명료함을 받은 것입니다. 라이브러리나 도구로는 풀 수 없어 새 언어가 필요했다는 것이 결론이었습니다. Go 가 그 45분짜리 빌드를 기다리던 중에 구상됐다는 이야기가 전해집니다. Robert Griesemer · Rob Pike · Ken Thompson 이 시작한 이 언어는 2009년 11월 오픈 소스로 공개됐습니다.
운영
Go 서버를 띄울 때 만지는 런타임 환경 변수는 넷입니다. 대상은 실행 파일 안에 든 런타임입니다. 보는 것은 각 변수의 기본값과 그 값이 정하는 동작입니다. 모든 Go 프로그램이 이 변수를 읽습니다. 다만 뜻과 쓰임은 판마다 바뀔 수 있습니다.
| 환경 변수 | 정하는 것 | 기본값 |
|---|---|---|
GOGC |
다음 가비지 컬렉션(아래에서는 수집) 전까지 힙이 얼마나 자라도 되나 | 100 |
GOMEMLIMIT |
런타임이 쓰는 메모리의 느슨한 상한 | math.MaxInt64 · 사실상 상한 없음 |
GOMAXPROCS |
Go 코드를 동시에 실행하는 운영체제 스레드 수 | 논리 CPU 수 · CPU 친화도 마스크 · 컨테이너 CPU 상한 가운데 가장 작은 쪽 |
GODEBUG=gctrace=1 |
수집할 때마다 한 줄씩 기록 | 켜야 나온다 |
GOGC
GOGC 는 수집의 처음 목표 퍼센트입니다. 지난 수집 뒤 살아남은 데이터에 견줘 새로 할당한 데이터가
이 퍼센트에 이르면 다음 수집이 시작됩니다. 실행 중에 바꾸려면 runtime/debug.SetGCPercent 를
부릅니다. 목표 힙 크기는 이렇게 정해집니다.
목표 힙 = 살아 있는 힙 + (살아 있는 힙 + 수집의 출발점) × GOGC / 100
수집의 출발점은 고루틴 스택과 전역 변수 안의 포인터입니다. 살아 있는 힙이 8 MiB, 고루틴 스택이 1 MiB, 전역 변수의 포인터가 1 MiB 인 프로그램을 보면 이렇습니다. MiB 는 2의 20제곱 바이트입니다.
GOGC |
다음 수집 전에 새로 할당하는 양 |
|---|---|
| 50 | 5 MiB |
| 100 | 10 MiB · 힙 전체로는 18 MiB |
| 200 | 20 MiB |
GOGC 를 두 배로 하면 힙 메모리 부담이 두 배가 됩니다. 수집에 드는 CPU 비용은 대략 절반이 됩니다.
GOGC=off 는 수집을 끕니다. 메모리 상한이 걸리지 않을 때의 이야기입니다. 힙 전체 크기는 최소
4 MiB 라서 목표가 그보다 작으면 4 MiB 로 올려 잡습니다.
GOMEMLIMIT
GOMEMLIMIT 는 런타임이 쓰는 메모리 전체에 거는 느슨한 상한입니다. Go 1.19 에 들어왔습니다.
Go 힙과 런타임이 관리하는 메모리는 들어갑니다. 실행 파일 자체의 매핑 · 다른 언어가 관리하는
메모리 · 운영체제가 Go 프로그램 대신 쥔 메모리는 빠집니다. 값은 바이트 수입니다. KiB · MiB ·
GiB 같은 단위를 붙일 수 있습니다.
기본값 math.MaxInt64 는 상한을 사실상 끄는 값입니다. GOGC 만으로 정해지는 메모리 정점보다 상한을
낮게 잡으면 가비지 컬렉터가 더 자주 돕니다.
상한이 지나치게 낮으면 위험합니다. 지킬 수 없는 상한을 맞추려고 수집이 쉬지 않고 돌면 프로그램이 제대로 나아가지 못합니다. 이것을 스래싱이라고 합니다. 힙이 잠깐 크게 튀는 것만으로도 프로그램이 하염없이 설 수 있습니다. 그래서 이 상한은 느슨한 상한으로 정의돼 있습니다. 런타임은 어떤 상황에서도 지킨다고 보장하지 않고, 적당한 노력만 약속합니다.
GOMAXPROCS
GOMAXPROCS 는 사용자 Go 코드를 동시에 실행하는 운영체제 스레드 수의 상한입니다. 시스템 호출로 막혀
있는 스레드는 이 수에 안 셉니다. 실행할 수 있는 고루틴이 1,000개여도 GOMAXPROCS=8 이면 스레드 8개로
8개씩 돌립니다.
cgroup 은 Linux 가 컨테이너에 CPU · 메모리 상한을 거는 장치입니다. CPU 친화도 마스크는 한
프로세스가 돌 수 있는 CPU 를 정해 둔 목록입니다. go.mod 는 그 프로그램을 어느 Go 판으로
빌드하는지 적어 두는 파일입니다. 기본값은 아래처럼 이 셋으로 정해집니다.
flowchart TD
A{"GOMAXPROCS 환경 변수가 양의 정수인가"} -->|예| V["그 값을 쓴다 · 자동 갱신은 꺼진다"]
A -->|아니오| B{"go.mod 의 Go 판이 1.24 이하인가"}
B -->|예| N["runtime.NumCPU 값"]
B -->|아니오| M["논리 CPU 수 · CPU 친화도 마스크 · cgroup CPU 상한 가운데 가장 작은 값"]
M --> P["CPU 수 · 친화도 · 상한이 바뀌면 주기적으로 다시 맞춘다"]
두 번째 「아니오」 쪽 끝 노드가 지금 판의 기본 동작입니다. 이 기본값은 2 보다 작아지지 않습니다.
논리 CPU 수나 CPU 친화도 마스크 자체가 2 아래일 때만 그보다 작아집니다. 환경 변수로 값을 주거나
runtime.GOMAXPROCS 를 부르면 자동 갱신이 꺼집니다.
이 기본값이 중요한 까닭은 스로틀링입니다. 컨테이너에 CPU 상한이 걸려 있는 경우를 봅니다. 그때
GOMAXPROCS 가 기계의 코어 수만큼 크면 프로그램이 상한보다 훨씬 많은 CPU 를 쓰려 들 수 있습니다.
그러면 Linux 커널이 그 컨테이너를 스로틀링합니다. 스로틀링은 그 주기의 남은 시간 동안 프로그램
실행을 통째로 세우는 장치입니다. 주기는 보통 100밀리초입니다. 그래서 꼬리 지연이 크게 늘 수
있습니다. 꼬리 지연은 응답이 가장 늦은 소수 요청이 겪는 지연입니다. 프로그램에 병렬로 돌 일이
적어도 가비지 컬렉션 같은 런타임의 일이 CPU 를 튀게 해 스로틀링을 부를 수 있습니다.
각주 — 컨테이너 CPU 상한을 기본값에 반영하는 동작은 Go 1.25 부터입니다. Go 1.5 부터 1.24 까지는 기본값이 기계의 CPU 코어 수였습니다.
수집을 지켜보는 gctrace
GODEBUG=gctrace=1 을 주면 가비지 컬렉터가 수집할 때마다 표준 오류로 한 줄을 씁니다. 그 줄은 거둔
메모리 양과 멈춘 시간을 요약합니다. 줄의 형식은 바뀔 수 있습니다. 각 칸이 무엇인지와 그 칸에
대응하는 runtime/metrics 지표는 runtime 패키지 문서의 환경 변수 절에 함께 실려 있습니다.
관련 항목
Go 를 이루는 구성 요소
고루틴 · 채널 · 런타임 · 가비지 컬렉션 · 인터페이스 · panic · recover · defer · 제네릭 · 구조적 타이핑 · 타입 임베딩 · 다중 반환값 · gc 툴체인
Go 가 바탕에 두는 개념
컴파일러 · 기계어 · 실행 파일 · 정적 링크 · 동시성 · 스레드 · 예외 · 상속 · 정적 타입 · 주소 공간 · 스택 · 힙 · 시스템 호출 · libc
Go 의 동시성 모델이 뿌리를 둔 이론과 언어
Communicating Sequential Processes · Erlang · Occam · 일급 객체
고루틴이 같은 값을 만질 때 부딪히는 문제와 장치
데이터 경쟁 · 뮤텍스 · 잠금 · 코루틴 · 참조 카운팅
Go 의 가비지 컬렉터를 설명하는 방식과 지표
마크 앤 스윕 · stop-the-world · 스래싱 · 처리량 · 지연
Go 실행 파일에 함께 들어가는 정보
리플렉션 · 스택 추적 · DWARF · 타입 정보
Go 런타임을 조정하는 환경 변수
GOGC · GOMEMLIMIT · GOMAXPROCS · GODEBUG
Go 를 컨테이너에서 굴릴 때 부딪히는 CPU 제한 개념
cgroup · 스로틀링 · 꼬리 지연 · CPU 친화도
같은 일을 두고 겨루는 프로그래밍 언어
Java · Python · Rust · C · C++ · Kotlin · JavaScript
Go 로 짠 제품
Docker · Kubernetes · k6 · Prometheus · Terraform · Railgun
Go 를 서비스에 써 본 회사
Google · Uber · Cloudflare · Discord
Go 의 실행 파일과 맞세워지는 실행 방식
JVM · 바이트코드 · 인터프리터 · 동적 링크 · 가상 머신 · Node.js
Go 가 속하는 분류와 배포 방식
프로그래밍 언어 · 오픈 소스 · 컨테이너 · 크로스 컴파일 · WebAssembly
다른 이름: Go 언어 · Golang · golang · 고랭