-
Notifications
You must be signed in to change notification settings - Fork 10
Description
自从看了朴灵老师写的《深入浅出Nodejs》之后一直想总结一篇关于异步编程的文章,本文从I/O模型及浏览器线程调度来阐述Javascript异步编程的原理,里面还会涉及到Javascript执行环境。最后从简单的Ajax出发,来描述异步请求存在的问题及解决方案。
我们知道Javascript是运行在 单线程 上的,同时也被贴上了 异步、非阻塞 的标签,细心的你可能会发现,既然Javascrit是单线程,我们就不能通过创建子线程来处理异步的操作。程序按顺序执行,执行完上一个程序段后,才能进入下一段的执行环境,所以何来的 异步,非阻塞 呢?既然不是异步,难道是同步的?有点糊涂了,那么到底该如何理解同步/异步,阻塞/非阻塞呢?
我们之所以认为Javascirpt是异步/非阻塞,主要是源于 ajax应用,当我们创建ajax请求后,Javascript不会等到返回结果,而是直接执行下一个程序,同理 定时器函数 也是非阻塞操作。
那么到底什么是同步/异步,阻塞/非阻塞呢,他们有什么区别吗?
- 简单的说阻塞就是让线程挂起(sleep)交出CPU的使用权,所以不要认为
while循环就是阻塞操作;而非阻塞则是线程一直处于执行活动状态,而刚才说的while循环就是非阻塞,因为程序一直在执行; - 同步指的程序段按顺序依次执行,每个程序段必须完全执行完成获取结果后才能进入下一段程序;而异步则不必关注当前执行的程序段是否返回最终结果,请求或者调用发出后而直接执行下一程序,而执行结果在随后又内核机制(如Event Loop)来通知当前线程。
上面介绍了同步/异步,阻塞/非阻塞的区别,而实际上这些都是针对IO操作来讲的(定时器除外)。Richard Stevens在 《Unix Network Programming Vol1 1002003, 3Ed》- $6.2 I/O Models 中将Linux的I/O模型分为: 阻塞I/O,非阻塞I/O,I/O多路复用,信号驱动I/O,异步IO/AIO 5种,其中前4种( blocking I/O,nonblocking I/O,I/O multipexing,signal driven I/O )都是阻塞的,唯独将第五种asynchronous I/O是非阻塞的。奇怪的是为什么把nonbocking I/O归纳为阻塞I/O呢? Richard Stevens认为只有进行真正的I/O调用时,非阻塞才是真正意义上的非阻塞,而 nonblocking I/O,mutipexing I/O,signal driven I/O 这三种模型虽然在开始阶段都是非阻塞的,但是在真正的I/O调用时确是阻塞的,所以认为这些都是 阻塞操作 见下图。
不同的是IBM DeveloperWorks Linux文档库中《使用异步I/O大大提升应用程序性能》并未将同步,异步,阻塞,非阻塞分开定义,而将其组合在一起分为4种:
但是不管是如何定义,原理都是一样的。为了便于理解简单的说明一下这些I/0模型。
I/O模型
1. block I/O
应用程序发出I/O系统调用后挂起(sleep)切换到内核态,当系统调用完成后,系统会唤醒应用程序。
2. nonblocking I/O
和nonblocking I/O相比,应用程序会循环不停的执行系统调用。开始阶段由于数据没有准备好,会在每次调用时返回一个错误的代码,直到系统准备好数据后,这时系统会调用read操作,当前应用程序阻塞挂起,止到调用完成返回。
3. I/O mutiplexing
在nonblocking I/O的基础上,将初始阶段的循环调用交给阻塞select系统来做,select会判断fd的事件状态。这样的好处是select可以支持更多的连接数(32位-32_32,64位:64*32),也就是所谓的多路复用。而poll的原理亦是如此,只不过由于poll采用链表的结构,连接数不受限制。但是不管是poll还是select由于从 _用户态* 向 内核态 切换的开销,所以并不是支持的连接数越多越好,内核还要遍历所有的fd判断状态。关于read,select,poll还有后来epoll的区别,可参阅:http://blog.sina.com.cn/s/blog_8fa7dd41010153zx.html
4. signal driven I/O
信号模型更像异步I/O,在初始化阶段,系统调用后返回,应用程序可以执行别的操作,当数据准备好后,内核会通知应用程序,进行read操作,此时应用程序会挂起,止到完成。
5. asynchronous I/O - AIO
而异步I/O当系统准备好数据后,会直接将数据通过 信号 或者callback函数 直接从内核传递到用户空间,中间不存在二次系统调用的过程,所以是真正的非阻塞。
Javascript是异步非阻塞模型,只不过不同的是,内核会将数据传递给浏览器的消息队列,然后通过一种叫 事件循环 的方式通知Javascript线程的。很明显这个循环函数不可能在Javascript线程中,否则别的代码没法执行了。事件循环 是由宿主(浏览器)提供的,尽管Javascript是单线程,但是浏览器确是多线程的,不同线程可以负责不同的任务,线程之间可以通讯(线程通讯的常用方式:管道,套接字,信号,信号量,共享内存,消息队列 )。不同线程通过 消息队列 进行通讯,利用 事件循环 查看消息队列中是否有待执行的消息,分配给不同的线程执行,从而实现异步的。
既然 事件驱动 是有浏览器提供,那么可以认为 事件循环 是浏览器中得一个单独的线程,用于循环读取 消息队列 是否有待执行的message。
浏览器的事件循环类似于这样:
while(queue.waitForMessage()){
queue.processNextMessage();
}
如果queue队列中没有消息,queue.waitForMessage会循环的等待直到有新的消息,然后将消息传递到stack中执行。
浏览器的主要线程包括:Javascript线程,UI渲染线程,定时触发线程,事件触发线程,web请求线程等。参见:JavaScript Event Loop 浅析。
这些线程中有些是互斥的,例如Javascript会改变页面的DOM结构和样式而导致重新渲染,所以当进行UI渲染时Javascript线程会挂起,反之亦然 - 关于进程/线程可以参考 《计算机的心智操作系统之哲学原理》 。
前面谈了这么多暂时先总结一下:当执行异步操作时 - 异步HTTP请求、定时函数,或者点击事件,浏览器会根据调用,触发不同的操作。不同的线程执行完后会将回调函数和数据,放到消息队列Queue中,然后浏览器事件循环会等到Javascript线程的执行stack中没有活动对象时,将其push到stack中执行。下面详细的说明一下关于 Javascript执行环境
Javascript的执行环境
先来看看Javascript的内存实现,这里引用MDN的Javascript引擎的内存实现模型:
Stack为JS 可执行代码 提供执行环境;Heap存放Object类型数据(而primitive类型在执行的过程中直接存放在Stack中);而queue则用于存放异步调用所产生的message的队列。
ECMAScript中约定Javascript的可执行代码:Global code,Eval code,Function code(具体参照 ECMAScript - Executable Code and Execution Contexts)。当页面加载后,Javascript首先会创建 全局执行环境,初始化所有的全局变量(undefined),绑定当前执行环境的this对象(window),之后开始顺序执行 赋值或者调用。同理当调用函数时会在stack中为其创建执行环境 - LexicalEnvironment,VariableEnvironment,thisBinding(LexicalEnvironment和VariableEnvironment实际上就是作用域scope)绑定this对象,当函数执行完毕后就会从stack中退出。如果函数中包含另外一个函数的调用,Javascript会在当stack上面创建一新的执行环境,待执行完后退出。然后父函数接着执行,直到执行结束后退出。
需要说明的是,一般情况下认为LexicalEnvironment和VariableEnvironment是一样的,他们的区别是,如果函数中包含另外一个函数,而这个函数可以修改其外部函数的变量的值时,那么当外部作用域指向的变量被修改的时,LexicalEnviroment就会随之改变,而VariableEnvironmen不会改变,因为这个值是在进入执行环境后就被定义好了,不会改变。thisBinding则指定了函数执行过程中this关键字指向的对象。
下图展示了执行环境的结构,具体可参阅: What is the Execution Context & Stack in JavaScript?
下面通过一个例子来详细说明Javascript的执行过程。
alert(a);//undefined
alert(b);//undefined
alert(c);//function c(){var a=3;return 2;}
alert(d);//undefined
alert(e);//undefined
var a = 1;
var b = function(){
var a = 2;
c();
}
function c(){
var a = 3;
return 3;
}
var d = {"a":4};
b();
var e = setTimeout(function(){
return 2;
},1000);
var f = 5;
开始Javascript会在stack中创建全局执行环境,程序在全局执行环境中首先初始化所有的全局变量,设置全局变量的值为undefined(function c(){}不是变量所以不会被设置成undifined,而b是变量所以会被设置成undefined),然后根据程序代码依次进行赋值或者调用 - 赋值时会根据value的类型选择是存放在stack中还是heap中;当调用函数b()时,会在stack中push一个新的执行环境 - 创建作用域绑定this对象,由于函数b中调用函数c,那么stack会为c这个函数创建一个新的执行环境,push到当前执行环境的上面执行,开始执行,运行完成后c退出,接着继续执行外部函数b退出。e函数是个定时函数,所以当执行时,会将其交给定时器线程,然后立马返回执行f赋值操作。而1秒钟后,event loop会将callback放入Javascript消息队列,待stack中没有活动对象时,从消息队列中取出执行。可以参考:《The JavaScript Event Loop: Explained》
异步I/O的问题
Javascript的执行是基于事件驱动的异步I/O,但是这个模型并非完美,根据之前的描述会出现下面这些问题:
计时器
setTimeout实际上等待的时间会大于设定的值,而setInterval在第一调用的时候会大于设定值,而之后的执行时却可能会小于设定的时间。
事件DOM渲染
当页面 局部渲染 的时候,比如说通过innerHTML改变某一元素的内容,这时会触发UI渲染线程,所以会在消息队列的后面添加一新的message待浏览器执行DOM更新操作。可是这个问题在哪呢?假如某个回调函数中包含以下代码:
elm.innerHTML = 'do something';
sleep(10);//等待10秒,可以通过while来模拟;
elm.innerHTML = 'Done';
这个时候会发现页面不会像预期那样:在出现do something10秒后显示为Done,而是直接显示Done。这主要是因为当调用innerHTML='do something'后会调用渲染线程会给消息队列添加一更新DOM的消息,然后是10秒后,innerHTML='Done'又会调用渲染线程给消息队列添加一新的消息,当执行消息队列时,Done会瞬间替换掉前面的do something。详见:JavaScript Event Loop 浅析
异步编程
关于这部分想了好久不知道该怎么写,感觉朴灵老师已经总结的相当全面了,我就不重复造车了,只是总结一下异步编程需要注意的问题。
先想想异步编程有什么问题?做前端开发平时接触到的异步编程主要的可能就是AJAX或者计时函数:
//Ajax - 这里引用封装好的jQuery为例
$.ajax(url,{
sucess:functon(){},
error:function(){}
})
//计时函数
window.setTimeout(callback,1000)
他们都包含一个callback函数(ajax中封装好的success,error),用于处理对应事件完成后的执行函数,看起来似乎没问题。那么假设Ajax请求需要3个参数,而这三个参数确实需要从另外3个请求中获取,那么该怎么办?最简单的方式可能是这样的:
$.ajax(url1,{
success:function(){
$.ajax(url2,{
success:function(){
$.ajax(url3,{
success:function(){
//... ...
}
})
}
})
}
})
虽然可以解决问题,但是代码看起来很糟糕一层叠加一层,对于和我一样有点代码洁癖的朋友估计都看不下去了,所以需要寻找其他的方式让代码看起来优雅点,方便管理和重复利用。另外还有个问题,上面的代码是执行完url1之后才执行url2,然后url3,如果这三个url请求没有相互依赖的话,这样不利于发挥异步请求效率的优势,这个时候可以同时执行这三个url的异步请求,然后通过一个变量来收集这三个请求的返回结果,等所有结构收到后在做其他操作。
我们需要一种模式来解决上面说的这些问题,朴灵老师在《深入浅出Nodejs》中列出了三种方式:pub/sub,promise和流程控制,而且还列出了4种典型基于流程的异步操作库。不管是pub/sub,promise还是流程,最后都是围绕事件设计的。对于前端开发来说,很少会用到promise模式,所以这里简单的说明一下,promise是在pub/sub的基础上的延伸:
构造函数Promise通过原型链上的then方法绑定事件,当异步调用响应时触发promise实例对象then方法所绑定的事件(emit('sucess'))。这么看来构造函数Deferred貌似没有存在的必要,然而如果省去了Deferred,异步调用中就需要调用Promise内部定义的事件,这样就耦合的有点厉害了。图中Promise是通过给原型方法then自定义事件来绑定callback函数的,实际上也不一定非得通过自定义事件这种模式,这只是一种实现方式而已,不要因此禁锢了思想。










