Kiểu mẫu Singleton là kiểu mẫu thiết kế đơn giản nhất trong thiết kế phần mềm. Nó đảm bảo chỉ có một thể hiện của một đối tượng tồn tại trên toàn cầu, bất kể đối tượng đó được khởi tạo bao nhiêu lần. Dựa trên các đặc điểm của kiểu mẫu singleton, nó có thể được áp dụng cho các kịch bản như cấu hình duy nhất toàn cầu, các đối tượng kết nối cơ sở dữ liệu, các đối tượng truy cập tệp, v.v. Trong ngôn ngữ Go, có nhiều cách để triển khai kiểu mẫu singleton. Hôm nay, chúng ta hãy cùng tìm hiểu về một số cách tiếp cận này.
Khởi tạo tích cực
Triển khai kiểu mẫu singleton bằng cách khởi tạo tích cực rất đơn giản. Hãy xem trực tiếp mã:
package singleton
type singleton struct{}
var instance = &singleton{}
func GetSingleton() *singleton {
return instance
}
Khi gói singleton
được nhập, thể hiện instance
được tự động khởi tạo. Để lấy đối tượng singleton của cấu trúc singleton, chỉ cần gọi hàm singleton.GetSingleton()
.
Vì đối tượng singleton được tạo ngay lập tức trong quá trình tải gói, nên việc triển khai này được đặt tên mô tả là khởi tạo tích cực. Đối trọng của điều này là khởi tạo lười, trong đó thể hiện chỉ được tạo khi nó được sử dụng lần đầu tiên.
Điều quan trọng cần lưu ý là mặc dù khởi tạo tích cực rất đơn giản để triển khai kiểu mẫu singleton, nhưng nhìn chung nó không được khuyến nghị, đặc biệt nếu quá trình khởi tạo cho thể hiện singleton tốn nhiều thời gian. Điều này có thể dẫn đến thời gian tải chương trình dài hơn.
Khởi tạo lười
Tiếp theo, chúng ta hãy xem cách triển khai kiểu mẫu singleton bằng cách khởi tạo lười:
package singleton
type singleton struct{}
var instance *singleton
func GetSingleton() *singleton {
if instance == nil {
instance = &singleton{}
}
return instance
}
Trái ngược với việc triển khai khởi tạo tích cực, chúng ta đã di chuyển mã để khởi tạo cấu trúc singleton bên trong hàm GetSingleton()
. Bằng cách này, chúng ta trì hoãn việc khởi tạo đối tượng cho đến khi GetSingleton()
được gọi lần đầu tiên.
Tuy nhiên, việc sử dụng điều kiện instance == nil
để triển khai singleton không hoàn toàn đáng tin cậy, vì nó không thể đảm bảo an toàn đồng thời khi nhiều goroutine đồng thời gọi GetSingleton()
.
Singleton an toàn đồng thời
Nếu bạn có kinh nghiệm lập trình đồng thời trong Go, bạn có thể nhanh chóng nghĩ ra một giải pháp để giải quyết vấn đề an toàn đồng thời trong kiểu mẫu singleton khởi tạo lười.
package singleton
import "sync"
type singleton struct{}
var instance *singleton
var mu sync.Mutex
func GetSingleton() *singleton {
mu.Lock()
defer mu.Unlock()
if instance == nil {
instance = &singleton{}
}
return instance
}
Sửa đổi chính mà chúng ta đã thực hiện đối với mã là thêm hai dòng sau vào đầu hàm GetSingleton()
:
mu.Lock()
defer mu.Unlock()
Bằng cách sử dụng cơ chế khóa, chúng ta có thể đảm bảo rằng việc triển khai kiểu mẫu singleton này là an toàn đồng thời.
Tuy nhiên, có một nhược điểm đối với cách tiếp cận này. Do sử dụng khóa, mỗi khi GetSingleton()
được gọi, chương trình đều trải qua quá trình lấy và giải phóng khóa. Điều này có thể dẫn đến giảm hiệu suất chương trình.
Khóa kép
Khóa có thể gây giảm hiệu suất, nhưng chúng ta vẫn cần sử dụng khóa để đảm bảo an toàn đồng thời. Do đó, một người đã đưa ra giải pháp khóa kép:
package singleton
import "sync"
type singleton struct{}
var instance *singleton
var mu sync.Mutex
func GetSingleton() *singleton {
if instance == nil {
mu.Lock()
defer mu.Unlock()
if instance == nil {
instance = &singleton{}
}
}
return instance
}
Như bạn có thể thấy, cái gọi là khóa kép thực sự là thêm một kiểm tra instance == nil
bổ sung trước khi lấy khóa. Cách tiếp cận này kết hợp cả hiệu suất và các khía cạnh an toàn.
Tuy nhiên, mã này có vẻ hơi lạ. Vì kiểm tra bên ngoài instance == nil
đã được thực hiện, nên việc thực hiện kiểm tra instance == nil
thứ hai sau khi lấy khóa có vẻ dư thừa. Trên thực tế, kiểm tra instance == nil
bên ngoài là để cải thiện hiệu quả thực thi của chương trình. Nếu instance đã tồn tại, không cần phải vào logic if và chương trình có thể trả về instance trực tiếp. Điều này tránh được chi phí của việc lấy khóa cho mỗi lần gọi GetSingleton()
và làm cho việc khóa tinh tế hơn. Kiểm tra instance == nil
bên trong là để xem xét an toàn đồng thời. Trong những trường hợp cực đoan, khi nhiều goroutine đạt đến điểm lấy khóa đồng thời, kiểm tra bên trong sẽ được sử dụng.
Cách tiếp cận được ưu tiên cho Gophers
Mặc dù cơ chế khóa kép cho phép chúng ta đạt được cả hiệu suất và an toàn đồng thời, nhưng mã trông có vẻ hơi xấu và không đáp ứng được kỳ vọng của hầu hết các Gophers. May mắn thay, ngôn ngữ Go cung cấp gói sync
với cơ chế Once
, cho phép chúng ta viết mã đẹp hơn:
package singleton
import "sync"
type singleton struct{}
var instance *singleton
var once sync.Once
func GetSingleton() *singleton {
once.Do(func() {
instance = &singleton{}
})
return instance
}
Once
là một cấu trúc đảm bảo an toàn đồng thời bên trong phương thức Do
bằng cách sử dụng các thao tác nguyên tử và cơ chế khóa. Phương thức once.Do
đảm bảo rằng việc tạo &singleton{}
chỉ xảy ra một lần, ngay cả khi nhiều goroutine đang thực thi đồng thời.
Trên thực tế, Once
không bí ẩn. Việc triển khai nội bộ của nó rất giống với cơ chế khóa kép đã đề cập trước đó, ngoại trừ việc nó thay thế instance == nil
bằng các thao tác nguyên tử. Những người đọc quan tâm có thể tham khảo mã nguồn tương ứng để biết thêm chi tiết.
Tóm tắt
Trên đây là một số cách tiếp cận phổ biến để triển khai kiểu mẫu singleton trong Go. Sau khi so sánh chúng, chúng ta có thể kết luận rằng cách tiếp cận được khuyến nghị nhất là sử dụng once.Do
. Gói sync.Once
giúp che giấu một số chi tiết, đồng thời cải thiện đáng kể khả năng đọc mã.