Go에서 sync.Once 이해
Takashi Yamamoto
Infrastructure Engineer · Leapcell

소개
특정 시나리오에서는 싱글톤 객체, 구성 등과 같은 일부 리소스를 초기화해야 합니다. 패키지 수준 변수를 정의하거나 init 함수 또는 main 함수에서 초기화하는 등 리소스 초기화를 구현하는 방법은 여러 가지가 있습니다. 세 가지 접근 방식 모두 프로그램이 시작될 때 동시성 안전과 완전한 리소스 초기화를 보장할 수 있습니다.
그러나 때로는 리소스가 실제로 필요할 때만 초기화되는 지연 초기화를 사용하는 것을 선호합니다. 이를 위해서는 동시성 안전이 필요하며, 이러한 경우 Go의 sync.Once는 우아하고 스레드 안전한 솔루션을 제공합니다. 이 기사에서는 sync.Once를 소개합니다.
sync.Once의 기본 개념
sync.Once란 무엇인가
sync.Once는 특정 작업 또는 함수가 동시 환경에서 한 번만 실행되도록 보장하는 Go의 동기화 기본 요소입니다. Do라는 하나의 내보내기된 메서드만 노출하며, 이 메서드는 함수를 매개변수로 허용합니다. Do 메서드를 호출한 후에는 제공된 함수가 실행되며, 여러 고루틴이 동시에 호출하더라도 한 번만 실행됩니다.
sync.Once의 적용 시나리오
sync.Once는 주로 다음 시나리오에서 사용됩니다.
- 싱글톤 패턴: 중복된 리소스 생성을 방지하여 하나의 글로벌 인스턴스 객체만 존재하도록 보장합니다.
- 지연 초기화: 프로그램 실행 중 필요할 때
sync.Once를 통해 리소스를 동적으로 초기화할 수 있습니다. - 한 번만 실행해야 하는 작업: 예를 들어, 한 번만 실행해야 하는 구성 로딩, 데이터 정리 등입니다.
sync.Once의 적용 예제
싱글톤 패턴
싱글톤 패턴에서는 구조체가 한 번만 초기화되도록 해야 합니다. sync.Once를 사용하면 이 목표를 쉽게 달성할 수 있습니다.
package main import ( "fmt" "sync" ) type Singleton struct{} var ( instance *Singleton once sync.Once ) func GetInstance() *Singleton { once.Do(func() { instance = &Singleton{} }) return instance } func main() { var wg sync.WaitGroup for i := 0; i < 5; i++ { wg.Add(1) go func() { defer wg.Done() s := GetInstance() fmt.Printf("Singleton instance address: %p\n", s) }() } wg.Wait() }
위 코드에서 GetInstance 함수는 instance가 한 번만 초기화되도록 once.Do()를 사용합니다. 동시 환경에서 여러 고루틴이 동시에 GetInstance를 호출하면 하나의 고루틴만 instance = &Singleton{}를 실행하고 모든 고루틴은 동일한 인스턴스 s를 받습니다.
지연 초기화
때로는 특정 리소스가 필요할 때만 초기화하고 싶을 수 있습니다. 이는 sync.Once를 사용하여 달성할 수 있습니다.
package main import ( "fmt" "sync" ) type Config struct { config map[string]string } var ( config *Config once sync.Once ) func GetConfig() *Config { once.Do(func() { fmt.Println("init config...") config = &Config{ config: map[string]string{ "c1": "v1", "c2": "v2", }, } }) return config } func main() { // 구성이 처음 필요할 때 구성이 초기화됩니다. cfg := GetConfig() fmt.Println("c1: ", cfg.config["c1"]) // 두 번째로 구성은 이미 초기화되어 다시 초기화되지 않습니다. cfg2 := GetConfig() fmt.Println("c2: ", cfg2.config["c2"]) }
이 예제에서는 일부 구성 설정을 보유하기 위해 Config 구조체가 정의되었습니다. GetConfig 함수는 Config 구조체를 처음 호출될 때 초기화하기 위해 sync.Once를 사용합니다. 이렇게 하면 Config는 실제로 필요할 때만 초기화되어 불필요한 오버헤드를 피할 수 있습니다.
sync.Once의 구현 원리
type Once struct { // 작업이 수행되었는지 여부를 나타냅니다. done uint32 // 하나의 고루틴만 작업을 수행하도록 보장하는 뮤텍스 m Mutex } func (o *Once) Do(f func()) { // 완료가 0인지 확인합니다. 즉, f가 아직 실행되지 않았습니다. if atomic.LoadUint32(&o.done) == 0 { // Do에서 빠른 경로 인라이닝을 허용하기 위해 느린 경로를 호출합니다. o.doSlow(f) } } func (o *Once) doSlow(f func()) { // 잠금 o.m.Lock() defer o.m.Unlock() // f를 여러 번 실행하지 않도록 이중 확인합니다. if o.done == 0 { // 함수를 실행한 후 완료를 설정합니다. defer atomic.StoreUint32(&o.done, 1) // 함수를 실행합니다. f() } }
sync.Once 구조체에는 done과 m이라는 두 개의 필드가 있습니다.
done은 작업이 이미 수행되었는지 여부를 나타내는 데 사용되는uint32변수입니다.m은 동시에 액세스할 때 하나의 고루틴만 작업을 수행할 수 있도록 하는 데 사용되는 뮤텍스입니다.
sync.Once는 Do와 doSlow라는 두 가지 메서드를 제공합니다. Do 메서드가 핵심입니다. 함수 f를 허용합니다. 먼저 원자적 작업 atomic.LoadUint32를 사용하여 done 값을 확인합니다(동시성 안전 보장). done이 0이면 함수 f가 아직 실행되지 않았음을 의미하고 doSlow가 호출됩니다.
doSlow 메서드 내부에서는 먼저 뮤텍스 잠금 m을 획득하여 한 번에 하나의 고루틴만 f를 실행할 수 있도록 합니다. 그런 다음 done 변수에 대한 두 번째 검사를 수행합니다. done이 여전히 0이면 잠금을 획득하는 동안 다른 고루틴이 함수를 실행하지 않았는지 확인합니다.
이중 검사는 대부분의 경우 잠금 경합을 피하고 성능을 향상시키는 데 도움이 됩니다.
별도의 doSlow 메서드가 있는 이유는 무엇입니까?
doSlow 메서드는 주로 성능 최적화를 위해 존재합니다. Do 메서드에서 느린 경로 로직을 분리함으로써 Do의 빠른 경로는 컴파일러에 의해 인라인될 수 있어 성능이 향상됩니다.
이중 검사를 사용하는 이유는 무엇입니까?
소스 코드에서 볼 수 있듯이 done 값은 두 번 확인됩니다.
- 첫 번째 확인: 잠금을 획득하기 전에
atomic.LoadUint32를 사용하여done을 확인합니다. 값이1이면 작업이 이미 수행되었음을 의미하므로doSlow가 건너뛰어져 불필요한 잠금 경합을 피할 수 있습니다. - 두 번째 확인: 잠금을 획득한 후
done을 다시 확인합니다. 이렇게 하면 잠금을 획득하는 동안 다른 고루틴이 함수를 실행하지 않았는지 확인합니다.
향상된 sync.Once
sync.Once에서 제공하는 Do 메서드는 값을 반환하지 않습니다. 즉, 전달된 함수가 오류를 반환하고 초기화에 실패하면 Do에 대한 후속 호출은 초기화를 다시 시도하지 않습니다. 이 문제를 해결하기 위해 sync.Once와 유사한 사용자 지정 동기화 기본 요소를 구현할 수 있습니다.
package main import ( "sync" "sync/atomic" ) type Once struct { done uint32 m sync.Mutex } func (o *Once) Do(f func() error) error { if atomic.LoadUint32(&o.done) == 0 { return o.doSlow(f) } return nil } func (o *Once) doSlow(f func() error) error { o.m.Lock() defer o.m.Unlock() var err error if o.done == 0 { err = f() // 오류가 발생하지 않은 경우에만 완료를 설정합니다. if err == nil { atomic.StoreUint32(&o.done, 1) } } return err }
위의 코드는 향상된 Once 구조체를 구현합니다. 표준 sync.Once와 달리 이 버전에서는 Do 메서드에 전달된 함수가 오류를 반환할 수 있습니다. 함수가 오류를 반환하지 않으면 done이 설정되어 함수가 성공적으로 실행되었음을 나타냅니다. 후속 호출에서는 이전에 성공적으로 완료된 경우에만 함수가 건너뛰어져 처리되지 않은 초기화 실패를 방지합니다.
sync.Once의 주의 사항
데드락
sync.Once 소스 코드를 분석한 결과 뮤텍스 필드 m이 포함되어 있음을 알 수 있습니다. 다른 Do 호출 내부에서 Do를 재귀적으로 호출하면 동일한 잠금을 여러 번 획득하려고 시도하게 됩니다. 뮤텍스는 재진입이 아니므로 데드락이 발생합니다.
func main() { once := sync.Once{} once.Do(func() { once.Do(func() { fmt.Println("init...") }) }) }
초기화 실패
여기서 초기화 실패는 Do에 전달된 함수를 실행하는 동안 오류가 발생하는 것을 의미합니다. 표준 sync.Once는 이러한 실패를 감지하는 방법을 제공하지 않습니다. 이 문제를 해결하기 위해 앞에서 언급한 오류 처리를 지원하고 조건부 재시도를 지원하는 향상된 Once를 사용할 수 있습니다.
결론
이 기사에서는 Go 프로그래밍 언어의 sync.Once에 대한 기본 정의, 사용 시나리오, 적용 예제 및 소스 코드 분석을 포함하여 자세한 소개를 제공했습니다.
실제 개발에서는 sync.Once가 싱글톤 패턴 및 지연 초기화를 구현하는 데 자주 사용됩니다.
sync.Once는 간단하고 효율적이지만 잘못 사용하면 예기치 않은 문제가 발생할 수 있으므로 주의해서 사용해야 합니다.
요약하자면 sync.Once는 Go에서 매우 유용한 동시성 기본 요소로, 개발자가 다양한 동시 시나리오에서 스레드로부터 안전한 작업을 수행하는 데 도움이 됩니다. 작업이 한 번만 초기화되어야 하는 상황이 발생할 때마다 sync.Once가 훌륭한 선택입니다.
Go 프로젝트 호스팅을 위한 최고의 선택, Leapcell입니다.
Leapcell은 웹 호스팅, 비동기 작업 및 Redis를 위한 차세대 서버리스 플랫폼입니다.
다국어 지원
- Node.js, Python, Go 또는 Rust로 개발합니다.
무료로 무제한 프로젝트 배포
- 사용량에 대해서만 지불합니다. 요청도 없고 요금도 없습니다.
탁월한 비용 효율성
- 사용한 만큼 지불하고 유휴 요금이 없습니다.
- 예: $25로 평균 응답 시간 60ms에서 694만 건의 요청을 지원합니다.
간소화된 개발자 경험
- 간편한 설정을 위한 직관적인 UI
- 완전 자동화된 CI/CD 파이프라인 및 GitOps 통합
- 실행 가능한 통찰력을 위한 실시간 메트릭 및 로깅
손쉬운 확장성 및 고성능
- 쉬운 동시성 처리를 위한 자동 크기 조정
- 운영 오버헤드가 없으므로 구축에만 집중하세요.
설명서에서 자세히 알아보세요!
X에서 팔로우하세요: @LeapcellHQ



