Bài trước về Singleton có thực sự dễ, tôi nhận được vài comment rất chuẩn về cách cài đặt xử lý với theading. Vì bài trước tập trung nói về Singleton và các vấn đề có thể gặp phải với reflection, threading, serializable… nên tất cả những giải pháp đưa ra chỉ dừng ở mức ý tưởng không làm bạn đọc phân tâm. Bài này nói rõ hơn về xử lý theading khi cài đặt Singleton.
Tôi sẽ đi vào cài đặt “chuẩn” đã đưa cuối bài Singleton có thực sự dễ trước:
public class AppConfig { private static volatile AppConfig self; private AppConfig() { if (self != null) { throw new UnsupportedOperationException("Use getInstance()"); } } public static synchronized AppConfig getInstance() { if (self == null) { self = new AppConfig(); } return self; } }
Khi phân tích về threading trong bài trước bạn thấy tôi viết “Vậy nên cần phải synchronized việc tạo object.”. Nếu để ý kỹ, có 2 phần thay đổi:
- synchronized được thêm vào method getInstance()
- volatile được thêm vào self (phần này được tôi lờ đi)
Có mấy vấn đề ở đây liên quan tới threading bạn nên biết.
synchronized cần phải tối thiểu
Cần phải khẳng định ngay rằng cách viết trên cho hiệu năng rất thấp. synchronized sẽ thực hiện việc lock method khiến các thread không thể invoke method song song, chúng buộc phải invoke tuần tự. Nếu có 100 thread gọi getInstance(), thread thứ 100 sẽ nhận được object AppConfig sau khi 99 thread trước đã hoàn thành. Sẽ tốt hơn nếu object AppConfig được tạo ra bởi thread đầu tiên, 99 thread còn lại có thể đồng thời được nhận lại object AppConfig.
Double check locking nên được sử dụng trong trường hợp này. Double check locking là một design pattern phổ biến trong bài toán về threading, kiểm tra lock trước khi thực sự lock method (theo nguyên lý return as soon as possible).
public static synchronized AppConfig getInstance() { if (self == null) { synchronized (AppConfig.class) { if (self == null) { self = new AppConfig(); } } } return self; }
if (self == null) được thực hiện 2 lần, nên pattern này được gọi là double check. Đừng bỏ câu lệnh thứ 2 nếu bạn không muốn một hệ quả tai hại (100 object được tạo ra… tuần tự, tại sao?). Đấy là lý do tôi muốn synchronized cả method để ai đọc cũng hiểu.
Chỉ lock những statement thực sự cần thiết và không nhiều hơn mức cần thiết là nguyên lý cơ bản của threading.
synchronized vẫn chưa đủ
Ngay cả khi bạn không bỏ đi câu lệnh if (self == null) nào, vẫn có thể có hàng chục object AppConfig được tạo ra.
(http://tutorials.jenkov.com/images/java-concurrency/java-volatile-1.png)
Hãy nhớ, máy tính có đến mấy loại bộ nhớ, nên việc đảm bảo dữ liệu đồng nhất không dễ. Lan man 1 chút về kiến trúc máy tính qua hình trên:
- Main memory là RAM.
- CPU xử lý dữ liệu được nạp vào bộ nhớ của CPU, không phải RAM. Bởi vậy dữ liệu được sao chép theo trình tự RAM -> Lx ->… -> L2 -> L1 cache. Trong lúc đấy hàng triệu thứ đã xảy ra.
- Tưởng tượng, counter là self, khi 2 thread bắt đầu, self = null, được copy vào cache. Sau đó dù có synchronized thì 2 CPU vẫn lấy giá trị trên 2 cache độc lập để ra quyết định. Kể cả sau khi xử lý xong, giá trị vẫn được giữ trên cache đến khi thực sự cần đưa xuống RAM.
Vậy là ta phải dùng volatile để giá trị của biến luôn được tham chiếu tới RAM khi thay đổi. Vậy đây là giá trị duy nhất.
Còn cách nào khác không?
Nếu để ý, bạn thấy volatile trở nên vô dụng khi ta lock toàn bộ method. Tức là cách cài đặt của tôi ở cuối bài trước là rất ngớ ngẩn. Thật ra tôi cố tình viết vậy, ai không hiểu về threading thì vẫn có giải pháp chạy đúng; người hiểu biết về threading thì có hint đi tiếp.
Tôi không muốn đi sâu vào threading vì vấn đề này phụ thuộc ngôn ngữ, nền tảng. Hy vọng bạn hiểu rằng một design pattern đơn giản như Singleton cũng cần cài đặt cẩn thận và hiểu biết sâu sắc.
Kết
Có 2 vấn đề muôn thưở của lập trình: performance và memory. Bài này đề cập tới performance. Một số comment khác về việc không quản lý tốt life cycle khi sử dụng Singleton gây ra memory leak. Tôi sẽ cố gắng viết sau.
Và đừng hỏi tại sao lập trình lại phải biết kiến trúc máy tính. Bởi vì code không chạy trên máy tính thì chạy ở đâu? Nền tảng quan trọng lắm.