`sync.Once`의 힘 공개: Go에서 단일 실행 보장
Daniel Hayes
Full-Stack Engineer · Leapcell

Go 표준 라이브러리는 잘 설계된 동시성 기본 요소의 보고이며, sync
패키지는 강력하고 스레드 안전한 애플리케이션 구축의 초석입니다. 여러 기능 중 sync.Once
는 일반적인 동시성 문제를 해결하기 위해 설계된 특히 우아하고 강력한 구조입니다. 즉, 특정 코드 조각이 여러 고루틴이 동시에 호출하려고 해도 정확히 한 번만 실행되도록 보장하는 것입니다.
문제: 일회성 초기화
애플리케이션의 전체 생명 주기 동안 전역 리소스를 초기화하거나 구성을 로드해야 하는 시나리오를 생각해 보세요. 이 리소스는 데이터베이스 연결 풀, 생성 비용이 많이 드는 객체 또는 HTTP 클라이언트일 수 있습니다. 적절한 동기화 없이 이 리소스에 동시에 액세스하거나 초기화하려고 시도하는 여러 고루틴은 다음과 같은 결과를 초래할 수 있습니다.
- 중복 초기화: 리소스가 여러 번 초기화되어 컴퓨팅 리소스를 낭비하고 일관되지 않은 상태를 초래할 수 있습니다.
- 경쟁 조건: 초기화에 공유 상태 수정이 포함되는 경우 동기화 없는 동시 액세스는 데이터 손상을 초래할 수 있습니다.
A naive approach might involve a global boolean flag and a mutex:
package main import ( "fmt" "sync" "time" ) var ( initialized bool mu sync.Mutex config string ) func initConfigNaive() { mu.Lock() defer mu.Unlock() if !initialized { fmt.Println("Initializing configuration (naive approach)...") time.Sleep(100 * time.Millisecond) // Simulate expensive initialization config = "Loaded Global Config" initialized = true fmt.Println("Configuration initialized.") } else { fmt.Println("Configuration already initialized, skipping.") } } func main() { var wg sync.WaitGroup for i := 0; i < 5; i++ { wg.Add(1) go func(id int) { defer wg.Done() fmt.Printf("Goroutine %d trying to get config...\n", id) initConfigNaive() fmt.Printf("Goroutine %d got config: %s\n", id, config) }(i) } wg.Wait() fmt.Println("All goroutines finished.") }
While this "naive" approach works, it's verbose and error-prone. You have to remember to declare the flag, the mutex, and implement the if !initialized
check every time. This is precisely the problem sync.Once
elegantly solves.
Enter sync.Once
: Simplicity and Guarantees
The sync.Once
type provides a simple Do
method that takes a function as an argument. The magic of sync.Once
is that it guarantees the function passed to its Do
method will be executed exactly once, even if Do
is called concurrently by multiple goroutines. Subsequent calls to Do
will do nothing, but they will wait for the initial execution to complete if it's still in progress.
A sync.Once
variable should not be copied after first use. It is typically embedded in a struct or used as a global variable. Its zero value is ready to use.
Let's refactor our configuration initialization using sync.Once
:
package main import ( "fmt" "sync" "time" ) var ( once sync.Once config string // Our global configuration ) // initConfigOnce simulates an expensive one-time initialization. func initConfigOnce() { fmt.Println("Initializing configuration (using sync.Once)...") time.Sleep(100 * time.Millisecond) // Simulate heavy work config = "Secret Application Config" fmt.Println("Configuration initialized.") } // GetConfig ensures that initConfigOnce is called only once. func GetConfig() string { once.Do(initConfigOnce) // This line guarantees initConfigOnce runs only once. return config } func main() { var wg sync.WaitGroup fmt.Println("Starting concurrent attempts to get config...") // Launch multiple goroutines to concurrently call GetConfig for i := 0; i < 5; i++ { wg.Add(1) go func(id int) { defer wg.Done() fmt.Printf("Goroutine %d trying to get config...\n", id) c := GetConfig() // All calls will funnel through once.Do fmt.Printf("Goroutine %d got config: %s\n", id, c) }(i) } wg.Wait() fmt.Println("\nAll goroutines finished. Config value is:", config) // Subsequent calls to GetConfig will not re-run initConfigOnce fmt.Println("\nCalling GetConfig again (should not re-initialize):") c := GetConfig() fmt.Println("Second call got config:", c) }
When you run this code, you will observe that Initializing configuration (using sync.Once)...
and Configuration initialized.
messages appear only once, even though GetConfig()
is called multiple times concurrently. All subsequent calls to GetConfig()
after the first successful initialization will immediately return the config
value without re-executing initConfigOnce
.
Under the Hood: How sync.Once
Works (Simplified)
While the internal implementation of sync.Once
is a bit more nuanced and optimized for performance (especially using atomic operations), conceptually it operates much like our initialized
flag and sync.Mutex
example, but with critical differences:
- Atomic Operations:
sync.Once
typically leverages atomic operations (likesync/atomic.LoadUint32
andsync/atomic.CompareAndSwapUint32
) to check if the function has already been run. This makes the check extremely fast and avoids the overhead of a full mutex lock/unlock for every check after the first execution. - Mutex for First Execution: For the very first call that finds the function not yet executed, it then acquires a
sync.Mutex
(or a similar synchronization primitive) to ensure that only one goroutine performs the actual initialization. - State Management: An internal field (often an integer or boolean) tracks the execution state. Once the function completes successfully, this state is updated atomically to indicate completion.
This pattern ensures that sync.Once
is highly efficient. Subsequent calls, after initialization, involve only a fast atomic read to determine that the function has already run, leading to minimal overhead.
Use Cases for sync.Once
sync.Once
is ideal for various scenarios requiring single-time execution:
- Global Resource Initialization: Database connection pools, application-wide configuration loading, setting up logging systems.
- Lazy Initialization: Initialize an expensive object only when it's first needed.
- Singleton Pattern Implementation: While Go doesn't have traditional classes,
sync.Once
is perfect for ensuring only one instance of a "service" or "manager" is ever created.
Example: Singleton Database Connection
package main import ( "fmt" "sync" "time" ) // DBClient represents our simulated database client. type DBClient struct { Name string } func (db *DBClient) Query(sql string) string { return fmt.Sprintf("Executing query '%s' on %s", sql, db.Name) } var ( dbOnce sync.Once dbConnection *DBClient ) func createDBConnection() { fmt.Println("Establishing database connection...") time.Sleep(500 * time.Millisecond) // Simulate connection setup time dbConnection = &DBClient{Name: "PostgresDB"} fmt.Println("Database connection established.") } // GetDBClient provides the singleton database client. func GetDBClient() *DBClient { dbOnce.Do(createDBConnection) return dbConnection } func main() { var wg sync.WaitGroup fmt.Println("Multiple goroutines attempting to get DB client:") for i := 0; i < 3; i++ { wg.Add(1) go func(id int) { defer wg.Done() fmt.Printf("Goroutine %d requesting DB client...\n", id) client := GetDBClient() fmt.Printf("Goroutine %d received client: %p, query result: %s\n", id, client, client.Query(fmt.Sprintf("SELECT * FROM users WHERE id=%d", id))) }(i) } wg.Wait() fmt.Println("\nAll goroutines finished. Verifying client instance:") client1 := GetDBClient() client2 := GetDBClient() fmt.Printf("Client 1 address: %p\n", client1) fmt.Printf("Client 2 address: %p\n", client2) fmt.Println("Are clients identical?", client1 == client2) // Should be true }
This example clearly shows how sync.Once
can be used to manage a single, shared database connection, preventing redundant connection attempts and ensuring all parts of the application use the same instance.
Important Considerations
- Panic Handling: If the function passed to
Do
panics,sync.Once
considers the call to have completed and will not re-execute the function on subsequent calls. This is usually the desired behavior for unrecoverable initialization errors. However, if you need to retry initialization after a panic,sync.Once
is not the right tool; you'd need a more intricate state management system. - Idempotence: The function passed to
Do
should ideally be idempotent, meaning calling it multiple times (even ifsync.Once
prevents actual re-execution) would not cause any side effects if hypothetically it were re-executed. This helps in reasoning about your code. - Initialization vs. Ongoing Logic:
sync.Once
is strictly for one-time initialization. It's not a general-purpose synchronization mechanism for protecting shared state during ongoing operations. For that, you'd usesync.Mutex
,sync.RWMutex
, or channels.
Conclusion
sync.Once
is a prime example of Go's philosophy of providing simple yet powerful concurrency primitives. By abstracting away the complexities of atomic operations, flag management, and synchronization waits, it allows developers to effortlessly guarantee that a block of code runs exactly once. Its elegance and efficiency make it an indispensable tool in the Go developer's toolkit for building robust and performant concurrent applications. Embrace sync.Once
when you need that single, definitive execution, and your code will be cleaner, safer, and more idiomatic.