异步编程总结-2
发布于 6 年前 作者 halu886 8196 次浏览 来自 分享

该文章阅读需要5分钟,更多文章请点击本人博客halu886

异步编程的难点

函数嵌套过深

这应该是Node中最受人诟病的地方。在前端开发中,较少存在异步多级依赖的业务场景。

$(selector).click(function(event){
    // TODO
});
$(selector).change(function(event){
    // TODO
})

但是在Node中,事物中多级异步调用的场景比比皆是。

fs.readdir(path.join(_dirname,'..'),function(err,files){
    files.forEach(function(filename,index){
        fs.readFile(filename,'utf8',funtion(err,file){
            // TODO
        })
    })
})

在上述场景中,因为两次操作存在依赖关系,嵌套情有可原。但是在网页渲染中,通常需要数据,模板,静态文件,但是三者并不相互依赖,但是最终渲染结果三者缺一不可,如果采用默认的异步方法调用。

fs.readFile(template_path,'utf8',function(err,template){
    db.query(sql,function(err,data){
        l10n.get(function(err,resources){
            // TODO
        })
    })
})

虽然从结果上来说这是没有问题的,但是这并没有使用Node的并行优势。

阻塞代码

对于Javascript的开发者可能会困惑,如何实现沉睡线程的功能,setTimeout()setInterval()能延后操作,但是不能阻塞后面的代码执行。 所以,我们可能会这样实现sleep(1000)的效果。

// TODO
var start = new Date();
while(new Date() - start <1000){
    // TODO
}
// 需要阻塞的代码

但是事实却是,CPU会持续计算,根本就没有起到线程沉睡的功能,并且Node是单线程的,CPU所有的资源都在为这段代码服务,导致任何请求都得不到响应。

存在这种需求,统一规划业务逻辑,调用setTimeout()实现效果会更好。

多线程编程

我们在讨论Javascript编程时,通常都是单线程编程,前端中UI渲染和Javascript执行线程共用一个线程。在Node中,只是没有UI渲染,模型基本相同。但是在多核服务器中,单个Node进程实际上没有充分利用多核CPU。随着业务复杂化,对于多核CPU的要求也会越来越高。浏览器提出Web Workers。它通常将Javascript执行与UI渲染分离,可以通过多核CPU进行大量运算。并且Web Worker也是一个通过消息机制合理使用多核CPU的合理模型。

1

遗憾的是浏览器对于标准存在明显的滞后,导致Web Worker并没有广泛的应用。并且虽然Web Worker解决了多核CPU和渲染UI的问题,但是并没有解决UI渲染的效率问题。但是Node借鉴了Web Worker的模式,child_process是基础API,cluster则是它的深层次应用。

异步编程解决方案

以上我们列举了一下异步编程的缺点,和异步编程的高性能相比,编程过程看起来并没有那么完美。但是事实也并没有那么糟糕,与问题相比,解决方案总是更多。

  • 事件发布/订阅模式
  • Promise/Deferred模式
  • 流程控制库

事件发布/订阅模式

事件监听器模式是广泛用于异步编程的模式,将回调函数异步化。又称发布/订阅模式

Node自身的events模块是发布订阅的一个简单实现,Node大多数模块都继承自它,这比前端的事件机制简单的多,不存在事件冒泡,也不存在preventDefault(),stopPropagetion(),stopImmediatePropagation()等控制事件传递的方法。具有addListener/on()once()removeListener()removeAllListeners()emit()等基本的事件监听模式的方法实现。

emitter.on("event1",function(message){
    console.log(message);
})

emitter.emit('event1',"I am message!");

订阅事件是高阶函数的应用,一个事件能够与多个回调函数相关联,一个回调函数又称为事件监听器。当使用emit()发布事件后,消息会传递给注册的事件监听器都会被执行,并且监听器能够方便的添加或者删除,这样能够实现事件和具体逻辑的解耦。

事件发布/订阅自身没有同步和异步的概念,emit()基于事件循环的概念而异步触发的。那么可以理解为发布/订阅模式应用于异步编程。

事件发布/订阅模式模式主要用于业务的解耦,事件的发布者不用关心订阅的监听者的业务逻辑是什么,有多少个监听者,数据可以通过消息的方法灵活的流转。可以将一个流程中不变的封装成一个个组件,容易变化的暴露出去给外部处理,可以理解为事件的设计是组件的接口设计。

从另外一个角度上来看,事件监听也是一种钩子(hook)模式,Node中大多数对象都是黑盒,可以通过事件将对象在运行状态的状态通过事件传递出来。

var options = {
    host: 'www.google.com',
    port: 80,
    path: '/upload',
    method: 'POST'
}

var req = http.request(options,function(res){
    console.log('STATUS' + res.statusCode);
    console.log('HEADERS:' + JSON.stringify(res.headers));

    res.setEncoding('utf8');
    res.on('data',function(chunk){
        console.log('BODY:'+chunk);
    });

    res.on('end',function(){
        // TODO
    })
    req.on('error',function(e){
        console.log('problem with request:' + e.message);
    })
    req.write('data\n');
    req.write('data\n');
    req.end();
})

在这段http请求中,我们只需要将重点放在error,data,end事件上,业务流程则不需要过于关注。

下面有两个基于健壮性考虑的细节

  • 如果事件的监听器超过10个,将会获得一条警告,初衷是担心导致内存泄露。可以通过emitter.setMaxListeners(0)关闭这个限制。
  • 为了处理异常,当事件处理中发生了一个异常,实例会将这个异常传递给已经存在error监听者,如果不存在异常监听者进行捕获,这个异常将会向外抛出,最后如果没有被捕获,则会导致线程推出,一个健壮的EventEmitter应该有异常监听者。

集成events模块

实现继承一个EventEmitter类也很简单

var events = require('events');

function Stream(){
    events.EventEmitter.call(this);
}
util.inherits(Stream,events.EventEmitter);

使用util轻松的继承的EventEmitter事件,通过事件来解决业务问题,Node中核心模块有一半的对象继承了EventEmitter对象。

利用事件队列解决雪崩问题

在事件订阅/发布模式中,存在once()方法,事件和监听器关联,只会被执行一次,之后就会解除关联。这个可以帮助我们过滤掉一些重复性的事件响应。

在计算机中,将缓存存储在内存中加快数据的读取。雪崩问题指的是当高访问量时,大并发量的情况下,缓存失效。此时大量请求涌入数据库,导致网站整体的性能。

var select = function(callback){
    db.select("SQL",function(results){
        callback(results);
    })
}

如果站点刚好启动,此时缓存不存在数据,但是如果访问量巨大时,同一条sql会在数据库中反复查询,会影响服务的整体性能。

可以添加一个状态锁。

var status = "ready";
var select = function(callback){
    if(status === "ready"){
        status = "pending";
        db.select("SQL",function(results){
            status = "ready";
            callback(results);
        })
    }
}

此时连续调用多次,只有第一条SQL执行成功,后续的SQL则是失效的。

这个时候可以引入队列服务。

var proxy = new events.EventEmitter();
var status = "ready";
var select = function(callback){
    proxy.once("selected",callback);
    if(status === "ready"){
        status = "pending";
        db.select("SQL",function(results){
            proxy.emit("selected",results);
            status = "ready";
        })
    }
}

这里我们利用了once()的特性,将所有的回调都压入事件队列中。在相同的SQL完成时,将得到的结果被所有的回调共同调用,所有回调都只会运行一次,执行完后就会被销毁。并且由于Node单线程的原因,也不用担心数据同步的问题。这个也能应用到其他远程调用的场景,即使外部没有缓存策略,也能节省重复开销。

不过此处可能会因为监听器过多而产生警告,需要调用setMaxListeners(0)移除掉警告,或者设置更大的警告阙值。

以上知识点均来自<<深入浅出Node.js>>,更多细节建议阅读书籍:-)

3 回复

楼主的坚持让我佩服,不要灰心,写文章大部分时候不是为了给别人看而是给自己看的,万事开头难,加油。

谢谢楼上大佬们的鼓励:)

回到顶部