38

接触前端也有一段时间了,逐渐开始接触Node.js,刚刚接触Node.js的时候一直都以为Node.js就是JavaScript,当对Node.js有一定的了解之后,其实并不然两者之间有关系,其中的关系又不是必然的,对Node.js进行的一些了解,对其进行一些概述,本篇文章并没有对Node.jsAPI进行讲解,而是能够更加的明白Node.js是什么。

到底什么是Node.js

先看一下Node.js官网中是如何形容Node.js的,打开官网看到的第一句话就是Node.js® is a JavaScript runtime built on Chrome's V8 JavaScript engine.(Node.js是一个JavaScript运行时建立在Chrome的V8 JavaScript引擎。)在上面这段话中最重要的一点就是运行时

到底什么是运行时呢?其实在笔者看来运行时就是程序在运行时所需要的组件,可以将其想象成为是一种编程语言的运行环境。然而这个运行环境包含了代码运行时所需要的解释器和底层操作系统的支持等。

文章开头也说过Node.jsJavaScript之间有关系,但是其关系也不是必然,到这里大概也就有点眉目了。对于任何语言来说,其中最终要的就是其解释器如何去处理这些编程语言。Node.js的底层是使用C++实现的,然而语法则是遵循ECMAScript规范,其实完全可以把其实现换乘一种新的编程语言,更换语言的同时也就意味着其解释器发生了翻天覆地的变化。

Node.js为什么要选择JavaScript

到了这里可能有些疑问,编程语言和解释器有关系,那么为什么要选择了JavaScript然而不是其他的语言呢?Node.js作者(Ryan Dahl)说,在创造Node.js的时,其目的是为了实现高性能的Web服务器,其看重的并不是JavaScript这门语言。但是他需要的事一种编程语言来实现其想法,这种编程语言不能带有任何的IO功能,并且需要良好的支持事件机制。说到这里感觉就是在说JavaScript这门语言(感觉就是天命之选,O(∩_∩)O哈哈~)。首先JavaScript完全满足上述的两个条件,然而就顺其自然的JavaScript就成了Node.js的主导者。

Runtime

上面一直提到的就是RuntimeRuntime是什么?运行时刻是指一个程序在运行(cc或者在被执行)的状态。也就是说,当你打开一个程序使它在电脑上运行的时候,那个程序就是处于运行时刻。在一些编程语言中,把某些可以重用的程序或者实例打包或者重建成为运行库。这些实例可以在它们运行的时候被链接或者被任何程序调用(节选自百度百科)。

其实对于开发者来说根本就不用去考虑其背后到底是怎样实现的,我们站在开发的角度来想一想,对于某一种语言的Runtime表示开发者可以在Runtime上运行某种语言所编写的代码,如果把这个概念扩大一下说的话,Chorome也是一个JavaScript运行时依赖于背后的JavaScript引擎来运行JavaScript代码而已。

其对应的Runtime可以对其编程语言进行一些拓展,比如在Node.js中的fs、Buffer就是其对ECMAScript的拓展,Runtime并不包含整个ECMAScript中的全部特性。反过来讲,就算一个特性没有体现在标准里,而大多数的运行时都支持它,也可以变成实际上的规范。通过上述所说我们可以理解到对于任何语言来讲我们无需对其底层的实现,所有的东西都依赖于其运行时的实现而已,运行时环境对其支持情况才能表现出其语言的特性。

同样的一段代码可能在浏览器端可以顺利执行,但是放到Node.js中不一定可以顺利执行,反之也是一样的,这样的就足可以说明上述问题了。

Node.js内部机制

Node.js中有几个很重要的关键词单线程,非阻塞异步IO,在笔者刚刚接触Node.js的时候,这几个词经常听到,有些懵懵懂懂不是太能理解。为了更好的了解其内部机制那么针对这些东西进行说明。

回调函数

为什么要说回调函数呢?对Node.js模块有一定了解的话Node.js中模块都是依赖于回调函数的,那么什么是回调函数呢?

回调函数就是一个通过函数指针调用的函数。如果你把函数的指针(地址)作为参数传递给另一个函数,当这个指针被用来调用其所指向的函数时,我们就说这是回调函数。回调函数不是由该函数的实现方直接调用,而是在特定的事件或条件发生时由另外的一方调用的,用于对该事件或条件进行响应。(节选自百度百科)。

上面说了一堆套话,其实回调函数就是讲一个函数作为参数传给另一个函数作为参数,并且该函数可以被执行。回调方法和主线程处于同一个线程,假设主线程发起了一个底层的系统调用,操作系统会执行这个系统调用,当这个系统调用完成之后则会再回到主进程去执行后续的方法。

Node.js中在操作过程中可能会有一个比较耗时的IO操作,当IO操作有了返回结果之后才会继续向下执行,其中在进行IO操作时就造成了代码的阻塞,在Node.js最初设计的时候已经考虑到了这一点,所以提出了异步函数回调函数的方式,也能实现高并发的处理。对于前端来讲Ajax就是一个异步回调函数,当发起请求时如果有后续代码会先向下继续执行,而不会等待期请求结果。

回调函数机制:

  1. 定义一个回调函数;
  2. 提供函数实现的一方在初始化的时候,将回调函数的函数指针注册给调用者;
  3. 当特定的事件或条件发生的时候,调用者使用函数指针调用回调函数对事件进行处理。
同步/异步

有关于同步/异步也搜索了一些文献,但是都是简简单单概括一下,没有细致的说明。所谓同步和异步其描述的事进程和线程的调用方式。因为Node.js的单线程,因此同个时间只能处理同个任务,所有任务都需要排队,前一个任务执行完,才能继续执行下一个任务,但是,如果前一个任务的执行时间很长,比如文件的读取操作或网络请求,后一个任务就不得不等着,拿文件的读取操作来说,当用户向后台读取大量的文件时,不得不等到所有数据都读取完毕才能进行下一步操作,后续程序只能在那里干等着,很有可能造成响应超时。因此,Node.js在设计的时候,就已经考虑到这个问题,主线程可以完全不用等待文件的读取完毕,可以先挂起处于等待中的任务,先运行排在后面的任务,等到文件的读取有了结果后,再回过头执行挂起的任务,因此,任务就可以分为同步任务和异步任务。

  1. 同步任务:同步任务是指在主线程上排队执行的任务,只有前一个任务执行完毕,才能继续执行下一个任务,当我们打开网站时,网站的渲染过程,比如元素的渲染,其实就是一个同步任务
  2. 异步任务:异步任务是指不进入主线程,而进入任务队列的任务,只有任务队列通知主线程,某个异步任务可以执行了,该任务才会进入主线程,当我们打开网站时,像图片的加载,音乐的加载,其实就是一个异步任务

上述所说同步调用指的是进程/线程发起调用后,一直等待调用结果返回后才会继续向下执行,但是对于Node.js来说虽然也是这样,但是并不代表的CPU在这段时间内也会一直等待,操作系统多半会切换到另一个进程/线程上等调用返回结果后在切回原有进程/线程。然而异步则恰恰相反,当发起异步调用时,进程/线程会继续向下执行,当调用返回结果后通过某种技术手段通知其调用者已经有其结果。

我们一直都在说的一句话就是JavaScript是一门异步语言,但是对于ECMAScript而言并没有对异步有明确的规范,其实是其解释器(Node.js或浏览器)的runtime的其他线程来实现的,这些并不是JavaScript这门语言本身的功能。

对于异步请参考:浅析JavaScript异步

阻塞/非阻塞

笔者在没有了解阻塞/非阻塞之前一直以为同步/异步与阻塞/非阻塞之间是没有区别的,然而现实就是这么的打脸,阻塞/非阻塞和同步/异步完全就是两组概念,他们之间没有任何的必然关系。很多人大概和我一样同步=阻塞异步=非阻塞,这种概念是完全不对的。

在了解阻塞与非阻塞之前首先要了解一下什么是IO操作,IO操作其实是内存与外部设备之间复制数据的过程。

在阻塞的情况,是会一直等待直到write完全部的数据再返回。这点行为上与读操作有所不同,究其原因主要是读数据的时候,通常刚开始我们并不知道要读的数据的长度,而是在数据的头部设置了一个长度,在读完指定长度的头部后,才知道整个要读的数据长度。如果一开始就贸然设置一个要读的数据长度,然后像阻塞的write那样去等读完,则很可能会造成死循环;而对于write,由于需要写的长度是已知的,所以可以一直再写,直到写完。不过问题是write是可能被打断造成write一次只write一部分数据,所以write的过程还是需要考虑循环write, 只不过多数情况下一次write调用就可能成功。

非阻塞写的情况,是采用可以写多少就写多少的策略。与读不一样的地方在于,有多少读多少是由网络发送端是否有数据传输到本地内核缓存为准。但是对于可以写多少是由本地的网络堵塞情况为标准的,在网络阻塞严重的时候,网络层没有足够的内存来进行写操作,这时候就会出现写不成功的情况,阻塞情况下会尽可能(有可能被中断)等待到数据全部发送完毕, 对于非阻塞的情况就是一次写多少算多少,没有中断的情况下也还是会出现write到一部分的情况。

其实用一句话来说讲的话,同步调用会造成进程的IO阻塞,而异步不会造成调用进程的IO阻塞。

单线程与多线程

Node.js并没有提供多进程的支持,这代表在程序中所编写的代码只能运行在当前进程中,用于运行代码的事件也是单线程进行的。开发者无法在一个独立进程中增加新的线程吗,但是可以派生出多个进程来达到必行完成任务。

进程

进程是指在操作系统中正在运行的一个应用程序

线程

线程是指进程内独立执行某个任务的一个单元。线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈)。

对于Node.js,如果说JavaScript的函数式编程方式使得其异步编程的思想对程序员展现得更自然,那么它背后的功臣Libuv,则为异步编程的实现提供了可能。

1141038-20180307094514784-1056185595.png

上图中从左往右分为两部分,一部分是与Network I/O相关的请求,而另外一部分是由File I/O, DNS Ops以及User code组成的请求。

从图中可以看出,对于Network I/O和以File I/O为代表的另一类请求,异步处理的底层支撑机制是完全不一样的。 对于Network I/O相关的请求, 根据OS平台不同,分别使用Linux上的epollOSXBSDOS上的kqueueSunOS上的event ports以及Windows上的IOCP机制。 而对于File I/O为代表的请求,则使用thread pool。利用thread pool的方式实现异步请求处理,在各类OS上都能获得很好的支持。Libuv团队为什么要选择thread pool的机制。基本上原因不外乎编码和维护复杂度太高、可支持的API太少且质量堪忧、技术支持较弱,而用thread pool则很好地避开了这些问题。

Node.js的异步调用时由Libuv来支持的,以readFile为例的话,读取文件的系统调用是由Libuv来完成的,Node.js只负责调用Libuv所提供的接口就可以了,等结果返回后在执行对应的回调方法。

并行与并发

自从Node.js出现后,JavaScript开始涉及后端领域,因为其出色的并发模型,被很多企业用来处理高并发请求。

与并发被同时提及到的还有并行,那么并行与并发有有什么区别?并行指在同一时间点同时执行,并发是指在同一时间片段同时执行,上面已将解释进程与线程,此时就可理解,进程之间相互独立,可实现并行,但线程不可以,多线程只能并发执行,实际还是顺执行,只是在同一时间片段,假似同时执行,CPU可以按时间切片执行,单核CPU同一个时刻只支持一个线程执行任务,多线程并发事实上就是多个线程排队申请调用CPUCPU处理任务速度非常快,所以看上去多个线程任务说并发处理。

并发指的是一个CPU在不同线程来回跳,然后你会看到两个线程抢夺CPU资源所以两个线程输出执行的顺序不固定。

Node.js中的并发任务处理:

  1. 每个Node.js进程只有一个主线程在执行程序代码,形成一个执行栈。
  2. 主线程之外,还维护了一个"事件队列"。当用户的网络请求或者其它的异步操作到来时,Node都会把它放到事件栈之中,此时并不会立即执行它,代码也不会被阻塞,继续往下走,直到主线程代码执行完毕。
  3. 主线程代码执行完毕完成后,然后通过事件循环,也就是事件循环机制,开始到事件栈的开头取出第一个事件,从线程池中分配一个线程去执行这个事件。

接下来继续取出第二个事件,再从线程池中分配一个线程去执行,然后第三个,第四个。主线程不断的检查事件队列中是否有未执行的事件,直到事件队列中所有事件都执行完了。

此后每当有新的事件加入到事件队列中,都会通知主线程按顺序取出交EventLoop处理。当有事件执行完毕后,会通知主线程,主线程执行回调,线程归还给线程池。

我们所看到的Node.js单线程只是一个JavaScript主线程,本质上的异步操作还是由线程池完成的,Node.js将所有的阻塞操作都交给了内部的线程池去实现,本身只负责不断的往返调度,并没有进行真正的I/O操作,从而实现异步非阻塞I/O,这便是Node.js单线程和事件驱动的精髓之处了。

总结

读完本篇文章应该对Node.js有了一个简单的认识,其中提到的EventLoop在本文章并没有进行解释,有时间会对其进一步说明。Node.js完成了它提供高度可伸缩服务器的目标。它使用了Google的一个非常快速的JavaScript引擎,即V8引擎。它使用一个事件驱动设计来保持代码最小且易于阅读。所有这些因素促成了Node.js的理想目标,即编写一个高度可伸缩的解决方案变得比较容易,其Node.js对于高并发的处理也有很好的支持,总之Node.js的强大之处还有很多仍然需要慢慢摸索。

文章中概念较多,大家可以作理解,最后感谢大家用这么长时间来阅读这篇文章,文章中如果有什么差错请在评论处提出,我会尽快做出改正。


Aaron
4k 声望6.1k 粉丝

Easy life, happy elimination of bugs.