■ Thread concept
- Thread là đơn vị thực thi nhỏ nhất mà hệ điều hành có thể sử dụng để lập lịch
- Process -Tiến trình là một nhóm các luồng liên kết được thực thi ở một môi trường dùng chung
- Thread trong cùng 1 process chia sẻ cùng không gian bộ nhớ và có thể giao tiếp trực tiếp với nhau
■ Thread Concurrency
Việc xử lý nhiều thread và process một lúc được gọi là đồng thời.
Làm thế nào để hệ thống quyết định thực thi cái gì khi có nhiều thread hơn CPU?
Các hệ điều hành sử dụng bộ lập lịch thread để xác định luồng nào hiện đang thực thi.Khi thời gian quy định của một luồng đã hoàn tất nhưng luồng đó chưa xử lý xong thì sẽ xảy ra hiện tưởng chuyển đổi ngữ cảnh ( context switch ). Chuyển ngữ cảnh là quá trình lưu trữ trạng thái hiện tại của luồng (current state) sau đó lại khôi phục trạng thái của luồng để tiếp tục thực thi.Ở đây sẽ tốn chi phí cho chuyển đổi ngữ cảnh do mất thời gian tải lại trạng thái của luồng.Tuy nhiên scheduler của hệ điều hành sẽ cố gắng để giảm thiếu số lượng switch context nhằm giữ ứng dụng chạy trơn chu.Một luồng có thể làm gián đoạn hoặc thay thế một luồng khác nếu nó có độ ưu tiên của luồng cao hơn luồng khác.Mức độ ưu tiên của luồng là một giá trị số được liên kết với một luồng được scheduler xem xét khi xác định luồng nào đang được thực thi. Trong java mức độ ưu tiên của luồng được chỉ định dưới dạng một giá trị integer.
■ Creating a Thread
Cách đơn giản nhất để xác định task cho 1 luồng là sử dụng Runnable. Runnable là một functional interface không có arguments và return data.
@FunctionalInterface public interface Runnable {
void run();
}
Rất dễ tạo thread bằng một dòng mã :
new Thread(() -> System.out.print("Hello")).start();
System.out.print("World");
Dòng đầu tiên tạo một đối tượng Thread mới và sau đó khởi động nó bằng phương thức start(). Mã này có in HelloWorld hoặc WorldHello không? Câu trả lời là chúng ta không biết.Tùy thuộc vào mức độ ưu tiên/lập lịch của luồng, có thể thực hiện được.Hãy nhớ rằng thứ tự thực hiện luồng thường không được đảm bảo.
Mặc dù thứ tự thực hiện luồng là không xác định sau khi các luồng đã được bắt đầu,nhưng thứ tự trong một luồng vẫn là tuyến tính.
So sánh run() và start() .Việc gọi run() trên Thread hoặc Runnable không bắt đầu một luồng mới.Mặc dù các đoạn mã sau sẽ được biên dịch nhưng không có đoạn mã nào thực thi một tác vụ trên một luồng riêng biệt:
System.out.println("Start");
new Thread(do something A).run();
new Thread(do something B).run();
new Thread(do something C).run();
System.out.println("end");
// Start -> do something A -> do something B -> do something C -> end
Không giống như ví dụ trước, mỗi dòng mã này sẽ đợi cho đến khi phương thức run() hoàn tất trước khi chuyển sang dòng tiếp theo.
Tổng quát hơn, chúng ta có thể tạo một Thread và tác vụ liên quan của nó theo một trong hai cách trong Java:
1.Cung cấp một đối tượng Runnable hoặc biểu thức lambda cho contructor Thread.
2.Tạo một lớp mở rộng Thread và ghi đè phương thức run().
■ Phân biệt các loại thread
Bạn có thể ngạc nhiên rằng tất cả các ứng dụng Java đều đa luồng vì chúng bao gồm các luồng hệ thống (system thread).
Một luồng hệ thống được tạo bởi Máy ảo Java (JVM) và chạy trong nền của ứng dụng.
Ví dụ: việc thu thập rác được quản lý bởi một luồng hệ thống do JVM tạo ra.
Ngoài ra, một luồng do người dùng xác định (user-defined thread) là một luồng được tạo bởi nhà phát triển ứng dụng để hoàn thành một nhiệm vụ cụ thể.
Để đơn giản, chúng ta thường coi các chương trình chỉ chứa một luồng do người dùng xác định là các ứng dụng đơn luồng.
Cả hai luồng do hệ thống và người dùng xác định đều có thể được tạo dưới dạng luồng daemon.
Một luồng daemon là một luồng sẽ không ngăn JVM thoát ra khi chương trình kết thúc.
Ứng dụng Java chấm dứt khi các luồng duy nhất đang chạy là các luồng daemon.
Ví dụ: nếu việc thu gom rác là luồng duy nhất còn chạy thì JVM sẽ tự động tắt.
Chúng ta hãy xem một ví dụ. Bạn nghĩ kết quả này là gì?
1: public class Zoo {
2: public static void pause() { // Defines the thread task
3: try {
4: Thread.sleep(10_000); // Wait for 10 seconds
5: } catch (InterruptedException e) {}
6: System.out.println("Thread finished!");
7: }
8:
9: public static void main(String[] unused) {
10: var job = new Thread(() -> pause()); // Create thread
11:
12: job.start(); // Start thread
13: System.out.println("Main method finished!");
14: } }
Out put:
Main method finished!
--- 10 second wait ---
Thread finished
Mặc dù phương thức main() đã được thực hiện, JVM sẽ đợi luồng người dùng được thực hiện trước khi kết thúc chương trình. Điều gì sẽ xảy ra nếu chúng ta thay đổi công việc thành một chuỗi daemon bằng cách thêm chuỗi này vào dòng 11?
11: job.setDaemon(true);
Chương trình sẽ in câu lệnh đầu tiên và kết thúc mà không in dòng thứ hai.Theo mặc định, user-defined threads không phải là daemon và chương trình sẽ đợi chúng kết thúc.
■ Daemon thread trong Java
Trong Java, các luồng daemon là các luồng có mức độ ưu tiên thấp chạy ở chế độ nền để thực hiện các tác vụ như thu gom rác hoặc cung cấp dịch vụ cho các luồng của người dùng.
Vòng đời của một luồng daemon phụ thuộc vào mức độ của các luồng của người dùng, nghĩa là khi tất cả các luồng của người dùng kết thúc quá trình thực thi của chúng.
Máy ảo Java (JVM) sẽ tự động chấm dứt luồng daemon.
Thuộc tính của luồng Java Daemon
Không ngăn chặn việc thoát JVM :
Các luồng Daemon không thể ngăn JVM thoát khi tất cả các luồng của người dùng hoàn tất quá trình thực thi của chúng. Nếu tất cả các luồng của người dùng hoàn thành nhiệm vụ của họ,JVM sẽ tự chấm dứt, bất kể có bất kỳ luồng daemon nào đang chạy hay không.
Tự động chấm dứt :
Nếu JVM phát hiện một luồng daemon đang chạy, nó sẽ chấm dứt luồng đó và sau đó tắt nó. JVM không kiểm tra xem luồng daemon có đang chạy hay không; nó chấm dứt nó bất kể.
Mức độ ưu tiên thấp :
Các luồng Daemon có mức độ ưu tiên thấp nhất trong số tất cả các luồng trong Java.
Bản chất mặc định của Daemon Thread
Theo mặc định, luồng chính luôn là luồng không phải daemon.Tuy nhiên, đối với tất cả các luồng khác, bản chất daemon của chúng được kế thừa từ luồng cha của chúng. Nếu luồng cha là daemon thì luồng con cũng là daemon và nếu luồng cha không phải là daemon thì luồng con cũng là không phải daemon.
■ Quản trị Thread’s Life Cycle
Luồng sẽ bao gồm các trạng thái :
- NEW : Trạng thái này đại diện cho một Thread được tạo mới, nhưng chưa được khởi chạy.
- RUNNABLE : Trong trạng thái này, Thread đã được khởi chạy và đang chờ để được phân bổ CPU để thực thi.
- BLOCKED : Trong trạng thái này, Thread đang chờ để có thể truy cập vào một tài nguyên đang bị khóa bởi một Thread khác.
- WAITING : Trong trạng thái này, Thread đang chờ một sự kiện xảy ra để có thể tiếp tục thực thi.
- TIMED_WAITING : Trong trạng thái này, Thread đang chờ một khoảng thời gian cụ thể trước khi tiếp tục thực thi.
- TERMINATED : Trong trạng thái này, Thread đã hoàn thành thực thi của nó.
Khi được khởi tạo luồng sẽ ở trạng thái NEW. Ngay sau khi start() được gọi, nó sẽ chuyển lên trạng thái RUNNABLE. Điều đó có nghĩa là nó thực sự đang chạy? Không chính xác, nó có thể đang chạy hoặc có thể không. Trạng thái RUNNABLE chỉ có nghĩa là nó có thể chạy được. Sau khi công việc của luồng hoàn thành hoặc phát sinh một ngoại lệ chưa được xử lý, trạng thái của luồng sẽ trở thành TERMINATED và không có công việc nào được thực hiện nữa.Khi ở trạng thái RUNNABLE luồng có thể chuyển sang một trong 3 trạng thái nơi nó tạm dừng công việc của mình : BLOCKED, WAITING, TIMED_WAITING
■ Interrupting a Thread
Ý nghĩa của Interrupting a Thread:
Interrupting a Thread không phải là dừng Thread đó mà chỉ là một yêu cầu để Thread tự dừng hoạt động của mình. Thread có thể chọn bỏ qua hoặc không phản ứng với yêu cầu này, tùy thuộc vào cách lập trình. Interrupting a Thread thường được sử dụng để yêu cầu một Thread kết thúc hoạt động của nó một cách gọn gàng và sạch sẽ.
Cách thực hiện Interrupting a Thread:
Sử dụng phương thức interrupt() để yêu cầu một Thread ngừng hoạt động.Khi một Thread bị ngắt, nó sẽ nhận được một InterruptedException nếu nó đang trong trạng thái "Waiting", "Timed Waiting" hoặc "Blocking".
Thread có thể kiểm tra trạng thái của nó bằng cách gọi phương thức isInterrupted().
■ Concurrency API
Executors service trong Java là một framework được cung cấp bởi thư viện Java Concurrency API(java.util.concurrent). Nó cung cấp một cách tiện lợi và hiệu quả để tạo và quản lý các luồng (threads) trong ứng dụng Java.Một số lợi ích chính của Executors service bao gồm:
- Quản lý Threads: Executors service quản lý một nhóm threads, giúp giảm gánh nặng cho lập trình viên khi phải tự tạo và quản lý các threads.
- Tái sử dụng Threads: Executors service tái sử dụng các threads, giúp tăng hiệu suất bằng cách tránh tạo và hủy threads mới liên tục.
- Flexible Task Scheduling: Executors service cung cấp các cách linh hoạt để lập lịch và thực hiện các tác vụ (tasks), như chạy các tác vụ theo lịch, chạy các tác vụ lặp lại, v.v.
- Quản lý Exceptions: Executors service quản lý các ngoại lệ (exceptions) phát sinh từ các tác vụ, giúp tránh ảnh hưởng đến các tác vụ khác.
- Cấu hình: Executors service cung cấp nhiều cấu hình sẵn như FixedThreadPool, CachedThreadPool,SingleThreadExecutor, v.v. để phù hợp với các nhu cầu khác nhau.
Các loại Executors service:
- FixedThreadPool: Sử dụng một số lượng cố định các threads để thực hiện các tác vụ.
- CachedThreadPool: Tự động tăng số lượng threads khi cần và tái sử dụng các threads không hoạt động.
- SingleThreadExecutor: Chỉ sử dụng một thread để thực hiện các tác vụ tuần tự.
- ScheduledThreadPoolExecutor: Cho phép lập lịch các tác vụ để chạy theo định kỳ hoặc với thời gian trì hoãn.
■ Shutting Down a Thread Executor
Khi bạn đã sử dụng xong bộ xử lý luồng, điều quan trọng là bạn phải gọi phương thức shutdown().Thread Executor tạo một luồng không phải daemon trong tác vụ đầu tiên được thực thi, do đó,việc không gọi shutdown() sẽ dẫn đến ứng dụng của bạn không bao giờ chấm dứt.
Quá trình shutdown đối với Thread Executor bao gồm việc trước tiên từ chối mọi tác vụ mới được gửi tới Thread Executor trong khi tiếp tục thực thi mọi tác vụ đã gửi trước đó.
Trong thời gian này, việc gọi isShutdown() sẽ trả về true, trong khi isTerminated() sẽ trả về false.Nếu một tác vụ mới được gửi đến bộ thực thi luồng trong khi nó đang tắt, một ngoại lệ RejectedExecutionException sẽ được ném ra.
Khi tất cả các tác vụ đang hoạt động đã được hoàn thành, isShutdown() và isTerminated() đều sẽ trả về true.Trong thời gian này, việc gọi isShutdown() sẽ trả về true, trong khi isTerminated() sẽ trả về false.
Nếu một tác vụ mới được gửi đến bộ thực thi luồng trong khi nó đang tắt,
một ngoại lệ RejectedExecutionException sẽ được ném ra.
Khi tất cả các tác vụ đang hoạt động đã được hoàn thành, isShutdown() và isTerminated() đều sẽ trả về true.
Nếu bạn muốn hủy tất cả các tác vụ đang chạy và sắp tới thì sao?
ExecutorService cung cấp một phương thức gọi là shutdownNow(), cố gắng dừng tất cả các tác vụ đang chạy và loại bỏ bất kỳ tác vụ nào chưa được bắt đầu. Nó không được đảm bảo thành công vì có thể tạo ra một luồng không bao giờ kết thúc, vì vậy mọi nỗ lực làm gián đoạn nó có thể bị bỏ qua.
Khi shut down một Executors service, có một số điều cần lưu ý:
Tránh gọi shutdown() và shutdownNow() cùng một lúc:
Việc gọi cả hai phương thức này có thể dẫn đến hành vi không xác định.
Tốt nhất là chỉ nên sử dụng một phương thức shutdown.
Xử lý các tác vụ chưa hoàn thành:
Khi gọi shutdown(), các tác vụ hiện tại vẫn sẽ được hoàn thành,nhưng không có tác vụ mới được chấp nhận. Nếu cần, chúng ta có thể gọi awaitTermination()
Xử lý các tác vụ không thể dừng kịp thời:
Khi gọi shutdownNow(), tất cả các tác vụ đang chạy sẽ được cố gắng dừng lại.Tuy nhiên, một số tác vụ có thể không thể dừng kịp thời. Trong trường hợp này, chúng ta có thể xử lý chúng bằng cách kiểm tra danh sách các Runnable tasks trả về từ shutdownNow().
Lưu ý về các resource:
Khi Executors service bị shut down, chúng ta cần đảm bảo rằng tất cả các resource như threads, connections, etc. được giải phóng một cách đúng đắn.
Kiểm tra trạng thái của Executors service:
Sau khi gọi shutdown() hoặc shutdownNow(), chúng ta có thể sử dụng các phương thức như isShutdown() và isTerminated() để kiểm tra trạng thái của Executors service.
■ Submitting Tasks
- Future submit(Callable task): Chấp nhận một Callable task và trả về một Future để theo dõi kết quả.
- Future<?> submit(Runnable task): Chấp nhận một Runnable task và trả về một Future.
- List> invokeAll(Collection<? extends Callable> tasks): Chấp nhận một tập hợp các Callable tasks và chờ đợi tất cả chúng hoàn thành.Trả về Danh sách các phiên bản Future theo thứ tự giống như chúng có trong Collection ban đầu.
- T invokeAny(Collection<? extends Callable> tasks): Chấp nhận một tập hợp các Callable tasks và chờ đợi một trong số chúng hoàn thành.
■ Waiting for Results
Sử dụng Future để kiểm tra kết quả.
V get() throws InterruptedException, ExecutionException;
V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException;
boolean isDone();
boolean isCancelled();
boolean cancel(boolean mayInterruptIfRunning); // Cố gắng hủy bỏ việc thực thi tác vụ và trả về true nếu nó bị hủy thành công hoặc sai nếu không thể thực hiện được`
■ Waiting for All Tasks to Finish
Sau khi gửi một tập hợp các tác vụ cho thread executor, thông thường phải chờ kết quả.Như bạn đã thấy trong các phần trước, một giải pháp là gọi get() trên mỗi đối tượng Future được trả về bởi phương thức submit(). Nếu chúng ta không cần kết quả của các tác vụ vàđã hoàn thành việc sử dụng trình thực thi luồng của mình thì có một cách tiếp cận đơn giản hơn.Đầu tiên, chúng ta tắt thread executor bằng phương thức shutdown().
Tiếp theo, sử dụng phương thức WaitTermination() có sẵn cho tất cả thread executor. Phương thức này đợi thời gian được chỉ định để hoàn thành tất cả các tác vụ,trả kết quả sớm hơn nếu tất cả các tác vụ hoàn thành hoặc phát hiện ra Ngoại lệ
InterruptedException.ExecutorService service = Executors.newSingleThreadExecutor();
try {
// Add tasks to the thread executor
// do something
} finally {
service.shutdown();
}
// Check whether all tasks are finished
if(service.isTerminated()) System.out.println("Finished!");
else System.out.println("At least one task is still running");
■ Scheduling Tasks
Thông thường trong Java, chúng ta cần lên lịch cho một tác vụ sẽ diễn ra vào một thời điểm nào đó trong tương lai. Chúng ta thậm chí có thể cần lên lịch để tác vụ được thực hiện lặp đi lặp lại vào một khoảng thời gian nhất định.
Ví dụ, hãy tưởng tượng rằng chúng ta muốn kiểm tra nguồn cung cấp thức ăn cho động vật trong vườn thú mỗi giờ một lần và bổ sung nó khi cần thiết.
ScheduledExecutorService, là giao diện con của ExecutorService, có thể được sử dụng cho nhiệm vụ như vậy.
ScheduledExecutorService service = Executors.newSingleThreadScheduledExecutor();
schedule(Callable<V> callable, long delay, TimeUnit unit); // task được gọi sau thời gian delay
schedule(Runnable command, long delay, TimeUnit unit); // task được gọi sau thời gian delay
scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit); // task được chạy sau thời gian
delay ban đầu, task mới sẽ được tạo sau mỗi chu kì thời gian trôi qua
scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit); // task được chạy sau thời gian
delay ban đầu, khi task hoàn thành thì tiếp tục delay rồi mới chạy tiếp
■ Increasing Concurrency with Pools
Sự khác biệt giữa single-thread và pooled-thread executor là điều xảy ra khi một tác vụ đang chạy.Trong khi single-thread sẽ đợi luồng có sẵn trước khi chạy tác vụ tiếp theo, thì pooled-thread executor có thể thực thi đồng thời tác vụ tiếp theo.Nếu nhóm hết các luồng có sẵn, tác vụ sẽ được thread executor xếp vào hàng đợi và chờ hoàn thành
■ Writing Thread-Safe Code
An toàn luồng là thuộc tính của một đối tượng đảm bảo thực thi an toàn bởi nhiều luồng cùng một lúc. Vì các luồng chạy trong môi trường và không gian bộ nhớ dùng chung, làm cách nào để ngăn hai luồng can thiệp lẫn nhau? Chúng ta phải tổ chức quyền truy cập vào dữ liệu để không nhận được kết quả không hợp lệ hoặc không mong muốn.
Các kĩ thuật được dùng để bảo vệ dữ liệu :
- Atomic classes
- Synchronized blocks
- Lock framework
- Cyclic barriers
■ Thread-Safety
"An toàn luồng" (thread safety) trong Java là một khái niệm quan trọng khi làm việc với đa luồng.Nó đề cập đến khả năng của một đoạn code hoặc một đối tượng có thể được truy cập và sử dụng bởi nhiều luồng cùng một lúc mà không gây ra lỗi hoặc kết quả không mong muốn.
■ Accessing Data with volatile (đảm bảo rằng quyền truy cập vào dữ liệu trong bộ nhớ là nhất quán.)
Thuộc tính volatile đảm bảo rằng chỉ có một luồng sửa đổi một biến cùng một lúc và dữ liệu được đọc giữa nhiều luồng là nhất quán.
Volatile có cung cấp sự an toàn cho luồng không?
Không chính xác.Xem xét ví dụ sau :
3: private volatile int sheepCount = 0;
4: private void incrementAndReport() {
5:System.out.print((++sheepCount)+" ");
6:}
Thật không may, mã này không an toàn cho luồng và vẫn có thể dẫn đến số bị bỏ sót :
Output : 2 6 1 7 5 3 2 9 4 8
Lý do mã này không an toàn cho luồng là vì ++sheepCount vẫn là hai thao tác riêng biệt. Nói cách khác, nếu toán tử tăng biểu thị biểu thức lambCount = lambCount + 1,thì mỗi thao tác đọc và ghi đều an toàn theo luồng, nhưng thao tác kết hợp thì không (=).
Như chúng ta đã thấy, toán tử tăng ++ không an toàn cho luồng, ngay cả khi sử dụng volatile.Nó không an toàn cho luồng vì hoạt động không mang tính nguyên tử, thực hiện hai tác vụ, đọc và ghi, có thể bị gián đoạn bởi các luồng khác
■ Protecting Data with Atomic Classes
Các lớp Atomic được cung cấp trong gói java.util.concurrent.atomic để giúp đảm bảo an toàn luồng (thread safety) khi làm việc với đa luồng.
Nguyên tử - Atomic là thuộc tính của một thao tác được thực hiện dưới dạng một đơn vị thực thi duy nhất mà không có bất kỳ sự can thiệp nào từ luồng khác. Phiên bản nguyên tử an toàn luồng của toán tử ++ sẽ thực hiện việc đọc và ghi biến dưới dạng một thao tác đơn lẻ, không cho phép bất kỳ luồng nào khác truy cập vào biến trong quá trình thao tác.
Cách hoạt động của các lớp atomic :
Một số lớp atomic :
+ AtomicBoolean
+ AtomicInteger
+ AtomicLong
...
Ví dụ :
3: private AtomicInteger sheepCount = new AtomicInteger(0);
4: private void incrementAndReport() {
5: System.out.print(sheepCount.incrementAndGet()+" ");
6: }
Output : 2 3 1 4 5 6 7 8 9 10 hoặc
1 4 3 2 5 6 7 8 9 10 hoặc
1 4 3 5 6 2 7 8 10 9 ..
Không giống như kết quả mẫu trước đây của chúng tôi, các số từ 1 đến 10 sẽ luôn được in, mặc dù thứ tự vẫn không được đảm bảo.
■ Improving Access with synchronized Blocks
Mặc dù các lớp nguyên tử rất tốt trong việc bảo vệ một biến duy nhất, nhưng chúng không đặc biệt hữu ích nếu bạn cần thực thi một loạt lệnh hoặc gọi một phương thức.
Kỹ thuật phổ biến nhất là sử dụng monitor để synchronize quyền truy cập.
Monitor, còn được gọi là khóa, là một cấu trúc hỗ trợ multual exclusion.
Mutual Exclusion (Độc quyền) là một khái niệm quan trọng trong lập trình đa luồng (multithreading) và đồng thời (concurrency).Nó đề cập đến việc chỉ cho phép một luồng hoặc một tiến trình truy cập vào một tài nguyên nhất định tại một thời điểm, ngăn chặn các luồng khác truy cập cùng lúc
Monitor là thuộc tính mà tối đa một luồng đang thực thi một đoạn mã cụ thể tại một thời điểm nhất định.
Trong Java, bất kỳ Đối tượng nào cũng có thể được sử dụng làm monitor, như trong ví dụ sau :
var manager = new SheepManager();
synchronized(manager) {
// bắt đầu khối code
// Work to be completed by one thread at a time
}
Ví dụ này được gọi là khối được đồng bộ hóa. Mỗi luồng đến trước tiên sẽ kiểm tra xem có luồng nào đang chạy khối hay không.
Nếu khóa không có sẵn, luồng sẽ chuyển sang trạng thái BLOCKED cho đến khi nó có thể “có được khóa”- acquire the lock..
Nếu khóa có sẵn (hoặc luồng đã giữ khóa), luồng đơn sẽ vào khối,
ngăn không cho tất cả các luồng khác xâm nhập. Khi luồng thực hiện xong khối, nó sẽ giải phóng khóa,cho phép một trong các luồng đang chờ tiếp tục.
Chú ý :
Để đồng bộ hóa quyền truy cập trên nhiều luồng, mỗi luồng phải cùng truy cập vào cùng một đối tượng.
Nếu mỗi luồng đồng bộ hóa trên các đối tượng khác nhau thì mã đó không an toàn cho luồng.
Chú ý kiểm tra việc đồng bộ thực thi execution
Ví dụ :
11: for(int i = 0; i < 10; i++) {
12: synchronized(manager) {
13: service.submit(() -> manager.incrementAndReport());
14: }
15: }
Bạn có thể nhận ra vấn đề? Chúng tôi đã đồng bộ hóa việc tạo các luồng nhưng chưa đồng bộ hóa việc thực thi các luồng.
Chúng ta có thể đồng bộ hóa trên bất kỳ đối tượng nào, miễn là đối tượng đó giống nhau. Ví dụ: đoạn mã sau cũng sẽ hoạt động:
4: private final Object herd = new Object();
5: private void incrementAndReport() {
6: synchronized(herd) {
7: System.out.print((++sheepCount)+" ");
8: }
9: }
Mặc dù chúng ta không cần đặt biến herd là final, nhưng làm như vậy sẽ đảm bảo rằng nó không bị gán lại sau khi các luồng bắt đầu sử dụng nó
■ Synchronizing on Methods
Chúng ta có thể thêm synchronized modifier vào bất kỳ phương thức phiên bản nào để tự động đồng bộ hóa trên chính đối tượng đó.
Ví dụ: hai định nghĩa phương thức sau đây là tương đương
Cách 1 :
void sing() {
synchronized(this) {
System.out.print("La la la!");
}
}
Cách 2 :
synchronized void sing() {
System.out.print("La la la!");
}
Chúng ta cũng có thể áp dụng công cụ sửa đổi được đồng bộ hóa cho static methods.
Đối tượng nào được sử dụng làm monitor khi chúng ta đồng bộ hóa trên static methods? Tất nhiên là đối tượng của lớp!
Điểm khác biệt so với việc sử dụng synchronized trên các phương thức thường (non-static) là, khi sử dụng static synchronized, thì khóa được áp dụng trên toàn bộ lớp, thay vì trên từng đối tượng.
Ví dụ: hai phương thức sau đây tương đương với việc đồng bộ hóa tĩnh
static void dance() {
synchronized(SheepManager.class) {
System.out.print("Time to dance!");
}
}
static synchronized void dance() {
System.out.print("Time to dance!");
}
Lưu ý rằng, việc sử dụng synchronized trên phương thức sẽ khóa toàn bộ đối tượng, nghĩa là chỉ có một luồng có thể thực hiện bất kỳ phương thức synchronized nào của đối tượng đó cùng một lúc.
■ Understanding the Lock Framework
A synchronized block chỉ hỗ trợ một bộ chức năng giới hạn. Ví dụ: điều gì sẽ xảy ra nếu chúng ta muốn kiểm tra xem khóa có sẵn hay không và nếu không có thì thực hiện một số tác vụ khác? Hơn nữa, nếu khóa không bao giờ có sẵn và chúng ta đồng bộ hóa nó, chúng ta có thể phải chờ đợi mãi mãi.
■ Applying a ReentrantLock
// Implementation #1 with a synchronized block
Object object = new Object();
synchronized(object) {
// Protected code
}
// Implementation #2 with a Lock
Lock lock = new ReentrantLock();
try {
lock.lock();
// Protected code
} finally {
lock.unlock();
}
Mặc dù chắc chắn là không bắt buộc nhưng bạn nên sử dụng khối try/finally cùng với các phiên bản Khóa.Làm như vậy đảm bảo rằng mọi khóa thu được đều được giải phóng đúng cách.
Bên cạnh việc luôn đảm bảo unlock, bạn cũng cần đảm bảo rằng bạn chỉ unlock khóa mà bạn có.Nếu bạn cố gắng unlock mà bạn không có, bạn sẽ gặp ngoại lệ khi chạy.
Lock lock = new ReentrantLock();
lock.unlock();
// IllegalMonitorStateException
■ Attempting to Acquire a Lock
void lock() Requests lock and blocks until lock is acquired
void unlock() Releases lock
boolean tryLock() Requests lock and returns immediately. Returns
boolean indicating whether lock was successfully acquired.
boolean tryLock(long timeout, TimeUnit unit) Requests lock and blocks for specified time or until lock is
acquired. Returns boolean indicating whether lock was successfully acquired.
Mẫu triển khai phổ biến :
Lock lock = new ReentrantLock();
new Thread(() -> printHello(lock)).start();
if(lock.tryLock()) {
try {
System.out.println("Lock obtained, entering protected code");
} finally {
lock.unlock();
}
} else {
System.out.println("Unable to acquire lock, doing something else");
}
■ Acquiring the Same Lock Twice - Lấy cùng một khóa hai lần
Lớp ReentrantLock duy trì bộ đếm số lần khóa được cấp thành công hoàn toàn cho một luồng.Để mở khóa cho các luồng khác sử dụng, unlock() phải được gọi bằng số lần khóa được cấp. Đoạn mã sau có lỗi. Bạn có thể nhận ra nó?
Lock lock = new ReentrantLock();
if(lock.tryLock()) {
try {
lock.lock();
System.out.println("Lock obtained, entering protected code");
} finally {
lock.unlock();
} }
■ Orchestrating Tasks with a CyclicBarrier
CyclicBarrier là một công cụ đồng bộ hóa trong Java được sử dụng để đợi một nhóm các luồng để đạt đến một điểm xác định trước,trước khi các luồng này có thể tiếp tục tiến hành. Đây là một lớp trong gói java.util.concurrent.
import java.util.concurrent.*;
public class LionPenManager {
private void removeLions() { System.out.println("Removing lions"); }
private void cleanPen() { System.out.println("Cleaning the pen"); }
private void addLions() { System.out.println("Adding lions"); }
public void performTask(CyclicBarrier c1, CyclicBarrier c2) {
try {
removeLions();
c1.await(); // Đợi đến ngưỡng c1
cleanPen();
c2.await(); // Đợi đến ngưỡng c2
addLions();
} catch (InterruptedException | BrokenBarrierException e) {
// Handle checked exceptions here
}
}
public static void main(String[] args) {
var service = Executors.newFixedThreadPool(4);
try {
var manager = new LionPenManager();
var c1 = new CyclicBarrier(4);
// Ngưỡng là 4
var c2 = new CyclicBarrier(4,() -> System.out.println("*** Pen Cleaned!"));
for (int i = 0; i < 4; i++)
service.submit(() -> manager.performTask(c1, c2));
} finally {
service.shutdown();
}
}
}
■ Identifying Threading Problems
Bây giờ bạn đã biết cách viết mã an toàn cho luồng, hãy nói về những gì được coi là vấn đề về luồng.Sự cố phân luồng có thể xảy ra trong các ứng dụng đa luồng khi hai hoặc nhiều luồng tương tác theo cách không mong muốn và không mong muốn.
Ví dụ: hai luồng có thể chặn nhau truy cập vào một đoạn mã cụ thể.
API Concurrent được tạo để giúp loại bỏ các vấn đề phân luồng tiềm ẩn thường gặp đối với tất cả các nhà phát triển. Như bạn đã thấy, Concurrent API tạo các luồng và quản lý các tương tác luồng phức tạp cho bạn, thường chỉ trong một vài dòng mã.
Mặc dù Concurrent API làm giảm khả năng xảy ra các sự cố phân luồng nhưng nó không loại bỏ được chúng.Trong thực tế, việc tìm kiếm và xác định các vấn đề về luồng trong ứng dụng thường là một trong những nhiệm vụ khó khăn nhất mà nhà phát triển có thể thực hiện.
■ Understanding Liveness
Liveness thread trong Java là một khái niệm quan trọng về vòng đời của một thread.Nó được định nghĩa như là trạng thái mà một thread vẫn đang hoạt động và chưa kết thúc.
Một thread được coi là sống (liveness) nếu nó đang trong một trong các trạng thái sau:
Runnable: Thread đang chạy hoặc sẵn sàng chạy.
Blocked: Thread đang chờ đợi một tài nguyên được giải phóng (ví dụ: một khóa).
Waiting: Thread đang chờ đợi một sự kiện xảy ra (ví dụ: sử dụng các phương thức Object.wait(), Thread.join() hoặc LockSupport.park()).
Timed Waiting: Thread đang chờ đợi trong một khoảng thời gian cụ thể (ví dụ: sử dụng các phương thức Thread.sleep() hoặc Object.wait(timeout)).
Như bạn đã thấy , nhiều thao tác luồng có thể được thực hiện độc lập,nhưng một số yêu cầu phối hợp.
Ví dụ: đồng bộ hóa trên một phương thức yêu cầu tất cả các luồng gọi phương thức đó phải đợi các luồng khác kết thúc trước khi tiếp tục.
Bạn cũng đã thấy ở phần trước rằng các luồng trong CyclicBarrier sẽ đợi đạt đến giới hạn rào cản trước khi tiếp tục.
Điều gì xảy ra với ứng dụng trong khi tất cả các luồng này đang chờ? Trong nhiều trường hợp,việc chờ đợi là tạm thời và người dùng có rất ít ý tưởng rằng có bất kỳ sự chậm trễ nào đã xảy ra.
Tuy nhiên, trong những trường hợp khác, thời gian chờ đợi có thể rất dài, có thể là vô tận.Liveness là khả năng của một ứng dụng có thể thực thi một cách kịp thời.Khi đó, các vấn đề về khả năng hoạt động là những vấn đề trong đó ứng dụng không phản hồi hoặc ở trạng thái "kẹt" nào đó.
Chính xác hơn, các vấn đề về Liveness thường là kết quả của việc một luồng đi vào trạng thái BLOCKED hoặc WAITING mãi mãi hoặc liên tục vào/ra các trạng thái này.
Một thread không còn liveness khi nó đã kết thúc hoặc bị hủy bỏ. Đảm bảo liveness của các thread là rất quan trọng để tránh các vấn đề như :
+ Deadlock
+ Starvation
+ Livelock
■ Deadlock
Deadlock là một tình huống nghiêm trọng trong lập trình đa luồng, trong đó hai hoặc nhiều hơn các thread tạm thời bị kẹt,không thể tiếp tục thực hiện công việc của chúng.
Deadlock xảy ra khi tồn tại một vòng lặp trong chuỗi các thread đang chờ đợi để giải phóng các tài nguyên do các thread khác đang nắm giữ. Cụ thể:
Tài nguyên bị nắm giữ và chờ đợi: Một thread đang giữ một tài nguyên và đang chờ đợi để lấy được tài nguyên khác.
Chu kỳ chờ đợi: Có ít nhất một chuỗi các thread đang trong trạng thái chờ đợi và hình thành một vòng lặp.
Độc quyền sử dụng: Các thread giữ các tài nguyên một cách độc quyền, không thể chia sẻ.
Không giải phóng tài nguyên: Các thread không thể tự giải phóng các tài nguyên mà chúng đang nắm giữ.Khi deadlock xảy ra, các thread sẽ ở trong trạng thái chờ đợi vô hạn, không thể tiến triển được nữa.
Điều này có thể dẫn đến ứng dụng treo hoặc không phản hồi.
Để tránh deadlock, các giải pháp thường được sử dụng bao gồm:
- Sử dụng các thuật toán cấp phát tài nguyên an toàn.
- Thiết kế luồng logic để tránh sự phụ thuộc vòng lặp.
- Sử dụng các phương thức như tryLock() thay vì lock().
- Sắp xếp thứ tự cấp phát tài nguyên.
- Sử dụng timeout để ngăn chặn việc chờ đợi vô hạn.
■ Starvation
Tình trạng chết đói - starvation xảy ra khi một luồng đơn bị từ chối vĩnh viễn quyền truy cập vào tài nguyên hoặc khóa được chia sẻ.
Luồng này vẫn hoạt động nhưng không thể hoàn thành công việc của mình do các luồng khác liên tục lấy tài nguyên mà nó đang cố truy cập.
Một số nguyên nhân chính dẫn đến starvation bao gồm:
Ưu tiên sai lệch: Các thread có độ ưu tiên thấp luôn luôn bị các thread có độ ưu tiên cao chiếm lấy tài nguyên.
Chính sách cấp phát tài nguyên không công bằng: Các thread có thể luôn được ưu tiên hơn các thread khác trong việc cấp phát tài nguyên.
Độ ưu tiên động: Các thread có độ ưu tiên thay đổi theo thời gian, khiến một số thread liên tục bị loại ra.
Thiết kế không tốt: Cách thiết kế luồng logic của ứng dụng có thể dẫn đến tình trạng một số thread không thể tiếp cận được tài nguyên.
Để giải quyết vấn đề starvation, một số giải pháp có thể được áp dụng:
Sử dụng chính sách cấp phát tài nguyên công bằng và luân phiên.
Thiết lập độ ưu tiên động cho các thread.Đảm bảo rằng các thread có cơ hội tiếp cận tài nguyên một cách công bằng.
Thiết kế luồng logic hợp lý, tránh sự phụ thuộc quá mức.
Sử dụng các kỹ thuật như queue, semaphore để điều phối truy cập tài nguyên.
■ Livelock
Một ví dụ về livelock:
Giả sử có 2 thread A và B, cùng cố gắng chiếm 2 tài nguyên shared resource 1 và 2. Thread A chiếm được tài nguyên 1 trước, trong khi thread B chiếm được tài nguyên 2 trước.Khi cả 2 thread cố gắng chiếm tài nguyên còn lại, sẽ xảy ra trạng thái livelock:
Thread A kiểm tra tài nguyên 2, thấy đã bị thread B chiếm, do đó nó buông tài nguyên 1 và chờ.Thread B kiểm tra tài nguyên 1, thấy đã bị thread A chiếm, do đó nó buông tài nguyên 2 và chờ.Quá trình này lặp lại không ngừng, các thread không thể tiến triển.
Livelock khác với deadlock ở chỗ các thread vẫn đang chạy, nhưng không thể tiến triển được do phản ứng liên tục với nhau.
Để giải quyết vấn đề livelock, các biện pháp sau có thể được áp dụng:
Sử dụng các chính sách cấp phát tài nguyên công bằng và luân phiên.
Thiết lập thời gian chờ ngẫu nhiên trước khi thử lại.
Sử dụng các kỹ thuật như backoff, random delay để tránh tình trạng livelock.
Thiết kế logic luồng hợp lý, giảm thiểu sự phụ thuộc lẫn nhau.
■ Managing Race Conditions
Race condition là một kết quả không mong muốn xảy ra khi hai nhiệm vụ cần được hoàn thành tuần tự lại được hoàn thành cùng một lúc.
Ví dụ về Race Conditions:
Giả sử có một biến đếm count được chia sẻ giữa hai luồng A và B.
Cả hai luồng đều cố gắng tăng giá trị của count lên 1. Quá trình diễn ra như sau:
- Luồng A đọc giá trị count = 0.
- Luồng B đọc giá trị count = 0.
- Luồng A tăng giá trị count lên 1.
- Luồng B tăng giá trị count lên 1.
- Kết quả cuối cùng là count = 1, thay vì count = 2 như mong đợi.
Điều này xảy ra do thứ tự thực hiện các thao tác trên biến count không được kiểm soát, dẫn đến hành vi không mong muốn.
Top comments (0)