Lập trình bất đồng bộ

Trái ngược với lập trình đồng bộ synchronous, các task phải hoàn thành theo thứ tự, hết cái này rồi mới đến cái kia, thì lập trình bất đồng bộ cho phép chúng ta gọi các task mà không phải theo 1 thứ tự nào cả, các task sẽ không bị block (nghĩa là không phải chờ các task khác thực hiện xong rồi mới đến lượt mình). Thực tế thì đang có 2 luồng ý kiến khác nhau với lập trình bất đồng bộ (chúng ta sẽ lấy A, B làm 2 task):

  1. Bất đồng bộ nghĩa là task B sẽ không làm block task A và chúng ta có thể chỉ sử dụng 1 thread duy nhất để làm bất đồng bộ
  2. Bất đồng bộ phải hội tụ đầy đủ các yếu tố sau:
    • task B sẽ không làm block task A
    • task B có thể thực hiện đồng thời với task A
    • Hiệu năng của chương trình có thể được cải thiện

Bây giờ chúng ta sẽ đi vào ví dụ để phân tích nhé. Mình sẽ sử dụng ngôn ngữ Java, vì nó cho phép chúng ta khởi tạo thread, code cũng dễ đọc hơn là C/C++.

Event Loop

Để làm các ví dụ minh hoạ, trước tiên chúng ta sẽ cần biết về event loop. Bản chất event loop là sự kết hợp giữ một vòng lặp while(true), một câu lệnh sleep và một hàng đợi. Code sẽ trông thế này:

public class EventLoop {
    public static void main(String[] args) throws Exception {
        Queue<Runnable> 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 là giúp cho chương trình, hoặc thread của chúng ta sẽ không bị dừng lại, nhưng hãy để ý rằng chúng ta cần phải sleep để cho CPU được nghỉ ngơi, nếu không nó sẽ gào rú và sớm đến 100%.

Đơn luồng

Nào bây giờ chúng ta sẽ sử dụng 1 thread duy nhất để làm async xem có được không nhé. Bài toán của chúng ta là in ra màn hình các chuối theo thứ tự này:

1 2 3 4

Với source code:

public class SingleThread {
    private static Async async = new Async();
    private static Queue<Runnable> 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ó ko 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) { }
    }
}

Nhưng sự thật, kết quả chúng ta lại nhận được là:

1 2 4 3

Tại 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 cơ mà? Ồ không, do chúng ta chỉ có 1 luồng, thế 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 mà nó gây ra tình trạng block ở đây và khiến kết quả của chúng ta không được như mong đợi.

Vậy có thể nói rằng, với một thread thì chúng ta chỉ có thể làm task bị delay tạm thời được thôi, chứ tình trạng block thực tế là vẫn diễn ra. Và với quan điểm cá nhân của mình, thế này không thể gọi là async được.

Đa luồng

Vẫn cùng một bài toán và chúng ta sẽ sử dụng đa luồng xem sao:

public class MultiThreadAsync {
    private static Async async = new Async();
    private static Queue<Runnable> 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) { }
    }
}

Và kết quả chúng ta thu được sẽ là:

1 2 3 4

Trùng khớp với những gì chúng ta muốn, tại sao vậy? Vì đối tượng executorService của chúng ta có 8 luồng với 1 queue ở bên trong, nên nó sẽ lấy ra được 2 task để cùng thực thi đồng thời (mặc dù có trễ xíu), 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.

Các framework UI

Hầu hết các framework UI như javascript trên browser, reactjs, flutter, react native android, swift, cocos2dx, unity ... Đều chỉ có một thread main duy nhất để render đồ hoạ, nếu cố tình sử dụng 2 luồng, chương trình sẽ bị crash hoặc đen xì màn hình. Chính vì vậy mà các framework này đều giấu, hoặc không cho phép chúng ta tạo bừa bãi các thread. Khi lập trình chúng ta thường chỉ thấy được main thread mà thôi, điều đó đôi khi khiến chúng ta hiểu nhầm rằng cả chương trình chỉ có 1 thread, nhưng kỳ thực không phải thế. Có rất nhiều thread ở phía dưới như http client, socket client, I/O stream, nếu tất cả nhưng việc này nằm hết trên main thread, chương trình của chúng ta sẽ không thể chạy nổi, FPS có thể không bao giờ được nổi 24 hình / giây, và người dùng sẽ từ bỏ ứng dụng. Việc chuyển dữ liệu từ các thread khác lên thread main đó là một kỹ thuật lập trình, hay một thủ thậ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 thread main nhé:

public class MultiThreadAsyncPrintOnMain {
    private static Async async = new Async();
    private static Queue<Runnable> 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 thread main:

(thread: main)1 (thread: main)2 (thread: main)3 (thread: main)4 

Tại sao vậy? 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 main thread thì chỉ cần đưa vào đối tượng queue của nó mà thôi, và phần còn lại event loop của main thread sẽ lo. Đây là cách mà tất cả các framework client hiện nay đang sử dụng, kể cả các client sdk của ezyfox-server

Tổng kết

Với mình bất đồng bộ phải đảm bảo 3 thứ sau:

  1. Các task sẽ không làm block nhau, và nếu có thì cũng nên ở mức tối thiểu
  2. Các task có thể thực hiện đồng thời (song song) với nhau mà không quan tâm đến thứ tự hoàn thành
  3. Hiệu suất của chương trình có thể tăng lên đáng kể khi sử dụng bất đồng bộ

Vậy nên bất đồng bộ và đa luồng sẽ luôn là người bạn đồng hành với nhau. Chúng ta cần nhớ khi lập trình với các thư viện UI đó là: hạn chế tối đa việc xử lý ở trên main thread, hãy để nó thực hiện công việc render đồ hoạ của mình, các tác vụ nặng nề hãy để các thread khác thực hiện và kết quả sẽ được trả về main thread thông qua main queue. Hiện nay các ngôn ngữ lập trình đều có các từ khoá async cho chúng ta sử dụng, trong java thì chúng ta có ExecutorService, nên việc lập trình async ngày nay là tương đối dễ dàng.

Tham khảo

  1. Ví dụ