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等奇技淫巧)

建議參考:

这几篇都是非常经典的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的函数,然后可以依照需求任意组合。当然也可以直接组合来执行,但是有时候系统需要有弹性依照不定的需求来弹性组合,这样就需要用一些方法来处理。

待续...

posted @ 2012-08-17 01:19  张三的猫  阅读(908)  评论(0编辑  收藏  举报