Tạo nên sự khác biệt

Chủ đề này chắc có lẽ phải đến hàng triệu bài viết rồi cũng nên. Vì nó cơ bản là vấn đề phổ biến mà ai cũng phải đối mặt, như ăn cơm, uống nước hàng ngày vậy. Thế nên mình sẽ không viết nhiều về khái niệm và định nghĩa nữa, các bạn có thể đọc tại wiki là cách đơn giản nhất nhé.

Thông thường thì một luồng xử lý cho một yêu cầu trong chương trình của chúng ta sẽ cần trả qua một số bước.

  1. Tiếp nhận yêu cầu
  2. Xử lý dữ liệu đầu vào
  3. Đọc ghi cơ sở dữ liệu
  4. Xử lý logic
  5. Đóng gói dữ liệu cần trả về cho đối tượng gửi yêu cầu
  6. Trả về dữ liệu cho đối tượng gửi yêu cầu

Nếu như tất cả các bước này chúng ta thực hiện từ đầu đến cuối trên cùng 1 luồng (Thread) chúng ta sẽ gọi nó là blocking, nếu chúng ta có thể tách việc xử lý này thành các luồng khác nhau, chúng ta sẽ tạm gọi nó là non-blocking. Vậy hãy thử xem sự khác biệt với 2 cách tiếp cận có gì khác biệt không nhé.

Bài toán thực tế

Hãy giả sử chúng ta đang làm về mạng xã hội, và chúng ta có một màn hình thông tin user (user profile) gồm 3 phần:

  1. Thông tin của user
  2. Danh sách bạn bè
  3. Danh sách tin nhắn

Như vậy client sẽ gửi lên 3 request để lấy thông tin của 3 thành phần này, và nhiệm vụ của chúng ta là:

  1. Trả về 3 thông tin 1 cách nhanh chóng nhất
  2. Phải đảm bảo chương trình có thể phục vụ nhiều người nhất có thể

Sử dụng blocking I/O

Để minh hoạ cho việc xử lý với blocking I/O chúng ta sẻ viết chương trình bằng java và sử dụng duy nhất một main thread.

public class BlockingIOExample {

    private final Database database;
    private final Map<String, Handler> userRequestHandlers;

    public BlockingIOExample() {
        database = new Database();
        userRequestHandlers = new HashMap<>();
        userRequestHandlers.put("userInfoGet", database::getUserInfo);
        userRequestHandlers.put("friendListGet", database::getFriendList);
        userRequestHandlers.put("messageListGet", database::getMessageList);
    }

    public void receivedUserRequest(String requestInput) {
        Request request = processRequestInput(requestInput);
        Object response = handlerRequest(request);
        responseToUser(request.getUserId(), response);
    }

    private Object handlerRequest(Request request) {
        Handler handler = userRequestHandlers.get(request.getApi());
        return handler.handle(request.getUserId());
    }

    private void responseToUser(String userId, Object responseData) {
        System.out.println("response to user: " + userId + " data: " + responseData);
    }

    private Request processRequestInput(String requestInput) {
        String[] apiData = requestInput.split(":");
        return new Request(apiData[0], apiData[1]);
    }

    public interface Handler {
        Object handle(String userId);
    }

    @Getter
    @AllArgsConstructor
    public static class Request {
        private final String api;
        private final String userId;
    }

    public static class Database {
        public String getUserInfo(String userId) {
            deplay(1000);
            return "id: " + userId + ", name: Monkey";
        }

        public String getFriendList(String userId) {
            deplay(1000);
            return "fox, cat";
        }

        public String getMessageList(String userId) {
            deplay(1000);
            return "hello, world";
        }
    }

    private static void deplay(int time) {
        try {
            Thread.sleep(time);
        } catch (InterruptedException e) { }
    }

    public static void main(String[] args) {
        BlockingIOExample nio = new BlockingIOExample();
        nio.receivedUserRequest("userInfoGet:tvd12");
        nio.receivedUserRequest("friendListGet:tvd12");
        nio.receivedUserRequest("messageListGet:tvd12");
    }
}

Nhìn có vẻ ổn đúng không? Tuy nhiên là chúng đang giả lập rằng việc truy xuất dữ liệu sẽ tiêu tốn 1 giây bằng việc thêm hàm deplay(1000);. Vậy nên mặc dù 3 yêu cầu này hoàn toàn có thể xử lý động lập thì giờ lại phụ thuộc và chờ lẫn nhau. Vậy thì chương trình của chúng ta sẽ không thể nào phục vụ được rất nhiều người dùng đồng thời được, đó là vấn đề nghiêm trọng.

Sử dụng Nonblocking I/O

Việc chuyển đổi từ lập trình I/O thành NonBlocking I/O ngày xưa tương đối phức tạp, vì khi code bằng C/C++ thì việc lập trình đa luồng sẽ phụ thuộc hệ điều hành cung cấp đến đâu. Nhưng ngày nay các ngôn ngữ bậc cao hơn như Java, C# hay Go đều cung cấp nhũng thư viện hết sức đơn giản và tuyệt vời để hỗ trợ chúng ta lập trình đa luồng. Với Java chúng ta sẽ có ExecutorService chuyên để xử lý đa luồng.

Bây giờ chúng ta sẽ thay đổi một chút, bằng việc sử dụng ExecutorService và hàm execute. Việc xử lý dữ liệu đầu vào, xử lý request, và response dữ liệu cho client sẽ nằm ở những luồng riêng biệt và sẽ không làm block nhau.

public class NonblockingExample {

    private final Database database;
    private final ExecutorService executorService;
    private final Map<String, Handler> userRequestHandlers;

    public NonblockingExample() {
        database = new Database();
        executorService = Executors.newFixedThreadPool(10);
        userRequestHandlers = new HashMap<>();
        userRequestHandlers.put("userInfoGet", database::getUserInfo);
        userRequestHandlers.put("friendListGet", database::getFriendList);
        userRequestHandlers.put("messageListGet", database::getMessageList);
    }

    public void receivedUserRequest(String requestInput) {
        Request request = processRequestInput(requestInput);
        executorService.execute(() -> {
            Object response = handlerRequest(request);
            executorService.execute(() ->
                responseToUser(request.getUserId(), response)
            );
        });
    }

    private Object handlerRequest(Request request) {
        Handler handler = userRequestHandlers.get(request.getApi());
        Object result = handler.handle(request.getUserId());
        return result;
    }

    private void responseToUser(String userId, Object responseData) {
        System.out.println("response to user: " + userId + " data: " + responseData);
    }

    private Request processRequestInput(String requestInput) {
        String[] apiData = requestInput.split(":");
        return new Request(apiData[0], apiData[1]);
    }

    private static void delay(int time) {
        try {
            Thread.sleep(time);
        } catch (InterruptedException e) { }
    }

    public interface Handler {
        Object handle(String userId);
    }

    @Getter
    @AllArgsConstructor
    public static class Request {
        private final String api;
        private final String userId;
    }

    public static class Database {
        public String getUserInfo(String userId) {
            delay(1000);
            return "id: " + userId + ", name: Monkey";
        }

        public String getFriendList(String userId) {
            delay(1000);
            return "fox, cat";
        }

        public String getMessageList(String userId) {
            delay(1000);
            return "hello, world";
        }
    }

    public static void main(String[] args) {
        NonblockingExample nio = new NonblockingExample();
        String users[] = new String[] {"tvd12", "meta"};
        for (String user : users) {
            nio.receivedUserRequest("userInfoGet:" + user);
            nio.receivedUserRequest("friendListGet:" + user);
            nio.receivedUserRequest("messageListGet:" + user);
        }
    }
}

Khi bạn chạy chương trình bạn sẽ thấy kết quả không được phân bố đều kiểu như thế này:

response to user: tvd12 data: fox, cat
response to user: meta data: hello, world
response to user: meta data: id: meta, name: Monkey
response to user: meta data: fox, cat
response to user: tvd12 data: id: tvd12, name: Monkey
response to user: tvd12 data: hello, world

Đó là do chương trình của chúng ta chạy đa luồng, mỗi giai đoạn lại được thực thi trên một luồng khác nhau. Tuy nhiên chúng ta đã đảm bảo được khả năng phục vụ nhiều người nhất có thể của chương trình.

So sánh Blocking I/O và NonBlocking I/O

Theo mình thì không nên so sánh 2 cái này làm gì, vì cơ bản là mỗi cái lại phục vụ cho các mục đích khác nhau, và bạn có thể cân nhắc sử dụng trong một số trường hợp thế này:

  • Đối với những phần đòi hỏi tính tuần tự thì nên dùng Blocking I/O vì để mà code theo kiển tuần tự thì NonBloking I/O sẽ phải code theo kiểu then().then() (kếu nối hàm) tương đối phúc tạp.
  • Đối với chương trình cần phục vụ nhiều người như kiểu server chẳng hạn thì nên dùng NonBlocking I/O. Tuy nhiên là nên giấu đi sự phức tạp của xử lý đa luồng, chỉ để lộ ra phần cài đặt logic đơn luồng cho người dùng (hiện nay tất cả các thư viện đều làm vậy).
  • Đối với những chương trình đòi hỏi lấy data từ nhiều nguồn khác nhau thì nên kết hợp sử NonBlocking I/O và Blocking I/O, có nghĩa là mỗi luồng sẽ lấy 1 phần dữ liệu và để 1 luồng gọi tổng hợp. Hiện nay thư viện RxJava đang làm rất tốt việc này.
  • Đối với các chương trình client thì cũng nên kết hợp Blocking I/O và NonBlocking I/O để giải phóng main thread và chương trình đỡ bị giật, có nghĩa là việc xử lý I/O sẽ ở background thread và khi xử lý xong sẽ đưa kết quả và main queue để hiển thị lên màn hình.
  • Hiệu năng của đơn luồng tính theo CPU time sẽ nhanh hơn (vì không phải switch context) tuy nhiên hiệu năng sử dụng và khả năng đáp ứng nhiều người dùng đồng thời thì NonBlocking sẽ cao hơn, hay nói cách khác thì đa luồng sẽ tận dụng CPU tốt hơn.

Tổng kết

Nhìn chung với sự ra đời của các thư viện lập trình cao cấp như hiện nay thì chúng ta vẫn đang lập trình NonBlocking I/O không khác gì Blocking I/O, cũng chẳng có gì là khó khăn cả. Cái chúng ta cần cài đặt cũng chỉ đơn giản là các hàm trong controller để xử lý yêu cầu người dùng.

Còn nếu chúng ta bắt buộc phải động đến NonBlocking I/O thì chúng ta hãy sử dụng các thư viện đã có sẵn cho đơn giản ví dụ như Reactive Prgramming cũng là một lựa chọn rất sáng suốt. Và theo mình thì cũng nên tận dụng các thư viện xử lý đa luồng kết hợp với Blocking I/O để biến nó thành NonBlocking I/O từ đó tận dụng được hết sức mạnh của CPU máy tính.

Việc so sánh giữ Blocking I/O và NonBlocking I/O là không cần thiết, tuỳ theo mục đích sử dụng dụng của chúng ta là gì mà cân nhắc sử dụng cho đơn giản. Nhưng thoe kinh nghiệm của mình là chúng ta sẽ thường kết hợp 2 thứ này lại với nhau chứ không có cái nào thay thế được cái nào cả.