- Published on
Tối ưu hóa khởi tạo ứng dụng trong Go với sync.Once
- Authors
- Name
- Nguyễn Văn Quang
- @_nguyenvanquang
Tối ưu hóa khởi tạo ứng dụng trong Go với sync.Once
Trong quá trình lập trình ứng dụng bằng Go (Golang), chắc hẳn bạn đã từng gặp các bài toán như: đọc file cấu hình (config), khởi tạo kết nối Database, hay nạp bộ đệm (cache) vào RAM khi ứng dụng bắt đầu chạy. Nếu hệ thống của bạn nhận hàng ngàn request đồng thời ngay thời điểm vừa bật server, làm thế nào để đảm bảo tài nguyên chỉ được khởi tạo ĐÚNG 1 LẦN duy nhất mà không gây ra xung đột dữ liệu (Data Race)?
Đó chính là lúc sync.Once phát huy sức mạnh. Dưới đây là bài viết chi tiết giúp bạn hiểu rõ cơ chế và cách ứng dụng sync.Once trong dự án Go thực tế.
Khái niệm
sync.Oncelà gì?sync.Oncelà một công cụ nằm trong package chuẩnsynccủa Go. Đúng như tên gọi, nó cung cấp một cơ chế đảm bảo một hàm chỉ được thực thi đúng một lần duy nhất trong suốt vòng đời của ứng dụng, bất kể có bao nhiêu Goroutines cùng gọi nó một lúc.Vấn đề khi không sử dụng
sync.Once(Data Race / Race Condition).Khi triển khai mẫu thiết kế Singleton hoặc Lazy Initialization (khởi tạo lười), cách viết thông thường rất dễ dẫn đến lỗi bất đồng bộ.
- Không nên:
var db *sql.DB func GetDB() *sql.DB { if db == nil { // Nếu 100 Goroutines cùng chạy vào đây một lúc, db sẽ bị khởi tạo 100 lần! db = connectToDatabase() } return db }- Nên:
var ( db *sql.DB once sync.Once ) func GetDB() *sql.DB { once.Do(func() { db = connectToDatabase() // Dù có bao nhiêu Goroutines gọi, hàm này chỉ chạy 1 lần duy nhất }) return db }Cơ chế hoạt động đằng sau hậu trường (Under the hood).
Bên trong,
sync.Oncesử dụng cấu trúc gồm một cờ trạng thái kiểu atomic (uint32) và một khóasync.Mutex.- Fast path (Kiểm tra nhanh): Dùng
atomic.LoadUint32kiểm tra nếu đã khởi tạo xong rồi thì bỏ qua ngay lập tức mà không cần Lock, giúp tối ưu hiệu năng cực cao. - Slow path (Khóa an toàn): Nếu chưa khởi tạo, nó sẽ khóa
Mutex, khởi tạo tài nguyên, đánh dấu đã hoàn thành rồi mới mở khóa.
- Fast path (Kiểm tra nhanh): Dùng
Bọc các hàm nhận tham số hoặc trả về lỗi.
Hàm truyền vào
once.Dobắt buộc phải là dạng không tham số và không return (func()). Để làm việc với các hàm có trả về lỗi, bạn nên áp dụng kỹ thuật Closure (bọc biến ra bên ngoài).- Không nên:
// Lỗi biên dịch vì once.Do không chấp nhận hàm trả về error once.Do(func() error { return initConfig() })- Nên:
var ( cfg *Config err error once sync.Once ) func LoadConfig() (*Config, error) { once.Do(func() { cfg, err = readConfigFile() // Gán giá trị và lỗi ra biến scope bên ngoài }) return cfg, err }Cẩn trọng với Panics và Lỗi khi sử dụng
sync.Once.Một điều quan trọng cần lưu ý:
sync.Oncechỉ quan tâm đến việc hàm đã từng được gọi hay chưa, chứ không quan tâm hàm đó chạy thành công hay thất bại. Nếu hàm bên trongonce.Dogặppanichoặc trả về lỗi,sync.Oncevẫn đánh dấu là ĐÃ CHẠY và sẽ không bao giờ thực thi lại ở các lần gọi tiếp theo.var once sync.Once func Initialize() { once.Do(func() { panic("Lỗi kết nối mạng!") }) } // Các lần gọi Initialize() sau đó sẽ bị bỏ qua, dẫn đến hệ thống chạy ở trạng thái lỗi vĩnh viễn!Áp dụng
sync.Onceđể xây dựng package Config chuẩn Go.Dưới đây là một cấu trúc thực tế khi áp dụng
sync.Onceđể tạo một package cấu hình (Config) an toàn tuyệt đối trên môi trường production:package config import ( "os" "sync" ) type Config struct { DatabaseURL string ServerPort string } var ( instance *Config once sync.Once ) func GetConfig() *Config { once.Do(func() { instance = &Config{ DatabaseURL: os.Getenv("DATABASE_URL"), ServerPort: os.Getenv("SERVER_PORT"), } }) return instance }Tổng kết: Khi nào nên sử dụng
sync.Once?Bạn nên nghĩ tới
sync.Oncengay khi gặp các trường hợp sau trong ứng dụng Go:- Khởi tạo kết nối Database, Redis, RabbitMQ (Connection Pool).
- Đọc file cấu hình hoặc biến môi trường ở dạng Singleton.
- Load dữ liệu tĩnh (Dictionary / Cache) vào bộ nhớ RAM khi khởi động server.