Nội dung bài viết
Đăng ký học lập trình C++
Tại STDIO bạn được dạy nền tảng lập trình tốt nhất.
Đăng ký học
Nguyễn Nghĩa Trong chương trình của chúng ta đôi khi ta cần thực thi nhiều tác vụ cùng một lúc như thao tác với network, đọc ghi file... Theo cách lập trình thông thường thì ta chỉ thực hiện được một tác vụ tại một thời điểm, để đáp ứng được yêu cầu thực hiện được nhiều tác vụ cùng một lúc thì ngôn ngữ Java cung cấp cho chúng ta lập trình multithreading. Mỗi tác vụ riêng được hiểu như 1 Thread và Thread này sẽ thực hiện một công việc do chúng ta chỉ định cho nó

Giới thiệu

Với sức mạnh của phần cứng ngày càng phát triển từ đơn nhân thành đơn nhân, hay từ máy tính chỉ chạy được một chương trình tại một thời điểm (đơn nhiệm), giờ đây máy tính có thể chạy nhiều chương trình cùng một lúc (đa nhiệm), ví dụ cùng lướt web, nghe nhạc, chơi game. Thì ở một phạm vi nhỏ hơn, trong một chương trình ứng dụng thì chúng ta cũng có thể làm được đa tác vụ như vậy . Tác giả đang nhắc tới đó chính là Thread. Trong bài viết này tác giả muốn đề cập đến lập trình đa luồng (multithreading) với Thread trong ngôn ngữ Java từ cơ bản cho đến nâng cao.

Tiền đề bài viết

Bài viết xuất phát từ niềm đam mê chia sẽ của tác tới các bạn đọc đam mê lập trình. Hy vọng những kiến thức nhỏ bé này sẽ giúp ích được cho các bạn trong học tập cũng như công việc mà các bạn đang gặp phải.

Đối tượng hướng đến

Bài viết hướng đến những lập trình viện đang tìm hiểu về lập trình đa luồng (multithreading). Cụ thể trong bài viết này tôi sẽ ví dụ với ngôn ngữ lập trình Java. Các ngôn ngữ khác hoàn toàn tương tự vì kiến thức về multithreading là kiến thức chung chứ không phải thuộc về một ngôn ngữ nào cả.

Process và Thread

Trước khi bắt đầu vào nội dung chính của bài viết, tôi muốn các bạn phân biệt được rõ hai khái niệm mà đa số chúng ta hay nhầm lẫn đó là Process và Thread.

Process

Process (tiểu trình) được hiểu như là một chương trình đang chạy, ví dụ các bạn đang đọc bài viết này thì chắc chắn phải sử dụng trình duyệt, trình duyệt bạn mở lên chính là một process và process này sẽ có ID (Process IDentifier) để phân biệt với các process khác . Tất cả các process đều được quản lý bởi hệ điều hành và mỗi process sẽ có một vùng nhớ làm việc riêng mà các process khác không được can thiệp vào.

Các process này có thể chạy song song với nhau. Nhưng về bản chất của thì khái niệm song song này được hiểu bởi con người, vì đối với máy tính tại một thời điểm CPU chỉ đáp ứng được 1 process. Những process được hệ điều hành lập lịch Scheduling (phân phố thời gian chạy cho mỗi process) sao cho các process sử dụng CPU hiệu quả nhất. Thời gian này quá nhanh đối với con người nên chúng ta cảm thấy các process được chạy song song với nhau.

Các bạn có thể tìm hiểu các giải thuật định thời với các giải thuật như First Come First Served (FCFS) Scheduling, Shortest-Job-First (SJF) Scheduling,  Priority Scheduling, Round Robin(RR) Scheduling.

Để xem các process đang chạy bạn có thể làm như sau:

Trên Windows:

Mở chương trình cmd và gõ tasklist để hiển thị danh sách các process đang chạy.

tasklist

Và dưới đây là hình ảnh các process đang chạy trên máy của tôi

nguyennghia_1

Hoặc có thể mở Task Manager để xem các process đang chạy

nguyennghia_2

Thread

Thread thường được nhắc tới với các tên là tiểu trình, luồng, tuyến. Trong bài viết tôi sẽ sử dụng nguyên gốc Thread mà không dịch ra Tiếng Việt để giữ đúng nghĩa của nó.

Trong một process thường có nhiều thread chạy song song với nhau, và các thread này sử dụng chung vùng nhớ của Process. Khi một chương trình được start hay là process start thì luôn luôn có một thread được tạo ra và thread này được gọi là Main Thread. Từ Main Thread này chúng ta có thể tạo ra các thread khác để xử lý những công việc riêng.

Hình ảnh mô tả Process và Thread

nguyennghia_3

Tại sao phải cần đến Thread

Trong ứng dụng có những công việc tốn khá nhiều thời gian. Ví dụ

  • Giao tiếp với network: Nếu chúng ta download file hay request lên server chờ trả về kết quả thì tốn khá nhiều thời gian
  • Đọc ghi file: Chi phí đọc ghi file là khác lớn

Nếu chúng ta thực hiện những công việc này trên Main Thread thì những công việc khác sẽ bị block và chờ sau khi hoàn thành công việc này mới tiếp tục thực việc công việc khác. Và việc chờ như vậy đôi khi sẽ khiến ứng dụng của bạn bị "đơ" một thời gian. Điều này quả thật không phải kết quả mà chúng ta mong chờ. 

Giải pháp đạt ra cho vấn đề trên là chúng ta cần tạo ra Thread khác để thực hiện những công việc khác nhau, chạy song song với Main Thread, để ứng dụng của chúng ta đạt performance cao nhất.

Multilthreading trong Java

Để tạo Thread trong java chúng ta có hai cách là tạo class kế thừa tứ lớp Thread và implements interface Runnale. Và Override lại phương thức run() để thực hiện các đoạn mã có trong phương thức này. Chúng ta cùng tìm hiểu hai cách này ở dưới đây:

Cách 1: Extends Thread

- Đầu tiên chúng ta tạo một class kế thừa lớp Thread và override lại phương thức run()

package com.nguyennghia.demothreading;

public class MyThread extends Thread {
	@Override
	public void run() {
		for(int i = 0; i < 50; i++){
			System.out.print("X");
		}
	}
}

Lưu ý phương thức run() là phương thức sẽ thực hiện các đoạn mã mà chúng ta viết trong phương thức này sau khi phương thức start() được gọi.

Phương thức này như sau: 

@Override
	public void run() {
		// TODO Auto-generated method stub
	}

Ở trên sẽ thực hiện in ra kí tự X 50 lần.

- Tiếp theo chúng ta tạo Thread trong hàm main:

package com.nguyennghia.demothreading;

public class Program {

	public static void main(String[] args) {

		MyThread myThread = new MyThread();
		myThread.start(); // call start() method to run thread
		
		for(int i = 0; i < 50; i++){
			System.out.print("Y");
		}
	}

}

Chạy chương trình sẽ thấy X Y được in ra như sau:

YYYYYYYYYYYYYYXXXXXYYYXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY

Như vậy chúng ta thấy myThread sẽ chạy song song với Main Thread.

Mỗi lần chạy lại các bạn sẽ thấy kết quả khác nhau.

Cách 2: Implements interface Runnable

- Đầu tiên chúng ta cũng tạo 1 class nhưng sẽ implements interface Runable

package com.nguyennghia.demothreading;

public class MyRunnale implements Runnable {
	@Override
	public void run() {
		for(int i = 0; i < 50; i++){
			System.out.print("X");
		}
	}
}

- Tiếp theo chúng ta tạo Thread trong hàm main:

package com.nguyennghia.demothreading;

public class Program {

	public static void main(String[] args) {
	
		Thread myThread = new Thread(new MyRunnale());
		myThread.start();
		
		for(int i = 0; i < 50; i++){
			System.out.print("Y");
		}
	}

}

Ở cách này chúng ta tạo Thread bằng cách là truyền đối tượng MyRunnable vào constructor của Thread

Thread myThread = new Thread(new MyRunnale());

Và cũng gọi phương thức start() để chạy thread

myThread.start();

Và kết quả sẽ xuất ra tương tự như cách 1.

Ngoài ra chúng ta có thể truyền đối tượng Runnable và override phương thức run() ngay khi tạo đối tượng thread:

package com.nguyennghia.demothreading;

public class Program {

	public static void main(String[] args) {
	
		Thread myThread = new Thread(new Runnable() {
			
			@Override
			public void run() {
				for(int i = 0; i < 50; i++){
					System.out.print("X");
				}
			}
		});
        myThread.start();
		
	
		for(int i = 0; i < 50; i++){
			System.out.print("Y");
		}
	}

}

Hình ảnh mô phỏng MainThread và myThread (New Thead) trong cả hai cách trên

nguyennghia_4

Các thông tin của Thread

ThreadID

Khi một Thread được tạo ra nó sẽ có một ID do máy ảo Java cung cấp, và chúng ta không thể thay đổi giá trị ID này.

Phương thức getID(); cho phép chúng ta lấy về ID của thread

myThread.getId(); //get id of myThread

ThreadName

Cũng giống như ID thì thread cũng sẽ có một cái tên đại diện cho thread đó. Chúng ta có thể set name cho thread sử dụng phương thức setName();

myThread.setName("ThreadName");

Và phương thức getName() để lấy về name của thread

myThread.getName();

ThreadPriority

Các thread khi tạo ra sẽ có độ ưu tiên, độ ưu tiên này sẽ được CPU sử dụng để lập lịch cho Thread. Priority có giá trị từ 0 đến 10. Và các giá trị phổ biến được định nghĩa sẵn trong lớp Thread là:

  • public final static int MAX_PRIORITY = 10; //The maximum priority value allowed for a thread.
  • public final static int MIN_PRIORITY = 1; //The minimum priority value allowed for a thread.
  • public final static int NORM_PRIORITY = 5; //The normal (default) priority value assigned to threads.

Sử dụng phương thức setPriority(); để set giá trị priority cho thread và getPriority(); lấy về giá trị priority của thread

myThread.setPriority(Thread.MAX_PRIORITY);
myThread.getPriority();

Mặc định khi thread tạo ra sẽ có giá trị priority là NORM_PRIORITY

ThreadState

Một Thread sẽ có các state (trạng thái dưới đây):

NEW: The thread has been created, but has never been started.

RUNNABLE: The thread may be run.

BLOCKED: The thread is blocked and waiting for a lock.

WAITING: The thread is waiting.

TIMED_WAITING: The thread is waiting for a specified amount of time.

TERMINATED: The thread has been terminated.

Đụng độ giữa các Thread và cách giải quyết

Như đã đề cập ở trên thì các thread tạo ra sẽ dùng chung một vùng nhớ. Nếu các process này cùng truy cập vào một vùng nhớ nào đó tại cùng một thời điểm thì dẫn đến sai, mất dữ liệu. 

Để khắc phục điều này thì Java cung cấp cho chúng ta synchronized để đồng bộ các thread khi sử dụng chung vùng nhớ chia sẻ (shared memory).

Mội khối synchronized đánh dấu một phương thức hay một khối mã được đồng bộ tránh xung đột giữa các thread.

Khi một thread can thiệt vào phương thức hay khối mã được đánh dấu là synchronized thì thread này sẽ khóa (lock) không cho các thread khác can thiệp vào cho đến khi thread này thực hiện xong thì mới đánh thức các thread khác. Và như vậy tại một thời điểm chỉ có một thread truy cập vào vùng nhớ được chia sẻ.

Cùng xét ví dụ để hiểu rõ thêm

Tạo class ShareMemory với phương thức là printData() đại diện cho dữ liệu dùng chung cho nhiều thread.

package com.nguyennghia.demothreading;

public class ShareMemory {
    public void printData(String threadName){
        for(int i = 0; i < 50; i++){
            System.out.println(threadName + ": " + i);
        }
    }
}

Tôi sẽ tiến hành tạo ba Thread để cùng truy cập vào phương thức printData của đối tượng ShareMemory.

package com.nguyennghia.demothreading;

public class MyThread extends Thread {
	private ShareMemory mShareMemory;
	private String mThreadName;
	
	public MyThread(ShareMemory sm, String threadName){
		this.mShareMemory = sm;
		this.mThreadName = threadName;
	}
	
	@Override
	public void run() {
		mShareMemory.printData(mThreadName);
	}
}

Hàm Main:

public class Program {

	public static void main(String[] args) {
	
		ShareMemory sm = new ShareMemory();
		MyThread thread1 = new MyThread(sm, "Thread1");
		MyThread thread2 = new MyThread(sm, "Thread2");
		MyThread thread3 = new MyThread(sm, "Thread3");

		thread1.start();
		thread2.start();
		thread3.start();
	}

}

Chạy và xem kết quả

Thread1: 0
Thread3: 0
Thread2: 0
Thread3: 1
Thread1: 1
Thread3: 2
Thread3: 3
Thread2: 1
Thread3: 4
Thread1: 2
Thread3: 5
Thread2: 2
Thread3: 6
Thread1: 3
Thread3: 7
Thread2: 3
Thread3: 8
Thread1: 4
Thread3: 9
Thread2: 4
Thread3: 10
.
.
.

Chúng ta thấy rằng cả ba thread đều truy cập vào 1 tài nguyên trong khi thread này vẫn nắm giữ.

Để đồng bộ chúng ta thêm từ khóa synchronized vào trước phương thức printData()

package com.nguyennghia.demothreading;

public class ShareMemory {
	public synchronized void printData(String threadName){
		for(int i = 0; i < 50; i++){
			System.out.println(threadName + ": " + i);
		}
	}
}

Và chạy lại chúng ta thấy kết quả

Thread1: 0
Thread1: 1
Thread1: 2
Thread1: 3
Thread1: 4
Thread1: 5
Thread1: 6
Thread1: 7
Thread1: 8
Thread1: 9
Thread1: 10
.
.
.
Thread3: 0
Thread3: 1
Thread3: 2
Thread3: 3
Thread3: 4
Thread3: 5
Thread3: 6
Thread3: 7
Thread3: 8
Thread3: 9
Thread3: 10
.
.
.
Thread2: 0
Thread2: 1
Thread2: 2
Thread2: 3
Thread2: 4
Thread2: 5
Thread2: 6
Thread2: 7
Thread2: 8
Thread2: 9
Thread2: 10

Chúng ta thấy rằng thread1 sẽ được giữ tài nguyên và sẽ khóa không cho thread2 và thread3 vào. Sau khi thread1 thực hiện xong thì sẽ đánh thức thread2 và thread3, lúc này thread3 sẽ được giữ tài nguyên và khóa không cho thread2 vào. Sau khi thực hiện xong sẽ đánh thức thread3 thực hiện. Như vậy tại một thời điểm chỉ có một thread được can thiệp vào vùng nhớ chia sẻ.

Như vậy, với từ khóa synchronized mà ngôn ngữ Java cung cấp chúng ta có thể đồng bộ hóa giữa các tiến trình một cách dễ dàng.

Lời kết

Qua bài viết tôi hy vọng các bạn nắm vững kiến thức mảng kiến thức, vì nó được sử dụng khá nhiều trong phát triển phần mềm hiện nay. Nếu có bất cứ  thắc mắc nào các bạn có thể để lại câu hỏi ở dưới hoặc liên hệ với tác giả Nguyễn Nghĩa để được giải đáp sớm nhất.

THẢO LUẬN
ĐÓNG