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 sống sót từ lịch sử vẻ vang, khi mà máy tính chỉ hoàn toàn có thể giải quyết và xử lý đơn nhiệm trên một lõi ( core ) của bộ vi giải quyết và xử lý ( chip ). Nhưng theo thời hạn, công nghệ tiên tiến 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ờ hoàn toàn có thể làm nhiều việc cùng một lúc thì người ta cần phải tâm lý đến việc làm sao tận dụng được tối đa tài nguyên giải quyết và xử lý của máy tính và tránh tiêu tốn lãng phí nó. Từ đó mà bất kỳ chỗ nào có phần giải quyết và xử lý Blocking không thiết yếu, 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 quy mô Non-Blocking, những dòng lệnh không nhất thiết phải khi nào cũng phải thực thi một cách tuần tự ( sequential ) và đồng điệu ( synchronous ) với nhau. Ở quy mô này nếu như về mặt logic dòng lệnh phía sau không phụ thuộc vào vào tác dụng của dòng lệnh phía trước, thì nó cũng hoàn toàn có thể trọn vẹn được triển khai ngay sau khi dòng lệnh phía trước được gọi mà không cần đợi cho tới khi tác dụng được sinh ra. Những dòng lệnh phía trước miêu tả ở trên còn hoàn toàn có thể gọi là được triển khai theo cách không đồng điệu ( 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 thi ngay sau khi có tác dụng trả về từ dòng lệnh không đồng điệu. Để triển khai quy mô Non-Blocking, người ta có những cách để triển khai 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í còn nhiều Process khác nhau ( inter-process communication – IPC ) để thực thi. Và mẫu thiết kết ( design pattern ) tên là event-loop là một trong những mẫu phong cách thiết kế nổi tiếng để thực thi chính sách Non-Blocking mà nếu có điều kiện kèm theo trong tương lai mình sẽ viết bài để ra mắt cho những bạn .
Trong bài viết này mình sẽ đưa ra một ví dụ để những bạn hiểu rõ hơn về Blocking và Non-Blocking, gồm có hình ảnh minh họa cũng như chương trình đơn thuần viết bằng Java. Ví dụ này diễn đạt quy trình lấy tài liệu từ 3 lời gọi hàm khác nhau và sau đó in tác dụng khi trả về từ hàm ra màn hình hiển thị. Lời gọi hàm trong ví dụ chỉ là một đoạn code đơn thuần mô phỏng một việc làm trong một thời hạn nhất định, trong trong thực tiễn việc này có thể thao tác disk IO như đọc tài liệu từ file hay database, hoặc thao tác tương quan đến liên kết mạng như gọi webservice … 3 hàm ở trên mình giả sử sẽ là 3 việc trong thực tiễn không tương quan gì đến nhau, và mình sẽ chỉ ra cùng là làm 3 việc thì chính sách Blocking sẽ khác với Non-Blocking như thế nào .
Trước tiên những 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 giải trí theo chính sách Blocking mà ở đây mặc dầu không có sự trực tiếp giữa 3 việc, nhưng những việc làm tiếp sau luôn phải chờ việc làm phía trước thực sự xong rồi mới hoàn toàn có thể mở màn triển khai. Các bước sẽ được miêu 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 hạn để thực thi xong hết những thao tác sẽ bằng tổng thời hạn 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 hoàn toàn có thể thấy Non-Blocking rút ngắn thời hạn triển khai chương trình hơn là Blocking, việc rút ngắn thời hạn này không phải vì những việc làm được triển khai nhanh hơn mà vì nhiều việc được thực thi 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 thuầ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 triển khai chạy chương trình, thứ tự những hiệu quả được in ra sẽ giống như diễn đạt ở hình vẽ tại phần đầu và tổng thời hạn 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 triển khai với chính sá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 hy vọng bài viết này sẽ giúp một phần nào đó cho những bạn hoàn toàn có thể tưởng tượng ra và phân biệt được quy mô lập trình Blocking, Non-Blocking và có được sự so sánh giữa chúng .
Good luck !

Chia sẻ bài viết

Thích bài này:

Thích

Đang tải …