12/08/2018, 13:34

Go with Multi thread

Đầu tiên, hãy thử suy nghĩ về 2 vấn đề sau đây: Vấn đề 1 : Vector ở STL trong C++ có phải là thread safe không? Vấn đề 2 : ArrayList của Java có phải là thread safe không? Câu trả lời cho cả 2 vấn đề trên đều là “Không, không phải là thread safe!”. Cái này chỉ cần hỏi ...

Đầu tiên, hãy thử suy nghĩ về 2 vấn đề sau đây:

Vấn đề 1: Vector ở STL trong C++ có phải là thread safe không?

Vấn đề 2: ArrayList của Java có phải là thread safe không? Câu trả lời cho cả 2 vấn đề trên đều là “Không, không phải là thread safe!”. Cái này chỉ cần hỏi “Google tiên sinh” là có thể biết ngay câu trả lời. Vậy, ta đi đến vấn đề tiếp theo.

Vấn đề 3: “ArrayList được tạo ra bằng Collections.synchronizedList của Java thì là thread safe nên không cần để ý đến multi thread.” có chính xác không?

Vấn đề này thì hơi phức tạp một chút. Nếu trả lời rằng: “Không cần để ý đến các method call đơn lẻ” thì câu trả lời của các bạn có thể được chấp nhận.

Chúng ta hãy bắt đầu câu chuyện từ chỗ “Bản chất thread safe là gì?”. Những người không hiểu hết nội dung câu hỏi đó thông qua vấn đề 1 và 2 đã nêu ở phần trước, sau đó tra cứu trên Google để tìm câu trả lời chính là đối tượng mà bài báo này hướng đến. Những người hiểu nội dung câu hỏi, biết câu trả lời cũng như biết nên dùng như thế nào để đạt hiệu quả thì không cần đọc bài báo này. Ngoài ra, lần này, tôi xin mạnh dạn đưa ra những câu chuyện ví dụ để độc giả “cảm thấy dễ hiểu”. Nếu suy xét một cách tỉ mỉ thì sẽ có nhiều vấn đề nhưng mong các bạn không quá chú ý vào những chi tiết nhỏ đó.

“Thread safe” nghĩa là: “trong 1 phần mềm multi thread, dù có truy cập từ nhiều thread thì chương trình cũng vẫn chạy bình thường mà không gặp vấn đề gì”. Lấy ví dụ trong công việc của con người, “thread safe” nghĩa là “kể cả khi nhận được yêu cầu công việc từ nhiều người nhưng người đó vẫn có thể nắm chính xác được từng việc một, và đạt được kết quả”. Hãy cùng nói cụ thể hơn, trong ví dụ này, hãy chú ý vào việc Việt-kun có thread safe hay không. Giả thử, Việt-kun không có đồng nào.

(1) Trọng-san: “Việt-kun, anh đưa cho chú 30k, mua giúp anh 2 cái bánh mì (15k/cái) nhé!”

(2) Hải-san: “Việt-kun, có 20k không thì cho anh vay?”

Trường hợp ngay sau khi Việt-kun được Trọng-san nhờ (1) lại được Hải-san nhờ (2) thì chúng ta hãy cùng suy nghĩ nếu Việt-kun không thread safe, cậu ấy sẽ hành động thế nào? Nếu thread safe thì cậu ấy sẽ xử lý ra sao?

Việt-kun không thread safe: Đưa 20k trong số 30k đã nhận từ Trọng-san trong (1) cho Hải-san nên không thể mua được bánh mì mà Trọng-san nhờ.

Việt-kun thread safe: Khi được Hải-san nhờ (2) thì sẽ phán đoán là: “30k này là tiền Trọng-san đưa nên không thể cho vay được” và từ chối yêu cầu của Hải-san rồi sau đó đi mua 2 cái bánh mì hộ Trọng-san.

Đây là một câu chuyện hết sức đơn giản nhưng về mặt khái niệm thì thread safe là kiểu như vậy. Tuy nhiên, cách để thực hiện thread safe thì không chỉ có một. Ví dụ có những phương pháp sau:

Việt-kun thread safe khác: Vì trong (1), Việt-kun đã được Trọng-san nhờ nên tạm thời không nghe chuyện của Hải-san trong (2), trước tiên đi mua 2 cái bánh mì đã. Sau khi đưa bánh mỳ cho Trọng-san, Việt-kun mới nghe yêu cầu của Hải-san. Tất nhiên khi đó Việt-kun đã không còn đồng nào nên không thể cho vay được.

Việt-kun đầu tiên là nhân vật không thread safe, Việt-kun thứ 2, thứ 3 là nhân vật có thread safe. Vậy Việt-kun thứ 2 và Việt-kun thứ 3 có gì khác nhau? Thực ra, điểm khác nhau này chính là sự khác nhau về ý tưởng thiết kế. Có thể nói là Việt-kun thứ 3 thiết kế với mục tiêu an toàn và chắc chắn, Việt-kun thứ 2 thì thiết kế hướng đến mục tiêu sao cho chương trình có khả năng xử lý toàn diện cao. Đến đây, các bạn đã hiểu được lý do chưa? Để hiểu được lý do này, thay vì yêu cầu (2) của Hải-san, chúng ta hãy thử suy nghĩ đến trường hợp Linh-san sẽ nhờ như sau:

(2’) Linh-san: “Việt-kun, tớ đưa cậu 15k này, mua giúp tớ cái bánh mì (15k/cái) nhé!”

Nếu là Việt-kun có năng lực cao thì sẽ đem 45k ra quán, mua 3 cái bánh mì 1 lần rồi đưa 2 cái cho Trọng-san, 1 cái cho Linh-san. Nếu là Việt-kun thiết kế với mục tiêu an toàn, chắc chắn thì đầu tiên sẽ đi mua 2 cái bánh mì cho Trọng-san rồi lại đi mua 1 cái bánh mì cho Linh-san lần nữa. Nghĩa là phải đi đến cửa hàng 2 lần để mua bánh mì.

Ngược lại, nếu là Việt-kun không thread safe thì sẽ xử lý yêu cầu (2’) như thế nào đây? Ở đây, Việt-kun có thể nghĩ ra rất nhiều phương pháp nhưng có thể có Việt-kun xử lý không tốt, đã “ghi đè” yêu cầu của Linh-san lên yêu cầu của Trọng-san trong “bộ nhớ” rồi chỉ mua 1 cái bánh mì ở cửa hàng đưa cho Linh-san. Sau đó thắc mắc “Tại sao mình lại có 30k nhỉ??”

Tất nhiên, nếu đưa câu chuyện này về chủ đề lập trình thì Việt-kun chính là 1 object. Những yêu cầu từ Trọng-san, Hải-san, Linh-san tương đương với việc gọi method. Nếu như bạn cố viết một đoạn code không đáng tin cậy thì sẽ có hậu quả như thế này: (Tất nhiên, không tồn tại ngôn ngữ lập trình nào như vậy cả)

1: class Viet {
2:      unsigned int mymoney = 0;
3:      void buyBanhMi(person, count, money) {
4:             mymoney += money;
5:             gotoCafeteria();
6:             unsigned int paymoney = 15000 * count;
7:             mymoney -= paymoney;
8:             BanhMi *banhmi = buyBanhMiAtCafe(count, paymoney);
9:             handBanhMi(person, banhmi);
10:      }
11:      void lendMoney(person, money) {
12:            if (mymoney >= money) {
13:                 mymoney -= money;
14:                 handMoney(person, money);
15:            }
16:      }
17: }

Chừng nào method buyBanhMi() và lendMoney() được gọi lần lượt từ 1 thread thì sẽ chẳng có vấn đề gì. Nhưng nếu 2 method này được gọi từ những thread riêng tại những thời điểm khác nhau thì vấn đề sẽ phát sinh.Trong method buyBanhMi() thì mymoney chỉ tăng lên phần money đã được truyền bằng đối số, nếu đánh giá ở dòng 12 trong method lendMoney() tại thời điểm đó thì có khả năng xử lý từ line 13 trở đi sẽ được thực hiện. Nếu vậy thì mymoney sẽ bị nhỏ hơn paymoney và sẽ phát sinh underflow của mymoney -= paymoney.

Vậy, hãy thử thay đổi chương trình không đáng tin cậy này thành như sau:

1: class Viet {
2:      unsigned int mymoney = 0;
3:      void buyBanhMi(person, count, money) {
4:             // mymoney += money;
5:             gotoCafeteria();
6:             unsigned int paymoney = 15000 * count;
7:             money -= paymoney;
8:             BanhMi *banhmi = buyBanhMiAtCafe(count, paymoney);
9:             handBanhMi(person, banhmi);
10:      }
11:      void lendMoney(person, money) {
12:            if (mymoney >= money) {
13:                 mymoney -= money;
14:                 handMoney(person, money);
15:            }
16:      }
17: }

Tôi đã thử thay đổi thành: comment out cho dòng 4, thì dòng 7 không phải thanh toán bằng mymoney mà thanh toán số tiền cần thiết từ money nhận được ở đối số. Khi làm như vậy thì method buyBanhMi() và lendMoney() hầu như sẽ được gọi đồng thời từ 2 thread. Như thế thì sẽ thành kết quả thế nào? Trong code lần này, do đã comment out dòng 4 rồi nên kể cả buyBanhMi() có được gọi thì mymoney vẫn là 0, do đó dù ngay sau đó lendMoney() có được gọi thì đánh giá ở dòng 12 cũng thành false và Hải-san không thể mượn tiền được. Ngược lại, tiền của Trọng-san không hề được bỏ vào túi của Việt-kun mà được mang thẳng đến cửa hàng và có thể vui vẻ mua bánh mì cho Trọng-san. Tóm lại, method buyBanhMi() là thread safe. Đặc biệt, điều tôi muốn các bạn chú ý là “trong method buyBanhMi() đang không sử dụng biến số member và biến số static của class.” Do xử lý chỉ gói gọn trong đối số của method và biến số local nên kể cả có bị gọi bao nhiêu lần thì vẫn là thread safe hoàn hảo. Thực ra đoạn code này tương ứng với “Việt-kun có năng lực cao” mà tôi đề cập ở phần trước. Tuy nhiên, trong tình trạng này thì method lendMoney() vẫn chưa phải là thread safe. Tôi muốn các bạn thử tự suy nghĩ xem trong tình huống như thế nào thì sẽ phát sinh vấn đề.

Bây giờ, tôi sẽ thay đổi Việt-kun bằng phương pháp khác

1: class Viet {
 2:      Wallet *myWallet = new Wallet(0);
 3:      void buyBanhMi(person, count, money) {
 4:             synchronized(myWallet) {
 5:                  myWallet.add(money);
 6:                  gotoCafeteria();
 7:                  unsigned int paymoney = 15000 * count;
 8:                  myWallet.withdraw(paymoney);
 9:                  BanhMi *banhmi = buyBanhMiAtCafe(count, paymoney);
10:                 handBanhMi(person, banhmi);
11:             }
12:      }
13:      void lendMoney(person, money) {
14:             synchronized(myWallet) {
15:                 if (myWallet.howMuch() > money) {
16:                      myWallet.withdraw(money);
17:                      handMoney(person, money);
18:                 }
19:            }
20:      }
21: }

Lần này, chúng ta hãy cùng sử dụng synchronized block của Java để giải quyết vấn đề. Để làm cho “có vẻ Java” tôi đã thay đổi mymoney thành myWallet nhưng đó không phải vấn đề cốt lõi. Trong đoạn code này, tôi muốn các bạn chú ý đến synchronized(myWallet) đang được ghi ở dòng 4 và dòng 14. Nó có ý nghĩa là “phần được khoanh vùng trong dấu { } của method synchronized ()(synchronized block) chỉ giới hạn truy cập đến object đã được chỉ định bằng đối số của synchronized. Nói cách khác, nếu thực hiện nội dung trong ngoặc của synchronized(myWallet) bằng 1 thread thì sẽ không thể thực hiện synchronized(myWallet) block của thread khác cho đến khi synchronized block đang chạy được chạy xong. Tóm lại, nếu xét trong ví dụ về Việt-kun ở phần trước thì nghĩa là: trước khi xử lý của method buyBanhMi() của Trọng-san được hoàn tất thì lendMoney() của Hải-san sẽ chưa được thực hiện. Vì vậy buyBanhMi() và lendMoney() đã an toàn. Tất nhiên, đây là đoạn code không đáng tin cậy về Việt-kun mà tôi đã nêu ở phần trước, trong đó Việt-kun phải đi đến cửa hàng 2 lần để mua bánh mì. Tuy nhiên, đây chính là phương pháp an toàn và đơn giản nhất để làm cho thao tác đối với object xác định nào đó trở thành thread safe. Trường hợp không có yêu cầu về performance thì chỉ cần dùng cách này là đủ.

Nếu là trong iOS thì có thể sử dụng directive gọi là @synchronized(object), trong Windows thì có thể dùng Critical Section. Với hệ điều hành dạng Unix như Linux, Mac v.v.. thì thường sử dụng mutex của pthread. Cách nào cũng chuyên xử lý việc chạy code multi thread. Việc control đồng thời multi thread này có thể khác nhau về cách gọi ở platform nhưng thường được gọi với tên chung là “kiểm soát độc quyền của thread sử dụng mutex”.

Điều cần chú ý trong trường hợp dùng mutex để thực hiện kiểm soát độc quyền cho object là có khả năng việc cải thiện performance sẽ bị cản trở do những phần được kiểm soát độc quyền được multi thread. Block được bảo vệ bởi mutex chỉ xử lý được 1 thread 1 lần. Kết quả là các thread chờ mutex block sẽ bị tích lại có thể dẫn đến tình trạng dù đã cố gắng để xử lý multi thread nhưng hoàn toàn không nhanh được hơn chút nào. Ngoài ra, cần phải hết sức tránh việc phức tạp hóa xử lý trong mutex block.Trường hợp xấu nhất là sẽ bị gọi đi gọi lại cùng 1 mutex block, bị đồng bộ khi kết thúc thread khác cùng sử dụng chung 1 mutex dẫn đến phát sinh dead lock.

Chúng ta hãy quay lại vấn đề đầu tiên. Trong vấn đề 1, 2, câu hỏi là về vector trong STL của C++ và ArrayList của Java thì có thread safe hay không. Thông thường, xử lý yêu cầu thực hiện multi thread hay “thực hiện cùng 1 xử lý đối với nhiều data”. Ví dụ như khi phân tích thống kê, xử lý ảnh. Những xử lý như vậy về cơ bản sẽ coi “mảng” (array) làm data đối tượng. Vì vậy, có rất nhiều cơ hội để xử lý multi thread cho vector hay ArrayList. Thêm nữa, việc xác định chính xác như vậy sẽ trở thành một yếu tố quan trọng khi thực hiện multi thread cho chương trình. (Nhưng chúng ta không bao giờ lưu data xử lý ảnh, data xử lý âm thanh vào vector vì vấn đề liên quan đến performance)

Tuy nhiên, kể cả việc làm thành vector có khả năng cao được truy cập từ multi thread hay làm thành ArrayList đều không thread safe.

Tất nhiên, có lý do rõ ràng cho việc này. Vì nếu chỉ làm cho mỗi vector hay ArrayList trở nên thread safe thì sẽ chỉ làm giảm performance mà cũng chẳng có đóng góp gì cho tính an toàn của việc multi thread chương trình. “Làm cho STL hay collection class tiêu chuẩn trở nên thread safe” nghĩa là: chỉ có thể đảm bảo thread safe theo đơn vị method. Nói cách khác, có thể làm cho từng đơn vị method như push_back() của vector, add() của ArrayList trở nên thread safe được nhưng không có nghĩa là có thể đảm bảo cho toàn bộ chương trình cũng trở nên thread safe bằng việc này được.

Cụ thể như thế nào thì hãy xem trong đoạn code C++ vô cùng đơn giản sau đây:

1:  for (std::vector<int>::iterator it = myvector.begin() ; it != myvector.end(); ++it) {
 2:      std::cout << ' ' << *it;
 3:  }

Khi access iterator it lấy được ở dòng 1 với *it ở dòng 2 thì dù dùng bất cứ cách nào cũng không thể đảm bảo thread safe của it này trong vector được. Vấn đề này khó nên tôi muốn các bạn suy nghĩ thật kỹ. Dù bảo vệ bằng mutex trong vector::begin() bao nhiêu đi chăng nữa nhưng do khi kết thúc begin() thì bắt buộc phải release mutex nên kiểu gì cũng có khả năng giá trị trả về iterator it bị sử dụng bởi thread khác. Ví dụ: nếu như thread khác chạy gần như đồng thời đoạn code sau thì sẽ như thế nào?

1: myvector.clear();

Trong đoạn code này, tất cả những yếu tố của myvector đã bị release nên it sẽ thành invalid point. Đến đây tôi xin nhắc lại là: điều cần chú ý là dù có làm cho các method của vector trở nên thread safe thì cũng không giải quyết được vấn đề này. Nếu không dùng mutex để thực hiện kiểm soát độc quyền cho myvector ở ngoài for loop trong đoạn code trên thì không thể giải quyết được vấn đề này. Có thể nói về ArrayList phát sinh trong Collections.synchronizedList của Java cũng tương tự như vậy.

 1:      List<String> mylist = Collections.synchronizedList(new ArrayList<String>());
 2:      // add some string elements to mylist.
 3:         :
 4:      Iterator<String> iterator = mylist.iterator();
 5:      while (iterator.hasNext()) {
 6:          System.out.println(iterator.next());
 7:      }

Chỉ là nếu chỉ code như thế này thôi thì trong trường hợp mylist bị thread khác thay đổi, không thể đảm bảo được tính đúng đắn của iterator ở dòng 5 và dòng 6.

Trong những trường hợp thế này thì cần phải thay đổi như sau để đảm bảo thread safe:

 1:      List<String> mylist = Collections.synchronizedList(new ArrayList<String>());
 2:      // add some string elements to mylist.
 3:         :
 4:      synchronized(mylist) {
 5:          Iterator<String> iterator = mylist.iterator();
 6:          while (iterator.hasNext()) {
 7:               System.out.println(iterator.next());
 8:          }
 9:      }

Tóm lại, dù có sử dụng SynchronizedList thì cũng cần gói gọn toàn bộ loop trong synchronized block. Hơn nữa, ở đây không chỉ bảo vệ mylist bằng synchronized block mà còn phải ngăn chặn việc mylist bị thread khác sử dụng bằng synchronized block. Không có nghĩa là “nếu sử dụng SynchronizedList thì có thể thực hiện multi thread an toàn.” Việc SynchronizedList đảm bảo thread safe cùng lắm cũng chỉ đảm bảo trong 1 lần gọi method.

Đọc phần giải thích đến đây, các bạn đã hiểu được tại sao trong vấn đề 3 ở đầu bài lại không phải là “yes” chưa nhỉ? Đó là bởi vì cần phải nắm được chính xác phạm vi bảo đảm thread safe. Nếu cần làm cho phần đó trở nên safe thì không tồn tại cơ chế tiện lợi nào có thể đảm bảo thread safe chỉ trong thư viện hay class. Tóm lại, cần phải viết code đảm bảo thread safe luôn chính xác về mặt logic. Thông thường thì việc đảm bảo thread safe cho các thư viện là class cũng chỉ làm giảm performance mà thôi. Để không gây ra việc làm giảm performance không cần thiết như vậy nên Vector và ArrayList mới không thread safe.

Cuối cùng, tôi muốn tóm tắt về thread safe trong multi thread:

  1. Kể cả có sử dụng class hay thư viện thread safe thì nếu như logic không thread safe thì cũng chẳng có ý nghĩa gì.

  2. Hãy hiểu rằng việc sử dụng mutex hay critical section sẽ gây ảnh hưởng đến performance. Nếu không sử dụng nó mà vẫn có thể làm cho thread safe được thì là tốt nhất.

  3. Trường hợp thực hiện kiểm soát độc quyền bằng mutex hay critical section thì cần thực hiện đầy đủ việc control đồng bộ bằng block và hết sức chú ý để không phát sinh dead lock.

Thế giới đã bước vào thời đại multi core. Các bạn cũng hãy cùng với Việt-kun multi thread nhé!

Special Thanks to Narita san !

0