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 đề.

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

374 total views, no views today

Hôm nay đồng nghiệp hỏi câu này: Tại sao nên viết như #a thay vì #b?

Tôi mở rộng câu hỏi thành: Sắp xếp các cách viết sau theo thứ tự “tốt dần”.

Khá khó để nói #3 và #4, cách nào tốt hơn; nhưng có thể dễ dàng khẳng định #1 và #2 không tốt bằng #3, #4. Tại sao?

Nguyên tắc chung của lập trình hướng đối tượng là trừu tượng hoá và cố gắng tối đa việc trừu tượng hoá. Trừu tượng (không phải là giải pháp toàn vẹn nhưng) là một phần trong cách tư duy về Open / Closed principle. #1 rất trực quan và dễ hiểu: tạo một danh sách lưu trữ sách dưới dạng ArrayList. Nhưng những thứ cụ thể rất khó để sửa đổi và thay thế. Ví dụ, sau khi implement, chúng ta phát hiện ra đoạn code phía sau sử dụng rất nhiều thao tác thêm phần tử vào list thay vì lấy phần tử ra; do đó lưu trữ dưới dạng Stack cho hiệu suất tốt hơn; chúng ta phải sửa đổi các đoạn code phía dưới với implement cụ thể của Stack (thay cho ArrayList). Một thời gian sau, nhu cầu lấy phần tử từ list đủ nhiều để sử dụng ArrayList cho hiệu suất tốt hơn, chúng ta phải sửa lại đoạn code phía dưới với implement cụ thể của ArrayList. (Tham khảo: hiệu suất các implement của List). Khổ chưa?

Một cách thông minh hơn là sử dụng #3, khi đó, đoạn code phía dưới chỉ sử dụng những method được định nghĩa cho interface List. Khi cần thay đổi implement cụ thể thành Stack, LinkedList…, chúng ta chỉ đơn giản thay new List<Book>() bởi new Stack<Book>() hay new LinkedList<Book>(). 

Và theo cách tư duy đó, #4 tốt hơn #3? Không hẳn, nếu chúng ta đã xác định list chỉ chứa Book, việc khai báo list chứa Object khiến chúng ta có thể mất công cast những object này trong trường hợp sử dụng những method cụ thể của Book (và thường là vậy). Nên dùng #4 thường là bất lợi hơn #3 (trừ trường hợp list chữa những object khác ngoài Book).

Do đó, sử dụng #3 (khai báo interface và khởi tạo bằng class (đương nhiên)) thường là cách viết nên được “quen tay”.

Thật ra #3 còn nên viết theo 1 cách khác tốt hơn như sau. Tại sao nhỉ?

808 total views, no views today

In my opinion, iterating through a collection is very basic and simple thing because it appears in any developer’s code everyday (of course in the languages support collection).

It’s normally my simplest question to start the interview to warmup and make the candidate more confident. But I’m wrong. I’m so surprised that 90% of our candidates, who have been (senior) developers for 2-3 years, cannot give the right answer:

Let say I have a collection LinkedList<int> list. What are differences between 2 types of iterating through list:

and

Most of answer I got is “we can have the index by #1 code that we cannot have in #2 code. The performance are the same.”. But it’s incorrect, #1 is really bad code in performance wise.

The performance are the same in the index-based collection, like array or ArrayList. But #1 code is very slow in other collection like LinkedList. The reason is for getting the item at index i (by calling get(i) method), LinkedList needs to iterate from the first item with the counter set to 0, increase it in each step and stop when the counter reach to i. So while the notation of #1 code is O(N2), the notation of #2 code is O(N).

#2 code actually works as

#3 code is exactly the way #2 code works before Java introduced for each syntax.

The best solution is always use #2 code for iterating through any collection. If you are worry about the index, another code would be:

Java is just a used language here but I believe this way works in any language today. Whatever the technology you are chasing, let start from the basic things, language and data structure.

833 total views, 1 views today