Diễn Đàn Tin Học » Tutorial Room » Lập trình » Software Engineering » Craftsman-Truyện dài nhiều tập Craftsman 9 - Dangerous Threads Câu chuyện tay học việc trẻ tuổi của chúng ta học được bài nằm lòng: Không để các threads đeo lủng lẳng - phải nắm chắc bạn kiểm soát bước kết thúc cũng như điểm khởi tạo của chúng.
Những threads nguy hiểm
Câu chuyện tay học việc trẻ tuổi của chúng ta học được bài nằm lòng: Không để
các threads đeo lủng lẳng - phải nắm chắc bạn kiểm soát bước kết thúc cũng như
điểm khởi tạo của chúng.
Robert C. Martin
Sáng nay chiếc PDA khe khẽ đánh thức tôi dậy. Cố trút cơn ngái ngủ từ não bộ,
tôi tắt máy báo thức và mò vào phòng tắm. Trong khi vòi phun kỳ cọ và xoa bóp
thân thể, tâm trí tôi vẩn vơ đi vào những biến cố ngày hôm trước.
Tôi trở phòng làm việc lại sau buổi giải lao, trong đầu vẫn nghĩ ngợi về giá trị
thực sự từ các cú thử nghiệm. Jerry đang đợi tôi, gã nói: "Tao mừng là mày trở
lại. Tao đang hoàn tất cái "test case" kế tiếp đây. Xem qua cái đi và thử đoán
mục đích của nó là gì."
|
|
public void testMultiThreaded() throws
Exception {
ss.serve(999, new
EchoServer());
Socket s1 = new Socket("localhost",
999);
BufferedReader br =
SocketService.getBufferedReader(s1);
PrintStream ps = SocketService.getPrintStream(s1);
Socket s2 = new Socket("localhost",
999);
BufferedReader br2 =
SocketService.getBufferedReader(s2);
PrintStream ps2 = SocketService.getPrintStream(s2);
ps2.println("MyMessage");
String answer2 = br2.readLine();
s2.close();
ps.println("MyMessage");
String answer = br.readLine();
s1.close();
assertEquals("MyMessage",
answer2);
assertEquals("MyMessage",
answer);
} |
|
"Nó hơi phức tạp một chút nhưng hình như ông muốn chứng minh là
SocketService có thể đối phó với hai mạch nối cùng một
lúc."
"Ðúng vậy," Jerry trả lời. "Mày có nhận ra là mạch nối thứ nhất lại đóng sau
cùng không?"
"Không, nhưng ông nói tôi mới thấy đó. Ông làm thế để làm chi vậy?"
"Tao muốn hai phiên truy cập cùng mở liên tục," Jerry đáp.
"Tại sao?" Tôi bối rối hỏi lại. "Bởi vì khi ấy method serve trong
class SocketService sẽ phải đi vào hai lần trong hai
threads khác nhau, trước khi cả hai có cơ hội kết thúc," Jerry tiếp tục. "Khi
một hàm được gọi vào hơn một lần trước khi nó kết thúc, cái này gọi là
reentrant."
"Nhưng sao ông lại muốn test nó làm gì?" tôi cứ khăng khăng hỏi tiếp.
"Bởi vì các hàm reentrant thường đem lại cho mình những sự cố rất lý thú," Jerry
mỉm cười. Tôi không hiểu nổi điều này nhưng tôi biết chắc rốt cuộc Jerry sẽ giải
thích vấn đề. "OK" gã nói. "Hãy chạy thử cái test đi."
Tôi biên dịch và chạy cái test. Thanh chỉ định màu xanh lá chuyển động lẹ làng
xuyên qua khung test cho chúng tôi biết rằng trọn bộ những test trước đây vẫn
làm việc ngon lành. Thế rồi, trước khi kết thúc, chương trình bị khựng lại. Tôi
đợi vài giây xem thử nó có thức dậy và hoàn tất hay không nhưng nó hoàn toàn
treo luôn.
Sau khi nghiên cứu mã nguồn ở đoạn SocketService.serve chừng
một phút, tôi nói, "Hãy xem đoạn lặp này."
|
|
while (running) {
try {
Socket s = serverSocket.accept();
itsServer.serve(s);
s.close();
} catch (IOException e) {
}
} |
|
"itsServer.serve không trở lại để bắt lấy mạch
nối thứ nhì," tôi tiếp tục. "Mạch nối thứ nhất bị treo trong đoạn
EchoServer đợi mình gởi đến một thông điệp. Bởi thế
chúng ta không bao giờ đi hết vòng lặp để gọi accept cho
mạch nối socket thứ nhì."
Jerry cười rạng rỡ. "Khá lắm! bây giờ mình làm sao với nó đây?" "Chúng ta cần
đưa itsServer.serve trong thread riêng của nó để cái
vòng lặp đó có cơ hội trở lại mà không phải đợi nó."
"Lại đúng lần nữa!" gã mỉm cười. "Dám chọc nó một phát không?"
Tôi vớ lấy bàn phím và đổi method SocketService.serve như
sau:
|
|
while (running) {
try {
Socket s = serverSocket.accept();
new Thread(new
ServiceRunnable(s)).start();
} catch (IOException e) {
}
} |
|
Kế tiếp tôi thêm một inner class mới bên trong
SocketService gọi là ServiceRunnable:
|
|
class ServiceRunnable implements Runnable
{
private Socket itsSocket;
ServiceRunnable(Socket s) {
itsSocket = s;
}
public void run() {
try {
itsServer.serve(itsSocket);
itsSocket.close();
} catch (IOException e) {
}
}
} |
|
"Vậy là đủ rồi," tôi nói. Tôi nhấn nút test và được đền bù bằng kết quả mỹ mãn.
"Nhấn nút thêm vài lần xem sao," Jerry đề nghị. "Ôi thôi, đừng chơi mấy trò
này," tôi cự nự, nhớ đến cái chứng khập khiễng lần đầu tiên chúng tôi khởi sự.
Tôi miễn cưỡng chạy phần test thêm vài lần. Hiển nhiên tôi thấy ngay chỗ hỏng:
|
|
1) testMultiThreaded(TestSocketServer)
java.lang.NullPointerException
at SocketService.close(SocketService.java:32)
at TestSocketServer.
(TestSocketServer.java:30) |
|
"Quỷ tha ma bắt gì đây?" tôi nhăn nhó nhìn dòng 32 của
SocketService.java
|
|
30
31
32
33 |
public void close() throws
Exception
{
running = false;
serverSocket.close();
} |
|
"Hẵng một phút," tôi chống chế. "Làm sao có thể bị null pointer exception
chỗ đó được cơ chứ?" Tôi kéo lên phần TestSocketServer ở
dòng 30:
|
|
29
30
31 |
public void tearDown() throws
Exception {
ss.close();
} |
|
"Vô lý. TearDown đóng
SockerService như giả định nhưng cái serverSocket lại
null là thế nào? Nếu serverSocket là null thì mình đã
dính ngay lỗi từ đoạn testMultiThreaded chớ không phải
trong đoạn tearDown."
Jerry hẳn cảm thấy hữu lý bởi gã nói, "Ừa."
"Jerry, cái quỷ gì đây? chẳng nghĩa lý gì cả," tôi cằn nhằn. "Cái biến
serverSocket không thể là null được."
"Alphonse," Jerry nói nhỏ nhẹ. "Hãy suy nghĩ phút chốc. Trạng thái các threads
thế nào?" "Hở?" tôi không bắt kịp gã. "Các cái threads," gã lặp lại một cách
kiên nhẫn. "Các threads này làm gì khi tearDown được
gọi?"
Tôi suy nghĩ vấn đề này chừng một phút. Rõ ràng phần test case đạt; không thì
tearDown đã không được gọi. Ðiều này có nghĩa là cả
hai mạch nối socket được tiếp nhận và serverThread đã
đi xuyên vòng lặp hai lần. serverThread có thể chặn cú
gọi tiếp nhận lần thứ ba hoặc giả nó chưa trở lại hàm khởi động dùng để kíck tác
thread ServiceRunnable thứ nhì.
Thread đầu của ServiceRunnable đã vào
EchoServer cái này đã được đọc và viết thông điệp
nhưng nó có thể chưa bị kết liễu. Nó có thể đợi phần println gởi
thông điệp ngược lại từ phần test case, nhưng thread thứ nhì của
ServiceRunnable hẳn có đó thời gian để kết thúc: nó đã
nhận và gởi thông điệp của nó đã lâu.
Tôi giảng giải tất cả mọi điều với Jerry và gã lặng lẽ gật đầu. "Vâng," gã nói.
"Tao cũng phân tích như thế." "Vậy thì sao lại có null pointer exception?" tôi
hỏi, vẫn còn chút căng thẳng. "Tao chả biết," gã rụt vai. "Nhưng sự thật là nó
bị đổ vỡ khi mình đóng cái serverSocket làm tao nghĩ
là mình để cho một vài thread nào đó chạy làm ảnh hưởng đến thư viện socket." "Ý
ông là có bug trong bộ thư viện socket?" tôi ré lên. Jerry chỉ dán mắt vào màn
hình và nói, "tao không chắc; có lẽ mình dùng không đúng. Hãy đi qua một vài thử
nghiệm. Ðiều gì xảy ra nếu cái serverThread từ phần
test trước chưa đóng ngay khi chúng ta thực thi
testMultiThreaded? Và rồi, khi cái close() của
serverThread trước cuối cùng cũng thực thi, cái này
ảnh hưởng thế nào đó đến phân đoạn close của
testMultiThreaded. Mình thử nghiệm giả thuyết này sao
đây?"
Tôi phải áp đặt những khái niệm này từng cái một trong não - như thường lệ,
Jerry đi trước tôi nhiều bước. Nhưng một lúc sau, tôi gật đầu và đề nghị, "chúng
ta có thể đợi ở phần cuối của tearDown để nắm chắc là
serviceThread đóng hoàn toàn." Jerry nghĩ ngợi một
giây. Với vẻ mặt rạng rỡ gã nói, "ý kiến hay đó! nếu giả thuyết của mình đúng,
phần thay đổi này hẳn phải làm cho các cái test đạt mọi lần."
Tôi thay đổi như sau và chạy cái test vài chục lần. Hoàn toàn không bị hỏng nữa.
|
|
public void tearDown() throws Exception
{
ss.close();
Thread.sleep(200);
} |
|
Jerry mỉm cười và nói, "OK, đó là một cái test để thoả mãn giả thuyết của chúng
ta. Trở ngại này dường như là một thứ ảnh hưởng nào đó giữa mấy cái test và
không bị lỗi một cách cụ thể với testMultiThreaded,
dẫu nó không giải thích lý do tại sao chúng ta không thấy lỗi này trước đây.
Nhất định có vấn đề gì đó với testMultiThreaded làm lộ
ra trạng thái này."
Tôi hơi oải với cái thay đổi cuối: "Mình không thể để cái
sleep trong đó, đúng không? Ðó không phải là giải pháp phải không?" tôi
hỏi. "Không, nhất định không - nó chỉ là một thứ thử nghiệm mà thôi. Ðem nó ra
đi," Jerry trả lời.
Tôi bỏ nó ra và kiểm nghiệm không có lỗi. Thế rồi điều gì đó nảy ra trong đầu
tôi. "Jerry, giả thuyết của mình không thể đúng được. Server sockets phải
có khả năng đồng thời mở và đóng trong hệ điều hành, phải không? mấy cái test
của mình không làm gì bất thường. Ý tôi là, thư viện socket chắc phải bị vỡ nặng
nề nếu nó gián đoạn quy trình đóng mở thỉnh thoảng bị chồng lên nhau."
"Thư viện này được dùng đã lâu. Tao không nghĩ là nó bị vỡ đâu," Jerry ngấm
ngoẳng. "Nhất định phải có gì đặc biệt trong cách chúng ta viết mấy cái test làm
cho thư viện phản ứng như thế này." "Có thể nào do chúng ta dùng cùng một cổng
số?" tôi hỏi.
Tôi đổi trọn bộ các test dùng cổng số khác nhau. Sau khi qua hàng chục test, tôi
nói, "nó sẽ không hỏng. Vấn đề nằm ở chỗ nhiều tests cùng dùng một cổng số."
"Ðây là điều rất lý thú," Jerry trả lời. "Ðiều đó giải thích lý do tại sao mình
không thấy lỗi này trên những hệ thống khác. Các hệ thống không dùng cùng một
cổng số."
Tôi lại thấy oải nữa. "Jerry, đây cũng chưa phải là giải pháp tốt cho mình.
Chúng ta phải tìm cách làm sao cho SocketService để
ngăn ngừa trở ngại này, phải không?" "Tuyệt đối là như vậy rồi Alphonse. Vậy thì
tiến hành đi và để cổng số y hệt như cũ và tính thử mình phải làm gì."
Ngay khi test có lỗi trở lại, tôi nhìn Jerry, đợi chờ. "OK, mình xử cái quỷ này
sao đây?" "Chúng ta không cho phép SocketService.close trở
về cho đến khi serverThread kết thúc," gã nói, vớ lấy
bàn phím và thay đổi như sau:
|
|
public void close() throws Exception
{
if (running) {
running = false;
serverSocket.close();
serverThread.join();
} else {
serverSocket.close();
}
} |
|
Sau hàng tá test, gã nói, "Ừa, đâu vào đấy." "Tôi đoán bài học ở đây là: đừng
để threads treo lủng lẳng. Phải nắm chắc mình kiểm soát được bước kết thúc cũng
như điểm khởi tạo của chúng," Tôi nói. "Ðó là một bài học nằm lòng rất
tốt," gã trả lời. "Một thread lủng lẳng có thể gây tai hoạ khi mày ít ngờ đến
nhất."
|