Nhân tiện mọi người có vẻ hào hứng về chủ đề nho nhỏ này và đang viết được, viết nôt kẻo ít hôm lại lười.

Trả lời ngắn gọn là dùng. Dùng chứ. Nhưng dùng thế nào cho đúng thì phải hiểu rõ một chút. Bài viết này cố gắng cung cấp nhiều góc nhìn để cân nhắc.

Vì sao ra đời?

Trong GoF, Singleton được đưa ra với mục đích sau: “Ensure a class only has one instance, and provide a global point of access to it.”“Đảm bảo một class chỉ có duy nhất một instance, và cung cấp một điểm truy cập duy nhất trên toàn cục tới instance.”

Cái gì hay?

Hay thì rõ rồi, nó cung cấp ý tưởng của việc một instance duy nhất, điều mà chúng ta gặp trong nhiều bài toán thực tế: một ứng dụng được khởi tạo, một cấu hình hệ thống, một logger… Tôi cá là nhiều người không biết tới Singleton thì không biết giải quyết bài toán này thế nào.

  • Runtime:
    • Không giống như static trong class, object và các giá trị trong đó chỉ được khởi tạo khi cần thiết. Memory và cả CPU đều được tiết kiệm.
    • Cho phép chủ động quản lý life cycle, giải phóng khi cần thiết.
  • Design:
    • Abstract hơn sử dụng static trong class;
    • Có khả năng thừa kế;
    • Có thể kếp hợp với những design pattern khác.

Cái gì dở?

Dở thì cũng có, bởi vậy mới có nhiều tranh luận.

  • Tư tưởng: Singleton trong GoF dở cơ bản về tư tưởng bởi nó giải quyết 2 bài toán khác nhau (dù có vẻ liên quan): “Ensure a class only has one instance, and provide a global point of access to it.”
    • Một class có duy nhất một instance;
    • Cung cấp một điểm truy cập duy nhất trên toàn cục tới instance.
    • Tác giả đã vô tình kèm cả lời giải trong bài toán với giả định: để có một điểm truy cập toàn cục duy nhất thì chỉ có duy nhất một instance được tạo ra từ một class. Bài toán “một điểm truy cập” có thể được giải quyết bởi Facade, Wrapper… không nhất thiết phải là Singleton.
  • Design:
    • Coupling: Vì là global state nên các thành phần bị gắn chặt với nhau;
    • Khó / không thể viết test;
    • Viết dễ sai sót, để lại lỗ hổng (xem bài trước Singleton có thực sự dễ?).
  • Runtime:
    • Không “thân thiện” với theading.

Nên dùng thế nào?

Như vậy, ta thấy rằng đa phần những thứ dở của design pattern này là ở tư tưởng global state. Vậy nên những ai yêu thích functional programming thì sẽ rất anti-pattern này. Cũng đúng thôi, GoF sinh ra cho OOP, không phải FP. Và thời đại của GoF (1994) cũng không quan tâm nhiều tới concurrency – bài toán trở thành rất cơ bản trong thời đại này. Bởi vậy, việc sử dụng Singleton có chút thay đổi. Có 3 điều cần chú ý:

  1. Concurrency: Global state là điều tệ hại cho concurrency. Hãy giảm thiểu tối đa nếu có thể. Global state bẻ cong cách suy nghĩ về luồng và gây mệt mỏi cho việc debug trong concurrency. Nếu bạn muốn thiết kế hệ thống tối ưu hiệu năng và concurrency thì không sử dụng Singleton cũng là một ý hay.
  2. Memory:
    1. Lưu cái gì? Global state cũng là một ý hay vì khiến việc thiết kế và lập trình dễ dàng hơn, nó chỉ không hay khi bạn không cân nhắc tới nên lưu gì. Rất nhiều thứ có thể nhìn dưới góc độ một instance nếu chúng ta không có khả năng khái quát hoá. Logger là Singleton không? Hay có errorlog, accesslog? Database là single thì lưu cả database? Hay chỉ connection? Hay chỉ connectionString? Lưu ít nhất có thể. 
    2. Lưu khi nào? Nhiều người hay gắn Singleton với life cycle của cả ứng dụng, kèm theo việc lưu trữ nhiều, hoặc giữ strong reference dẫn đến GC không thể hoạt động; sớm muộn gì cũng gây ra memory leak. Khởi tạo muộn nhất có thể, giải phóng sớm nhất có thể. 
  3. Language: Cần lưu ý cách sử dụng trong từng ngôn ngữ (xem bài trước Singleton có thực sự dễ?Singleton:threading() in Java), mỗi ngôn ngữ khác nhau sẽ có vấn đề khác nhau. Dù design pattern là mức thiết kế song đừng mang nguyên cách cài đặt từ ngôn ngữ này sang ngôn ngữ khác, hãy nhìn vào diagram và đặc trưng ngôn ngữ.

Trên đây là một số góc nhìn, gợi ý để bạn dùng Singleton đúng hơn. Không có đúng hay sai khi dùng Singleton, dùng đúng hay không mới là vấn đề.

577 total views, 1 views today

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:

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).

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.

544 total views, no views today