Bộ sưu tập

Blocking và Non-Blocking trong lập trình


https://codersontrang.files.wordpress.com/2017/09/codersontrang-com.png

Trong quá trình học tập và làm việc chúng ta chắc hẳn đâu đấy cũng đã từng thấy hay nghe về BlockingNon-Blocking. Nếu các bạn chưa từng nghe về hai thuật ngữ trên thì hôm nay sau khi đọc xong bài viết này, mình hi vọng rằng các bạn có thể có được một ý niệm cũng như hình tượng được ý nghĩa của chúng.

Blocking và Non-Blocking trong lập trình chủ yếu được đề cập khi muốn miêu tả về cách một chương trình thực hiện các dòng lệnh của nó. Chúng ta có thể hiểu một cách đơn giản, nếu chương trình được thực hiện theo mô hình Blocking có nghĩa là các dòng lệnh được thực hiện một cách tuần tự. Khi một dòng lệnh ở phía trước chưa được hoàn thành thì các dòng lệnh phía sau sẽ chưa được thực hiện và phải đợi khi mà thao tác phía trước hoàn tất, và nếu như các dòng lệnh trước là các thao tác cần nhiều thời gian xử lý như liên quan đến IO (input/output) hay mạng (networking) thì bản thân nó sẽ trở thành vật cản trở ( blocker ) cho các lệnh xử lý phía sau mặc dù theo logic thì có những việc ở phía sau ta có thể xử lý được luôn mà không cần phải đợi vì chúng không có liên quan gì đến nhau.

Mô hình blocking tồn tại từ lịch sử, khi mà máy tính chỉ có thể xử lý đơn nhiệm trên một lõi (core) của bộ vi xử lý (chip). Nhưng theo thời gian, công nghệ ngày một trưởng thành với những thành tựu về phần cứng, máy tính giờ có thể làm nhiều việc cùng một lúc thì người ta cần phải suy nghĩ đến việc làm sao tận dụng được tối đa tài nguyên xử lý của máy tính và tránh lãng phí nó. Từ đó mà bất cứ chỗ nào có phần xử lý Blocking không cần thiết, người ta cần thay vào một giải pháp xử dụng tài nguyên khôn ngoan hơn, đó là Non-Blocking.

Trong mô hình Non-Blocking, các dòng lệnh không nhất thiết phải lúc nào cũng phải thực hiện một cách tuần tự (sequential) và đồng bộ (synchronous) với nhau. Ở mô hình này nếu như về mặt logic dòng lệnh phía sau không phụ thuộc vào kết quả của dòng lệnh phía trước, thì nó cũng có thể hoàn toàn được thực hiện ngay sau khi dòng lệnh phía trước được gọi mà không cần đợi cho tới khi kết quả được sinh ra. Những dòng lệnh phía trước miêu tả ở trên còn có thể gọi là được thực hiện theo cách không đồng bộ (Asynchronous), và đi theo mỗi dòng lệnh thường có một callback (lời gọi lại) là đoạn mã sẽ được thực hiện ngay sau khi có kết quả trả về từ dòng lệnh không đồng bộ. Để thực hiện mô hình Non-Blocking, người ta có những cách để thực hiện khác nhau, nhưng về cơ bản vẫn dựa vào việc dùng nhiều Thread (luồng) khác nhau trong cùng một Process (tiến trình), hay thậm chí nhiều Process khác nhau (inter-process communication – IPC) để thực hiện. Và mẫu thiết kết (design pattern) tên là event-loop là một trong những mẫu thiết kế nổi tiếng để thực hiện cơ chế Non-Blocking mà nếu có điều kiện trong tương lai mình sẽ viết bài để giới thiệu cho các bạn.

Trong bài viết này mình sẽ đưa ra một ví dụ để các bạn hiểu rõ hơn về Blocking và Non-Blocking, bao gồm hình ảnh minh họa cũng như chương trình đơn giản viết bằng Java. Ví dụ này mô tả quá trình lấy dữ liệu từ 3 lời gọi hàm khác nhau và sau đó in kết quả khi trả về từ hàm ra màn hình. Lời gọi hàm trong ví dụ chỉ là một đoạn code đơn giản mô phỏng một việc làm trong một thời gian nhất định, trong thực tế việc này có thể thao tác disk IO như đọc dữ liệu từ file hay database, hoặc thao tác liên quan đến kết nối mạng như gọi webservice … 3 hàm ở trên mình giả sử sẽ là 3 việc thực tế không liên quan gì đến nhau, và mình sẽ chỉ ra cùng là làm 3 việc thì cơ chế Blocking sẽ khác với Non-Blocking như thế nào.

Trước tiên các bạn hãy nhìn vào hình ảnh minh họa về Blocking và Non-Blocking ở dưới đây

Phần phía trên miêu tả sự hoạt động theo cơ chế Blocking mà ở đây mặc dù không có sự liên đới giữa 3 việc, nhưng các công việc tiếp sau luôn phải chờ công việc phía trước thực sự xong rồi mới có thể bắt đầu thực hiện. Các bước sẽ được mô tả như dưới đây

  1. Hàm dataSync1.get() được gọi để lấy dữ liệu, vì nó là Blocking nên trước khi công việc này hoàn thành các việc tiếp sau sẽ phải đợi
  2. Hàm printData(d1) được gọi để in dữ liệu lấy về từ dataSync1.get(), tương tự nó cũng là Blocking
  3. Hàm dataSync2.get() được gọi để lấy dữ liệu, mặc dùng là nó không liên quan gì đến hai dòng lệnh trên, nhưng đến tận bây giờ nó mới được thực hiện và là Blocking nên chiếm một khoảng thời gian xử lý nữa
  4. Hàm printData(d2) được gọi để in dữ liệu lấy về từ dataSync2.get(), là Blocking
  5. Hàm dataSync3.get() được gọi để lấy dữ liệu, là Blocking
  6. Hàm printData(d3) được gọi để in dữ liệu lấy về dataSync3.get(), là Blocking

Ở phần này, mọi thao tác đều là blocking nên thời gian để thực hiện xong hết các thao tác sẽ bằng tổng thời gian của từng thao tác.

Phía dưới là phần thể hiện việc làm tất cả những việc trên, các thao tác in dự liệu printData(d1), printData(d2), printData(d3) vẫn là các thao tác Blocking nhưng ở đây có sự tham gia của Non-Blocking trong các thao tác lấy dữ liệu dataAsync1.get(), dataAsync2.get(), dataAsync3.get(). Các thao tác Non-Blocking sẽ được bắt đầu gần như ngay lập tức và không cần phải chờ các thao tác phía trước thực hiện xong. Sau khi có kết quả các thao tác Non-Blocking sẽ gọi lại callback để in kết quả trả về ra màn hình. Cụ thể sẽ được diễn giải như ở dưới đây:

  1. Hàm dataAsync1.get() được gọi để lấy dự liệu, vì nó là Non-Blocking nên quá trình thực thi sẽ không phải dừng ở đây mà tiếp tục thực hiện dòng lệnh tiếp sau gần như ngay lập tức, tất nhiên vẫn phải sau khi đăng ký một callback để in ra dữ liệu trả về từ dataAsync1.get().
  2. Như nói ở trên, ngay sau đó, hàm dataAsync2.get() được gọi cùng với đăng ký callback. Vì là Non-Blocking nên quá trình cũng giống như trên.
  3. Tiếp theo hàm dataAsync3.get() cũng được thực hiện tương tự. Đến đây, 3 hàm gọi để lấy dữ liệu gần như được thực hiện đồng thời mà không cần phải chờ nhau.
  4. Trong khi hàm dataAsync1.get()dataAsync3.get() đang thực hiện thì hàm dataAsync2.get() đã lấy được dữ liệu về, lúc này callback được gọi để in dữ liệu đó ra màn hình, trong callback lúc này printData(d2) được gọi và nó là Blocking.
  5. Trong thời gian printData(d2) đang thực hiện, dataAsync1.get() đã hoàn tất việc lấy dữ liệu, callback của nó được gọi tuy nhiên vì printData(d2) là Blocking và đang thực hiện nên việc thực hiện printData(d1) sẽ phải chờ.
  6. Cũng tương tự như trên, dataAsync3.get() cũng hoàn tất việc lấy dữ liệu, callback của nó được gọi, lần này printData(d3) không những phải chờ printData(d2) như trên mà nó còn phải chờ thêm cả printData(d1) bởi vì printData(d1) cũng là Blocking. Sau khi cả printData(d2)printData(d1) được hoàn thành thì printData(d3) được thực hiện và toàn bộ quá trình hoàn tất.

Bây giờ nhìn lại hình vẽ một lần nữa ta có thể thấy Non-Blocking rút ngắn thời gian thực hiện chương trình hơn là Blocking, việc rút ngắn thời gian này không phải vì các công việc được thực hiện nhanh hơn mà vì nhiều việc được thực hiện cùng một lúc hơn.

Sau đây là đoạn code demo cho việc thực thi với Blocking và Non-Blocking được viết bằng Java. Chúng ta sẽ tạo một Java project đơn giản trên IntelliJ IDE như hình dưới đây

DataSync.java


public class DataSync {
    private int id;
    private long simulationDuration;

    DataSync(int id, long simulationDuration){
        this.id = id;
        this.simulationDuration = simulationDuration;
    }

    public String get(){
        try {
            Thread.sleep(this.simulationDuration);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return "data-"+id;
    }
}

Lớp DataSync thể hiện nguồn dữ liệu có thể lấy về theo cơ chế Blocking, một nguồn dữ liệu được mô tả gồm có

  • id : định danh nguồn dữ liệu, như ở trên miêu tả, chúng ta có ba nguồn được định danh là 1, 2, 3
  • simulationDuration: tính bằng mili giây, giả lập quãng thời gian cần để lấy được dữ liệu về từ nguồn dữ liệu qua phương thức get().

MainSync.java


public class MainSync {

    public static void main(String[] args) {
        long startTime, endTime;

        DataSync dataSync1 = new DataSync(1, 5000); //5s
        DataSync dataSync2 = new DataSync(2, 3000); //3s
        DataSync dataSync3 = new DataSync(3, 6000); //6s

        startTime = System.currentTimeMillis();
        System.out.println("Start");

        String d1 = dataSync1.get();
        printData(d1);

        String d2 = dataSync2.get();
        printData(d2);

        String d3 = dataSync3.get();
        printData(d3);

        System.out.println("Done");
        endTime = System.currentTimeMillis();

        System.out.print("Execution time (ms): "+(endTime- startTime));
    }

    private static void printData(String data){
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("Synchronously printing "+data);
    }
}

MainSync.java bao gồm phương thức main() là điểm bắt đầu của chương trình. Đầu tiên ba nguồn dữ liệu Blocking được khởi tạo lần lượt là dataSync1, dataSync2dataSync3 với ba giá trị thời gian khác nhau là 5 giây, 3 giây và 6 giây. Như vậy nguồn dữ liệu số 2 sẽ thực hiện nhanh nhất rồi đến số 1 và cuối cùng là số 3. Sau đó ở mỗi nguồn dữ liệu sẽ được gọi phương thức get() để lấy dự liệu về theo cơ chế Blocking. Dữ liệu sẽ được in ra ngay sau khi được trả về từ nguồn dữ liệu qua phương thức printData(). Phương thức printData() cũng là Blocking và ta mô phỏng thời gian để thực hiện công việc này trong quãng thời gian 1 giây. Ở phía cuối ta cũng in khoảng thời gian tính bằng mili giây để toàn bộ chương trình hoàn tất.

Và khi chạy chương trình ta thấy được thứ tự các câu lệnh được thực hiện giống như mô tả trên hình minh họa phần đầu cũng như tổng thời gian hoàn tất là 17001 mili giây như hình dưới đây:

DataAsync.java


import java.util.function.Supplier;

public class DataAsync implements Supplier {

    private int id;
    private long simulationDuration;

    DataAsync(int id, long simulationDuration){
        this.id = id;
        this.simulationDuration = simulationDuration;
    }

    @Override
    public String get() {
        try{
            Thread.sleep(simulationDuration);
        }catch (Exception e){}

        return "data-"+id;
    }
}

Lớp DataAsync thể hiện nguồn dữ liệu có thể lấy về theo cơ chế Non-Blocking, và tương tự như lớp DataSync bên trên, ở đây cũng có idsimulationDuration.

MainAsync.java


import java.util.concurrent.*;

public class MainAsync {

    public static void main(String[] args) {
        long startTime, endTime;

        CountDownLatch latch = new CountDownLatch(3);
        DataAsync dataAsync1 = new DataAsync(1, 5000);
        DataAsync dataAsync2 = new DataAsync(2, 3000);
        DataAsync dataAsync3 = new DataAsync(3, 6000);

        startTime = System.currentTimeMillis();
        System.out.println("Start");
        try{
            CompletableFuture.supplyAsync(dataAsync1).thenAccept(d1 -> {
                printData(d1);
                latch.countDown();
            });

            CompletableFuture.supplyAsync(dataAsync2).thenAccept(d2 -> {
                printData(d2);
                latch.countDown();
            });

            CompletableFuture.supplyAsync(dataAsync3).thenAccept(d3 -> {
                printData(d3);
                latch.countDown();
            });

            latch.await();

            System.out.println("Done");
            endTime = System.currentTimeMillis();

            System.out.print("Execution time (ms): "+(endTime- startTime));
        }catch (Exception e){
        }

    }

    private static void printData(String data){
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("Synchronously printing "+data);
    }
}

Cũng giống như lớp MainSync ở trên, ta cũng khởi tạo 3 nguồn dữ liệu nhưng lần này là Non-Blocking, các định danh cũng thời gian giả lập để thực hiện lấy dữ liệu về không có gì thay đổi. Tiếp đó để thực hiện việc lấy dữ liệu theo cơ chế Non-Blocking, ta sử dụng CompletableFuture của Java 8 để nhận vào nguồn dữ liệu qua hàm supplyAsync(). supplyAsync() sẽ thực hiện hàm get() theo cách Non-Blocking từ tham số đầu vào là một java.util.function.Supplier, chính vì vậy mà ta thấy vì sao lớp DataAsync ở trên phải implement java.util.function.Supplier. Và đồng thời ta cũng có thể đăng ký callback cho mỗi lời gọi Non-Blocking này qua phương thức thenAccept() mà ở đây cụ thể là in giá trị trả về qua phương thức printData().

Ở đây chúng ta có một CountDownLatch được sử dụng, bởi vì các lời gọi hàm là Non-Blocking nên các lệnh phía sau sẽ thực hiện mà không cần chờ các lệnh phía trước nó hoàn thành. Chính vì vậy mà khi 3 nguồn dữ liệu chưa kịp trả về kết quả, thread thực hiện phương thức main() sẽ chạy hết chương trình trước mà không kịp in các dữ liệu trả về qua các callback. Đó chính là lý do vì sao chúng ta sử dụng CountDownLatch ở đây, mục đích chính là để chờ khi tất cả callback được hoàn tất thì ta mới kết thúc chương trình.

Khi tiến hành chạy chương trình, thứ tự các kết quả được in ra sẽ giống như mô tả ở hình vẽ tại phần đầu và tổng thời gian thực hiên chương trình lần này chỉ là 7171 mili giây thay vì 17001 mili giây khi thực hiện với cơ chế Blocking.

Ngày nay khi mà các thế hệ phần cứng ngày một trưởng thành với khả năng xử lý song song, thì việc các ứng dụng cần đáp ứng khả năng sử dụng tài nguyên một cách tối ưu là điều rất cần thiết. Non-Blocking là mô hình mà các ứng dụng sẽ luôn hướng đến mọi lúc có thể. Trong một số ngôn ngữ truyền thống như Java, mỗi một dòng lệnh đa phần sẽ là Blocking trong Thread gọi nó, các lập trình viên có thể tạo một cơ chế Non-Blocking trong chương trình của mình bằng việc chủ động sử dụng các API để tạo Thread khác, CompletableFuture… hoặc cao hơn là lập trình với giao thức Reactive Stream (RxJava). Trong các nền tảng hiện đại ra đời sau như NodeJS, mọi dòng code đa phần sẽ đều là Non-Blocking, giúp cho các lập trình viên dễ dàng hơn rất nhiều trong việc sử dụng tối ưu tài nguyên, tránh lãng phí khi không cần thiết phải đợi chờ các thao tác xử lý đa phần là liên quan đến IO và Network, cũng như tránh các vấn đề phức tạp khi các lập trình viên phải tự mình tạo ra và quản lý các luồng xử lý không đồng bộ với nhau.

Cuối cùng hi vọng bài viết này sẽ giúp một phần nào đó cho các bạn có thể hình dung ra và phân biệt được mô hình lập trình Blocking, Non-Blocking và có được sự so sánh giữa chúng.

Good luck!

Advertisements

6 comments on “Blocking và Non-Blocking trong lập trình

  1. Pingback: Giới thiệu về NodeJS | Coder Sơn Trang

  2. Pingback: Giới thiệu về Spring Webflux | Coder Sơn Trang

Trả lời

Mời bạn điền thông tin vào ô dưới đây hoặc kích vào một biểu tượng để đăng nhập:

WordPress.com Logo

Bạn đang bình luận bằng tài khoản WordPress.com Đăng xuất /  Thay đổi )

Google photo

Bạn đang bình luận bằng tài khoản Google Đăng xuất /  Thay đổi )

Twitter picture

Bạn đang bình luận bằng tài khoản Twitter Đăng xuất /  Thay đổi )

Facebook photo

Bạn đang bình luận bằng tài khoản Facebook Đăng xuất /  Thay đổi )

Connecting to %s