Golang 타이머 정밀도: 얼마나 정확할 수 있을까요?
Emily Parker
Product Engineer · Leapcell

Golang 타이머 정밀도: 얼마나 정확할 수 있을까요?
I. 문제 소개: Golang에서 타이머는 얼마나 정확할 수 있을까요?
Golang에서 타이머는 광범위한 응용 시나리오를 가지고 있습니다. 그러나 정확히 얼마나 정확한지에 대한 질문은 항상 개발자들의 관심사였습니다. 이 글에서는 Go에서 타이머 힙 관리와 런타임 시 시간을 얻는 메커니즘을 자세히 살펴보고, 타이머의 정확성에 얼마나 의존할 수 있는지 밝힐 것입니다.
II. Go가 시간을 얻는 방법
(I) time.Now
뒤에 있는 어셈블리 함수
time.Now
를 호출하면 결국 다음 어셈블리 함수를 호출합니다.
// func now() (sec int64, nsec int32) TEXT time·now(SB),NOSPLIT,$16 // Be careful. We're calling a function with gcc calling convention here. // We're guaranteed 128 bytes on entry, and we've taken 16, and the // call uses another 8. // That leaves 104 for the gettime code to use. Hope that's enough! MOVQ runtime·__vdso_clock_gettime_sym(SB), AX CMPQ AX, $0 JEQ fallback MOVL $0, DI // CLOCK_REALTIME LEAQ 0(SP), SI CALL AX MOVQ 0(SP), AX // sec MOVQ 8(SP), DX // nsec MOVQ AX, sec+0(FP) MOVL DX, nsec+8(FP) RET fallback: LEAQ 0(SP), DI MOVQ $0, SI MOVQ runtime·__vdso_gettimeofday_sym(SB), AX CALL AX MOVQ 0(SP), AX // sec MOVL 8(SP), DX // usec IMULQ $1000, DX MOVQ AX, sec+0(FP) MOVL DX, nsec+8(FP) RET
여기서 TEXT time·now(SB),NOSPLIT,$16
에서 time·now(SB)
는 함수 now
의 주소를 나타내고, NOSPLIT
플래그는 매개변수에 의존하지 않음을 나타내며, $16
은 반환된 내용이 16바이트임을 나타냅니다.
(II) 함수 호출 과정
먼저 __vdso_clock_gettime_sym(SB)
의 주소를 가져오는데, 이는 clock_gettime
함수를 가리킵니다. 이 심볼이 비어 있지 않으면 스택 맨 위의 주소를 계산하여 SI
로 전달합니다(LEA 명령어 사용). DI
와 SI
는 시스템 콜의 처음 두 매개변수에 대한 레지스터이며, 이는 clock_gettime(0, &ret)
을 호출하는 것과 같습니다. 해당 심볼이 초기화되지 않으면 fallback
분기로 들어가 gettimeofday
함수를 호출합니다.
(III) 스택 공간 제한
Go 함수 호출은 최소 128바이트의 스택을 보장합니다(이는 고루틴 스택이 아님). 자세한 내용은 runtime/stack.go
의 _StackSmall
을 참조하십시오. 그러나 해당 C 함수에 들어간 후에는 스택의 증가는 더 이상 Go에 의해 제어되지 않습니다. 따라서 나머지 104바이트는 호출이 스택 오버플로를 일으키지 않도록 해야 합니다. 다행히 시간을 얻기 위한 이 두 함수는 복잡하지 않으므로 일반적으로 스택 오버플로는 발생하지 않습니다.
(IV) VDSO 메커니즘
VDSO(Virtual Dynamic Shared Object)는 커널에서 제공하는 가상 .so
파일입니다. 디스크에 있는 것이 아니라 커널에 있으며 사용자 공간에 매핑됩니다. 이는 시스템 콜을 가속화하기 위한 메커니즘이자 호환성 모드입니다. gettimeofday
와 같은 함수의 경우 일반적인 시스템 콜을 사용하면 많은 컨텍스트 스위치가 발생하며, 특히 시간을 자주 얻는 프로그램의 경우 더욱 그렇습니다. VDSO 메커니즘을 통해 사용자 공간에 주소 섹션이 별도로 매핑되며, 여기에는 커널에서 노출하는 일부 시스템 콜이 포함됩니다. 특정 호출 방법(syscall
, int 80
또는 systenter
와 같은)은 glibc
버전과 kernel
버전 간의 호환성 문제를 방지하기 위해 커널에 의해 결정됩니다. 또한 VDSO는 vsyscall
의 업그레이드 버전으로, 일부 보안 문제를 피하고 매핑이 더 이상 정적으로 고정되지 않습니다.
(V) 커널의 시간 획득 업데이트 메커니즘
커널에서 시스템 콜에 의해 얻은 시간은 시간 인터럽트에 의해 업데이트되는 것을 볼 수 있으며, 호출 스택은 다음과 같습니다.
Hardware timer interrupt (generated by the 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마다 인터럽트 처리 중에 한 번 업데이트됩니다. 운영 체제의 관점에서 시간 granularity는 대략 ms 수준이지만, 이는 벤치마크 값일 뿐입니다. 시간을 얻을 때마다 클럭 소스의 시간을 여전히 검색합니다(하드웨어 카운터 또는 인터럽트의 jiffy일 수 있는 여러 유형의 클럭 소스가 있으며 일반적으로 ns 수준에 도달할 수 있음). 시간 획득의 정밀도는 us와 수백 ns 사이일 수 있습니다. 이론적으로 더 정확한 시간을 위해서는 어셈블리 명령어 rdtsc
를 사용하여 CPU 주기를 직접 읽어야 합니다.
(VI) 함수 심볼의 검색 및 연결
시간 획득을 위한 함수 심볼을 검색하는 과정은 ELF의 내용, 즉 동적 연결 과정을 포함합니다. .so
파일의 함수 심볼 주소를 확인하고 __vdso_clock_gettime_sym
과 같은 함수 포인터에 저장합니다. TEXT runtime·nanotime(SB),NOSPLIT,$16
과 같은 다른 함수도 유사한 과정을 거치며, 이 함수는 시간을 얻을 수 있습니다.
III. Go 런타임에 의한 타이머 힙 관리
(I) timer
구조체
// Package time knows the layout of this structure. // If this struct changes, adjust ../time/sleep.go:/runtimeTimer. // For GOOS=nacl, package syscall knows the layout of this structure. // If this struct changes, adjust ../syscall/net_nacl.go:/runtimeTimer. type timer struct { i int // heap index // Timer wakes up at when, and then at when+period, ... (period > 0 only) // each time calling f(now, arg) in the timer goroutine, so f must be // a well-behaved function and not block. when int64 period int64 f func(interface{}, uintptr) arg interface{} seq uintptr }
타이머는 힙(heap) 형태로 관리됩니다. 힙은 완전한 이진 트리이며 배열을 사용하여 저장할 수 있습니다. i
는 힙의 인덱스입니다. when
은 고루틴이 깨어나는 시간이고, period
는 기상 간격입니다. 다음 기상 시간은 when + period
등입니다. 함수 f(now, arg)
가 호출되며, 여기서 now
는 타임스탬프입니다.
(II) timers
구조체
var timers struct { lock mutex gp *g created bool sleeping bool rescheduling bool waitnote note t []*timer }
전체 타이머 힙은 timers
에 의해 관리됩니다. gp
는 스케줄러의 G 구조체를 가리키며, 즉 고루틴의 상태 유지 구조체입니다. 런타임에 의해 시작되는 시간 관리자의 별도 고루틴을 가리킵니다(타이머가 사용될 때만 시작됨). lock
은 timers
의 스레드 안전성을 보장하고, waitnote
는 조건 변수입니다.
(III) addtimer
함수
func addtimer(t *timer) { lock(&timers.lock) addtimerLocked(t) unlock(&timers.lock) }
addtimer
함수는 전체 타이머 시작의 진입점입니다. 간단히 잠근 다음 addtimerLocked
함수를 호출합니다.
(IV) addtimerLocked
함수
// Add a timer to the heap and start or kick the timer proc. // If the new timer is earlier than any of the others. // Timers are locked. func addtimerLocked(t *timer) { // when must never be negative; otherwise timerproc will overflow // during its delta calculation and never expire other 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 moved to top: new earliest deadline. if timers.sleeping { timers.sleeping = false notewakeup(&timers.waitnote) } if timers.rescheduling { timers.rescheduling = false goready(timers.gp, 0) } } if !timers.created { timers.created = true go timerproc() } }
addtimerLocked
함수에서 timers
가 생성되지 않은 경우 timerproc
코루틴이 시작됩니다.
(V) timerproc
함수
// Timerproc runs the time-driven events. // It sleeps until the next event in the timers heap. // If addtimer inserts a new earlier event, addtimer1 wakes timerproc early. func timerproc() { timers.gp = getg() for { lock(&timers.lock) timers.sleeping = false now := nanotime() delta := int64(-1) for { if len(timers.t) == 0 { delta = -1 break } t := timers.t[0] delta = t.when - now if delta > 0 { break } if t.period > 0 { // leave in heap but adjust next time to fire t.when += t.period * (1 + -delta/t.period) siftdownTimer(0) } else { // remove from heap last := len(timers.t) - 1 if last > 0 { timers.t[0] = timers.t[last] timers.t[0].i = 0 } timers.t[last] = nil timers.t = timers.t[:last] if last > 0 { siftdownTimer(0) } t.i = -1 // mark as removed } 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 { // No timers left - put goroutine to sleep. timers.rescheduling = true goparkunlock(&timers.lock, "timer goroutine (idle)", traceEvGoBlock, 1) continue } // At least one timer pending. Sleep until then. timers.sleeping = true noteclear(&timers.waitnote) unlock(&timers.lock) notetsleepg(&timers.waitnote, delta) } }
timerproc
의 주요 논리는 최소 힙에서 타이머를 꺼내 콜백 함수를 호출하는 것입니다. period
가 0보다 크면 타이머의 when
값이 수정되고 힙이 조정됩니다. 0보다 작으면 타이머가 힙에서 직접 제거됩니다. 그런 다음 OS 세마포어에 들어가 다음 처리를 위해 잠자기 상태가 되며, waitnote
변수에 의해 깨어날 수도 있습니다. 타이머가 남아 있지 않으면 G 구조체로 나타내는 고루틴이 잠자기 상태로 들어가고, 고루틴을 호스팅하는 M 구조체로 나타내는 OS 스레드는 실행 가능한 다른 고루틴을 찾아 실행합니다.
(VI) addtimerLocked
의 기상 메커니즘
새 타이머가 추가되면 확인됩니다. 새로 삽입된 타이머가 힙의 맨 위에 있으면 잠자는 timergorountine
을 깨워 힙에서 만료된 타이머를 확인하고 실행하기 시작합니다. 기상 및 이전 잠자기에 대한 두 가지 상태가 있습니다. timers.sleeping
은 M의 os 세마포어 잠자기에 들어가는 것을 의미하고, timers.rescheduling
은 G의 스케줄링 잠자기에 들어가는 것을 의미하며, M은 잠자지 않고 G를 다시 실행 가능한 상태로 만듭니다. 시간 만료와 새 타이머 추가는 함께 런타임 시 타이머 작동의 원동력이 됩니다.
IV. 타이머 정밀도에 영향을 미치는 요인
처음 질문인 "타이머는 얼마나 정확할 수 있을까요?"를 되돌아보면 실제로 두 가지 요인의 영향을 받습니다.
(I) 운영 체제 자체의 시간 Granularity
일반적으로 us 수준이며, 시간 벤치마크 업데이트는 ms 수준이며, 시간 정밀도는 us 수준에 도달할 수 있습니다.
(II) 타이머 자체 고루틴의 스케줄링 문제
런타임 로드가 너무 높거나 운영 체제 자체의 로드가 너무 높으면 타이머 자체 고루틴이 적시에 응답하지 않아 타이머가 적시에 트리거되지 않을 수 있습니다. 예를 들어, CPU 시간 할당이 매우 작은 cgroup에 의해 제한되는 일부 컨테이너 환경에서 20ms 타이머와 30ms 타이머가 동시에 실행되는 것처럼 보일 수 있습니다. 따라서 때로는 타이머의 타이밍에 지나치게 의존하여 프로그램의 정상적인 작동을 보장할 수 없습니다. NewTimer
의 주석은 또한 "NewTimer는 최소 기간 d 후에 현재 시간을 해당 채널로 보내는 새 타이머를 만듭니다."라고 강조합니다. 즉, 타이머가 정시에 실행된다는 것을 아무도 보장할 수 없습니다. 물론 시간 간격이 매우 크면 이와 관련된 영향은 무시할 수 있습니다.
Leapcell: Golang 앱 호스팅을 위한 차세대 서버리스 플랫폼
마지막으로 Go 서비스를 배포하기에 가장 적합한 플랫폼인 **Leapcell**을 추천합니다.
1. 다국어 지원
- JavaScript, Python, Go 또는 Rust로 개발하세요.
2. 무제한 프로젝트를 무료로 배포
- 사용량에 대해서만 지불하세요. 요청도 없고 요금도 없습니다.
3. 최고의 비용 효율성
- 유휴 요금 없이 사용한 만큼 지불하세요.
- 예: $25는 평균 응답 시간 60ms에서 694만 건의 요청을 지원합니다.
4. 간소화된 개발자 경험
- 간편한 설정을 위한 직관적인 UI.
- 완전 자동화된 CI/CD 파이프라인 및 GitOps 통합.
- 실행 가능한 통찰력을 위한 실시간 메트릭 및 로깅.
5. 간편한 확장성 및 고성능
- 고도의 동시성을 쉽게 처리하기 위한 자동 조정。
- 운영 오버헤드 제로 - 구축에만 집중하세요.
Leapcell 트위터: https://x.com/LeapcellHQ