Posted On 2026년 02월 15일

Go 런타임의 비밀: main() 함수가 실행되기 전에 일어나는 일

nobaksan 0 comments
여행하는 개발자 >> 기술 >> Go 런타임의 비밀: main() 함수가 실행되기 전에 일어나는 일

Go로 “아무것도 안 하는” 프로그램을 만들어 보자:

package main

func main() {
}

이걸 컴파일하면 1.5MB짜리 바이너리가 생긴다. 같은 일을 하는 C 프로그램은 고작 16KB인데! 왜 그럴까? Go가 정말 많은 일을 백그라운드에서 하고 있기 때문이다.

🚀 진짜 진입점은 main()이 아니다

첫 번째 충격: 네가 쓴 main() 함수는 진입점이 아니다. readelf로 확인하면:

$ readelf -h nothing_go | grep "Entry point"
 Entry point address: 0x467280

이 주소에 있는 건 _rt0_amd64_linux라는 어셈블리 함수다. 여기서부터 Go 런타임의 부트스트랩이 시작된다.

⚡ 부트스트랩 과정

1. g0와 m0 생성

Go에서 코드는 고루틴(goroutine) 위에서 돌아가고, 고루틴은 OS 스레드 위에서 실행된다. 런타임이 제일 먼저 하는 건 첫 번째 고루틴(g0)과 첫 번째 스레드(m0)를 만드는 것이다.

g0는 좀 특별하다 – 네 코드를 실행하지 않고, 런타임의 스케줄링 같은 내부 작업에만 쓰인다.

2. Thread-Local Storage (TLS) 설정

TLS는 각 스레드가 자기만의 저장 공간을 갖게 해주는 OS 기능이다. Go는 이걸 이용해서 “지금 이 스레드에서 어떤 고루틴이 돌고 있지?”라는 질문에 빠르게 답할 수 있다.

런타임은 TLS가 제대로 작동하는지 테스트하고, 안 되면 그냥 프로그램을 종료시킨다. 그만큼 중요하다는 뜻!

3. CPU 기능 탐지

어떤 CPU에서 돌아가는지, 어떤 기능(AVX, AES-NI 등)을 지원하는지 확인한다. Go 바이너리가 최신 CPU 명령어를 쓰도록 컴파일됐는데 CPU가 지원 안 하면 에러 메시지 띄우고 종료한다.

📋 schedinit() – 본격적인 초기화

어셈블리 레벨 작업이 끝나면 schedinit()이라는 Go 함수가 호출된다. 여기서 진짜 중요한 게 다 설정된다:

스택 풀 초기화

Go 고루틴은 작은 2KB 스택으로 시작해서 필요하면 늘어난다. 런타임은 여러 크기의 스택 세그먼트를 미리 풀(pool)에 저장해둔다. 고루틴 생성이 빠른 이유다!

메모리 할당자 초기화

mallocinit()은 Go의 메모리 할당자를 설정한다. 핵심 아이디어: make([]byte, 100) 할 때마다 OS한테 메모리 달라고 하면 느리니까, 큰 덩어리를 미리 받아서 작게 잘라 쓴다.

메모리는 68개 크기 클래스로 관리된다 (8바이트 ~ 32KB). 50바이트 할당하면? 딱 50바이트가 아니라 64바이트 슬롯을 주는 식이다. 32KB 넘는 건 힙에서 직접 할당한다.

해시 함수 선택

alginit()은 Go 맵이 쓸 해시 함수를 결정한다. CPU가 하드웨어 AES를 지원하면 그걸 쓰고, 아니면 소프트웨어 구현을 쓴다. 모든 맵 연산에 영향을 주니까 처음에 잘 결정해야 한다.

P(Processor) 생성

P를 워크스테이션이라고 생각하면 된다. 고루틴이 일하려면 P에 앉아야 하고, OS 스레드가 그 워크스테이션을 운영한다. 각 P는:

  • 자기만의 고루틴 실행 대기열
  • 자기만의 메모리 캐시 (락 없이 빠른 할당!)
  • 타이머와 GC 워커 상태

P 개수는 GOMAXPROCS로 결정되는데, 기본값은 CPU 코어 수다. 8코어 머신이면 P가 8개, 즉 최대 8개 고루틴이 진짜로 병렬 실행될 수 있다.

🎬 runtime.main() – 마지막 단계

스케줄러가 시작되면 runtime.main()이라는 특별한 고루틴이 실행된다. 네 main()이 아니라 런타임의 main이다!

시스템 모니터 (sysmon)

워치독 같은 백그라운드 스레드가 시작된다:

  • 고루틴이 P를 너무 오래 독점하면 양보하게 만듦
  • 시스템 콜에 묶인 스레드에서 P를 빼앗아 다른 고루틴에게 줌
  • GC가 한동안 안 돌았으면 실행시킴
  • 네트워크 I/O 폴링
  • 안 쓰는 메모리를 OS에 반환

GC 활성화

schedinit()에서 GC는 초기화됐지만 비활성 상태였다. 여기서 gcenable()이 호출되면서 백그라운드 스위퍼와 스캐빈저 고루틴이 생성되고, 비로소 GC가 작동한다.

init() 함수들 실행

네 패키지와 임포트한 모든 패키지의 init() 함수가 의존성 순서대로 실행된다. 패키지 레벨 변수도 여기서 초기화된다.

드디어… 네 main()

어셈블리 진입점 → TLS → CPU 탐지 → 메모리 할당자 → 스케줄러 → GC → 시스템 모니터 → init 함수들… 이 모든 게 끝나고 나서야 네가 쓴 main()이 호출된다!

💡 main()이 끝나면?

런타임은 바로 종료하지 않고 패닉 처리 중인 고루틴이 정리할 시간을 잠깐 준다. 하지만 아직 돌고 있는 다른 고루틴들은? 경고 없이 그냥 죽는다. 끝까지 기다려야 하면 sync.WaitGroup 같은 걸 써서 명시적으로 동기화해야 한다.

📊 결론

Go가 “아무것도 안 하면서” 왜 느린지 이제 알겠지? 사실 아무것도 안 하는 게 아니다!

main()이 실행되기 전에 런타임은 이미 완전한 실행 환경을 구축했다:

  • 첫 고루틴과 스레드
  • Thread-Local Storage
  • 스택 풀
  • 메모리 할당자
  • 맵용 해시 함수
  • 가비지 컬렉터
  • CPU 코어 수만큼의 P가 있는 스케줄러
  • 시스템 모니터 스레드
  • 모든 init() 함수 실행

이게 바로 Go가 쉬워 보이는 이유다. 고루틴이 가벼운 건 스택 풀과 할당자가 이미 준비돼 있기 때문이고, 메모리 관리가 투명한 건 GC가 이미 돌고 있기 때문이고, 동시성이 “그냥 작동”하는 건 스케줄러가 첫 줄 코드 실행 전에 이미 세팅됐기 때문이다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

Related Post

코드 리뷰를 잘하는 법

코드 리뷰는 코드 품질을 높이는 중요한 과정이다. 하지만 잘못하면 팀 분위기를 해칠 수 있다. 효과적인…

컴퓨터공학 전공 이탈의 시대: 학생들은 어디로 향하는가

미국 UC 캠퍼스에서 이상한 일이 벌어지고 있다. 닷컴 버블 붕괴 이후 처음으로 컴퓨터공학 전공 등록률이…

Rust는 2025년에도 여전히 뜨거운가

JetBrains의 2025년 개발자 생태계 설문조사 결과가 나왔다. Rust는 여전히 인기 있고 수요도 있다. 개발자들이 학습,…