[Design Pattern] Liệu bạn có thực sự hiểu về Singleton pattern?

Nam Vu
7 min readMay 20, 2020

Đầu tiên hãy định nghĩa Singleton là gì đã.

Singleton là một mẫu thiết kế (pattern) mà nó đảm bảo rằng chỉ có một Object/Instance được tồn tại trong một vòng đời của ứng dụng.

Rất đơn giản và dễ hiểu, ngay đến cái tên của nó đã cho thấy sự cô đơn rồi. Thực sự đây là một pattern vô cùng đơn giản và ai cũng tưởng là mình hiểu rõ nó lắm… nhưng thực tế rằng có rất nhiều vấn đề thú vị về nó … hãy cùng tôi tìm hiểu nhé 😆.

OK vậy bây giờ hãy thử một ví dụ để minh họa tính ứng dụng của nó trong thực tế thế này. Ví dụ: công ty bạn đang làm một Video Game về các siêu anh hùng Marvel, và công việc của bạn là tạo một thư viện để tạo ra các đối tượng (object) siêu anh hùng (superheroes), mà phải thỏa mãn điều kiện là: mỗi superhero chỉ được phép có một phiên bản duy nhất, và thật kinh hãi 😱 nếu chúng ta có hẳn 2 ông… Thanos … 😰

I’m inevitable

Chắc chắn giải pháp đầu tiên xuất hiện trong đầu chúng ta sẽ là sử dụng Singleton và tôi sẽ cài đặt một class đại diện cho Hulk như sau:

Với cách cài đặt contructor trong private nó sẽ đảm bảo bên ngoài chương trình không thể khởi tạo Hulk bằng contructor mà bắt buộc phải sử dụng getInstance()

Đoạn code trên khá ổn, nhưng ứng dụng sẽ luôn luôn khởi tạo ra một Object Hulk khi chương trình khởi động, và tất nhiên chẳng ai muốn thằng cha Hulk điên 😳 này lúc nào cũng xuất hiện ngoài những cần kíp như khi "bem nhau" 👊 với quân của Thanos chẳng hạn. Nói theo ngôn ngữ lập trình thì ta có thể đang tốn bộ nhớ để chứa Object Hulk mà chẳng bao giờ sử dụng tới cả.

Ok, vậy ta có thể viết lại bằng cách chỉ khi hàm getInstance() được gọi tới ta mới khởi tạo Object Hulk như sau:

public class Hulk {
private static Hulk hulk;

// We have marked the constructor private
private Hulk() {
}

public static Hulk getInstance() {
if (hulk == null) {
hulk = new Hulk();
}
return hulk;
}

// Object method
public void flight() {
System.out.println("I am Hulk & I will beat you up !");
}
}

Có vẻ ổn hơn, nhưng tuy nhiên sẽ có bạn đọc phát hiện ra một lỗi tiềm ẩn trong hàm getInstance() đó là nếu trong môi trường hoạt động đa luồng (multi-threaded) khi nhiều thread đang cùng gọi tới thì hiện tượng context switch* sẽ xuất hiện. Lúc đó một thread A đang gọi tới getInstance() có thể bị context switch dẫn tới một thread B khác có thể tiến tới getInstance() và tạo ra một bản thể Hulk, rồi sau đó thread A tiếp tục và lại tạo ra một bản thể Hulk khác, lúc này chúng ta sẽ có tận 2 ông Hulk điên 😅 xuất hiện. Vậy là toang rồi ông giáo ơi 🐶.

(*) Việc điều phối các process sẽ bao gồm, việc ngừng process hiện tại lại, lưu lại trạng thái của process này, lựa chọn process tiếp theo sẽ được chạy, load trạng thái của process tiếp theo đó lên, rồi chạy tiếp process đó. Quá trình này được gọi là Context Switch.

OK bạn đọc chắc lúc này cũng sẽ nghĩ tới người bạn tốt synchronized để đảm bảo rằng trong một thời điểm chỉ có một thread duy nhất khởi tạo Object. Đoạn code trên sẽ được easy fix lại như sau:

public class Hulk {
private static Hulk hulk;

// We have marked the constructor private
private Hulk() {
}

public static Hulk getInstance() {
if (hulk == null) {
hulk = new Hulk();
}
return hulk;
}

// Object method
public void flight() {
System.out.println("I am Hulk & I will beat you up !");
}
}

Wow lúc này chương trình có vẻ ngon 👌, nhưng sẽ có một chút vấn đề về performance, do sử dụng synchronized cho nên trong mọi thời điểm kể cả lúc Hulk đã được khởi tạo thì việc lấy ra Object Hulk là luôn tuần tự, lúc này performance của chương trình sẽ bị ảnh hưởng. Vậy lúc này liệu ta có thế vừa đảm bảo việc Hulk chỉ có duy nhất một bản thể và performace của chương trình vẫn tốt? Câu trả lời là CÓ, và kỹ thuật này thường được gọi là double checked locking.

Ý tưởng của double checked locking đơn giản chỉ là ta sẽ check hai lần hulk==null , lần check đầu tiên sẽ nằm ngoài synchronized do đó sẽ đảm bảo được rằng nếu Hulk đã được khởi tạo do đó các thread khác sẽ không bị chặn bởi synchronized và sẽ trả về Object ngay lập tức. Nó sẽ đảm bảo rằng Hulk luôn luôn chỉ có một bản thể và performance của hệ thống sẽ được cải thiện. Đoạn code trên sẽ được double checked looking fix lại như sau:

public class Hulk {
private static Hulk hulk;

// We have marked the constructor private
private Hulk() {
}

public static Hulk getInstance() {
// Check if object is uninitialized
if (hulk == null) {
// Now synchronize on the class object, so that only
// 1 thread gets a chance to initialize the superman
// object. Note that multiple threads can actually find
// the superman object to be null and fall into the
// first if clause
synchronized (Hulk.class) {
// Must check once more if the superman object is still
// null. It is possible that another thread might have
// initialized it already as multiple threads could have
// made past the first if check.
if (hulk == null) {
hulk = new Hulk();
}
}
}
return hulk;
}

// Object method
public void flight() {
System.out.println("I am Hulk & I will beat you up !");
}
}

Đoạn code trên gần như hoàn hảo, tuy nhiên vẫn còn thiếu một mảnh ghép cuối cùng, để hiểu vấn đề này ta sẽ nói lại một chút về CPU Cache mà tôi đã đề cập tới ở bài viết này

Mỗi CPU core đều có bộ nhớ cache riêng đó là bộ nhớ L1, L2 và bộ nhớ chung (share memory) L3 cho tất cả các lõi (CPU core) và CPU sẽ cache data lên đó. Vì vậy thay vì chương tình sẽ truy vấn vào DRAM thì chương trình sẽ truy vấn vào bộ nhớ riêng L1, L2, nếu không thấy thông tin hợp lệ thì mới truy cập vào share memory L3 và sau đó mới là main memory (DRAM).

Do đó rất có thể sẽ có một vấn đề như sau xuất hiện.

  • Giả dụ lúc này Hulk chưa hề được khởi tạo và có một Thread A và đi qua double checked locking và nó sẽ khởi tạo Object Hulk vào bộ nhớ riêng L1, nhưng chưa thực sự hoàn thành và context switch đột nhiên xuất hiện.
  • Đúng lúc này Thread B xuất hiện và cũng cần sử dụng Hulk, và nó cũng qua double checked locking và phát hiện ra rằng Object Hulk đã được khởi tạo ở bước trước ở bộ nhớ riêng nhờ Cache coherence protocols, lúc này Thread B sẽ tưởng rằng Object Hulk đã thực sự tồn tại nó sẽ lấy ra và trả về dữ liệu, lúc này chương trình sẽ bị crash vì thực sự Object Hulk vẫn chưa thực sự hoàn thành việc khởi tạo.

Để giải quyết trường hợp trên thì ta sẽ sử dụng volatile keyword để đảm bảo Object Hulk luôn luôn được lưu trên Main Memory. Đoạn code chuẩn cuối cùng về một mẫu thiết kế Singleton đúng đắn👌

public class Hulk {
private static volatile Hulk hulk;

// We have marked the constructor private
private Hulk() {
}

public static Hulk getInstance() {
// Check if object is uninitialized
if (hulk == null) {
// Now synchronize on the class object, so that only
// 1 thread gets a chance to initialize the superman
// object. Note that multiple threads can actually find
// the superman object to be null and fall into the
// first if clause
synchronized (Hulk.class) {
// Must check once more if the superman object is still
// null. It is possible that another thread might have
// initialized it already as multiple threads could have
// made past the first if check.
if (hulk == null) {
hulk = new Hulk();
}
}
}
return hulk;
}

// Object method
public void flight() {
System.out.println("I am Hulk & I will beat you up !");
}
}

Conclusion:

Để cài đặt được một Singleton chuẩn không đơn giản như hầu hết chúng ta lầm tưởng, và với việc tìm hiểu cách cài đặt một Singleton đúng đắn trong môi trường multi-threading giúp chúng ta thêm những hiểu biết về lập trình đa luồng. Theo kinh nghiệm của người viết thì Singleton rất hay được hỏi trong các buổi PV, và hy vọng bài viết này có thể sẽ giúp bạn trong một cuộc PV nào đó trong tương lai 🙌.

~ Hết ~

--

--