Chuyển tới nội dung chính

Tạo luồng và vòng đời

Phần lớn chương trình thực tế không chạy một mạch từ trên xuống dưới. Một ứng dụng tải tệp trong khi vẫn cho người dùng bấm nút, một máy chủ phục vụ nhiều khách cùng lúc, một bộ xử lý ảnh chia khung hình cho nhiều nhân CPU: tất cả đều cần làm nhiều việc song song. Đa luồng (multithreading) là công cụ để diễn đạt sự song song đó ngay trong một chương trình. Bài này giải thích luồng là gì, cách tạo luồng trong Java và vòng đời của một luồng. Hai bài tiếp theo bàn về đồng bộ hóa dữ liệu chia sẻ và bộ thư viện hiện đại java.util.concurrent.

Hãy hình dung một ứng dụng có nút "Tải báo cáo": người dùng bấm nút, chương trình gọi máy chủ, chờ mười giây rồi vẽ bảng số liệu. Nếu tất cả chạy trên đúng một luồng thì suốt mười giây ấy cửa sổ đóng băng, nút không bấm được, hệ điều hành báo chương trình không phản hồi. Lời giải là chuyển việc chờ mạng sang một mạch thực thi khác để mạch vẽ giao diện rảnh tay phục vụ thao tác. Mạch thứ hai đó chính là một luồng. Đổi lại, ta phải học thêm: luồng được tạo ra sao, làm sao chờ nó xong và làm sao dừng nó cho tử tế.

Tiến trình và luồng

Hai khái niệm nền

Tiến trình (process) là một chương trình đang chạy, được hệ điều hành cấp vùng nhớ riêng biệt và các tài nguyên độc lập. Luồng (thread) là một đơn vị thực thi bên trong một tiến trình; nhiều luồng cùng tiến trình chia sẻ chung vùng nhớ của tiến trình đó nhưng mỗi luồng có ngăn xếp lời gọi (call stack) riêng.

Sự khác biệt cốt lõi nằm ở vùng nhớ. Hai tiến trình tách biệt nhau hoàn toàn; muốn trao đổi dữ liệu chúng phải nhờ tới cơ chế liên tiến trình tốn kém. Ngược lại, các luồng trong cùng một tiến trình nhìn thấy chung các đối tượng trên vùng nhớ động (heap), nên việc chia sẻ dữ liệu rất rẻ. Chính sự rẻ này vừa là sức mạnh, vừa là nguồn gốc của mọi rắc rối đồng bộ hóa mà ta sẽ gặp ở bài sau.

Mỗi ứng dụng Java khởi động đã có sẵn ít nhất một luồng: luồng main chạy phương thức main. Bên cạnh đó máy ảo còn duy trì những luồng nền (daemon) như luồng thu gom rác. Khi ta nói tạo luồng, nghĩa là ta xin máy ảo thêm một mạch thực thi mới chạy song song với luồng main.

Vì sao cần đa luồng

Có ba lý do thực dụng. Thứ nhất là tính đáp ứng (responsiveness): tách việc nặng sang luồng phụ để luồng giao diện không bị treo, người dùng vẫn thao tác được. Thứ hai là tận dụng nhiều nhân: máy tính ngày nay có nhiều nhân CPU, chỉ chương trình đa luồng mới chạy thật sự song song trên các nhân đó để rút ngắn thời gian tính toán. Thứ ba là che giấu độ trễ: khi một luồng phải chờ đọc tệp hay chờ mạng, các luồng khác vẫn tiếp tục làm việc thay vì để CPU ngồi không.

cảnh báo

Đa luồng không miễn phí. Mỗi luồng tốn bộ nhớ cho ngăn xếp riêng, và việc hệ điều hành chuyển qua lại giữa các luồng (context switch) cũng mất thời gian. Tạo quá nhiều luồng cho việc quá nhỏ có thể chậm hơn chạy tuần tự. Hãy đa luồng khi có việc thật sự đáng làm song song.

Tạo luồng trong Java

Java cho hai con đường để mô tả công việc của một luồng. Hiểu được khác biệt giữa chúng giúp ta chọn đúng và là bước đệm để chuyển sang mô hình hiện đại ở bài cuối.

Cách 1: kế thừa lớp Thread

Cách trực tiếp nhất là viết một lớp con của Thread và ghi đè phương thức run. Thân phương thức run chính là công việc luồng sẽ làm. Lưu ý quan trọng: ta gọi start chứ không gọi run. Gọi start mới sinh ra luồng mới rồi cho nó chạy run; gọi thẳng run chỉ là một lời gọi phương thức bình thường trên luồng hiện tại, không có gì song song.

public class DemNguoc extends Thread { // subclass of Thread
private final String ten;
public DemNguoc(String ten) { this.ten = ten; }

@Override
public void run() { // the work of this thread
for (int i = 3; i >= 1; i--) {
System.out.println(ten + ": " + i);
try { Thread.sleep(200); } // pause 200 ms
catch (InterruptedException e) {
Thread.currentThread().interrupt(); // restore the flag
return; // then leave run()
}
}
}

public static void main(String[] args) {
new DemNguoc("Luong A").start(); // start() launches a new thread
new DemNguoc("Luong B").start(); // runs concurrently with A
}
}

Cách 2: cài đặt giao tiếp Runnable

Cách thứ hai tách công việc ra khỏi luồng. Ta viết một lớp cài đặt giao tiếp Runnable với đúng một phương thức run, rồi trao thể hiện đó cho một Thread để chạy. Vì Runnable là một giao tiếp hàm (functional interface), ta có thể viết gọn bằng biểu thức Lambda.

public class ViDuRunnable {
public static void main(String[] args) {
Runnable congViec = () -> { // Runnable is a functional interface
for (int i = 0; i < 3; i++)
System.out.println("Buoc " + i
+ " tren " + Thread.currentThread().getName());
};
Thread t = new Thread(congViec, "Luong-Worker"); // pass the task in
t.start(); // launch the worker thread
System.out.println("main van chay tiep");
}
}

Nên ưu tiên cách Runnable. Lý do thiết kế: lớp của ta giữ được quyền kế thừa một lớp khác có ý nghĩa nghiệp vụ, thay vì tiêu phí suất kế thừa duy nhất của Java vào Thread. Quan trọng hơn, Runnable tách bạch việc cần làm khỏi cách chạy việc đó, nhờ vậy cùng một công việc có thể giao cho một Thread thủ công hoặc cho một bộ điều phối ExecutorService mà ta sẽ gặp ở bài cuối.

Ghi nhớ

Gọi start để chạy song song; gọi run chỉ chạy tuần tự trên luồng hiện tại. Hãy mô tả công việc bằng Runnable thay vì kế thừa Thread trong đa số trường hợp.

Lỗi kinh điển: gọi run thay vì start

Chương trình biên dịch trơn tru, chạy không báo lỗi, in ra đủ mọi dòng ta mong đợi, chỉ có điều nó hoàn toàn không song song. Nguyên nhân: run chỉ là một phương thức thường, gọi t.run() nghĩa là luồng hiện tại nhảy vào thân run, chạy hết rồi quay ra. Cách kiểm chứng là in tên luồng đang chạy.

public class StartVaRun {
public static void main(String[] args) {
Runnable congViec = () -> System.out.println(
"Chay tren: " + Thread.currentThread().getName());

new Thread(congViec, "Luong-1").run(); // WRONG: plain method call
new Thread(congViec, "Luong-2").start(); // RIGHT: a new thread
}
}

Kết quả in ra là Chay tren: main rồi Chay tren: Luong-2. Dòng đầu tố cáo rằng Luong-1 chưa bao giờ thực sự chạy: chính luồng main đã làm hộ việc của nó.

Ba cái bẫy quanh start
  • Gọi run thay start: chương trình vẫn ra đúng kết quả nhưng tuần tự, mất sạch tính song song, và trình biên dịch không hề cảnh báo.
  • Gọi start hai lần trên cùng một đối tượng Thread: ném IllegalThreadStateException, muốn chạy lại phải tạo Thread mới.
  • Tưởng thứ tự in ra là cố định: hệ điều hành quyết định luồng nào chạy trước, hai lần chạy có thể cho hai thứ tự khác nhau.

Chờ và dừng luồng

join, sleep và InterruptedException

Sau khi gọi start, luồng cha đi tiếp ngay chứ không chờ luồng con. Nhiều bài toán lại cần điều ngược lại: chia việc cho vài luồng, khi tất cả xong mới gộp kết quả. t.join() khiến luồng đang gọi ngủ cho tới khi luồng t kết thúc. Quên join sinh ra loại lỗi khó chịu: luồng main đọc kết quả khi luồng con chưa kịp ghi. Dạng t.join(2000) chờ tối đa hai giây rồi đi tiếp, tránh treo vô hạn vì một luồng chậm.

Thread.sleep(ms) cho luồng hiện tại tạm ngừng một lúc. Nó là phương thức tĩnh, luôn tác động lên luồng đang gọi, nên viết t.sleep(100) là gây hiểu nhầm vì nó không làm luồng t ngủ. Khác wait, sleep giữ nguyên mọi khoá đang cầm. Cả sleep lẫn join đều ném InterruptedException, một tín hiệu chứ không phải lỗi: có ai đó đã gọi interrupt để xin luồng dừng việc lại sớm.

public class NguoiLaoDong implements Runnable {
@Override
public void run() {
try {
for (int i = 1; i <= 10; i++) {
System.out.println("Xu ly muc " + i);
Thread.sleep(300); // may be interrupted
}
} catch (InterruptedException e) {
System.out.println("Duoc yeu cau dung, don dep roi thoat");
Thread.currentThread().interrupt(); // restore the flag
}
}

public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(new NguoiLaoDong(), "Worker");
t.start();
Thread.sleep(1000); // let the worker run for 1 second
t.interrupt(); // politely ask it to stop
t.join(); // wait until it has really finished
}
}
Đừng nuốt InterruptedException

Viết catch (InterruptedException e) { } với thân rỗng là thói quen tệ nhất trong mã đa luồng. Khi bắt ngoại lệ này, máy ảo đã xoá cờ ngắt của luồng; nếu ta không làm gì thì tín hiệu dừng biến mất, tầng gọi bên ngoài không bao giờ biết có ai yêu cầu dừng, hậu quả là bể luồng không tắt được và chương trình không thoát. Luôn làm một trong hai việc: gọi Thread.currentThread().interrupt() để khôi phục cờ, hoặc ném ngoại lệ ra ngoài.

Vì sao không dùng stop

Lớp Thread từng có phương thức stop giết luồng ngay lập tức, và nó đã bị vô hiệu hoá ở các phiên bản Java gần đây. Lý do: stop giết luồng ở bất kỳ điểm nào, kể cả khi luồng đang cập nhật dở một đối tượng chia sẻ, đồng thời nhả mọi khoá nó đang giữ. Đối tượng bị bỏ lại ở trạng thái hỏng nhưng khoá bảo vệ đã mở, các luồng khác lao vào và đọc phải dữ liệu sai; ta không biết luồng bị giết ở dòng nào nên cũng không thể viết mã phòng vệ.

Cách dừng đúng là hợp tác, như ví dụ trên: bên ngoài gọi t.interrupt() để dựng cờ, bên trong tự chọn một điểm an toàn để thoát. Luồng đang sleep hay join nhận InterruptedException; luồng đang tính toán liên tục thì tự kiểm tra Thread.currentThread().isInterrupted() trong điều kiện vòng lặp. interrupt không giết ai cả, nó chỉ dựng một lá cờ.

Luồng nền (daemon thread)

Máy ảo Java kết thúc khi luồng thường (user thread) cuối cùng kết thúc, bất kể còn luồng nền nào đang chạy. Luồng nền là luồng phụ trợ mà sự tồn tại của nó không đáng để giữ chương trình sống: bộ thu gom rác, một bộ đếm giờ tự động lưu, một luồng ghi nhật ký định kỳ. Đánh dấu bằng t.setDaemon(true) và bắt buộc gọi trước t.start(), gọi sau sẽ nhận IllegalThreadStateException. Quên đánh dấu thì một luồng thường chạy vòng lặp vô hạn sẽ giữ máy ảo sống mãi. Đổi lại, luồng nền bị cắt ngang khi máy ảo tắt và khối finally của nó có thể không chạy, nên đừng giao cho nó việc phải hoàn tất trọn vẹn.

Bước tiếp theo: từ Thread lên ExecutorService

Mọi thứ tới đây đều là tạo luồng thủ công. Cách đó đủ để hiểu cơ chế nhưng không dùng được trong ứng dụng thật: mỗi Thread chỉ chạy một lần rồi bỏ, chi phí tạo và huỷ luồng không nhỏ, và không có gì chặn số luồng bùng nổ tới mức cạn bộ nhớ. Giải pháp là bể luồng (thread pool): giữ sẵn một số luồng, nộp công việc vào hàng đợi, luồng rảnh tự nhặt việc mà làm và không chết sau mỗi việc. Mặt tiền của cơ chế này là ExecutorService.

import java.util.concurrent.*;

public class MoDauExecutor {
static long tong(int tu, int den) {
long s = 0;
for (int i = tu; i <= den; i++) s += i;
return s;
}

public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(2); // reuse 2 threads
Future<Long> f1 = pool.submit(() -> tong(1, 500)); // submit a task
Future<Long> f2 = pool.submit(() -> tong(501, 1000));
System.out.println("Tong = " + (f1.get() + f2.get())); // get() waits
pool.shutdown(); // always shut it down
}
}

So với cách tự tạo Thread, ta không phải viết lớp con, không phải khai báo trường để hứng kết quả, không phải gọi join: việc chờ và việc lấy kết quả gộp thành một lời gọi get. Bài java.util.concurrent sẽ khai triển đầy đủ chủ đề này.

Vòng đời của luồng

Một luồng đi qua một số trạng thái xác định, từ lúc được tạo cho tới khi kết thúc. Java mô hình hóa các trạng thái này trong kiểu liệt kê Thread.State.

Luồng vừa được tạo bằng new ở trạng thái Mới (New), chưa chạy. Gọi start đưa nó sang Chạy được (Runnable): nó đã sẵn sàng và đang chờ bộ điều phối của hệ điều hành cấp CPU. Khi đang chạy mà cần một khoá đang bị luồng khác giữ, nó rơi vào Bị chặn (Blocked). Khi gọi wait để đợi một điều kiện, nó vào Chờ (Waiting) cho tới khi luồng khác gọi notify. Khi gọi sleep hay join có thời hạn, nó vào Chờ có hạn (Timed Waiting) và tự quay lại khi hết giờ. Cuối cùng, khi run kết thúc, luồng vào Kết thúc (Terminated) và không thể khởi chạy lại.

Vòng đời một luồng
Mới
tạo bằng new, chưa chạy
Chạy được
gọi start, chờ CPU
Đang chạy
được cấp CPU, thân run chạy
Bị chặn / Chờ
đợi khoá, wait, sleep hay join
Kết thúc
run xong, không chạy lại

Khép lại bài, hãy gom ba cách tổ chức công việc đa luồng đã đi qua vào một bảng để thấy rõ đặc điểm của từng cách:

Tiêu chíKế thừa ThreadCài RunnableExecutorService
Cách dùngViết lớp con, ghi đè run, gọi startViết run, trao cho ThreadNộp tác vụ, nhóm chạy lại luồng
Suất kế thừaTốn mất, không kế thừa được lớp khácCòn nguyên, lớp tự do kế thừaCòn nguyên
Tái dùng luồngKhông, mỗi luồng một lầnKhông tự cóCó, dùng bể luồng (thread pool)
Trả về kết quảKhông trực tiếpKhông trực tiếpCó, qua Callable/Future
Khi nào dùngHọc tập, ví dụ nhỏTách việc khỏi luồngỨng dụng thật, nhiều tác vụ
Ghi nhớ

Luồng là đơn vị thực thi chia sẻ vùng nhớ trong một tiến trình, nên chia sẻ dữ liệu rẻ nhưng dễ sinh tranh chấp. Tạo luồng bằng cách kế thừa Thread hoặc, tốt hơn, cài Runnable; luôn gọi start để chạy song song. Cột ExecutorService trong bảng trên sẽ được giải thích đầy đủ ở bài cuối của chương.

Bài tập thực hành

Bài 1. Phân biệt tiến trình và luồng. Nêu hai lý do vì sao các luồng trong cùng một tiến trình chia sẻ dữ liệu rẻ hơn hai tiến trình tách biệt.

Gợi ý

Tiến trình có vùng nhớ riêng, luồng cùng tiến trình dùng chung vùng nhớ động. Hai lý do chia sẻ rẻ: các luồng cùng nhìn thấy chung đối tượng trên heap nên truyền dữ liệu chỉ là truyền tham chiếu, không cần sao chép; và không phải gọi tới cơ chế liên tiến trình tốn kém của hệ điều hành.

Bài 2. Giải thích vì sao phải gọi start chứ không phải run để chạy một luồng song song. Điều gì xảy ra nếu ta gọi nhầm run?

Gợi ý

start yêu cầu máy ảo cấp một mạch thực thi mới rồi cho nó chạy run trên luồng đó. Gọi thẳng run chỉ là một lời gọi phương thức bình thường, thân run chạy tuần tự ngay trên luồng hiện tại, không sinh luồng mới và không có song song.

Bài 3. Vẽ lại sơ đồ vòng đời luồng và chú thích: hành động nào đưa luồng vào trạng thái Chờ có hạn (Timed Waiting), hành động nào đưa nó về Chạy được (Runnable).

Gợi ý

Gọi sleep có thời hạn hoặc join có thời hạn đưa luồng vào Chờ có hạn; khi hết hạn luồng tự quay về Chạy được. Ngoài ra, được cấp khoá đang chờ đưa luồng từ Bị chặn về Chạy được, và một lời gọi notify từ luồng khác đưa luồng từ Chờ về Chạy được.

Bài 4. Tạo ba luồng, mỗi luồng đếm số ước của một số nguyên khác nhau, rồi luồng main chờ cả ba xong và in tổng số ước tìm được.

Lời giải

Cho mỗi luồng một trường để hứng kết quả, start cả ba rồi join cả ba, chỉ đọc trường sau khi join trả về.

public class DemUoc extends Thread {
private final int n;
int soUoc; // written by run, read after join

DemUoc(int n) { this.n = n; }

@Override
public void run() {
int d = 0;
for (int i = 1; i <= n; i++)
if (n % i == 0) d++;
soUoc = d;
}

public static void main(String[] args) throws InterruptedException {
DemUoc a = new DemUoc(36), b = new DemUoc(60), c = new DemUoc(97);
a.start(); b.start(); c.start(); // run concurrently
a.join(); b.join(); c.join(); // wait for all three
System.out.println("Tong so uoc = "
+ (a.soUoc + b.soUoc + c.soUoc)); // 9 + 12 + 2 = 23
}
}

Bỏ ba lời gọi join đi thì main có thể đọc soUoc khi nó vẫn còn là 0 và in ra kết quả sai.

Bài 5. Một luồng in dòng "dang luu tu dong" mỗi 300 mili giây trong vòng lặp vô hạn, còn main chạy hai giây rồi kết thúc. Chương trình có thoát được không, và điều gì xảy ra nếu quên gọi setDaemon(true)?

Lời giải

Có thoát được nếu luồng đó là luồng nền: khi luồng thường cuối cùng là main kết thúc, máy ảo tắt và kéo theo luồng nền, dù vòng lặp của nó vô hạn. Quên setDaemon(true) thì luồng đó là luồng thường, máy ảo không có lý do gì để tắt và chương trình treo mãi. Đặt setDaemon(true) sau start sẽ ném IllegalThreadStateException.

Tự kiểm tra

Vì sao nên ưu tiên cài Runnable thay vì kế thừa Thread?

Vì Java chỉ cho kế thừa một lớp duy nhất; kế thừa Thread tiêu mất suất kế thừa đó. Cài Runnable giữ cho lớp tự do kế thừa một lớp nghiệp vụ khác, đồng thời tách việc cần làm khỏi cách chạy việc, nên cùng một công việc có thể giao cho Thread thủ công hay cho một bộ điều phối ExecutorService.

Câu hỏi tự kiểm

Kiểm tra nhanh: tạo luồng và vòng đời0/4 đúngchưa trả lời
  1. 1Gọi run() trực tiếp thay vì start() trên một đối tượng Thread dẫn tới điều gì?
  2. 2Vì sao nên ưu tiên cài Runnable thay vì kế thừa Thread?
  3. 3Nên hiểu InterruptedException là gì?
  4. 4Gọi start() hai lần trên cùng một đối tượng Thread thì sao?

Tóm tắt

Luồng cùng tiến trình dùng chung heap nên chia sẻ dữ liệu rẻ nhưng dễ tranh chấp. Hãy mô tả công việc bằng Runnable, luôn gọi start chứ đừng gọi run, và nhớ rằng một đối tượng Thread chỉ start được một lần. Dùng join để chờ luồng khác hoàn tất trước khi đọc kết quả của nó; sleep chỉ tạm ngừng luồng hiện tại và không nhả khoá. InterruptedException là tín hiệu xin dừng chứ không phải lỗi, đừng bao giờ bắt rồi bỏ trống. Phương thức stop đã bị vô hiệu hoá vì nó bỏ lại dữ liệu dở dang, cách dừng đúng là hợp tác qua interrupt. Luồng nền cần setDaemon(true) trước khi start. Trong ứng dụng thật, hãy chuyển sang ExecutorService.