
Golang (고랭) 타이머 정밀도: 얼마나 정확할 수 있을까요?
Golang (고랭) 타이머 정밀도의 미스터리 탐구
1. 문제 소개: Golang (고랭)에서 타이머는 얼마나 정확할 수 있을까요?
Golang (고랭)의 세계에서 타이머는 광범위한 응용 시나리오를 가지고 있습니다.
그러나 정확히 얼마나 정밀한지에 대한 질문은 항상 개발자들의 관심사였습니다.
이 글에서는 Go (고)의 타이머 힙(heap) 관리와 런타임 시 시간 획득 메커니즘을 심층적으로 파헤쳐 타이머의 정확성에 어느 정도 의존할 수 있는지 밝혀낼 것입니다.
2. Go (고)가 시간을 얻는 방법
(1) time.Now (타임 점 나우) 뒤의 어셈블리 함수
우리가 time.Now (타임 점 나우)를 호출하면 결국 다음 어셈블리 함수를 호출하게 됩니다.
// func now() (sec int64, nsec int32)
// time·now(SB)는 now 함수의 주소를 나타냅니다.
// NOSPLIT 플래그는 매개변수에 의존하지 않음을 나타냅니다.
// $16은 반환되는 내용이 16바이트임을 나타냅니다.
TEXT time·now(SB),NOSPLIT,$16
// 주의하세요. 여기서 gcc 호출 규약을 사용하는 함수를 호출하고 있습니다.
// 진입 시 128바이트가 보장되고, 16바이트를 사용했으며,
// 호출은 추가로 8바이트를 사용합니다.
// 이는 gettime 코드가 사용할 수 있는 104바이트를 남깁니다. 충분하기를 바랍니다!
// runtime·__vdso_clock_gettime_sym(SB)의 주소를 AX 레지스터로 이동합니다.
MOVQ runtime·__vdso_clock_gettime_sym(SB), AX
CMPQ AX, $0 // AX 레지스터 값과 0을 비교합니다.
JEQ fallback // 같으면 fallback 레이블로 점프합니다.
// clock_gettime 시스템 콜 준비
MOVL $0, DI // 첫 번째 인자 CLOCK_REALTIME (0)을 DI 레지스터에 설정합니다.
LEAQ 0(SP), SI // 두 번째 인자 (시간을 저장할 구조체의 주소)를 스택 포인터(SP) 기준으로 계산하여 SI 레지스터에 설정합니다.
CALL AX // AX 레지스터에 저장된 주소(clock_gettime)를 호출합니다.
MOVQ 0(SP), AX // 스택에서 초(sec) 값을 AX 레지스터로 이동합니다.
MOVQ 8(SP), DX // 스택에서 나노초(nsec) 값을 DX 레지스터로 이동합니다.
MOVQ AX, sec+0(FP) // 반환 값 sec에 AX 값을 저장합니다.
MOVL DX, nsec+8(FP) // 반환 값 nsec에 DX 값을 저장합니다.
RET // 함수를 반환합니다.
fallback: // clock_gettime을 사용할 수 없는 경우의 대체 경로
LEAQ 0(SP), DI // gettimeofday 시스템 콜의 첫 번째 인자 (시간을 저장할 구조체의 주소)를 설정합니다.
MOVQ $0, SI // 두 번째 인자 (타임존, 사용 안 함)를 설정합니다.
MOVQ runtime·__vdso_gettimeofday_sym(SB), AX // runtime·__vdso_gettimeofday_sym(SB)의 주소를 AX로 이동합니다.
CALL AX // AX 레지스터에 저장된 주소(gettimeofday)를 호출합니다.
MOVQ 0(SP), AX // 스택에서 초(sec) 값을 AX로 이동합니다.
MOVL 8(SP), DX // 스택에서 마이크로초(usec) 값을 DX로 이동합니다.
IMULQ $1000, DX // 마이크로초를 나노초로 변환합니다 (DX = DX * 1000).
MOVQ AX, sec+0(FP) // 반환 값 sec에 AX 값을 저장합니다.
MOVL DX, nsec+8(FP) // 반환 값 nsec에 변환된 나노초 값을 저장합니다.
RET // 함수를 반환합니다.여기서 TEXT time·now(SB),NOSPLIT,$16 (텍스트 타임 점 나우 에스비 콤마 노스플릿 콤마 달러십육)에서 time·now(SB) (타임 점 나우 에스비)는 now (나우) 함수의 주소를 나타내고, NOSPLIT (노스플릿) 플래그는 매개변수에 의존하지 않음을 나타내며, $16 (달러십육)은 반환되는 내용이 16바이트임을 나타냅니다.
(2) 함수 호출 과정
먼저 __vdso_clock_gettime_sym(SB) (언더바언더바브이디에스오 언더바클락 언더바겟타임 언더바심 에스비)의 주소를 가져오는데, 이는 clock_gettime (클락 겟타임) 함수를 가리킵니다.
이 심볼이 비어 있지 않으면 스택 맨 위 주소를 계산하여 SI (에스아이) (LEA 명령어 사용)에 전달합니다.DI (디아이)와 SI (에스아이)는 시스템 콜의 처음 두 매개변수에 대한 레지스터이며, 이는 clock_gettime(0, &ret) (클락 겟타임 영 콤마 앤드렛)을 호출하는 것과 같습니다.
해당 심볼이 초기화되지 않은 경우 fallback (폴백) 분기로 들어가 gettimeofday (겟타임오브데이) 함수를 호출합니다.
(3) 스택 공간 제한
Go (고) 함수 호출은 최소 128바이트의 스택을 보장하며(고루틴(goroutine) 스택이 아님에 유의), 자세한 내용은 runtime/stack.go (런타임 슬래시 스택 점 고)의 _StackSmall (언더바스택스몰)을 참조할 수 있습니다.
그러나 해당 C 함수에 진입한 후에는 스택 성장이 더 이상 Go (고)에 의해 제어되지 않습니다.
따라서 나머지 104바이트는 호출이 스택 오버플로를 일으키지 않도록 보장해야 합니다.
다행히 시간을 얻는 이 두 함수는 복잡하지 않으므로 일반적으로 스택 오버플로가 발생하지 않습니다.
(4) VDSO 메커니즘
VDSO (브이디에스오)는 Virtual Dynamic Shared Object (가상 동적 공유 객체)의 약자로, 커널에서 제공하는 가상 .so 파일입니다.
디스크에는 없지만 커널에 있으며 사용자 공간에 매핑됩니다.
이는 시스템 콜을 가속화하고 호환성 모드를 제공하는 메커니즘입니다.gettimeofday (겟타임오브데이)와 같은 함수의 경우 일반 시스템 콜을 사용하면 특히 시간을 자주 얻는 프로그램의 경우 많은 컨텍스트 전환이 발생합니다.
VDSO (브이디에스오) 메커니즘을 통해 사용자 공간에 커널에서 노출하는 일부 시스템 콜을 포함하는 주소 섹션이 별도로 매핑됩니다.
특정 호출 방법(예: syscall, int 80 또는 systenter)은 glibc (지엘아이비씨) 버전과 커널 버전 간의 호환성 문제를 방지하기 위해 커널에서 결정합니다.
또한 VDSO (브이디에스오)는 vsyscall (브이시스콜)의 업그레이드 버전으로, 일부 보안 문제를 피하고 매핑이 더 이상 정적으로 고정되지 않습니다.
(5) 커널에서 시간 획득 업데이트 메커니즘
커널에서 시스템 콜로 얻은 시간은 시간 인터럽트에 의해 업데이트되며 호출 스택은 다음과 같습니다.
하드웨어 타이머 인터럽트 (Programmable Interrupt Timer - PIT (프로그래머블 인터럽트 타이머)에 의해 생성됨)
-> tick_periodic(); (틱 피리오딕 세미콜론)
-> do_timer(1); (두 타이머 일 세미콜론)
-> update_wall_time(); (업데이트 월 타임 세미콜론)
-> timekeeping_update(tk, false); (타임키핑 업데이트 티케이 콤마 폴스 세미콜론)
-> update_vsyscall(tk); (업데이트 브이시스콜 티케이 세미콜론)update_wall_time (업데이트 월 타임)은 클럭 소스의 시간을 사용하며 정밀도는 ns 수준에 도달할 수 있습니다.
그러나 일반적으로 Linux (리눅스) 커널의 시간 인터럽트는 100HZ이며 경우에 따라 1000HZ까지 높을 수 있습니다.
즉, 시간은 일반적으로 인터럽트 처리 중 매 10ms 또는 1ms마다 한 번 업데이트됩니다.
운영 체제 관점에서 시간 단위는 대략 ms 수준이지만 이는 단지 벤치마크 값일 뿐입니다.
시간을 얻을 때마다 클럭 소스의 시간(클럭 소스에는 여러 유형이 있으며 하드웨어 카운터이거나 인터럽트의 jiffy (지피)일 수 있으며 일반적으로 ns 수준에 도달 가능)을 계속 검색하며 시간 획득 정밀도는 us와 수백 ns 사이일 수 있습니다.
이론적으로 더 정확한 시간을 얻으려면 어셈블리 명령어 rdtsc (알디티에스씨)를 사용하여 CPU 사이클을 직접 읽어야 합니다.
(6) 함수 심볼 검색 및 링크
시간을 얻기 위한 함수 심볼을 검색하는 과정에는 ELF (이엘에프) 내용, 즉 동적 링크 과정이 포함됩니다.
.so 파일의 함수 심볼 주소를 확인하고 __vdso_clock_gettime_sym (언더바언더바브이디에스오 언더바클락 언더바겟타임 언더바심)과 같은 함수 포인터에 저장합니다.TEXT runtime·nanotime(SB),NOSPLIT,$16 (텍스트 런타임 점 나노타임 에스비 콤마 노스플릿 콤마 달러십육)과 같은 다른 함수도 유사한 과정을 거치며 이 함수는 시간을 얻을 수 있습니다.
3. Go (고) 런타임의 타이머 힙 관리
(1) timer (타이머) 구조체
// time 패키지는 이 구조체의 레이아웃을 알고 있습니다.
// 이 구조체가 변경되면 ../time/sleep.go:/runtimeTimer를 조정해야 합니다.
// GOOS=nacl의 경우 syscall 패키지는 이 구조체의 레이아웃을 알고 있습니다.
// 이 구조체가 변경되면 ../syscall/net_nacl.go:/runtimeTimer를 조정해야 합니다.
type timer struct {
i int // 힙 인덱스
// 타이머는 when에 깨어나고, 그 다음 when+period, ... (period > 0인 경우에만)
// 매번 타이머 고루틴에서 f(now, arg)를 호출하므로, f는
// 잘 동작하는 함수여야 하며 차단되지 않아야 합니다.
when int64 // 깨어날 시간 (나노초 단위 유닉스 타임)
period int64 // 반복 간격 (0이면 반복 안 함)
f func(interface{}, uintptr) // 타이머 만료 시 호출될 함수
arg interface{} // f에 전달될 첫 번째 인자
seq uintptr // f에 전달될 두 번째 인자 (주로 시퀀스 번호)
}타이머는 힙(heap) 형태로 관리됩니다.
힙은 완전 이진 트리이며 배열을 사용하여 저장할 수 있습니다.i는 힙의 인덱스입니다.when (웬)은 고루틴(goroutine)이 깨어나는 시간이고, period (피리어드)는 깨어나는 간격입니다.
다음 깨어나는 시간은 when + period (웬 플러스 피리어드) 등입니다.
함수 f(now, arg) (에프 나우 콤마 아그)가 호출되며, 여기서 now (나우)는 타임스탬프입니다.
(2) timers (타이머즈) 구조체
var timers struct {
lock mutex // 타이머 힙에 대한 락
gp *g // 타이머를 처리하는 고루틴 (timerproc)
created bool // timerproc 고루틴이 생성되었는지 여부
sleeping bool // timerproc 고루틴이 잠자고 있는지 여부 (OS 레벨 슬립)
rescheduling bool // timerproc 고루틴이 재스케줄링 대기 중인지 여부 (Go 스케줄러 슬립)
waitnote note // timerproc 고루틴을 깨우기 위한 알림 객체
t []*timer // 타이머 힙 (슬라이스로 구현된 최소 힙)
}전체 타이머 힙은 timers (타이머즈)에 의해 관리됩니다.gp (지피)는 스케줄러의 G (지) 구조체, 즉 고루틴(goroutine)의 상태 유지 구조체를 가리킵니다.
시간 관리자의 별도 고루틴(goroutine)을 가리키며, 런타임에 의해 시작됩니다(타이머가 사용될 때만 시작됨).lock (락)은 timers (타이머즈)의 스레드 안전성을 보장하고 waitnote (웨이트노트)는 조건 변수입니다.
(3) addtimer (애드타이머) 함수
// addtimer 함수는 새로운 타이머를 타이머 힙에 추가합니다.
func addtimer(t *timer) {
lock(&timers.lock) // 타이머 힙에 대한 락을 획득합니다.
addtimerLocked(t) // 락을 보유한 상태에서 실제 타이머 추가 로직을 호출합니다.
unlock(&timers.lock) // 타이머 힙에 대한 락을 해제합니다.
}addtimer (애드타이머) 함수는 전체 타이머 시작의 진입점입니다.
단순히 잠그고 addtimerLocked (애드타이머락트) 함수를 호출합니다.
(4) addtimerLocked (애드타이머락트) 함수
// addtimerLocked는 타이머를 힙에 추가하고 timerproc을 시작하거나 깨웁니다.
// 새로운 타이머가 다른 타이머들보다 빠르면 timerproc을 깨웁니다.
// 호출 시 timers.lock을 보유하고 있어야 합니다.
func addtimerLocked(t *timer) {
// when은 절대 음수여서는 안 됩니다. 그렇지 않으면 timerproc이
// 델타 계산 중 오버플로되어 다른 runtime·timers가 절대 만료되지 않을 수 있습니다.
if t.when < 0 {
t.when = 1<<63 - 1 // 매우 큰 값으로 설정하여 사실상 비활성화합니다.
}
t.i = len(timers.t) // 새 타이머의 인덱스를 현재 힙 크기로 설정합니다.
timers.t = append(timers.t, t) // 힙(슬라이스)에 새 타이머를 추가합니다.
siftupTimer(t.i) // 추가된 타이머를 올바른 위치로 올립니다 (힙 속성 유지).
if t.i == 0 { // 만약 새 타이머가 힙의 최상단(가장 빠른 만료 시간)으로 이동했다면
// siftup이 최상단으로 이동: 새로운 가장 빠른 마감 시간입니다.
if timers.sleeping { // timerproc이 OS 레벨에서 잠자고 있다면
timers.sleeping = false
notewakeup(&timers.waitnote) // waitnote를 통해 timerproc을 깨웁니다.
}
if timers.rescheduling { // timerproc이 Go 스케줄러에 의해 재스케줄링 대기 중이라면
timers.rescheduling = false
goready(timers.gp, 0) // timerproc 고루틴을 실행 가능 상태로 만듭니다.
}
}
if !timers.created { // timerproc 고루틴이 아직 생성되지 않았다면
timers.created = true
go timerproc() // timerproc 고루틴을 시작합니다.
}
}addtimerLocked (애드타이머락트) 함수에서 timers (타이머즈)가 생성되지 않은 경우 timerproc (타이머프록) 코루틴이 시작됩니다.
(5) timerproc (타이머프록) 함수
// timerproc은 시간 기반 이벤트를 실행합니다.
// timers 힙의 다음 이벤트까지 잠듭니다.
// addtimer가 더 빠른 새 이벤트를 삽입하면, addtimer1이 timerproc을 일찍 깨웁니다.
func timerproc() {
timers.gp = getg() // 현재 실행 중인 고루틴(timerproc 자신)의 g 구조체를 저장합니다.
for {
lock(&timers.lock) // 타이머 힙에 대한 락을 획득합니다.
timers.sleeping = false // 잠자는 상태가 아님을 표시합니다.
now := nanotime() // 현재 시간을 나노초 단위로 가져옵니다.
delta := int64(-1) // 다음 타이머까지의 시간 간격, -1은 타이머 없음을 의미합니다.
for { // 힙에서 만료된 타이머를 처리하는 루프입니다.
if len(timers.t) == 0 { // 힙에 타이머가 없으면
delta = -1 // 시간 간격을 -1로 설정하고 루프를 종료합니다.
break
}
t := timers.t[0] // 힙의 첫 번째 타이머 (가장 빨리 만료될 타이머)를 가져옵니다.
delta = t.when - now // 현재 시간과의 차이를 계산합니다.
if delta > 0 { // 만료 시간이 아직 지나지 않았으면 루프를 종료합니다.
break
}
// 타이머가 만료되었습니다.
if t.period > 0 { // 주기적인 타이머인 경우
// 힙에 남겨두지만 다음 실행 시간을 조정합니다.
// 1 + -delta/t.period는 만약 현재 시간이 when보다 많이 지났을 경우
// 여러 주기를 건너뛰고 다음 올바른 주기에 실행되도록 합니다.
t.when += t.period * (1 + -delta/t.period)
siftdownTimer(0) // 변경된 when 값에 따라 힙을 재정렬합니다.
} else { // 일회성 타이머인 경우
// 힙에서 제거합니다.
last := len(timers.t) - 1
if last > 0 { // 힙에 다른 타이머가 있으면
timers.t[0] = timers.t[last] // 마지막 타이머를 첫 번째 위치로 옮깁니다.
timers.t[0].i = 0 // 인덱스를 0으로 설정합니다.
}
timers.t[last] = nil // 마지막 위치를 nil로 설정하여 가비지 컬렉션을 돕습니다.
timers.t = timers.t[:last] // 슬라이스 크기를 줄입니다.
if last > 0 { // 힙이 비어있지 않으면
siftdownTimer(0) // 첫 번째 위치로 옮겨진 타이머를 올바른 위치로 내립니다.
}
t.i = -1 // 제거됨을 표시합니다.
}
// 타이머 콜백 함수 실행 준비
f := t.f
arg := t.arg
seq := t.seq
unlock(&timers.lock) // 콜백 함수 실행 전에 락을 해제합니다.
if raceenabled { // 레이스 디텍터가 활성화된 경우
raceacquire(unsafe.Pointer(t)) // 타이머 객체에 대한 접근을 기록합니다.
}
f(arg, seq) // 콜백 함수를 실행합니다.
lock(&timers.lock) // 콜백 함수 실행 후 다시 락을 획득합니다.
}
if delta < 0 || faketime > 0 { // 처리할 타이머가 없거나 (delta < 0) 테스트용 가짜 시간이 설정된 경우
// 남은 타이머 없음 - 고루틴을 잠재웁니다.
timers.rescheduling = true // 재스케줄링 대기 상태로 설정합니다.
// 락을 해제하고 "timer goroutine (idle)" 상태로 파킹(스케줄러에 양보)합니다.
goparkunlock(&timers.lock, "timer goroutine (idle)", traceEvGoBlock, 1)
continue // 다음 루프로 돌아가 다시 타이머를 확인합니다.
}
// 적어도 하나의 타이머가 보류 중입니다. 그때까지 잠듭니다.
timers.sleeping = true // OS 레벨에서 잠자는 상태로 설정합니다.
noteclear(&timers.waitnote) // waitnote를 초기화합니다.
unlock(&timers.lock) // 락을 해제합니다.
// delta 시간만큼 OS 레벨에서 잠듭니다. addtimerLocked에서 깨울 수 있습니다.
notetsleepg(&timers.waitnote, delta)
}
}timerproc (타이머프록)의 주요 로직은 최소 힙에서 타이머를 꺼내 콜백 함수를 호출하는 것입니다.period (피리어드)가 0보다 크면 타이머의 when (웬) 값을 수정하고 힙을 조정합니다.
0보다 작으면 힙에서 직접 타이머를 제거합니다.
그런 다음 OS 세마포어에 들어가 다음 처리를 기다리며 잠들고, waitnote (웨이트노트) 변수에 의해 깨어날 수도 있습니다.
남은 타이머가 없으면 G (지) 구조체로 표현되는 고루틴(goroutine)은 잠자는 상태로 들어가고, 고루틴(goroutine)을 호스팅하는 M (엠) 구조체로 표현되는 OS 스레드는 실행할 다른 실행 가능한 고루틴(goroutine)을 찾습니다.
(6) addtimerLocked (애드타이머락트)의 깨우기 메커니즘
새 타이머가 추가되면 확인됩니다.
새로 삽입된 타이머가 힙 맨 위에 있으면 잠자는 timergorountine (타이머고루틴)을 깨워 힙에서 만료된 타이머를 확인하고 실행하도록 합니다.
깨우기와 이전 잠자기에 대해 두 가지 상태가 있습니다.timers.sleeping (타이머즈 점 슬리핑)은 M (엠)의 OS 세마포어 잠자기에 들어감을 의미하고, timers.rescheduling (타이머즈 점 리스케줄링)은 G (지)의 스케줄링 잠자기에 들어감을 의미하며, M (엠)은 잠자지 않고 G (지)를 다시 실행 가능한 상태로 만듭니다.
시간 만료와 새 타이머 추가가 함께 런타임 시 타이머 작동의 원동력을 구성합니다.
4. 타이머 정밀도에 영향을 미치는 요인
"타이머는 얼마나 정확할 수 있을까요?"라는 초기 질문으로 돌아가 보면, 실제로는 두 가지 요인의 영향을 받습니다.
(1) 운영 체제 자체의 시간 단위
일반적으로 us 수준이며, 시간 벤치마크 업데이트는 ms 수준이고 시간 정밀도는 us 수준에 도달할 수 있습니다.
(2) 타이머 자체 고루틴(Goroutine)의 스케줄링 문제
런타임 부하가 너무 높거나 운영 체제 자체의 부하가 너무 높으면 타이머 자체 고루틴(goroutine)이 제때 응답하지 못하여 타이머가 제때 트리거되지 않을 수 있습니다.
예를 들어, 20ms 타이머와 30ms 타이머가 동시에 실행되는 것처럼 보일 수 있으며, 특히 CPU 시간 할당이 매우 작은 cgroup (씨그룹)으로 제한된 일부 컨테이너 환경에서는 더욱 그렇습니다.
따라서 때로는 프로그램의 정상적인 작동을 보장하기 위해 타이머의 타이밍에 과도하게 의존할 수 없습니다.NewTimer (뉴타이머)의 주석에서도 "NewTimer creates a new Timer that will send the current time on its channel after at least duration d." (NewTimer는 최소 d 지속 시간 후에 채널에 현재 시간을 보내는 새 타이머를 만듭니다.)라고 강조하며, 이는 아무도 타이머가 정시에 실행될 것이라고 보장할 수 없음을 의미합니다.
물론 시간 간격이 매우 크면 이 점에 대한 영향은 무시할 수 있습니다.
'Go' 카테고리의 다른 글
| 고(Go)에서 go get으로 파일 제외하기? 빌드 제약으로 똑똑하게 관리하는 법! (0) | 2025.05.20 |
|---|---|
| 쌩초보도 OK! 고(Go) 언어 웹 서버 직접 만들기 A to Z (0) | 2025.05.20 |
| Hugo (휴고) 심층 분석: 이상적인 정적 블로그 프레임워크 (3) | 2025.05.17 |
| Go (고): 다양한 시나리오에서 RWMutex (알더블유뮤텍스)와 Mutex (뮤텍스)의 성능 비교 (0) | 2025.05.17 |
| Go (고) 컴파일러 성능 최적화 팁과 트릭 (0) | 2025.05.17 |