Node.js中的事件驅(qū)動編程詳解
在傳統(tǒng)程編程模里,I/O操作就像一個普通的本地函數(shù)調(diào)用:在函數(shù)執(zhí)行完之前程序被堵塞,無法繼續(xù)運行。堵塞I/O起源于早先的時間片模型,這種模型下每個進程就像一個獨立的人,目的是將每個人區(qū)分開,而且每個人在同一時刻通常只能做一件事,必須等待前面的事做完才能決定下一件事做什么。但是這種在計算機網(wǎng)絡(luò)和Internet上被廣泛使用的“一個用戶,一個進程”的模型伸縮性很差。管理多個進程時,會耗費很多內(nèi)存,上下文切換也會占用大量資源,這些對操作系統(tǒng)是個很大的負擔(dān),而且隨著進程數(shù)的遞增,會導(dǎo)致系統(tǒng)性能急劇衰減。
多線程是個替代方案,線程是一個輕量級的進程,它會和同一個進程內(nèi)的其它線程共享內(nèi)存,它更像傳統(tǒng)模型的擴展,用來并發(fā)執(zhí)行多個線程,當一個線程等待I/O操作時,其它線程可以接管CPU,當I/O操作完成,前面等待的線程會被喚醒。就是說,一個運行中的線程可以被中斷,然后稍候再被恢復(fù)。此外,在一些系統(tǒng)下線程可以在多核CPU的不同核心下并行運行。
程序員并不知道線程會在什么具體時間運行,他們必須很小心的處理共享內(nèi)存的并發(fā)訪問,因此必須使用一些同步原語來同步訪問某個數(shù)據(jù)結(jié)構(gòu),比如使用鎖或信號量,以此來強制線程以特定的行為和計劃執(zhí)行。那些大量依賴線程間的共享狀態(tài)的應(yīng)用程序,很容易就會出現(xiàn)一些隨機性很強,難以查找的奇怪問題。
還有一種方式是使用多線程協(xié)作,由你自己負責(zé)顯式的釋放CPU,并把CPU時間交給其他線程使用,因為由你親自來控制線程的執(zhí)行計劃,因此減小了對同步的需求,但是也提高了程序的復(fù)雜度和出錯的機會,而且并沒有避免多線程的那些問題。
什么是事件驅(qū)動編程
事件驅(qū)動編程(Evnet-driven programming)是一種編程風(fēng)格,由事件來決定程序的執(zhí)行流程,事件由事件處理器(event handler)或事件回調(diào)(event callback)來處理,事件回調(diào)是當某個特定事件發(fā)生時被調(diào)用的函數(shù),比如數(shù)據(jù)庫返回了查詢結(jié)果或者用戶單擊了一個按鈕。
回想下,在傳統(tǒng)的堵塞I/O編程模式里,數(shù)據(jù)庫查詢可能像這樣:
result = query('SELECT * FROM posts WHERE id = 1');
do_something_with(result);
上面的query函數(shù)會讓當前線程或進程一直處于等待狀態(tài),直到底層數(shù)據(jù)庫完成查詢操作并返回。
在事件驅(qū)動模型里,這個查詢會變成這樣:
query_finished = function(result) {
do_something_with(result);
}
query('SELECT * FROM posts WHERE id = 1', query_finished);
首先你定義了一個叫query_finished的函數(shù),它包含了查詢完成后要做的事。然后把這個函數(shù)當做參數(shù)傳遞給query函數(shù),當query執(zhí)行完畢會調(diào)用query_finished,而不是僅僅返回查詢結(jié)果。
當你感興趣的事件發(fā)生時會調(diào)用你定義的函數(shù),而不是簡單的返回結(jié)果值,這種編程模型就叫事件驅(qū)動編程或異步編程。這是Node一個最明顯的特性,這種編程模型意味著當前進程在執(zhí)行I/O操作時不會被阻塞,因此,多個I/O操作可以并行執(zhí)行,當操作完成后相應(yīng)的回調(diào)函數(shù)就會被調(diào)用。
事件驅(qū)動編程底層依賴于事件循環(huán)(event loop),事件循環(huán)基本上是事件檢測和事件處理器觸發(fā)這兩種函數(shù)不斷循環(huán)調(diào)用的一個結(jié)構(gòu)。在每次循環(huán)里,事件循環(huán)機制需要檢測發(fā)生了哪些事件,當事件發(fā)生時,它找到對應(yīng)的回調(diào)函數(shù)并調(diào)用它。
事件循環(huán)只是運行在進程內(nèi)的一個線程,當事件發(fā)生時,事件處理器可以單獨運行并且不會被中斷,也就是說:
1.在某個特定時刻最多有一個事件回調(diào)函數(shù)運行
2.任何事件處理器運行時都不會被中斷
有了這個,開發(fā)人員就可以不再為線程同步和并發(fā)修改共享內(nèi)存這些事頭疼了。
一個眾所周知的秘密:
很久以前,系統(tǒng)編程社區(qū)的人們就知道事件驅(qū)動編程是創(chuàng)建高并發(fā)服務(wù)最佳方式,因為它不用保存很多上下文,因此節(jié)省了大量內(nèi)存,也沒有那么多上下文切換,又節(jié)省了大量執(zhí)行時間。
慢慢的,這種理念滲透到了其他的平臺和社區(qū),出現(xiàn)了一些有名的事件循環(huán)實現(xiàn),比如Ruby的Event machine,Perl的AnyEvnet,以及Python的Twisted,除了這些還有很多其它的實現(xiàn)和語言。
用這些框架做開發(fā),需要學(xué)習(xí)框架相關(guān)的特定知識以及框架特定的類庫,比如,使用Event Machine時,為了享受非阻塞帶來的好處,你得避免使用同步類庫,只能用Event Machine的異步類庫。如果你使用了任何阻塞類庫(比如Ruby的大多數(shù)標準庫),你的服務(wù)器就失去了最佳的伸縮性,因為事件循環(huán)依然會不斷地被阻塞,時不時地阻礙了I/O事件的處理。
Node最初就被設(shè)計成一個非阻塞I/O服務(wù)器平臺,因此一般情況下,你應(yīng)該期望運行在它上面的所有代碼都是非阻塞的。因為JavaScript非常小,而且它不強制使用任何I/O模型(因為它沒有標準的I/O類庫),因此Node建立在一個很純凈的環(huán)境里,不會有什么歷史遺留問題。
Node和JavaScript如何簡化了異步應(yīng)用程序
Node的作者Ryan Dahl,最初使用C來開發(fā)這個項目,但是發(fā)現(xiàn)維護函數(shù)調(diào)用的上下文太復(fù)雜,導(dǎo)致代碼復(fù)雜度很高。然后他轉(zhuǎn)用Lua,但是Lua已經(jīng)有個幾個阻塞的I/O類庫,阻塞和非阻塞混在一起可能會讓開發(fā)人員很迷惑并因此阻礙了很多人構(gòu)建可伸縮的應(yīng)用,于是Lua也被Dahl拋棄了。最后他轉(zhuǎn)向了JavaScript,JavaScript中的閉包及第一級對象的函數(shù),這些特性使JavaScript非常適合用作事件驅(qū)動編程。JavaScript的魔力是讓Node如此流行的一個主要原因。
什么是閉包
閉包可以理解為一個特殊的函數(shù),但是它可以繼承并訪問它自身被定義的那個作用域里的變量。當你將一個回調(diào)函數(shù)作為參數(shù)傳遞給另外一個函數(shù)時,它稍候會被調(diào)用,神奇的是,這個回調(diào)函數(shù)被稍候調(diào)用時,它居然記住了它自身定義所在的那個上下文以及父上下文里的變量,而且還可以正常訪問它們。這個強大的特性是Node成功的核心。
下面的例子將展示在Web瀏覽器里JavaScript閉包是如何工作的。假如,你要監(jiān)聽一個按鈕的單機事件,你可以這樣做:
var clickCount = 0;
document.getElementById('myButton').onclick = function() {
clickCount += 1;
alert("clicked " + clickCount + " times.");
};
使用jQuery時是這樣:
var clickCount = 0;
$('button#mybutton').click(function() {
clickedCount ++;
alert('Clicked ' + clickCount + ' times.');
});
JavaScript里,函數(shù)是第一類對象,就是說你可以把函數(shù)當作參數(shù)來傳遞給其他函數(shù)。上面的兩個例子,前者把一個函數(shù)賦值給另一個函數(shù),后者把函數(shù)作為參數(shù)傳遞給另一個函數(shù),單擊事件的處理函數(shù)(回調(diào)函數(shù))可以訪問函數(shù)定義所在代碼塊下的每個變量,在這個例子里,它可以訪問在它父閉包內(nèi)定義的clickCount變量。
clickCount變量處在全局作用域(JavaScript里最外層的作用域),它保存了用戶點擊按鈕的次數(shù),通常在全局作用域下存儲變量是個壞習(xí)慣,因為那樣很容易跟其他代碼沖突,你應(yīng)該把變量放在使用它們的本地作用域里。大多時候,只用把代碼用一個函數(shù)包裝起來,等于另外創(chuàng)建了閉包,這樣就可以很容易避免污染全局環(huán)境,就像這樣:
(function() {
var clickCount = 0;
$('button#mybutton').click(function() {
clickCount ++;
alert('Clicked ' + clickCount + ' times.');
});
}());
注意:上面代碼的第七行,定義了一個函數(shù)后立刻調(diào)用它,這是JavaScript里一個常見的設(shè)計模式:通過創(chuàng)建函數(shù)來創(chuàng)建一個新的作用域。
閉包如何幫助異步編程
在事件驅(qū)動編程模型里,先編寫事件發(fā)生后將要運行的代碼,然后把這些代碼放到一個函數(shù)里,最后把這個函數(shù)當作參數(shù)傳遞給調(diào)用者,稍后由調(diào)用者函數(shù)調(diào)用。
在JavaScript里,一個函數(shù)并不是個孤立的定義,它同時會記住自己被聲明的那個作用域的上下文,這種機制讓JavaScript的函數(shù)可以訪問函數(shù)定義所在那個上下文及父上下文里的所有變量。
當你把一個回調(diào)函數(shù)當作參數(shù)傳遞給調(diào)用者后,這個函數(shù)就會在稍后的某個時刻被調(diào)用。即使定義回調(diào)函數(shù)的那個作用域已經(jīng)結(jié)束,在回調(diào)函數(shù)被調(diào)用時,它依然能夠訪問這個已結(jié)束的作用域及其父作用域里的所有變量。像最后那個例子,回調(diào)函數(shù)在jQuery的click()內(nèi)部被調(diào)用,它卻依然能訪問clickCount變量。
前面展現(xiàn)了閉包的神奇之處,把狀態(tài)變量傳遞給一個函數(shù)就可以讓你不用維護狀態(tài)就能進行事件驅(qū)動編程,JavaScript的閉包機制會幫你維護它們。
小結(jié)
事件驅(qū)動編程是一種通過事件觸發(fā)來決定程序執(zhí)行流程的編程模型。程序員為他們感興趣的事件注冊回調(diào)函數(shù)(通常被稱作事件處理器),然后系統(tǒng)在事件發(fā)生時調(diào)用已注冊的事件處理器。這種編程模型有很多傳統(tǒng)阻塞編程模型所不具備的優(yōu)勢,以前要實現(xiàn)類似的特性,就必須使用多進程/多線程才行。
JavaScript是種強大的語言,因為它的第一類型對象的函數(shù)和閉包特性,讓它很適合事件驅(qū)動編程。
相關(guān)文章
node.js實現(xiàn)http服務(wù)器與瀏覽器之間的內(nèi)容緩存操作示例
這篇文章主要介紹了node.js實現(xiàn)http服務(wù)器與瀏覽器之間的內(nèi)容緩存操作,結(jié)合實例形式分析了node.js http服務(wù)器與瀏覽器之間的內(nèi)容緩存原理與具體實現(xiàn)技巧,需要的朋友可以參考下2020-02-02詳解如何在Node.js中執(zhí)行CPU密集型任務(wù)
Node.js通常被認為不適合CPU密集型應(yīng)用程序,Node.js的工作原理使其在處理I/O密集型任務(wù)時大放異彩,雖然執(zhí)行CPU密集型任務(wù)肯定不是Node的主要使用場景,但是我們依舊有方法來改善這些問題,本文詳細給大家介紹了如何在Node.js中執(zhí)行CPU密集型任務(wù)2023-12-12在Debian(Raspberry Pi)樹莓派上安裝NodeJS的教程詳解
在樹莓派上運行NodeJS并不需要特別的配置,你只需要確??梢杂胦penssh遠程連接到你的樹莓派就ok了,關(guān)于在Debian(Raspberry Pi)樹莓派上安裝NodeJS的方法,大家可以通過本文了解下2017-09-09