Giới thiệu
lập trình bất đồng bộ là một phương pháp cho phép chúng ta thực hiện các công việc mà không cần tuân theo một thứ tự nhất định. Khác với lập trình đồng bộ, lập trình bất đồng bộ cho phép các tác vụ không bị "block" (chờ đợi các tác vụ khác hoàn thành trước) và có thể thực hiện song song. Có hai quan điểm khác nhau về lập trình bất đồng bộ:
- Bất đồng bộ chỉ đơn giản là tác vụ B không làm trì hoãn tác vụ A và có thể sử dụng chỉ một luồng duy nhất cho việc bất đồng bộ.
- Bất đồng bộ phải đáp ứng ba yếu tố sau: a) Tác vụ B không làm trì hoãn tác vụ A, b) Tác vụ B có thể thực hiện song song với tác vụ A, c) Hiệu suất của chương trình có thể được cải thiện.
Trong bài viết này, chúng ta sẽ đi vào một ví dụ để phân tích.
Event Loop
Để làm ví dụ minh họa, trước tiên chúng ta cần hiểu về event loop. Event loop kết hợp một vòng lặp while(true)
, một câu lệnh sleep
và một hàng đợi. Code sẽ có dạng như sau:
public class EventLoop { public static void main(String[] args) throws Exception { Queue queue = new LinkedList<>(); while (true) { Thread.sleep(3); while (queue.size() > 0) { final Runnable task = queue.poll(); process(task); } } } private static void process(Runnable task) { task.run(); } }
Mục tiêu của event loop là để đảm bảo rằng chương trình hoặc luồng của chúng ta không bị dừng lại. Tuy nhiên, chúng ta cần sử dụng sleep
để cho CPU được nghỉ ngơi, nếu không, nó có thể quá tải và gây ra trạng thái 100% tải.
Đơn luồng
Bây giờ, chúng ta sẽ sử dụng chỉ một luồng duy nhất để thực hiện bất đồng bộ và xem kết quả như thế nào. Bài toán của chúng ta là in ra màn hình các chuỗi theo thứ tự: 1 2 3 4
Với source code:
public class SingleThread { private static Async async = new Async(); private static Queue queue = new LinkedList<>(); public static void main(String[] args) throws Exception { final Runnable task1 = () -> { // chắc chắn sẽ được in ra đầu tiên System.out.print("1 "); async.register(() -> { sleep(3000); // chúng ta kỳ vọng sẽ in ra cuối cùng vì nó bị sleep 3 giây System.out.print("4 "); }); }; final Runnable task2 = () -> { // chắc chắn sẽ in ra thứ 2 System.out.print("2 "); async.register(() -> { // chúng ta kỳ vọng sẽ in ra thứ 3 vì nó không bị sleep System.out.print("3 "); }); }; queue.add(task1); queue.add(task2); while (true) { Thread.sleep(3); while (queue.size() > 0) { final Runnable task = queue.poll(); process(task); } } } private static void process(Runnable task) { task.run(); } private static class Async { void register(Runnable task) { queue.add(task); } } private static void sleep(int millis) { try { Thread.sleep(millis); } catch (InterruptedException e) { } } }
Tuy nhiên, kết quả chúng ta nhận được là: 1 2 4 3. Vì sao lại là 4 3? Rõ ràng là System.out.print("3 ")
không bị sleep bất kì giây nào. Ồ không, do chúng ta chỉ có một luồng, cho nên đoạn code sleep(3000); System.out.print("4 ");
sẽ được đưa vào queue trước và đoạn code System.out.print("3 ");
sẽ được đưa vào queue sau. Chính vì vậy nó gây ra tình trạng block ở đây và khiến kết quả không như mong đợi.
Vậy có thể nói rằng, với chỉ một luồng, chúng ta chỉ có thể làm tác vụ bị trễ tạm thời được thôi, chứ không thể đạt được tính chất bất đồng bộ. Và theo quan điểm cá nhân của mình, cách này không thể gọi là bất đồng bộ.
Đa luồng
Bây giờ, chúng ta sẽ sử dụng đa luồng và xem kết quả như thế nào:
public class MultiThreadAsync { private static Async async = new Async(); private static Queue queue = new LinkedList<>(); public static void main(String[] args) throws Exception { final Runnable task1 = () -> { System.out.print("1 "); async.run(() -> { sleep(3000); System.out.print("4 "); }); }; final Runnable task2 = () -> { System.out.print("2 "); async.run(() -> { System.out.print("3 "); }); }; queue.add(task1); queue.add(task2); while (true) { Thread.sleep(3); while (queue.size() > 0) { final Runnable task = queue.poll(); process(task); } } } private static void process(Runnable task) { task.run(); } private static class Async { private ExecutorService executorService = Executors.newFixedThreadPool(8); void run(Runnable task) { executorService.execute(task); } } private static void sleep(int millis) { try { Thread.sleep(millis); } catch (InterruptedException e) { } } }
Kết quả sẽ là: 1 2 3 4. Trùng khớp với những gì chúng ta muốn. Vì đối tượng executorService
của chúng ta có 8 luồng với một queue bên trong, nên nó sẽ lấy ra được 2 task để thực thi đồng thời (mặc dù có một chút trễ), nên task System.out.print("3 ");
không bị sleep nên nó sẽ được thực thi trước, còn task sleep(3000); System.out.print("4 ");
bị sleep lâu nên sẽ in kết quả ra sau.
Framework UI
Hầu hết các framework UI như JavaScript trên trình duyệt, ReactJS, Flutter, React Native Android, Swift, Cocos2d-x, Unity... đều chỉ có một luồng chính duy nhất để render đồ hoạ. Nếu cố tình sử dụng hai luồng, chương trình sẽ bị crash hoặc màn hình sẽ trở thành màu đen.
Tuy nhiên, các framework này ẩn hoặc không cho phép chúng ta tạo các luồng tùy ý. Khi lập trình, chúng ta thường chỉ nhìn thấy luồng chính, điều này có thể dẫn đến hiểu nhầm rằng chương trình chỉ có một luồng. Tuy nhiên, thực tế không phải như vậy. Có rất nhiều luồng ở "phía dưới" như HTTP client, Socket client, I/O stream. Nếu tất cả những việc này đều thực hiện trên luồng chính, chương trình sẽ không thể chạy được và tốc độ khung hình trên màn hình có thể không bao giờ đạt 24 hình/giây. Điều này dẫn đến việc người dùng sẽ từ bỏ ứng dụng class='hover-show-link replace-link-5' ứng dụng span class='hover-show-content'> Chuyển dữ liệu từ các luồng khác lên luồng chính là một kỹ thuật lập trình, hoặc một "thủ thuật" của việc sử dụng queue. Bây giờ, chúng ta hãy sửa code để tất cả sẽ được in trên luồng chính:
public class MultiThreadAsyncPrintOnMain { private static Async async = new Async(); private static Queue queue = new LinkedBlockingQueue<>(); public static void main(String[] args) throws Exception { final Runnable task1 = () -> { print("1 "); async.run(() -> { sleep(3000); print("4 "); }); }; final Runnable task2 = () -> { print("2 "); async.run(() -> { print("3 "); }); }; queue.add(task1); queue.add(task2); while (true) { Thread.sleep(3); while (queue.size() > 0) { final Runnable task = queue.poll(); process(task); } } } private static void process(Runnable task) { task.run(); } private static class Async { private ExecutorService executorService = Executors.newFixedThreadPool(8); void run(Runnable task) { executorService.execute(task); } } private static void sleep(int millis) { try { Thread.sleep(millis); } catch (InterruptedException e) { } } private static void print(String message) { queue.add(() -> { final String threadName = Thread.currentThread().getName(); System.out.print("(thread: " + threadName + ")" + message); }); } }
Và tất cả sẽ được in trên luồng chính: (thread: main)1 (thread: main)2 (thread: main)3 (thread: main)4. Bí ẩn nằm ở hàm print
:
private static void print(String message) { queue.add(() -> { final String threadName = Thread.currentThread().getName(); System.out.print("(thread: " + threadName + ")" + message); }); }
Tất cả dữ liệu muốn chuyển lên luồng chính chỉ cần đưa vào đối tượng queue của nó và phần còn lại sẽ được event loop của luồng chính lo. Đây là cách mà tất cả các framework client hiện nay đang sử dụng, kể cả các SDK của Ezyfox Server.
Tổng kết
Theo quan điểm của tôi, bất đồng bộ phải đảm bảo ba yếu tố sau:
- Các tác vụ không làm trì hoãn lẫn nhau và nếu có, thì chỉ ở mức tối thiểu.
- Các tác vụ có thể thực hiện đồng thời (song song) mà không cần quan tâm đến thứ tự hoàn thành.
- Hiệu suất của chương trình có thể được cải thiện.
Do đó, bất đồng bộ và đa luồng luôn là người bạn đồng hành của nhau. Khi lập trình với các framework UI, hạn chế tối đa việc xử lý trên luồng chính. Hãy để nó thực hiện công việc render đồ hoạ của nó. Các tác vụ nặng nề hãy để các luồng khác thực hiện và kết quả sẽ được trả về luồng chính thông qua queue chính. Hiện nay, hầu hết các ngôn ngữ lập trình đều cung cấp từ khóa "async" cho chúng ta sử dụng, ví dụ như ExecutorService trong Java, do đó việc lập trình bất đồng bộ ngày nay khá dễ dàng.
Tham khảo
- Ví dụ