(五)、對於node.js的阻塞與非阻塞(優缺點)的理解

當寫程序的時候,遇到一個大功能需要很長時間做完,但是突然有一個急需的小功能需要先完成,那麼就會暫停大功能,先做小功能,這種方式稱爲 阻塞。當小功能做完了,再繼續做大功能。這就是通常的同步式 (Synchronous)或阻塞式 (Blocking)。

相應地,異步式  (Asynchronous )或非阻塞式  (Non-blocking )則針對所有要完成的功能都不採用阻塞的策略。當線程遇到操作時,不會以阻塞的方式等待  操作的完成或數據的返回,而只是將 請求發送給操作系統,繼續執行下一條語句。當操作系統完成 操作時,以事件的形式通知執行 操作的線程,線程會在特定時候處理這個事件。爲了處理異步,線程必須有事件循環,不斷地檢查有沒有未處理的事件,依次予以處理。


阻塞模式下,一個線程只能處理一項任務,要想提高吞吐量必須通過多線程。而非阻塞模式下,一個線程永遠在執行計算操作,這個線程所使用的 CPU 核心利用率永遠是 100%。在阻塞模式下,多線程往往能提高系統吞吐量,因爲一個線程阻塞時還有其他線程在工作,多線程可以讓 CPU 資源不被阻塞中的線程浪費。

而在非阻塞模式下,線程不會被阻塞,永遠在利用 CPU。多線程帶來的好處僅僅是在多核 CPU 的情況下利用更多的核,而Node.js 使用了單線程、非阻塞的事件編程模式也能帶來同樣的好處。


假設我們有一個功能,可以分爲一個計算部分和一個邏輯 部分,邏輯部分佔的時間比計算多得多。如果我們使用阻塞 ,那麼要想獲得高併發就必須開啓多個線程。而使用異步式時,單線程即可勝任。 

單線程事件驅動的異步式 比傳統的多線程阻塞式究竟好在哪裏呢?簡而言之,異步式就是少了多線程的開銷。對操作系統來說,創建一個線程的代價是十分昂貴的,需要給它分配內存、列入調度,同時在線程切換的時候還要執行內存換頁,CPU 的緩存被清空,切換回來的時候還要重新從內存中讀取信息。當然,異步式編程的缺點在於編碼和調試都有不小的困難。但是現在已經有了不少專門解決異步式編程問題的庫(如async)。

下面看一個例子(對於模塊和包的概念就不再講,不明白看我的第三篇關於node.js的文章)

讓我們從讓請求處理程序返回需要在瀏覽器中顯示的信息開始。需要寫requestHandler.js文件如下形式:   

function start() {
  console.log("Request handler 'start' was called.");
  return "Hello Start";
}

function upload() {
  console.log("Request handler 'upload' was called.");
  return "Hello Upload";
} 
exports.start = start;
exports.upload = upload;

同樣的,路由需要將請求處理程序返回給它的信息返回給服務器。需要寫 router.js 文件如下形式:  

function route(handle, pathname) {
  console.log("About to route a request for " + pathname);
  if (typeof handle[pathname] === 'function') {
    return handle[pathname]();
  } else {
    console.log("No request handler found for " + pathname);
    return "404 Not found";
  }
}

exports.route = route;

正如上述代碼所示,當請求無“路”的時候,我們也返回了一些相關的錯誤信息。  
最後,我們需要寫 server.js 文件使得它能夠將請求處理程序通過請求的路由返回的內容響應給瀏覽器,如下所示:   

var http = require("http");
var url = require("url");

function start(route, handle) {
  function onRequest(request, response) {
    var pathname = url.parse(request.url).pathname;
    console.log("Request for " + pathname + " received.");

    response.writeHead(200, {"Content-Type": "text/plain"});
    var content = route(handle, pathname)
    response.write(content);
    response.end();
  }

  http.createServer(onRequest).listen(8888);
  console.log("Server has started.");
}

exports.start = start;

 

將對象引入到主文件 index.js 中:

var server = require("./server"); 
var router = require("./router"); 
var requestHandlers = require("./requestHandlers"); 

var handle = {} 
handle["/"] = requestHandlers.start; 
handle["/start"] = requestHandlers.start; 
handle["/upload"] = requestHandlers.upload; 

server.start(router.route, handle);

 

如果我們運行重構後的應用,一切都會工作的很好:請求
http://localhost:8888/start,瀏覽器會輸出“Hello Start”,請求
http://localhost:8888/upload 會輸出“Hello Upload”,而請求
http://localhost:8888/foo  會輸出“404 Not found”。  
好,那麼問題在哪裏呢?簡單的說就是:  當未來有請求處理程序需要進行非阻塞的操作的時候,我們的應用就“掛”了。  
下面就來詳細解釋下。


正如此前所提到的,當在請求處理程序中包括非阻塞操作時就會出問題。但是,在說這之前,我們先來看看什麼是阻塞操作。  
我們直接來看,當在請求處理程序中加入阻塞操作時會發生什麼。   這裏,我們來修改下 start請求處理程序,我們讓它等待 10 秒以後再返回
“Hello Start”。因爲,JavaScript中沒有類似 sleep()這樣的操作,所以這裏只能夠用小程序來模擬實現。  
讓我們將requestHandlers.js 修改成如下形式:

function start() {
  console.log("Request handler 'start' was called.");

  function sleep(milliSeconds) {
    var startTime = new Date().getTime();
    while (new Date().getTime() < startTime + milliSeconds);
  }

  sleep(10000);
  return "Hello Start";
}

function upload() {
  console.log("Request handler 'upload' was called.");
 return "Hello Upload";
}

exports.start = start;
exports.upload = upload;

上述代碼中,當函數 start()被調用的時候,Node.js 會先等待 10秒,之後纔會返回“Hello Start”。當調用 upload()的時候,會和此前一樣立即返回。  
(這裏只是模擬休眠 10秒。)  
接下來就讓我們來看看,我們的改動帶來了哪些變化。  
如往常一樣,我們先要重啓下服務器。爲了看到效果,我們要進行一些相對複雜的操作(跟着我一起做):  首先,打開兩個瀏覽器窗口或者標籤
頁。在第一個瀏覽器窗口的地址欄中輸入http://localhost:8888/start,   但是先不要打開它!在第二個瀏覽器窗口的地址欄中輸入 http://localhost:8888/upload,  同樣的,先不要打開它!接下來,做如下操作:在第一個窗口中(“/start”)按下回車,然後快速切換到第二個窗口中(“/upload”)按下回車。  
注意,發生了什麼:  /start URL 加載花了 10 秒,這和我們預期的一樣。但是,/upload URL 居然也花了 10秒,而它在對應的請求處理程序中並
沒有類似於 sleep()這樣的操作!  
這到底是爲什麼呢?原因就是 start()包含了阻塞操作。形象的說就是“它阻塞了所有其他的處理工作”。  
這顯然是個問題,因爲 Node一向是這樣來說自己的:“在 node 中除了代碼,所有一切都是並行執行的”。  
這句話的意思是說,Node.js 可以在不新增額外線程的情況下,依然可以對任務進行並行處理  —— Node.js 是單線程的。它通過事件輪詢來實現並行操作,對此,我們應該要充分利用這一點  ——  儘可能的避免阻塞操作,取而代之,多使用非阻塞操作。  
然而,要用非阻塞操作,我們需要使用回調,通過將函數作爲參數傳遞給其他需要花時間做處理的函數(比方說,休眠 10秒)。   對於Node.js 來說,它是這樣處理的:“Function()(注:這裏指的就是需要花時間處理的函數),你繼續處理你的事情,我(Node.js 線程)先不等你了,我繼續去處理你後面的代碼,請你提供一個callbackFunction()(回調函數),等你處理完之後我會去調用該回調函數的   

  
接下來,我們會介紹一種錯誤的使用非阻塞操作的方式。    
這次我們還是拿 start 請求處理程序來試驗。將其修改成如下形式:  

var exec = require("child_process").exec;

function start() {
  console.log("Request handler 'start' was called.");
  var content = "empty";

  exec("ls -lah", function (error, stdout, stderr) {
    content = stdout;
  });

  return content; }

function upload() {
  console.log("Request handler 'upload' was called.");
  return "Hello Upload";
}

exports.start = start;
exports.upload = upload;

上述代碼中,我們引入了一個新的 Node.js 模塊,child_process。之所以用它,是爲了實現一個既簡單又實用的非阻塞操作:exec()。  exec()它從 Node.js 來執行一個 shell 命令。在上述例子中,我們用它來獲取當前目錄下所有的文件(“ls -lah”),然後,當/startURL請求的時候將文件信息輸出到瀏覽器中。  

上述代碼是創建了一個新的變量 content(初始值爲“empty”),執行“ls -lah”命令,將結果賦值給 content,最後將 content返回。 

和往常一樣,我們啓動服務器,然後訪問“http://localhost:8888/start”  。  
之後會載入一個漂亮的 web 頁面,其內容爲“empty”。 這個時候,你可能大致已經猜到了,在非阻塞這塊發揮了作用。有了exec(),我們可以執行非常耗時的操作而無需迫使我們的應用停下來等待該操作。   (如果想要證明這一點,可以將“ls -lah”換成比如“find /”這樣更耗時的操作來效果)。  

然而,針對瀏覽器顯示的結果來看,我們的非阻塞操作並不好。  
接下來,我們來修正這個問題。在這過程中,讓我們先來看看爲什麼當前的這種方式不起作用。  
問題就在於,爲了進行非阻塞工作,exec()使用了回調函數。  
在我們的例子中,該回調函數就是作爲第二個參數傳遞給 exec()的匿名函數:  

function (error, stdout, stderr) {
  content = stdout;
}

 現在就到了問題根源所在了:我們的代碼是同步執行的,這就意味着在調用exec()之後, Node.js 會立即執行  return content  ;在這個時候, content
仍然是“empty”,因爲傳遞給 exec()的回調函數還未執行到——因爲exec()的操作是異步的。  
這裏“ls -lah”的操作其實是非常快的。這也是爲什麼回調函數也會很快的執行到  ——  不過,不管怎麼說它還是異步的。  
爲了讓效果更加明顯,我們想象一個更耗時的命令:  “find /”,它在我機器上需要執行 1分鐘左右的時間,然而,儘管在請求處理程序中,我把“ls-lah”換成“find /”,當打開/start URL 的時候,依然能夠立即獲得 HTTP響應  ——  很明顯,當 exec()在後臺執行的時候,Node.js 自身會繼續執行後面的代碼。並且我們這裏假設傳遞給 exec()的回調函數,只會在“find /”命令執行完成之後纔會被調用。  
那究竟我們要如何才能實現將當前目錄下的文件列表顯示給用戶呢?  
瞭解了這種不好的實現方式之後,我們接下來來介紹如何以正確的方式讓請求處理程序對瀏覽器請求作出響應。  

以非阻塞操作進行請求響應
我剛剛提到了這樣一個短語  —— “正確的方式”。而事實上通常“正確的方式”一般都不簡單。  
不過,用 Node.js 就有這樣一種實現方案:  函數傳遞。下面就讓我們來具體看看如何實現。  
到目前爲止,我們的應用已經可以通過應用各層之間傳遞值的方式(請求處理程序  ->  請求路由 ->  服務器)將請求處理程序返回的內容(請求處
理程序最終要顯示給用戶的內容)傳遞給 HTTP 服務器。  
現在我們採用如下這種新的實現方式:相對採用將內容傳遞給服務器的方式,我們這次採用將服務器“傳遞”給內容的方式。  從實踐角度來說,就是
將response 對象(從服務器的回調函數 onRequest()獲取)通過請求路由傳遞給請求處理程序。  隨後,處理程序就可以採用該對象上的函數來對
請求作出響應。  
原理就是如此,接下來讓我們來一步步實現這種方案。  
先從server.js 開始:   

var http = require("http");
var url = require("url");

function start(route, handle) {
  function onRequest(request, response) {
    var pathname = url.parse(request.url).pathname;
    console.log("Request for " + pathname + " received.");

    route(handle, pathname, response);
  }

  http.createServer(onRequest).listen(8888);
  console.log("Server has started.");
}

exports.start = start;

相對此前從 route()函數獲取返回值的做法,這次我們將 response 對象作爲第三個參數傳遞給 route()函數,並且,我們將 onRequest()處理程序中
所有有關response 的函數調都移除,因爲我們希望這部分工作讓 route()函數來完成。  
下面就來看看我們的 router.js:  

function route(handle, pathname, response) {
  console.log("About to route a request for " + pathname);
  if (typeof handle[pathname] === 'function') {
    handle[pathname](response);
  } else {
    console.log("No request handler found for " + pathname);
    response.writeHead(404, {"Content-Type": "text/plain"});     response.write("404 Not found");
    response.end();
  }
}

exports.route = route;

同樣的模式:相對此前從請求處理程序中獲取返回值,這次取而代之的是直接傳遞response 對象。  
如果沒有對應的請求處理器處理,我們就直接返回“404”錯誤。   最後,我們將 requestHandler.js 修改爲如下形式:  

var exec = require("child_process").exec;

function start(response) {
  console.log("Request handler 'start' was called.");

  exec("ls -lah", function (error, stdout, stderr) {
    response.writeHead(200, {"Content-Type": "text/plain"});
    response.write(stdout);
    response.end();
  });
}

function upload(response) {
  console.log("Request handler 'upload' was called.");
  response.writeHead(200, {"Content-Type": "text/plain"});
  response.write("Hello Upload");
  response.end();
} 
exports.start = start;
exports.upload = upload;

我們的處理程序函數需要接收 response 參數,爲了對請求作出直接的響應。    start處理程序在 exec()的匿名回調函數中做請求響應的操作,而 upload
處理程序仍然是簡單的回覆“Hello World”,只是這次是使用 response 對象而已。  
這時再次我們啓動應用(node index.js),一切都會工作的很好。   如果想要證明/start 處理程序中耗時的操作不會阻塞對/upload 請求作出
立即響應的話,可以將 requestHandlers.js 修改爲如下形式:  

var exec = require("child_process").exec;

function start(response) {
  console.log("Request handler 'start' was called.");

  exec("find /",
    { timeout: 10000, maxBuffer: 20000*1024 },
    function (error, stdout, stderr) {
      response.writeHead(200, {"Content-Type": "text/plain"});
      response.write(stdout);
      response.end();
    });
} 
function upload(response) {
  console.log("Request handler 'upload' was called.");
  response.writeHead(200, {"Content-Type": "text/plain"});
  response.write("Hello Upload");
  response.end();
}

exports.start = start;
exports.upload = upload;

這樣一來,當請求 http://localhost:8888/start 的時候,會花 10 秒鐘的時間才載入,而當請求 http://localhost:8888/upload 的時候,會立即響應,
縱然這個時候/start 響應還在處理中。

同步式 I/O 和異步式 I/O 的特點

wKioL1TcFV-QzSohAAHTUIJ5FEY753.jpg

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章