Event Loop、Scope 以及Callback(转)
看到一篇很好的文章,于是转过来收藏用!原文是繁体中文,做了简单转码,很多名词还是保留台湾翻译。
原文标题:JavaScript 與 NodeJS
原文地址:http://book.nodejs.tw/zh-tw/node_javascript.html
=============转载分割线============
其实使用JavaScript 在网页端与伺服器端的差距并不大, 但是为了使NodeJS 可以发挥他最强大的能力, 有一些知识还是必要的, 所以还是针对这些主题介绍一下。其中Event Loop、Scope 以及Callback 其实是比较需要了解的基本知识, cps、currying、flow control是更进阶的技巧与应用。
Event Loop
可能很多人在写Javascript时,并不知道他是怎么被执行的。这个时候可以参考一下jQuery作者John Resig一篇好文章,介绍事件及timer怎么在浏览器中执行:How JavaScript Timers Work。通常在网页中,所有的Javascript执行完毕后(这部份全部都在global scope跑,除非执行函数),接下来就是如John Resig解释的这样,所有的事件处理函数,以及timer执行的函数,会排在一个queue结构中,利用一个无穷回圈,不断从queue中取出函数来执行。这个就是event loop。
(除了John Resig的那篇文章,Nicholas C. Zakas的“Professional Javascript for Web Developer 2nd edition” 有一个试阅本:http://yuiblog.com/assets/pdf/zakas-projs-2ed-ch18.pdf ,598页刚好也有简短的说明)
所以在Javascript中,虽然有非同步,但是他并不是使用执行绪。所有的事件或是非同步执行的函数,都是在同一个执行绪中,利用event loop的方式在执行。至于一些比较慢的动作例如I/O、网页render, reflow等,实际动作会在其他执行绪跑,等到有结果时才利用事件来触发处理函数来处理。这样的模型有几个好处: 没有执行绪的额外成本,所以反应速度很快不会有任何程式同时用到同一个变数,不必考虑lock,也不会产生dead lock 所以程式撰写很简单但是也有一些潜在问题: 任一个函数执行时间较长,都会让其他函数更慢执行(因为一个跑完才会跑另一个) 在多核心硬体普遍的现在,无法用单一的应用程式instance发挥所有的硬体能力用NodeJS撰写伺服器程式,碰到的也是一样的状况。要让系统发挥event loop的效能,就要尽量利用事件的方式来组织程式架构。另外,对于一些有可能较为耗时的操作,可以考虑使用process.nextTick 函数来让他以非同步的方式执行,避免在同一个函数中执行太久,挡住所有函数的执行。
如果想要测试event loop怎样在「浏览器」中运行,可以在函数中呼叫alert(),这样会让所有Javascript的执行停下来,尤其会干扰所有使用timer的函数执行。有一个简单的例子,这是一个会依照设定的时间间隔严格执行动作的动画,如果时间过了就会跳过要执行的动作。点按图片以后,人物会快速旋转,但是在旋转执行完毕前按下「delay」按钮,让alert讯息等久一点,接下来的动画就完全不会出现了。
Scope 與 Closure
要快速理解JavaScript 的Scope(变数作用范围)原理,只要记住他是Lexical Scope就差不多了。简单地说,变数作用范围是依照程式定义时(或者叫做程式文本?)的上下文决定,而不是执行时的上下文决定。
为了维护程式执行时所依赖的变数,即使执行时程式运行在原本的scope之外,他的变数作用范围仍然维持不变。这时程式依赖的自由变数(定义时不是local的,而是在上一层scope定义的变数)一样可以使用,就好像被关闭起来,所以叫做Closure。用程式看比较好懂:
function outter(arg1) { //arg1及free_variable1對inner函數來說,都是自由變數 var free_variable1 = 3; return function inner(arg2) { var local_variable1 =2;//arg2及local_variable1對inner函數來說,都是本地變數 return arg1 + arg2 + free_variable1 + local_variable1; }; }
var a = outter(1);//变数a 就是outter函数执行后返回的inner函数var b = a(4);//执行inner函数,执行时上下文已经在outter函数之外,但是仍然能正常执行,而且可以使用定义在outter函数里面的arg1及free_variable1变数console.log(b);//结果10
在Javascript中,scope最主要的单位是函数(另外有global及eval),所以有可能制造出closure的状况,通常在形式上都是有巢状的函数定义,而且内侧的函数使用到定义在外侧函数里面的变数。
Closure有可能会造成记忆体泄漏,主要是因为被参考的变数无法被垃圾收集机制处理,造成占用的资源无法释放,所以使用上必须考虑清楚,不要造成意外的记忆体泄漏。 (在上面的例子中,如果a一直未执行,使用到的记忆体就不会被释放)
跟透过函数的参数把变数传给函数比较起来,Javascript Engine会比较难对Closure进行最佳化。如果有效能上的考量,这一点也需要注意。
Callback
要介绍Callback 之前, 要先提到JavaScript 的特色。
JavaScript 是一种函数式语言(functional language),所有Javascript语言内的函数,都是高阶函数(higher order function,这是数学名词,计算机用语好像是first class function,意指函数使用没有任何限制,与其他物件一样)。也就是说,函数可以作为函数的参数传给函数,也可以当作函数的返回值。这个特性,让Javascript的函数,使用上非常有弹性,而且功能强大。
callback在形式上,其实就是把函数传给函数,然后在适当的时机呼叫传入的函数。 Javascript使用的事件系统,通常就是使用这种形式。 NodeJS中,有一个物件叫做EventEmitter,这是NodeJS事件处理的核心物件,所有会使用事件处理的函数,都会「继承」这个物件。 (这里说的继承,实作上应该像是mixin)他的使用很简单: 可以使用物件.on(事件名称, callback函数) 或是物件.addListener(事件名称, callback函数) 把你想要处理事件的函数传入在物件中,可以使用物件.emit(事件名称, 参数...) 呼叫传入的callback函数这是Observer Pattern的简单实作,而且跟在网页中使用DOM的addEventListener使用上很类似,也很容易上手。不过NodeJS是大量使用非同步方式执行的应用,所以程式逻辑几乎都是写在callback函数中,当逻辑比较复杂时,大量的callback会让程式看起来很复杂,也比较难单元测试。举例来说:
var p_client = new Db(‘integration_tests_20’, new Server(“127.0.0.1”, 27017, {}), {‘pk’:CustomPKFactory}); p_client.open(function(err, p_client) { p_client.dropDatabase(function(err, done) { p_client.createCollection(‘test_custom_key’, function(err, collection) { collection.insert({‘a’:1}, function(err, docs) { collection.find({‘_id’:new ObjectID(“aaaaaaaaaaaa”)}, function(err, cursor) { cursor.toArray(function(err, items) { test.assertEquals(1, items.length); p_client.close(); }); }); }); }); }); });
这是在网路上看到的一段操作mongodb的程式码,为了循序操作,所以必须在一个callback里面呼叫下一个动作要使用的函数,这个函数里面还是会使用callback,最后就形成一个非常深的巢状。
这样的程式码,会比较难进行单元测试。有一个简单的解决方式,是尽量不要使用匿名函数来当作callback或是event handler。透过这样的方式,就可以对各个handler做单元测试了。例如:
var http = require('http'); var tools = { cookieParser: function(request, response) { if(request.headers['Cookie']) { //do parsing } } }; var server = http.createServer(function(request, response) { this.emit('init', request, response); //... }); server.on('init', tools.cookieParser); server.listen(8080, '127.0.0.1');
更进一步,可以把tools改成外部module,例如叫做tools.js:
module.exports = { cookieParser: function(request, response) { if(request.headers[‘Cookie’]) { //do parsing } } };
然後把程式改成:
var http = require('http'); var server = http.createServer(function(request, response) { this.emit('init', request, response); //... }); server.on('init', require('./tools').cookieParser); server.listen(8080, '127.0.0.1');
這樣就可以單元測試cookieParser了。例如使用nodeunit時,可以這樣寫:
var testCase = require('nodeunit').testCase; module.exports = testCase({ 'setUp': function(cb) { this.request = { headers: { Cookie: 'name1:val1; name2:val2' } }; this.response = {}; this.result = { name1:'val1', name2:'val2' }; cb(); }, 'tearDown': function(cb) { cb(); }, 'normal_case': function(test) { test.expect(1); var obj = require('./tools').cookieParser(this.request, this.response); test.deepEqual(obj, this.result); test.done(); } });
善於利用模組,可以讓程式更好維護與測試。
CPS(Continuation-Passing Style)
cps是callback使用上的特例,形式上就是在函数最后呼叫callback,这样就好像把函数执行后把结果交给callback继续运行,所以称作continuation-passing style。利用cps,可以在非同步执行的情况下,透过传给callback的这个cps callback来获知callback执行完毕,或是取得执行结果。例如:
<html> <body> <div id='panel' style='visibility:hidden'></div> </body> </html> <script> var request = new XMLHttpRequest(); request.open('GET', 'test749.txt?timestamp='+new Date().getTime(), true); request.addEventListener('readystatechange', function(next){ return function() { if(this.readyState===4&&this.status===200){ next(this.responseText); //<==傳入的cps callback在動作完成時執行並取得結果進一步處理 } }; }(function(str){ //<==這個匿名函數就是cps callback document.getElementById('panel').innerHTML=str; document.getElementById('panel').style.visibility = 'visible'; }), false); request.send(); </script>
進一步的應用,也可以參考2-6 流程控制。
函數返回函數與Currying
前面的cps范例里面,使用了函数返回函数,这是为了把cps callback传递给onreadystatechange事件处理函数的方法。 (因为这个事件处理函数并没有设计好会传送/接收这样的参数)实际会执行的事件处理函数其实是内层返回的那个函数,之外包覆的这个函数,主要是为了利用Closure,把next传给内层的事件处理函数。这个方法更常使用的地方,是为了解决一些scope问题。例如:
var accu=0,count=10; for(var i=0; i<count; i++) { setTimeout(function(){ count–; accu+=i; if(count<=0){ console.log(accu) } }, 50) }
最后得出的结果会是100,而不是想像中的45,这是因为等到setTimeout指定的函数执行时,变数i已经变成10而离开回圈了。要解决这个问题,就需要透过Closure来保存变数i:
var accu=0,count=10; for(var i=0; i<count; i++) { setTimeout(function(i) { return function(){ count–; accu+=i; if(count<=0){ console.log(accu) } }; }(i), 50) }
函數返回函數的另外一個用途,是可以暫緩函數執行。例如:
function add(m, n) { return m+n; } var a = add(20, 10); console.log(a);
add这个函数,必须同时输入两个参数,才有办法执行。如果我希望这个函数可以先给它一个参数,等一些处理过后再给一个参数,然后得到结果,就必须用函数返回函数的方式做修改:
function add(m) { return function(n) { return m+n; }; } var wait_another_arg = add(20);//先給一個參數 var a = function(arr) { var ret=0; for(var i=0;i<arr.length;i++) { ret+=arr[i]; } return ret; }([1,2,3,4]); //計算一下另一個參數 var b = wait_another_arg(a);//然後再繼續執行 console.log(b);
像这样利用函数返回函数,使得原本接受多个参数的函数,可以一次接受一个参数,直到参数接收完成才执行得到结果的方式,有一个学名就叫做...Currying
综合以上许多奇技淫巧,就可以透过用函数来处理函数的方式,调整程式流程。接下来看看...
流程控制
(以sync方式使用async函数、避开巢状callback循序呼叫async callback等奇技淫巧)
建議參考:
- http://howtonode.org/control-flow
- http://howtonode.org/control-flow-part-ii
- http://howtonode.org/control-flow-part-iii
- http://blog.mixu.net/2011/02/02/essential-node-js-patterns-and-snippets
这几篇都是非常经典的NodeJS/Javascript流程控制好文章(阿,mixu是在介绍一些pattern时提到这方面的主题)。不过我还是用几个简单的程式介绍一下做法跟概念:
並發與等待
下面的程式參考了mixu文章中的做法:
var wait = function(callbacks, done) { console.log('wait start'); var counter = callbacks.length; var results = []; var next = function(result) {//接收函數執行結果,並判斷是否結束執行 results.push(result); if(--counter == 0) { done(results);//如果結束執行,就把所有執行結果傳給指定的callback處理 } }; for(var i = 0; i < callbacks.length; i++) {//依次呼叫所有要執行的函數 callbacks[i](next); } console.log('wait end'); } wait([function(next){ setTimeout(function(){ console.log('done a'); var result = 500; next(result) },500);}, function(next){ setTimeout(function(){ console.log('done b'); var result = 1000; next(result) },1000);}, function(next){ setTimeout(function(){ console.log('done c'); var result = 1500; next(1500) },1500); }], function(results){ var ret = 0, i=0; for(; i<results.length; i++) { ret += results[i]; } console.log('done all. result: '+ret); } );
執行結果:
wait start
wait end
done a
done b
done c
done all. result: 3000
可以看出来,其实wait并不是真的等到所有函数执行完才结束执行,而是在所有传给他的函数执行完毕后(不论同步、非同步),才执行处理结果的函数(也就是done( ))
不过这样的写法,还不够实用,因为没办法实际让函数可以等待执行完毕,又能当作事件处理函数来实际使用。上面参考到的Tim Caswell的文章,里面有一种解法,不过还需要额外包装(在他的例子中)NodeJS核心的fs物件,把一些函数(例如readFile)用Currying处理。类似像这样:
var fs = require('fs'); var readFile = function(path) { return function(callback, errback) { fs.readFile(path, function(err, data) { if(err) { errback(); } else { callback(data); } }); }; }
其他部份可以参考Tim Caswell的文章,他的Do.parallel跟上面的wait差不多意思,这里只提示一下他没说到的地方。
另外一种做法是去修饰一下callback,当他作为事件处理函数执行后,再用cps的方式取得结果:
function Wait(fns, done) { var count = 0; var results = []; this.getCallback = function(index) { count++; return (function(waitback) { return function() { var i=0,args=[]; for(;i<arguments.length;i++) { args.push(arguments[i]); } args.push(waitback); fns[index].apply(this, args); }; })(function(result) { results.push(result); if(--count == 0) { done(results); } }); } } var a = new Wait([function(waitback){ console.log('done a'); var result = 500; waitback(result) }, function(waitback){ console.log('done b'); var result = 1000; waitback(result) }, function(waitback){ console.log('done c'); var result = 1500; waitback(result) }], function(results){ var ret = 0, i=0; for(; i<results.length; i++) { ret += results[i]; } console.log('done all. result: '+ret); }); var callbacks = [a.getCallback(0),a.getCallback(1),a.getCallback(0),a.getCallback(2)]; //一次取出要使用的callbacks,避免結果提早送出 setTimeout(callbacks[0], 500); setTimeout(callbacks[1], 1000); setTimeout(callbacks[2], 1500); setTimeout(callbacks[3], 2000); //當所有取出的callbacks執行完畢,就呼叫done()來處理結果
執行結果:
done a
done b
done a
done c
done all. result: 3500
上面只是一些小實驗,更成熟的作品是Tim Caswell的step:https://github.com/creationix/step
如果希望真正使用同步的方式寫非同步,則需要使用Promise.js這一類的library來轉換非同步函數,不過他結構比較複雜XD(見仁見智,不過有些人認為Promise有點過頭了):http://blogs.msdn.com/b/rbuckton/archive/2011/08/15/promise-js-2-0-promise-framework-for-javascript.aspx
如果想不透過其他Library做轉換,又能直接用同步方式執行非同步函數,大概就要使用一些需要額外compile原始程式碼的方法了。例如Bruno Jouhier的streamline.js:https://github.com/Sage/streamlinejs
循序執行
循序執行可以協助把非常深的巢狀callback結構攤平,例如用這樣的簡單模組來做(serial.js):
module.exports = function(funs) { var c = 0; if(!isArrayOfFunctions(funs)) { throw('Argument type was not matched. Should be array of functions.'); } return function() { var args = Array.prototype.slice.call(arguments, 0); if(!(c>=funs.length)) { c++; return funs[c-1].apply(this, args); } }; } function isArrayOfFunctions(f) { if(typeof f !== 'object') return false; if(!f.length) return false; if(!f.concat) return false; if(!f.splice) return false; var i = 0; for(; i<f.length; i++) { if(typeof f[i] !== 'function') return false; } return true; }
简单的测试范例(testSerial.js),使用fs模组,确定某个path是档案,然后读取印出档案内容。这样会用到两层的callback,所以测试中有使用serial的版本与nested callbacks的版本做对照:
var serial = require('./serial'), fs = require('fs'), path = './dclient.js', cb = serial([ function(err, data) { if(!err) { if(data.isFile) { fs.readFile(path, cb); } } else { console.log(err); } }, function(err, data) { if(!err) { console.log('[flattened by searial:]'); console.log(data.toString('utf8')); } else { console.log(err); } } ]); fs.stat(path, cb); fs.stat(path, function(err, data) { //第一層callback if(!err) { if(data.isFile) { fs.readFile(path, function(err, data) { //第二層callback if(!err) { console.log('[nested callbacks:]'); console.log(data.toString('utf8')); } else { console.log(err); } }); } else { console.log(err); } } });
关键在于,这些callback的执行是有顺序性的,所以利用serial返回的一个函数cb来取代这些callback,然后在cb中控制每次会循序呼叫的函数,就可以把巢状的callback摊平成循序的function阵列(就是传给serial函数的参数)。
测试中的./dclient.js是一个简单的dnode测试程式,放在跟testSerial.js同一个目录:
var dnode = require('dnode'); dnode.connect(8000, 'localhost', function(remote) { remote.restart(function(str) { console.log(str); process.exit(); }); });
執行測試程式後,出現結果:
[flattened by searial:] var dnode = require('dnode'); dnode.connect(8000, 'localhost', function(remote) { remote.restart(function(str) { console.log(str); process.exit(); }); }); [nested callbacks:] var dnode = require('dnode'); dnode.connect(8000, 'localhost', function(remote) { remote.restart(function(str) { console.log(str); process.exit(); }); });
对照起来看,两种写法的结果其实是一样的,但是利用serial.js,巢状的callback结构就会消失。
不过这样也只限于顺序单纯的状况,如果函数执行的顺序比较复杂(不只是一直线),还是需要用功能更完整的流程控制模组比较好,例如 https://github.com/caolan/async 。
組合
简单地说,组合就是把函数执行的结果作为参数传给另外一个函数,用这样的形式把好几个函数串接起来,然后一次执行。
例如:
var a = function(a){return a*3;}; var b = function(b){return b+5;}; console.log(a(b(2)));//結果是21,a(b(2))這樣就是組合
有些时候,可能需要预先做好处理一些payload的函数,然后可以依照需求任意组合。当然也可以直接组合来执行,但是有时候系统需要有弹性依照不定的需求来弹性组合,这样就需要用一些方法来处理。
待续...