阅读 2808

小邵教你玩转nodejs之nodejs概念、事件环机制(1)

前言:大家好,我叫邵威儒,大家都喜欢喊我小邵,学的金融专业却凭借兴趣爱好入了程序猿的坑,从大学买的第一本vb和自学vb,我就与编程结下不解之缘,随后自学易语言写游戏辅助、交易软件,至今进入了前端领域,看到不少朋友都写文章分享,自己也弄一个玩玩,以下文章纯属个人理解,便于记录学习,肯定有理解错误或理解不到位的地方,意在站在前辈的肩膀,分享个人对技术的通俗理解,共同成长!

后续我会陆陆续续更新javascript方面,尽量把javascript这个学习路径体系都写一下
包括前端所常用的es6、angular、react、vue、nodejs、koa、express、公众号等等
都会从浅到深,从入门开始逐步写,希望能让大家有所收获,也希望大家关注我~

文章列表:juejin.im/user/5a84f8…

Author: 邵威儒
Email: 166661688@qq.com
Wechat: 166661688
github: github.com/iamswr/


接下来会写nodejs连载的笔记,本文主要是讲nodejs解决了什么问题有什么优势、进程与线程的概念、同步与异步的概念、阻塞与非阻塞的概念、队列和栈的概念、宏任务和微任务以及非常重要的浏览器的事件环和nodejs的事件环(event loop)。


Node解决了什么问题,有什么优势?

我们前端和后端交互,主要是请求个接口或者让后端返回个页面,频繁进行io操作,web服务最大的瓶颈就是处理高并发(同一时间并发访问服务器的数量),而node则在高并发、io密集型的场景,有明显的优势。

i/o密集型是指文件操作、网络操作、读取等操作;
cpu密集型则是指需要进行大量的逻辑处理运算、加解密、压缩解压等操作;

node是什么?

Node.js是一个基于 Chrome V8 引擎的JavaScript运行环境(runtime)。

虽然node是用javascript的语法,但是并非是完全的javascript,我们知道javascript是包含了ECMAScript、DOM、BOM,而node则不包含DOM和BOM,但是它也提供了一系列模块供我们使用,如http、fs模块。

node是使用了事件驱动非阻塞式 I/O的模型,使其轻量而且高效,我们在开发中,会大量接触到node的第三方模块包,以及node拥有全球最大的开源库生态系统。

事件驱动:发送事件后,通过回调的消息通知机制通知;
非阻塞式 I/O:如操作文件,通过非阻塞异步的方式读取文件;


进程和线程

假设我们使用java、php等服务器

一般启服务器用tomcat(apache)、iis,属于多线程同步阻塞,然后启动服务的时候会配置线程数。

mysql、mongo、redis等则是数据库。

一般是从客户端发起请求给服务器,服务器操作数据库,然后数据库把数据返给服务器,服务器再返回给客户端。

当客户端发起请求到服务器时,服务器会有线程来处理这条请求,假如是tomcat iis等是属于多线程同步阻塞的,在起服务的时候会配置线程数,然后再通过服务器发送请求到数据库请求数据,此时该线程会一直等待数据库的数据返回,当数据库返回数据给服务器后,服务器再把数据返回给客户端。

当并发量很大时,请求超过线程数时,则排在后面的请求会等待前面的请求完成后才会执行,线程完成后,并不是马上销毁,再创建,而是完成上一次请求后,会被复用到下一个请求当中。

图中显示的外层方形,为进程,内层方形为线程,一个进程可以分配多个线程,我们实际开发中,一个项目一般是多进程。

那什么是进程?进程是操作系统分配资源和调度任务的基本单位,线程是建立在进程上的一次程序运行单位,一个进程上可以有多个线程。

假如我们使用node

那么nodejs,我们说了是单线程,并不是说一个进程里面只能跑一条线程,而是主线程是单线程,node是如上图这样的。

当客户端同时发送请求时,第一条请求发送到服务器后会有一条线程处理,服务器会请求数据库,此时线程并不像上面那种方式,在等待数据的返回,该线程而是去处理第二条请求,当数据库返回第一条数据时,该线程再通过callback、事件环等机制执行。

虽然node是单线程,但是可以通过setTimeout开启多个线程,当并发量很高的时候可以这样玩。

但是node并不是什么场景都能使用的,对于cpu密集型的场景,反而不太实用,cpu密集型是指需要大量逻辑、计算,比如大量计算、压缩、加密、解密等(这部分c++优势大),node比较适合的是io操作(io密集),为什么说node适合前端?因为前端主要就是请求个接口,或者服务器渲染返回页面,所以node会非常适合前端这种场景。

我们常用node作为中间层,客户端访问node,然后由node去访问服务器,比如java层,java把数据返回给node,node再把数据返回给客户端。

我们常见的java是同步多线程,node是异步单线程,如果说java的并发量是1000万,那么node并发量可以达到3倍以上。

那么我们接下来了解一下经常和前端打交道的浏览器进程、线程

  • User Interface(用户界面 进程):如地址栏、标签、前进后退等;
  • Browser engine(浏览器引擎 浏览器的主进程):在用户界面和渲染引擎之间传达指令;
  • Data Persistence(持久层 进程):存放cookies、sessionStorage、loaclStorage、indexedDB等;
  • Rendering engine(渲染引擎 进程):渲染引擎内部是多线程的,其中有Networking(ajax请求)、JavaScript Interpreter(js线程)、UI Backend(UI线程)

在渲染引擎中,需要注意的是,在其内部有两个非常重要的线程,就是js线程和ui线程,js线程和ui线程是互斥的,共用同一条线程。

那么为什么js线程和ui线程是互斥的?为什么是单线程?我们可以设想一下,当我们通过js操作一个DOM节点的时候,如果同时执行,那么就存在快慢之分,会显得很混乱,再设想一下,如果是多线程的话,多条线程操作同一个DOM节点,是不是也显得很混乱?所以js设计为单线程。

衍生一下,使用java时,如果多线程访问某一个同样的资源,往 往会给这个资源加一把锁,有点类似下课了,多个同学上厕所,而厕所只有一间,先进去的人把门锁上了,后面的人只能排队,但是nodejs就基本不用担心这个问题。

js单线程指的是js的主线程是单线程。

浏览器中还有其他线程
  • 浏览器事件触发线程
  • 定时器触发线程
  • 异步HTTP请求触发线程

异步和同步、阻塞和非阻塞

主要分为以下几类组合

  • 同步阻塞
  • 异步阻塞
  • 同步非阻塞
  • 异步非阻塞

假设调用方为小明,被调用方为小红 图1:小明喜欢小红,小明于是乎决定给小红打电话表白,小红接电话,如果此时小红把电话晾在那,小明则有两种状态,一种是阻塞、一种是非阻塞,阻塞就是小红晾电话的同时,小明还在等着,叫做阻塞,非阻塞就是小红晾电话的同时,小明可以去干别的事情,叫做非阻塞,小红接电话后说,我要想一想再给你答复,此时如果没挂掉电话,那么是在同步,如果挂掉电话一会再告诉小明,那么就会是异步。

图2:当小红接电话后,说想一想,一会再告诉你结果,然后把电话挂了,此时属于异步,然后小明如果还在痴情地等待电话回复(即2.1),那么称为阻塞,如果小明此时并不是干等这个答复,而是打电话向另外一个妹子表白(即2.2),那么称为非阻塞,结合起来就是异步堵塞或异步非堵塞。

图3:当小红接电话后,说想一想,一会再告诉你结果,然后电话也不挂,一直通话,此时属于同步,但是小明此时偷偷向另外一个妹子打电话表白,这个行为属于非堵塞,结合起来就是同步非阻塞。


队列和栈

  • 队列的特点:队列的特点是先进先出,如数组,依次往后添加。

  • 栈的特点则是先进后出

首先我们往栈里分别放1、2、3进去,然后取出时,是按照3 2 1取出

function a() {
  function b() {
    function c() {

    }
    c()
  }
  b()
}

a()

// 这个代码中,我们是依次执行了a函数、b函数、c函数
// 但是在销毁的时候,是先从c函数销毁,然后再销毁b函数
// 最后销毁a函数,如果是先销毁a函数的话,那么b就会失去了其执行栈
// 也就是执行上下文,所以在执行栈中,是先进后出。
复制代码

宏任务、微任务(都属于异步操作,暂时以浏览器事件环机制来讲)

大家都知道异步,但是在异步当中,又分为两大类,即宏任务、微任务,在浏览器事件环当中,微任务是在宏任务执行之前执行的。

常见的宏任务:

  • setTimeout
  • setImmediate(只有ie支持)
  • setInterval
  • messageChannel

常见的微任务:

  • Promise.then()
  • mutationObserver
我们在使用vue的时候,有一个nextTick方法,意思是把一个方法插入到下一个队列当中,我们可以看看它的源码是怎样实现的

源码:github.com/vuejs/vue/b…

if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else if (typeof MessageChannel !== 'undefined' && (
  isNative(MessageChannel) ||
  // PhantomJS
  MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
  const channel = new MessageChannel()
  const port = channel.port2
  channel.port1.onmessage = flushCallbacks
  macroTimerFunc = () => {
    port.postMessage(1)
  }
} else {
  /* istanbul ignore next */
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}
复制代码

在这段代码中,可以看出vue的nextTick对于宏任务的处理,首先是判断是否有setImmediate,如果没有的话,则判断是否有MessageChannel,如果还没有的话,最后降级为setTimeout。

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  microTimerFunc = () => {
    p.then(flushCallbacks)
    if (isIOS) setTimeout(noop)
  }
} else {
  // fallback to macro
  microTimerFunc = macroTimerFunc
}
复制代码

在这段代码中,可以看出vue的nextTick对于微任务的处理,首先是判断是否有Promise,如果没有Promise的话,则降级为宏任务。

setImmediate

接下来我们看下这段代码如何执行(注意setImmediate需要ie浏览器打开)

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
</head>
<body>
  <script>
    setImmediate(function(){
      console.log('我是setImmediate')
    },0)

    Promise.resolve().then(function(){
      console.log('我是Promise')
    })
    console.log('我是同步代码')
  </script>
</body>
</html>
复制代码

依次打印出 '我是同步代码' -> '我是Promise' -> '我是setImmediate'
可以看出执行顺序是先执行同步代码,然后微任务的代码,然后宏任务的代码。

MessageChannel

以下代码依次打印出 "我是同步代码" -> "hello swr",因为MessageChannel也是宏任务。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
</head>
<body>
  <script>
    let messageChannel = new MessageChannel()
    let port1 = messageChannel.port1
    let port2 = messageChannel.port2
    port1.postMessage('hello swr')
    port2.onmessage = function (data) {
      console.log(data.data)
    }
    console.log('我是同步代码')
  </script>
</body>
</html>
复制代码

MutationObserver

MutationObserve主要是用于监控DOM节点的更新,比如我们有个需求,希望插入DOM完成后,才执行某些行为,我们就可以这样做。

首先会打印"我是同步代码",然后执行两个for循环插入dom节点,最终dom节点更新完毕后,会打印“插入完成 100”。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
</head>
<body>
  <div id='div'>
    
  </div>
  <script>
    let observe = new MutationObserver(function(){
      // 这一步之所以打印出p的数量,是为了验证插完dom节点后,才执行这一步。
      console.log('插入完成',document.querySelectorAll('p').length)
    })
    observe.observe(div,{childList:true})
    console.log('我是同步代码')
    for(let i = 0 ;i < 50;i++){
      div.appendChild(document.createElement('p'))
    }
    for(let i = 0 ;i < 50;i++){
      div.appendChild(document.createElement('p'))
    }
  </script>
</body>
</html>
复制代码

宏任务和微任务如何执行

首先我们看一段代码

setTimeout(() => {
  console.log('我是setTimeout1')
  Promise.resolve().then(()=>{
    console.log('我是Promise1')
  })
}, 0);

Promise.resolve().then(()=>{
  console.log('我是Promise2')
  setTimeout(() => {
    console.log('我是setTimeout2')
  }, 0);
  Promise.resolve().then(()=>{
    console.log('我是Promise3')
  })
})
复制代码

在这段代码中,我们要弄明白,什么是宏任务,什么是微任务,
setTimeout是宏任务,而Promise.resolve().then()是微任务。

还有个概念就是特别需要注意的,这也是和node.js有所区别,
在浏览器事件环机制中,当执行栈的同步代码清空后,系统会去读取任务队列,其中会优先读取微任务,把微任务清空后,再依次读取宏任务,这里特别注意,并非一次性执行完所有宏任务,而是像队列那样,先取一个宏任务执行,执行完后,再去看是否有微任务,如果有,则执行微任务,然后再读取一个宏任务执行,不断循环。

这里需要注意的是,在nodejs的事件环机制中,是优先执行微任务,但是当执行完微任务,进入宏任务的时候,即使在执行宏任务过程中存在新的微任务,也不会优先执行微任务,而是把宏任务队列中执行完毕。

首先,代码会从上到下执行,碰到setTimeout1,会丢到宏任务队列中,然后往下执行遇到Promise2,那么在执行栈执行完毕后,会优先执行Promise2,打印出“我是Promise2”,执行了Promise2后,发现里面有个setTimeout2,此时会把setTimeout2丢到宏任务队列中,然后继续往下执行,会碰到Promise3,此时会把Promise3丢到微任务中,并且执行,打印出“我是Promise3”,然后此时微任务队列执行完毕了,会去宏任务中读setTimeout1出来执行,打印出“我是setTimeout1”,再继续执行,发现里面有个Promise1,那么此时会把Promise1丢到微任务中,并且执行Promise1,打印出“我是Promise1”,此时微任务队列又清空了,再去宏任务队列中取出setTimeout2并且执行,打印出“我是setTimeout2”。

打印顺序为'我是Promise2' -> '我是Promise3' -> '我是setTimeout1' -> '我是Promise1' -> '我是setTimeout2'


事件环之浏览器的事件环Event Loop

这个图就是浏览器的事件环,JS中分为两部分,为堆(heap)和栈(stack),一般栈,我们也可以成为执行栈、执行上下文,我们在栈中操作的时候,会发一些比如ajax请求操作、定时器等,可以看图中的WebAPIs,是属于多线程,那么这个WebAPIs多线程是怎样放到栈中执行呢?

比如ajax请求成功后后,会把ajax的回调放到队列(callback queue)中,然后当执行栈中把所有的同步任务执行完毕后,系统会读取队列中的事件放到执行栈中依次执行,如果执行栈中有同步任务,那么则会执行同步任务中的任务后再依读取队列中的事件到执行栈中依次执行,这个过程是不断循环的。

总结:

  1. 所有的同步任务在主线程上执行,形成了一个执行栈;
  2. 如在执行栈中有异步任务,那么当这个异步任务有运行结果后,会放置任务队列中;
  3. 如果执行栈中的同步任务执行完毕,那么会从任务队列中依次读取事件到执行栈中依次执行;
  4. 执行栈从任务队列中读取事件的过程,是不断循环的;

举些例子验证

// 我们有3个setTimeout
setTimeout(() => {
  console.log('a')
}, 0);

setTimeout(() => {
  console.log('b')
}, 0);

setTimeout(() => {
  console.log('c')
}, 0);
console.log('hello swr')
// 首先会依次从上到下执行代码,遇到setTimeout异步事件,则放到WebAPIs中
// 如果有了结果(记住,是要有了运行结果!),则会放到任务队列中
// 然后会执行同步代码console.log('hello swr')
// 此时的流程是:
// 首先打印出 'hello swr',然后执行栈中同步代码已经执行完毕,则去任务队列中
// 依次取出这3个setTimeout的事件执行,依次打印出 'a' 'b' 'c'
// 这个顺序永远都不会乱,因为遵循了事件环的机制
复制代码

那么为什么说执行栈中的同步代码执行完后才会执行任务队列中的任务呢?
接下来我们可以看看这个例子,假如我们在同步代码中写了死循环,那么还会执行任务队列中的事件吗?

setTimeout(() => {
  console.log('a')
}, 0);

setTimeout(() => {
  console.log('b')
}, 0);

setTimeout(() => {
  console.log('c')
}, 0);
for(;;){} 
// 死循环,我们发现永远都不会打印出'a' 'b' 'c'
// 因为同步代码是死循环,一直处于执行状态,执行栈中的同步代码还没执行完毕
// 是不会去读取任务队列中的事件的
复制代码

事件环之nodejs的事件环Event Loop

nodejs也有它自己的事件环,和浏览器事件环的机制并非都一样的,我们写的应用代码一般是运行在V8引起里面,它里面并非仅仅是V8引擎里面的东西,比如setTimeout,比如eval都是V8引擎提供的,我们写代码还会基于一些node api,也就是node.js bindings,比如node的fs模块,可以发一些异步的io操作,但是node里面的异步和浏览器的不一样,它是自己有一套LIBUV库,专门处理异步的io操作的,它靠的是多线程实现的(worker threads),它用多线程模拟了异步的机制,我们每次调用node api的时候,它里面会进入LIBUV调用多个线程执行,同步堵塞调用,模拟了异步的机制,成功以后,通过callback执行放到一个队列里,然后返回给我们的客户端。

nodejs事件环,给每个阶段都划分得很清楚,因为nodejs里面有libuv库,里面有以上这几个方面,每一个都是一个队列。

在4中,会不断进行轮询poll中的i/o队列和检查定时器是否到时,如果是的话,会从把这个事件切换到1的队列中。

在5中,只存放setImmediate,如果4处于轮询时,发现有check阶段,那么就会往下走进入check阶段。

执行顺序

  • 首先执行完执行栈中的代码;
  • 如微任务中有事件,则执行微任务中的所有队列执行完毕;
  • 执行1中的队列;
  • 然后依次执行下一个队列,需要注意的是,这里对于微任务的处理,和浏览器事件环机制不同,比如node在执行1中的队列时,是依次执行完,哪怕中途有新的微任务,也不会执行微任务,而是当这个队列执行完毕后,切换到下一个队列之前,才执行微任务;
  • 队列之间的切换,会执行一次微任务;
  • 这个过程是不断循环的;

这段代码可以看出,在node的事件环中,当执行到1的队列中时,即使有新的微任务,也不会马上执行微任务,而是把当前的队列清空后才会执行微任务。

setTimeout(() => {
  console.log('setTimeout1')
  process.nextTick(()=>{
    console.log('nextTick')
  })
}, 0);

setTimeout(() => {
  console.log('setTimeout2')
}, 0);

// 依次打印输出 'setTimeout1' -> 'setTimeout2' -> 'nextTick'
复制代码

小伙伴提问区:

关注下面的标签,发现更多相似文章
评论