Phát triển với Java thời gian thực - Phần 2: Cải thiện chất lượng dịch vụ
Tóm tắt: Một số ứng dụng Java™ không cung cấp được chất lượng hợp lý của
dịch vụ mặc dù đạt được các mục tiêu hiệu năng khác, chẳng hạn như thời gian trễ
trung bình hoặc thông lượng tổng thể. Bằng cách đưa ra các đoạn dừng hoặc ngắt
không chịu kiểm soát của ứng dụng, ngôn ngữ Java và hệ thống thời gian chạy đôi
khi có thể chịu trách nhiệm về không đáp ứng các độ đo hiệu năng của ứng dụng.
Bài viết này là bài thứ hai trong loạt bài ba phần, giải thích nguồn gốc căn nguyên
của trễ và ngắt trong một JVM và mô tả các kỹ thuật cho phép bạn có thể dùng để
giảm thiểu các căn nguyên, nhằm ứng dụng của bạn cung cấp chất lượng dịch vụ
ổn định hơn.
Tính đa dạngtrong một ứng dụng Java —thường gây ra do các đoạn dừng, hoặc
trễ, xảy ra vào nhữnglúc không thể đoán trước được —có thể xảy ra qua ngăn
xếp phần mềm. Các trễ có thể xuất hiện do:
Phần cứng (trong các quá trình xử lý chẳng hạn như nhớ nhanh).
Phần đệm (xử lý của các ngắt quản lý hệ thống chẳng hạn như dữ liệu về
nhiệt độ bộ xử lý trungtâm).
Hệ điều hành (trả lời một ngắt hoặc khai thác một hoạt động thông minh đã
lên lịch thường kì).
Các chương trình khác chạy trên cùng hệ thống.
JVM (gom rác, biên dịch Đúng lúc, và tải lớp).
Chính ứng dụng Java.
is 603 operations / second
Histogram of operation times:
9ms - 10ms 9942 99 %
10ms - 11ms 2 0 %
11ms - 12ms 32 0 %
30ms - 40ms 4 0 %
70ms - 80ms 1 0 %
200ms - 300ms 6 0 %
400ms - 500ms 6 0 %
500ms - 542ms 6 0 %
Bạn có thể thấy rằng hầu như tất cả các phép toán hoàn tất trong 10 mili-giây,
nhưng một số phép toán mất hơn một nửa giây (chậm hơn 50 lần.) Đó đúng là một
sự khác nhau! Chúng ta hãy xem cách chúng ta có thể loại bỏ một số thay đổi này
bằng cách loại bỏ các trễ xuất hiện do việc nạp lớp Java, Biên dịch mã riêng JIT,
GC, và xử lí.
Đầu tiên chúng ta đã thu thập danh sách các lớp được nạp bởi ứng dụng qua việc
chạy đầy đủ với -verbose:class. Chúng ta đã lưu lại kết quả vào một tệp và sau đó
sửa đổi nó để có một tên được định dạng thích hợp trên mỗi dòng của tệp đó.
Chúng ta đã gộp một phương thức preload() vào lớp Server để nạp các lớp, JIT
biên dịch tất cả các phương thức của các lớp đó, và sau đó vô hiệu hóa bộ biên
dịch JIT, như trình bày trong Liệt kê 9:
Liệt kê 9. Nạp trước các lớp và các phương thức cho máy chủ
private void preload(String classesFileName) {
try {
FileReader fReader = new FileReader(classesFileName);
BufferedReader reader = new BufferedReader(fReader);
String className = reader.readLine();
while (className != null) {
try {
Class clazz = Class.forName(className);
String n = clazz.getName();
Compiler.compileClass(clazz);
} catch (Exception e) {
}
className = reader.readLine();
}
} catch (Exception e) {
}
Compiler.disable();
}
Việc nạp lớp không phải là một vấn đề quan trọng trong máy chủ đơn giản của
chúng ta vì phương thức TaskHandler.run() của chúng ta rất đơn giản: một khi lớp
nào đó được nạp, việc nạp lớp xảy ra không nhiều sau đó trong khai thác của
Server, nó có thể được xác thực bằng cách chạy với -verbose:class. Lợi ích chính
thu được từ việc biên dịch các phương thức trước khi chạy bất kỳ phép toán
TaskHandler được đo. Mặc dù chúng ta đã có thể sử dụng được một vòng lặp khởi
động (warm-up loop), cách tiếp cận này có xu hướng riêng cho JVM vì sự suy
nghiệm mà bộ biên dịch JIT sử dụng để chọn ra các phương thức để biên dịch
khác với các cài đặt JVM. Việc sử dụng dịch vụ Compiler.compile() đưa vào hoạt
động biên dịch có thể điều khiển được nhiều hơn, nhưng như chúng ta đã đề cập
trước đó trong bài viết, chúng ta sẽ chờ đợi sự giảm thông lượng khi dùng tiếp cận
này. Các kết quả từ việc chạy ứng dụng với các tuỳ chọn này là:
$ java -Xms700m -Xmx700m -Xgcpolicy:optthruput Server 6
10000 operations in 20936 ms
Throughput is 477 operations / second
Histogram of operation times:
11ms - 12ms 9509 95 %
12ms - 13ms 478 4 %
13ms - 14ms 1 0 %
400ms - 500ms 6 0 %
500ms - 527ms 6 0 %
Chú ý rằng mặc dù các trễ dài nhất không thay đổi nhiều, biểu đồ này ngắn hơn
nhiều so với ban đầu. Nhiều trễ ngắn hơn thấy được ngay từ bộ biên dịch JIT nên
việc thực hiện các biên dịch trước đó và sau đó vô hiệu hoá bộ biên dịch JIT rõ
ràng là một bước tiến. Một sự nhận xét thú vị khác là số thời gian hoạt động chung
đã chiếm thời gian lâu hơn một chút (từ khoảng 9 đến 10 mili-giây, đến 11-12
mili-giây). Các phép toán đã bị chậm lại do chất lượng của mã được tạo ra bởi một
biên dịch JIT bị áp đặt trước khi gọi các phương thức đó thường thấp hơn các
phương thức của bộ mã được áp dụng đầy đủ. Đó không phải là một kết quả bất
ngờ, vì một trong những lợi thế to lớn của bộ biên dịch JIT là khai thác các đặc
tính động của ứng dụng đang chạy để làm cho nó chạy hiệu quả hơn.
Chúng ta sẽ tiếp tục sử dụng bộ mã nạp trước lớp này và biên dịch trước phương
thức này trong phần còn lại của bài viết.
Vì GCStressThread của chúng ta gây nên thay đổi đều đặn tập hợp dữ liệu sống,
việc sử dụng chính sách GC sản sinh là không mong muốn, để đảm bảo lợi ích
nhiều thời gian đoạn dừng hơn. Thay vào đó, chúng ta đã thử bộ gom rác thời gian
thực trong sản phẩm Thời gian Thực WebSphere của IBM dùng cho Linux Thời
gian Thực V2.0 SR1. Các kết quả ban đầu thật thất vọng, thậm chí sau khi chúng
ta đã bổ sung tuỳ chọn -Xgcthreads8, cho phép bộ gom sử dụng 8 xử lí GC chứ
không phải xử lí đơn lẻ mặc định. (Bộ gom không thể theo kịp một cách tin cậy
tốc độ phân bổ của ứng dụng này với chỉ một xử lí GC đơn lẻ.)
$ java -Xms700m -Xmx700m -Xgcpolicy:metronome -Xgcthreads8 Server 6
10000 operations in 72024 ms
Throughput is 138 operations / second
Histogram of operation times:
11ms - 12ms 82 0 %
12ms - 13ms 250 2 %
13ms - 14ms 19 0 %
14ms - 15ms 50 0 %
15ms - 16ms 339 3 %
16ms - 17ms 889 8 %
17ms - 18ms 730 7 %
18ms - 19ms 411 4 %
19ms - 20ms 287 2 %
20ms - 30ms 1051 10 %
30ms - 40ms 504 5 %
40ms - 50ms 846 8 %
50ms - 60ms 1168 11 %
60ms - 70ms 1434 14 %
70ms - 80ms 980 9 %
80ms - 90ms 349 3 %
90ms - 100ms 28 0 %
100ms - 112ms 7 0 %
Việc sử dụng thời gian thực bộ gom đã giảm bớt thời gian phép toán tối đa một
cách đáng kể, nhưng nó cũng tăng thêm thời gian phép toán kéo dài. Tồi hơn nữa,
tốc độ truyền thông giảm đáng kể.
Bước cuối cùng là sử dụng các RealtimeThread — chứ không phải là các xử lí
Java thông thường — đối với các xử lí làm việc. Chúng ta đã tạo ra một lớp
RealtimeThreadFactory cho phép chúng ta có thể cung cấp cho dịch vụ Executors,
như trong Liệt kê 10:
Liệt kê 10. Lớp RealtimeThreadFactory
import java.util.concurrent.ThreadFactory;
import javax.realtime.PriorityScheduler;
import javax.realtime.RealtimeThread;
import javax.realtime.Scheduler;
import javax.realtime.PriorityParameters;
class RealtimeThreadFactory implements ThreadFactory {
public Thread newThread(Runnable r) {
RealtimeThread rtThread = new RealtimeThread(null, null, null, null, null, r);
// adjust parameters as needed
PriorityParameters pp = (PriorityParameters)
rtThread.getSchedulingParameters();
PriorityScheduler scheduler = PriorityScheduler.instance();
pp.setPriority(scheduler.getMaxPriority());
return rtThread;
}
}
Việc vượt qua một cá thể của lớp RealtimeThreadFactory đến dịch vụ
Executors.newFixedThreadPool() làm cho các xử lí làm việc là các
RealtimeThread bằng cách sử dụng việc lập lịch biểu FIFO với quyền ưu tiên cao
nhất sẵn có. Bộ gom rác sẽ vẫn còn ngắt các xử lí này khi nó cần thực hiện công
việc, nhưng không có tác vụ ưu-tiên-thấp-hơn nào khác sẽ can thiệp với các xử lí
làm việc:
$ java -Xms700m -Xmx700m -Xgcpolicy:metronome -Xgcthreads8 Server 6
Handled 10000 operations in 27975 ms
Throughput is 357 operations / second
Histogram of operation times:
11ms - 12ms 159 1 %
12ms - 13ms 61 0 %
13ms - 14ms 17 0 %
14ms - 15ms 63 0 %
15ms - 16ms 1613 16 %
16ms - 17ms 4249 42 %
17ms - 18ms 2862 28 %
18ms - 19ms 975 9 %
19ms - 20ms 1 0 %
Với thay đổi cuối cùng này, chúng ta cải thiện đáng kể cả thời gian phép toán xấu
nhất (xuống chỉ còn 19 mili-giây) cũng như là thông lượng tổng thể (lên đến 357
phép toán mỗi giây). Như vậy chúng ta đã cải thiện đáng kể theo độ đa dạng của
các thời gian phép toán, nhưng chúng ta đã trả một giá khá đắt về hiệu năng thông
lượng. Phép toán của bộ gom rác, sử dụng đến 3 mili-giây cứ mỗi 10 mili-giây,
giải thích tại sao một phép toán mà thường mất khoảng 12 mili-giây có thể được
kéo dài đến 4 - 5 mili-giây, đó là lý do số lượng lớn các phép toán bây giờ lại mất
khoảng 16 - 17 mili-giây. Sự cắt giảm thông lượng gần như chắc chắn nhiều hơn
bạn mong đợi vì JVM thời gian thực, ngoài việc sử dụng bộ gom rác thời gian
thực Metronome, cũng đã sửa đổi việc khoá các sơ khởi (primitives) cho phép
chống được việc đảo ngược quyền ưu tiên, một vấn đề quan trọng khi việc lập lịch
biểu FIFO được dùng (xem "Java thời gian thực, Phần 1: Sử dụng mã Java để lập
trình các hệ thống thời gian thực"). Đáng tiếc là, việc đồng bộ hoá giữa xử lí chủ
(master thread) và các xử lí làm việc tạo nên nhiều gia tăng ảnh hưởng về thông
lượng, mặc dù nó không được đo đạc như là một bộ phận của bất kỳ thời gian
phép toán (nên nó không thể hiện trong biểu đồ).
Như vậy khi máy chủ của chúng ta được lợi từ các sửa đổi được thực hiện để cải
thiện khả năng dự đoán, chắc chắn nó trải qua một sự giảm thông lượng khá lớn.
Dù sao, nếu vài phép toán chạy lâu khó tin nổi, ứng với mức độ chất lượng không
thể chấp nhận được, thì sử dụng các RealtimeThread với một JVM thời gian thực
có thể chính là giải pháp đúng.
Tóm lược
Trong thế giới của các ứng dụng Java, thông lượng và thời gian chờ theo truyền
thống đã trở thành các độ đo được ứng dụng chọn và các nhà thiết kế băng chuẩn
để báo cáo và tối ưu hoá. Lựa chọn này đã có một tác động rộng khắp lên sự tiến
hoá của các thời gian chạy Java được xây dựng nên để cải thiện hiệu năng. Mặc dù
các thời gian chạy Java được khởi động như là các phiên dịch viên với thời gian
chờ chạy và thông lượng vô cùng chậm, các JVM hiện đại có thể cạnh tranh tốt
với các ngôn ngữ khác về các thước đo này đối với nhiều ứng dụng. Mặc dù cho
đến thời kỳ tương đối gần đây cũng không thể được nói giống như vậy về một số
thước đo khác mà có thể có một ảnh hưởng lớn về một hiệu năng nhận biết của
ứng dụng — nhất là độ đa dạng, mà ảnh hưởng đến chất lượng dịch vụ.
Việc đưa ra Java thời gian thực đã cho các nhà thiết kế ứng dụng các công cụ mà
họ cần phải nhắm đến nguồn thay đổi trong một JVM và trong các ứng dụng của
họ để cung cấp chất lượng dịch vụ mà các khách hàng và tác nhân tiêu thụ của họ
chờ đợi. Bài viết này đã giới thiệu một số kỹ thuật mà bạn có thể sử dụng để sửa
đổi một ứng dụng Java để giảm bớt các đoạn dừng và sự thay đổi mà bắt nguồn từ
JVM và từ việc lập lịch biểu xử lí. Việc giảm bớt sự thay đổi thường gây ra việc
cắt bớt về thời gian chờ và hiệu năng thông lượng. Mức độ theo đó việc cắt bỏ có
thể chấp nhận được xác định các công cụ nào là thích hợp cho một ứng dụng đặc
thù.
Mục lục
Nhằm vào các nguồn biến đổi
Một thí dụ về máy chủ Java
Tóm lược
File đính kèm:
Phát triển với Java thời gian thực, Phần 2 Cải thiện chất lượng dịch vụ.pdf

