Giải pháp chuyển đổi số trọn gói cho doanh nghiệp Việt
Như vậy sau khi làm quen với Phần 2 về Thread Pool, bạn đã biết đến một cách khá dễ dàng để chúng ta có thể nhanh chóng xây dựng một Thread Pool mà không cần biết quá nhiều về các thông số rườm rà khác. Tuy nhiên cái gì cũng vậy, tiện dụng thì kèm theo đó công năng có thể không đủ mạnh. Chính vì vậy mà chúng ta phải cần tìm hiểu kỹ hơn về cách sử sụng Thread Pool trong Java, thông qua một cách sử dụng có phần “thủ công” hơn. Rồi cuối cùng sẽ cùng so sánh xem là với mỗi cách, điểm mạnh điểm yếu của chúng là gì nhé.
Như đã được giới thiệu ở bài đầu tiên, ThreadPoolExecutor chính là nhân vật chính của Thread Pool. Việc mà bài hôm trước Helper Class Executors gọi ra các câu lệnh, chẳng hạn như Executors.newFixedThreadPool(5), thực chất cũng là vì muốn giúp chúng ta đơn giản hơn trong việc khai báo một ThreadPoolExecutor mà thôi. Bạn có thể dừng ở việc hiểu về Executors ở bài trước, điều đó đủ để bạn có thể xây dựng các Thread Pool tiện dụng được sử dụng ở hầu hết các nhu cầu của bạn. Nhưng với việc tìm hiểu thêm về ThreadPoolExecutor, bạn sẽ biết được bản chất thực sự của một Thread Pool được khai báo trong Java như thế nào.
Chúng ta hãy bắt đầu với việc nhìn lại sơ đồ mô phỏng một ThreadPoolExecutor sau.
Chúng ta cũng cần thống nhất lại một số cách gọi trong bài viết hôm nay để đỡ đau đầu. Khi nói đến các Runnable, thì đó chính là các tác vụ bên trái ở sơ đồ trên, chúng sẽ được đưa vào Pool. Còn khi nói đến Queue hay hàng đợi, đó chính là các Runnable đã được vào Pool nhưng vẫn phải xếp hàng ở Task Queue ở giữa sơ đồ trên và chờ được thực thi. Và khi nói đến Thread chính là các Runnable khi này đã được thực thi (hay start) thành Thread ở dãy bên phải trong Pool.
Một vài quy ước dùng từ trong bài viết hôm nay
Sơ đồ là vậy, còn về code, bài hôm nay chúng ta sẽ làm quen với phương thức khởi tạo một ThreadPoolExecutor với các tham số đầy đủ như sau.
Ối sao nhiều tham số vậy. Bạn đừng lo, dù khá là nhiều tham số, nhưng vai trò của chúng khá rạch ròi, và việc hiểu các thông số này không quá khó, nếu kết hợp bài bản với nhau bạn sẽ có trong tay một công cụ tuyệt vời để xây dựng một Thread Pool hoàn toàn theo ý muốn.
À, phương thức khởi tạo mẫu mình đưa ra trên đây là phương thức với đầy đủ tham số nhất. Sẽ có những phương thức khởi tạo ít tham số hơn được dùng trong các bài thực hành của bài học, bạn sẽ được làm quen hết với các loại phương thức khởi tạo này.
Một ý nữa, nếu nhìn vào khai báo lớp này, thì ThreadPoolExecutor thực ra cũng là một lớp triển khai của ExecutorService. Cho nên bài viết của ngày hôm nay sẽ tập trung hoàn toàn vào việc khai báo ThreadPoolExecutor, còn việc sử dụng lớp này như thế nào thì y chang như hướng dẫn ở phần này của bài hôm trước bạn nhé.
Và một ý cuối cùng trước khi đi qua các phần bên dưới. Rằng bài học tuy dài và có nhiều source code, nhưng các source code cũng chỉ là sự lặp đi lặp lại việc phối hợp các tham số khởi tạo cho ThreadPoolExecutor mà thôi, bạn sẽ không bị quá đau đầu về số lượng code của bài hôm nay đâu.
Như bạn có thể thấy trên đây. Để điều khiển ThreadPoolExecutor, việc đầu tiên đó là chúng ta cần nắm vững các tham số của phương thức khởi tạo lớp này trước, rồi sau đó mới vận dụng chúng để tạo một Thread Pool mong muốn.
Nào chúng ta sẽ cùng hiểu rõ dần các tham số trên thông qua các bài thực hành sau.
Bài này chúng ta cùng xây dựng lại Thread Pool mà ở Bài thực hành số 1 của bài học trước chúng ta đã xây dựng thông qua Executors. Khi đó Thread Pool của chúng ta mỗi lần chỉ có duy nhất một Thread được chạy.
Trước hết, mình xin hiển thị lại lớp MyRunnable, mình lấy lại lớp này từ bài hôm trước, nhưng có thay đổi một chút xíu như sau để các bài thực hành được rõ nghĩa hơn.
Và đây là cách dùng ThreadPoolExecutor ở phương thức main().
Bạn có thể so sánh nhanh với code của bài hôm trước. Hôm nay chúng ta “thủ công hóa” với một đống tham số dùng cho phương thức khởi tạo của ThreadPoolExecutor (nếu bạn biết cách vào xem phương thức newSingleThreadExecutors() được xây dựng sẵn của bài hôm trước trên Eclipse hay InteliJ thì thấy với khai báo tham số hôm nay là tương tự nhau). Chắc các bạn cũng đoán được nhưng mình cũng xin giải nghĩa một chút cách sử dụng các tham số.
Có thể bạn chưa hiểu lắm các thông số. Nhưng cứ thực thi chương trình đi nhé, chúng ta sẽ làm rõ dần các thông số này ở các bài thực hành tiếp theo bên dưới. Và đây là kết quả in ra console của code trên đây.
Bạn có thể thấy số Thread đang chạy luôn luôn là 1. Cứ mỗi một Runnable được thực thi, thì con số Thread đang chờ (đúng hơn sẽ là các Runnable đang chờ) sẽ giảm dần.
Chúng ta lại thử xây dựng Thread Pool như Bài thực hành số 2 của bài học trước. Để cùng nhau thấy rằng việc dùng Thread Pool với cách của bài trước hay bài hôm nay đều cho các kết quả tương đương, chỉ khác một chút cách khai báo tham số mà thôi.
Bài thực hành này mong muốn rằng mỗi một lần sẽ có 5 Thread được start. Cứ Thread nào kết thúc mà trong Queue vẫn còn Runnable đang đợi thì sẽ được Thread Pool start tiếp cho đủ số lượng corePoolSize mong muốn. Code sau cũng không khác Bài thực hành 1 trên kia lắm, chỉ thay các thông số corePoolSize và maximumPoolSize từ 1 lên 5 mà thôi.
Kết quả thực thi Pool này như bài học trước bạn cũng thấy. Đầu tiên 5 Thread được thực thi. Sau đó hễ Thread nào xong thì Thread khác được start. Đảm bảo luôn luôn không quá 5 Thread được thực thi trong Pool.
Sở dĩ hàng chờ ban đầu không có Thread nào là vì Thread nào đưa vào Queue cũng được thực thi ngay, không có Thread nào kịp vào chờ cả. Sau đó khi corePoolSize đã đầy, thì Queue mới chứa các Runnable.
À và vì việc các Thread được start và câu lệnh in ra console không “đồng bộ” với nhau khiến thứ tự in ra của chúng hơi lộn xộn, bạn nên nhìn theo số thứ tự của Runnable, như Runnable 1, Runnable 2,… sẽ thấy số thread đang chạy sẽ tăng theo đúng với số Thread trong Pool thật bạn nhé.
Nói thêm
Tiếp tục với việc xây dựng Thread Pool tương đồng với Bài thực hành số 3 của bài học hôm trước, để chúng ta hiểu rõ hơn về cách kiểm soát ThreadPoolExecutor của bài hôm nay.
Thực sự thì với việc sử dụng Executors.newCachedThreadPool() từ bài hôm trước, có thể bạn cũng không mường tượng được hết công dụng của loại Thread Pool này. Hôm nay với việc khai báo thủ công Thread Pool này một lần nữa, sẽ hé lộ thêm nhiều thông tin đấy nhé. Trước hết hãy đến việc code cái nào.
Wow, cách sử dụng tham số khi này có một chút đặc biệt. Chúng ta hãy cùng xem qua.
Kết quả thực thi cho thấy tất cả các Thread khi này đều được thực thi cùng lúc, và không có Thread nào phải chờ cả.
Với 3 bài thực hành trên đây đã giúp bạn phần nào hiểu rõ hơn về ThreadPoolExecutor đúng không nào. Việc của bạn chỉ là hiểu rõ và phối hợp các tham số khởi tạo để có được một Thread Pool đa dạng theo nhu cầu của bạn mà thôi.
Tuy nhiên các ví dụ trên cũng chỉ là các gợi ý ban đầu về việc kết hợp các tham số khởi tạo này. Chúng ta hãy cùng nhau đi sâu hơn một tí về từng tham số để cùng hiểu sâu hơn.
Như giới thiệu từ đầu bài học thì bạn đã hiểu corePoolSize và maximumPoolSize rồi. Hai tham số này giúp điều khiển số lượng Thread cùng chạy song song bên trong một Pool.
Nếu corePoolSize và maximumPoolSize có cùng giá trị. Chúng được gọi là Fixed-size Thread Pool, tức là các Thread Pool ở Bài thực hành số 1 và 2 trên đây. Điều này chúng ta không cần phải nói nhiều.
Nhưng nếu chúng khác nhau về giá trị, khi đó maximumPoolSize buộc phải lớn hơn giá trị corePoolSize nếu không muốn ứng dụng sẽ tung ra một IllegalArgumentException. Với việc khai báo kiểu này, thì chỉ khi Queue đã chứa đầy các Runnable cần chạy, khi đó số lượng Thread trong Pool mới được thực thi vượt giá trị corePoolSize và có thể đạt đến con số maximumPoolSize.
Chúng ta hãy đến với các bài thực hành sau để hiểu rõ hơn cách dùng hai tham số này.
Chúng ta cùng xem. Với corePoolSize là 2, và maximumPoolSize là 4, Queue của chúng ta chỉ khai báo cho phép chứa được 6 Runnable. Thì với việc nhét 10 Runnable vào Pool, với hình chụp console bên dưới bạn sẽ thấy nhanh chóng Queue sẽ chứa đầy 6 Runnable. Việc Queue “đầy ắp” thế này khiến Thread Pool sẽ lấy tới con số lớn nhất là 4 Thread cùng chạy cùng một lúc, nếu không sẽ “vỡ đê” (thực ra nếu Thread Pool không mở rộng số Thread cần chạy, nhiều Runnable sẽ bị hủy nếu bạn xem tiếp các ví dụ bên dưới bài học).
Cũng với code trên, lần này chúng ta nới thêm số lượng Queue lên 15, khiến cho số lượng Runnable đưa vào Pool không đủ làm đầy Queue này, chính vì thế mà chúng ta thấy mỗi lần chỉ có 2 Thread được thực thi thôi, đó cũng chính là giá trị của corePoolSize.
Ở các ví dụ trên đây chúng ta không cần để ý đến threadFactory, khi này Thread Pool sẽ mặc định gọi đến Executors.defaultThreadFactory() cho chúng ta. Vậy threadFactory là gì?
Như chúng ta đã biết, khi dùng đến Thread Pool, chúng ta chỉ truyền vào đây các Runnable. Mà như cách thức tạo Thread từ Runnable mình có nói, để một Runnable start được, chúng ta phải đưa nó vào một khởi tạo của lớp Thread rồi gọi đến phương thức start() từ lớp Thread này. Trong Thread Pool cũng vậy, nó cũng cần có một Thread bao lấy Runnable cần chạy rồi start Runnable đó. Và Executors.defaultThreadFactory() giúp tận dụng pattern Factory để xây dựng sẵn một kịch bản giúp tạo ra Thread từ Runnable này, nó không đơn thuần chỉ new một Thread rồi truyền Runnable vào đâu, mà nó còn tạo sẵn các giá trị đồng nhất cho các Thread trong Pool, như đặt tên mặc định cho Thread, nhóm chung vào một ThreadGroup, khai báo cùng độ ưu tiên,…
Cũng với code quen thuộc như các bài thực hành trước, nhưng dưới đây mình thay việc sử dụng MyRunnable bằng một lớp vô danh của Runnable để nhanh chóng sửa nội dung in ra console của nó khi được chạy.
Bạn thấy rằng code trên chưa cũng chưa khai báo gì tới threadFactory cả và vì vậy mà tên của Runnable khi start sẽ như sau.
Bạn có thấy rằng các Thread này được nhóm chung một ThreadGroup nên chúng có cùng tên pool-1 ở đầu không. Còn tại sao chỉ chó thread-1 và thread-2 được thực thi trong khi chúng ta có tới 10 Runnable, điều này là vì 2 Thread được tạo ra với các tên thread-1 và thread-2 được tận dụng trở lại để khởi chạy các Runnable tiếp theo trong Queue, hệ thống không tạo ra thêm một Thread nào mới ngoài 2 Thread này hết. Bạn đã thấy sự lợi hại của Thread Pool trong việc tận dụng tối đa resource cho việc thực thi các tác vụ song song chưa nào.
Dưới đây là việc thay đổi nhỏ, bằng cách tạo ra một lớp vô danh nữa kế thừa từ lớp ThreadFactory và rồi override phương thức newThread của nó. Bằng cách này chúng ta sẽ kiểm soát tên Thread được khởi tạo thông qua hai tham số của Thread như sau.
Với việc thay đổi trên thì console sẽ như sau.
Như các ví dụ trên bạn cũng đã hiểu workQueue là một tham số giúp chúng ta định nghĩa thể loại Queue mà Thread Pool dùng để chứa đựng các Runnable đã vào Pool nhưng còn đang đợi, chưa được thực thi. Có Queue cho phép chúng ta chỉ định cứng độ lớn cho hàng đợi, có Queue không cần. Vậy thì tóm lại là ThreadPoolExecutor có thể có bao nhiêu hàng đợi. Mình xin liệt kê chúng từng phần như sau.
Queue này được giới thiệu là một dạng Queue bàn giao trực tiếp. Tức là chúng không lưu trữ bất kỳ Runnable nào trên Queue cả, ngay khi chúng nhận được các Runnable, chúng sẽ tìm cách thực thi ngay các Runnable này. Queue này không thể chỉ định độ lớn, nó chứa đựng tối đa các Runnable đưa vào Pool. Bạn đã làm quen với Queue này ở Bài thực hành số 3 trên đây.
Tuy nhiên nếu như có nhiều Runnable vào Queue hơn số lượng có thể thực thi (thông qua các tham số corePoolSize và maximumPoolSize mà bạn đã biết), thì các Runnable chưa được start ngay sẽ bị hủy, và một RejectedExecutionException sẽ được tung ra. Bài thực hành dưới đây mình sẽ cho bạn xem ý này, một lát nữa ở mục bên dưới chúng ta sẽ xử lý Exception này sao cho đẹp mắt hơn.
Bạn thấy rằng Pool của chúng ta được nới rộng ra chạy với tối đa 4 Thread thì… boom, Exception bị tung ra và các Runnable còn chưa kịp start đều đã bị hủy hết.
Được mệnh danh là Queue không giới hạn. Thường thì người ta dùng đến hàng đợi này mà không quá quan tâm đến sức chứa tối đa của nó (mặc dù bạn hoàn toàn có thể chỉ định sức chứa này thông qua phương thức khởi tạo của nó). Việc xem như có sức chứa không giới hạn này giúp cho các Thread bên trong Thread Pool thường chỉ dùng đến số lượng corePoolSize để thực thi các tác vụ, và như vậy maximumPoolSize cũng sẽ bị lu mờ trong việc dùng hàng đợi kiểu này. Bạn xem code ở bài thực hành sau (Bài thực hành số 1 & 2 trên đây đã nói về LinkedBlockingQueue rồi, tuy nhiên lúc đó bạn chỉ định corePoolSize và maximumPoolSize như nhau, bài thực hành dưới đây cho thấy nếu chúng ra dùng khác nhau 2 thông số này).
Bạn xem Pool của chúng ta chỉ chạy mỗi 2 Thread một lần mà thôi, và không có Thread nào bị hủy do Queue chứa được rất nhiều.
Nếu bạn sợ các hàng đợi không giới hạn trên đây có thể gây ảnh hưởng xấu đến hiệu năng của hệ thống. Thì đây ArrayBlockingQueue luôn đòi hỏi bạn phải khai báo độ lớn của hàng đợi. Tuy nhiên việc dùng Queue kiểu này thường khá đau đầu, vì chúng ta không biết chỉ định độ lớn của hàng đợi bao nhiêu là đủ. Không phải việc tiết kiệm hàng đợi thông qua chỉ định độ lớn nhỏ lại là một ý hay đâu nhé, việc các Runnable không được vào hàng đợi do không đủ chỗ cho nó, có thể khiến các Runnable này bị hủy, và lại dẫn tới việc tăng corePoolSize hay maximumPoolSize lên, thì lại là vấn đề đau đầu khác. Như bài thực hành sau nếu bạn chỉ định hàng đợi chỉ có 2 Runnable chờ, thì không đủ để chạy 10 Runnable, và như vậy bạn sẽ thấy số còn lại chưa kịp vào hàng đợi sẽ bị hủy và một Exception cũng xuất hiện.
Các ví dụ trên đây chưa có ví dụ nào nói rõ về việc sử dụng handler này cả. Mình dùng đến tên tham số handle để chỉ về mục cuối cùng cần tìm hiểu của bài viết hôm nay thôi, thực ra nó là RejectedExecutionHandler. Đọc đầy đủ tên lớp giúp chúng ta nắm rõ hơn về công năng của thành phần này đúng không nào. Đây là thành phần giúp chúng ta quản lý cách thức hành xử khi mà các Runnable bị từ chối thực hiện bởi Thread Pool, bởi vì không vào được Queue, như hai Bài thực hành số 7 & 9 trên đây.
Để hiểu về RejectedExecutionHandler hơn là để vận dụng các handler vào thực tế, chúng ta hãy đến với bài thực hành sau.
Với bài thực hành này chúng ta sẽ xây dựng một thể hiện của RejectedExecutionHandler, với thể hiện này chúng ta chỉ cần in ra console Runnable nào đã được hủy mà thôi. Code của Bài thực hành số 9 trên đây được xây dựng lại như sau.
Với việc chỉ định handler như trên, bạn thấy rằng khi này console sẽ in ra các Runnable bị hủy rõ ràng đúng không nào, thay vì tung ra một Exception và không rõ nghĩa gì cả ở Bài thực hành số 9.
Tuy với bài thực hành trên chúng ta hiểu được cách mà Thread Pool gọi đến handle một khi Runnable bị hủy. Nhưng cách thức trên đây dù sao cũng chỉ mang tính in ra console để tham khảo. Thực tế khi sử dụng ThreadPoolExecutor chúng ta có các handler được xây dựng sẵn như sau.
Nếu bạn sử dụng handler này. Uhm, nó y chang Bài thực hành số 9. Vì thực ra nếu chúng ta không khai báo gì cho handler, ThreadPoolExecutor sẽ sử dụng ThreadPoolExecutor.AbortPolicy làm mặc định cho chúng ta. Tuy nhiên bài thực hành tiếp theo đây chúng ta cũng sẽ khai báo nó một cách tường minh cho dễ hiểu.
Bạn hãy để ý đến tham số cuối cùng của ThreadPoolExecutor như sau.
Kết quả như mình có nói, không khác Bài thực hành số 9.
Khi sử dụng handler này thì bạn có thể yên tâm rằng không có một Runnable nào bị hủy cả. Ngay khi Runnable đó bị từ chối, thay vì tung ra một Exception, handle dựng sẵn này sẽ thực thi lại Runnable đó.
Nào chúng ta chỉ thay thế một chút tham số của code ở bài thực hành trên thôi.
Kết quả là đầy đủ 10 Runnable được thực thi.
Handler này chỉ đơn giản là bỏ qua Runnable bị từ chối thôi, không làm gì cả.
Không nói nhiều, mời bạn cùng xem code.
Handler này đảm bảo khi Runnable chưa được thực thi mà bị hủy, thì những Runnable tồn tại lâu nhất sẽ bị hủy trước.
Cũng chỉ với một thay đổi ở tham số.
Bạn xem với 4 Runnable đầu không có gì phải bàn, nó là các Runnable được Thread Pool mang vào thực thi sớm nhất. Thế nhưng khi Runnable được lấy tiếp từ Queue để thực thi, thì chính là các Runnable số 9 & 10 được chỉ định, đây là các Runnable “trẻ nhất”, chúng được thêm vào Queue sau cùng, các Runnable “già” khác đã thăng thiên sớm rồi nhé.
Chưa có câu hỏi nào cho bài này.