[Design Patterns #2] Singleton Pattern

Bài toán cần giải quyết

(Tình huống giả định) —– Tôi là chuyên gia về thiết kế phần mềm ở công ty XYZ và tôi đang phải xử lý một vấn đề liên quan đến performance – hiệu suất. “Hệ thống của mình đang chạy chậm vãi chưởng anh ạ !” – Technical Leader của dự án nói với tôi. “Anh đã check thử và để ý thấy rằng database object của các chú đang có kích thước rất lớn, tận gần 20MB.” “Vâng.” “Khi hệ thống đang chạy thì có khoảng bao nhiêu database object ?” “Khoảng 200 anh ạ.” “Đm, vãi, 200 nhân với 20MB, các chú không thấy vấn đề gì ở đây à ?” “Dạ không ạ.” “Các chú đang sử dụng quá nhiều tài nguyên hệ thống. Có cả trăm object với kích thước lớn (~20MB) được khởi tạo trong bộ nhớ RAM. Trong khi server chạy App chỉ có 4GB RAM, dùng full cả RAM thì nó chả chậm. Có thực sự cần nhiều database object như vậy không ?” “Bla bla…” “A nghĩ là dell cần đâu… Thôi được rồi, vấn đề này phải xử lý bằng Singleton Pattern.” —–

Trong bài này chúng ta sẽ nói về vấn đề việc kiểm soát số lượng đối tượng trong chương trình để tối ưu hóa việc sử dụng tài nguyên hệ thống sử dụng Singleton Pattern. Với một class theo Singleton Pattern, chỉ có tối đa một đối tượng cụ thể của class đó được khởi tạo xuyên suốt chương trình.

Chỉ khởi tạo một đối tượng với Singleton Pattern

Quay trở lại với bài toán bên trên, các lập trình viên đang tạo ra hàng trăm Database objects khi chương trình chạy, trong khi đó kích thước của mỗi object rất lớn (lên đến gần 20MB). Chúng ta sẽ cùng nhau sử dụng Singleton Pattern để giải quyết vấn đề này.

Mục đích chính của Singleton Pattern là đảm bảo chỉ có thể tạo ra một object của một class cụ thể. Nếu chúng ta không sử dụng pattern này thì với mỗi toán tử new hoặc lệnh khai báo đối tượng như sau, chương trình sẽ tạo ra các object khác nhau của cùng một class.

Singleton Pattern sẽ đảm bảo chỉ có tối đa một object của class được tạo ra trong chương trình cho dù bạn cố gắng tạo đối tượng mới bao nhiêu lần đi nữa. Dó đó bạn nên sử dụng Singleton Pattern khi bạn muốn hạn chế việc sử dụng tài nguyên (thay vì tạo số lượng đối tượng lớn không giới hạn) hoặc bạn muốn có một object chứa dữ liệu chia sẻ cho cả hệ thống (ví dụ: registry, logging, caching,…).

Việc chỉ tạo một object cũng có ý nghĩa khá quan trọng khi bạn phát triển ứng dụng đa luồng (multithreading) và không muốn bị xung đột trong việc truy cập data khi có nhiều object của cùng một class cùng hoạt động. Với trường hợp làm việc với database objects và có nhiều thread, mỗi thread tạo ra database object riêng của nó, tất cả các objects này đều truy cập chung một data store bên dưới. Khi đó việc xảy ra lỗi do xung đột truy cập dữ liệu cùng lúc là hoàn toàn có thể xảy ra.

Demo Code

*** Tạo Singleton database class

Chúng ta sẽ bắt đầu với class có tên là Database → 

Tiếp tục, chúng ta thêm 2 methods là editRecord (dùng để edit record trong database) và getName (trả về tên của database) →  Ok tạm ổn. Tuy nhiên vấn đề về việc khởi tại nhiều object của class Database vẫn chưa được giải quyết. Đoạn code sau sẽ tạo ra 3 Database objects trong hệ thống →  Vậy làm thế nào để ngăn chặn việc tạo object mới với mỗi câu lệnh new (hoặc khai báo biến) như trên ? Câu trả lời: hãy để contructor của Database private →  Bây giờ thì không ai có thể tạo object của class Database ở bên ngoài class này một cách tùy tiện được nữa. Chờ đã… What the fuck ?? Vậy làm thế dell nào tạo được object của class này để mà dùng đây ?

OK. Chúng ta cần tạo một static method có tên là getInstance() dùng để tạo object của class Database, method này sẽ call đến constructor của Database (vì method này là hàm của class nên nó được phép call đến hàm private). Ngoài ra hàm này cũng chịu trách nhiệm đảm bảo chỉ có duy nhất một object của class được tạo ra trong hệ thống → 

Ngoài việc thêm hàm getInstance() chúng ta cũng cần thêm một biến member static là mInstancePtr – là con trỏ sẽ trỏ đến object duy nhất của Database. Hàm getInstance() sẽ phải kiểm tra object đã tồn tại chưa, nếu chưa tồn tại thì tạo object mới và gán mInstancePtr trỏ vào object đó, sau đó trả về mInstancePtr. Nếu object đã tồn tại (mInstancePtr khác nullptr) thì trả về mInstancePtr luôn mà không tạo object mới.

Như vậy vấn đề vấn đề đã được giải quyết 99% (còn 1% liên quan đến đa luồng sẽ thảo luận sau), chỉ có duy nhất một object cúa class Database tại một thời điểm. Call hàm getInstance() sẽ lấy được object của Database, và tất cả object đó vẫn chỉ là một object duy nhất mà thôi.

*** Test Singleton Database class

Để test class Database, mình sẽ tạo hàm main như bên dưới →  Chạy chương trình sẽ cho ra kết quả trên console như sau →  Từ kết quả chúng ta có thể nhận thấy rằng 2 lần call hàm getInstance() đều trả về con trỏ của cùng một object.

Xem xét vấn đề đa luồng (multithreading) trong Singleton Pattern

Hãy cùng xem xét lại hàm getInstance() của class Database → 

Có một lỗ hổng tiềm ẩn nguy hiểm ở đây. Chúng ta muốn đảm bảo rằng chỉ có duy nhất 1 object của class Database có thể được tạo ra. Nhưng nếu chương trình có nhiều threads cùng chạy một lúc và cùng call đến hàm getInstance() tại cùng thời điểm, chúng ta có thể gặp rắc rối ở đây. Lỗ hổng ở đây nằm ở câu lệnh check null con trỏ mInstancePtr.

Giả sử có 2 threads cùng call đến hàm getInstance() một lúc và lúc đó chưa có object nào của class Database được tạo. Khi đó có thể xảy ra trường hợp câu lệnh “if (nullptr == mInstancePtr)” ở cả 2 threads đều “true”, và cả 2 threads đều tạo ra object mới.

Để ngăn chặn lỗi này chúng ta có 2 phương pháp:
  1. Sử dụng mutex để đồng bộ xử lý của các threads
  2. Tạo object tĩnh ngay từ lúc khởi chạy chương trình (trước khi vào hàm main)

*** Sử dụng mutex để đồng bộ xử lý của các threads

Từ C++11 chúng ta có thể sử dụng mutex trong thư viện chuẩn, file header là <mutex>. Trước khi check null mInstancePtr và tạo object mới (nếu chưa tồn tại) ta cần call hàm lock() của mLocker (một biến member kiểu mutex của Database) và sau khi thực hiện xong thì call hàm unlock() → 

*** Tạo object tĩnh ngay từ lúc khởi chạy chương trình (trước khi vào hàm main)

Chạy chương trình này vẫn cho ra kết quả như lúc trước. Điểm khác biệt lớn nhất ở đây là object của Database được tạo ra ngay từ đầu chương trình chứ không phải tạo ở lần đầu tiên call hàm getInstance()

— Phạm Minh Tuấn (Shun) —