Chia sẻ kiến thức lập trình

Single Thread

Có chạy được không?

Chúng ta vẫn thường nghe thấy javascript là single thread, dart là single thread phải không nhỉ? Nhưng liệu nó có đúng single thread, nghĩa là cả chương trình của chúng ta chỉ có 1 thread duy nhất đang chạy mà thôi? Chúng ta hãy cùng đi giải mã bí ẩn này nhé.

Thử chạy Node.js

Chúng ta hãy tạo ra file index.js với 1 dòng duy nhất:

while(true);

Để đảm bảo chúng ta không tạo ra bất kì thread nào ngoài main thread. Sau khi chạy chương trình trên MacOS, chúng ta sẽ count số thread với câu lệnh:

NUM=`ps M <pid> | wc -l` && echo number of thread is: $((NUM-1))

Trên máy của mình sẽ nhận được kết quả thế này:

Nghĩ là đang có 7 thread đang tồn tại trong chương trình của chúng ta.

Thử chạy với dart

Chúng cũng tạo ra một app có file hello_world.dart với 1 dòng duy nhất:

while(true);

Cũng là để đảm bảo chúng ta không tạo ra bất kì thread nào ngoài main thread. Và khi chạy lệnh count số thread mình nhận được kết quả:

Nghĩ là đang có 4 thread đang tồn tại trong chương trình của chúng ta.

Giải mã bí ẩn

Qua 2 ví dụ với các framework, chúng ta thể thấy thực sự không có đơn luồng nào ở đây cả. Nó có thể chỉ có 1 luồng duy nhất để xử lý, nhưng sẽ có rất nhiều luồng để cung cấp sự kiện và hỗ trợ cho nó.

Nhiều thành phần tác động

Một chương trình sẽ chịu tác động từ rất nhiều thành phần khác nhau. Các thành phần này có thể là:

  1. Mạng internet (hay socket): cung cấp dữ liệu cho chương trình.
  2. Từ các thiết bị ngoại vi: điều khiển chương trình.
  3. Trình dọn rác: giúp chúng ta tự động giải phóng bộ nhớ.
  4. Các trình lập lịch của hệ điều hành.

Đối với các chương trình chạy trên server thì nó chịu tác động chủ yếu từ socket để xử lý và phản hồi lại thông tin cho người dùng.

Một luồng duy nhất

Để rõ ràng hơn, chúng ta hãy nhìn vào chương trình chỉ có 1 luồng duy nhất này nhé:

public class SingleThread {
    public static void main(String[] args) {
        NonBlockingEventLoop eventLoop = new NonBlockingEventLoop(3000);
        eventLoop.onUpdate(() -> {
            // các event có thể được thêm ở đây.
            eventLoop.addEvent(() -> System.out.println("Hello World"));
        });
        eventLoop.start();
        // vòng lặp đã chạy, nên chúng ta sẽ không thể thêm event ở đây
    }
}

Rõ ràng là chúng ta sẽ chỉ có hàm onUpdate để thêm được các event và queue, như trong ví dụ chúng ta chỉ có mỗi việc in ra 1 dòng "Hello World". Câu hỏi đặt ra là làm thế nào để phát sinh ra được các sự kiện? Nếu không có sự kiện nào thì chương trình sẽ hoạt động 1 cách vô nghĩa.

Cơ chế chuyển luồng

Vậy câu hỏi đặt ra là với nhiều luồng như vậy thì làm sao đưa hết dữ liệu về main thread được?

Vậy với nhiều thành phần tác động, cũng tương đương với nhiều luồng tác động như vậy, thì làm sao chúng ta có thể chuyển hết sự kiện về 1 main thread duy nhất được? Câu trả lời đó là sự kết hợp giữa các queue và các event loop.

Các luồng I/O sẽ phát sinh ra các sự kiện cho main queue, main thread sẽ xử lý các sự kiện này và nó cũng tạo ra các sự kiện khác nhau cho các queue khác nhau. Các thread tương ứng với các queue này sẽ xử lý các event đó và tạo ra một vòng tuần hoàn cho chương trình của chúng.

Ưu điểm

Đơn luồng có một ưu thế cực lớn khi nó:

  • Giúp chúng ta không phải lo lắng về lock, về synchronize, về race condition cũng như deadlock. Đặc biệt với các ứng dụng client, nơi chúng ta cần phải render đồ hoạ và đảm bảo tính nhất quán cao, tránh cho chương trình bị crash và tăng trải nghiệm của người dùng.
  • Rất dễ dàng để tạo ra các chương trình với chỉ một vài dòng lệnh.

Nhược điểm

Nhưng được cái này thì lại mất cái kia. Không phải làm quen với synchronized hay lock thì chúng ta lại phải làm quen với async và await. Và đôi khi đơn luồng chính là cái bẫy khiến cho toàn bộ hệ thống của chúng ta bị treo, và đây không phải trường hợp hiếm gặp. Ví dụ với chương trình này:

function runWitPromise() {
    return new Promise(resolve => {
        while(true);
    });
}

async function runAsync() {
    runWitPromise();
}

runAsync();

console.log("Finished");

Vì hàm callback của promise sẽ gọi trên main thread nên while(true) sẽ làm block toàn bộ chương trình của chúng ta nên Finished sẽ không được in ra. Hậu quả là trong những chương trình xử lý phức tạp, khả năng phục vụ cho toàn bộ user sẽ bị chậm trễ, một hàm xử lý nặng nề nào đó sẽ làm cho toàn bộ hệ thống bị ảnh hưởng.

Tổng kết

Qua các thử nghiệm chúng ta có thể thấy rõ ràng với 1 thread duy nhất sẽ rất khó để tạo ra các chương trình lớn, phục vụ các bài toán phức tạp. Có chăng đơn luồng chỉ phục vụ cho các bài toán nhúng kiểu như đèn nhấp nháy hay các thiết bị tương đối đơn giản.

Cái mà chúng ta vẫn gọi là đơn luồng trên các framework như node.js, dart hay các redis, thực chất chỉ là "1 luồng xử lý duy nhất", chứ xung quanh nó vẫn tồn tại rất nhiều các luồng khác để làm I/O và GC.

Làm việc với đơn luồng sẽ giúp chúng ta tránh phải học các kiến thức đau đầu với đa luồng, tuy nhiên khi sử cụng await phải rất cẩn thận để tránh làm block cả chương trình, hãy ưu tiên sử dụng callback hơn nếu có thể nhé.

Share: