Dieser Artikel bietet Ihnen eine Zusammenfassung der Unterschiede zwischen Browsern und den Ereignisschleifen von Node. Ich hoffe, dass er Ihnen hilfreich sein wird.
In diesem Artikel werden wir das Prinzip der asynchronen Implementierung in JS vorstellen und verstehen, dass Event Loop in Browsern und Node tatsächlich unterschiedlich ist.
1. Threads und Prozesse
1. Konzept
Wir sagen oft, dass JS in einem einzelnen Thread ausgeführt wird. Das bedeutet, dass es in einem Prozess nur einen Hauptthread gibt. Was genau ist also ein Thread? Was ist ein Prozess?
Die offizielle Aussage lautet: Prozess ist die kleinste Einheit der CPU-Ressourcenzuweisung; Thread ist die kleinste Einheit der CPU-Planung. Diese beiden Sätze sind nicht leicht zu verstehen. Schauen wir uns zunächst das Bild an:
Der Prozess ist wie in der Fabrik Das Bild verfügt über separate und exklusive Fabrikressourcen.
Threads sind wie Arbeiter auf dem Bild. Die Beziehung zwischen der Fabrik und den Arbeitern ist 1:n. Das heißtEin Prozess besteht aus einem oder mehreren Threads, und Threads sind verschiedene Ausführungsrouten von Code in einem Prozess;
Der Raum der Fabrik wird gemeinsam genutzt von Arbeitern, was symbolisiert, dass der Speicherbereich eines Prozesses gemeinsam genutzt wird und jeder Thread diese gemeinsamen Erinnerungen nutzen kann .
Mehrere Fabriken existieren unabhängig voneinander.
Multiprozess: Gleichzeitig, wenn zwei oder mehr Prozesse zugelassen sind Auf demselben Computersystem werden mehr als zwei Prozesse ausgeführt. Die Vorteile mehrerer Prozesse liegen auf der Hand: Sie können beispielsweise einen Editor öffnen und Code eingeben, während Sie Musik hören, und die Prozesse des Editors und der Musik-Hörsoftware stören sich überhaupt nicht.
Multithreading: Das Programm enthält mehrere Ausführungsströme, d. h. mehrere verschiedene Threads können gleichzeitig in einem Programm ausgeführt werden, um unterschiedliche Aufgaben auszuführen, was bedeutet, dass ein einzelnes Programm dies tun darf Erstellen Sie mehrere Threads, die parallel ausgeführt werden, um ihre jeweiligen Aufgaben zu erledigen.
Nehmen Sie den Chrome-Browser als Beispiel. Wenn Sie eine Tab-Seite öffnen, kann es tatsächlich mehrere Threads in einem Prozess geben (weitere Details finden Sie weiter unten). Rendering-Thread, JS-Engine-Thread, HTTP-Anfrage-Thread usw. Wenn Sie eine Anfrage initiieren, erstellen Sie tatsächlich einen Thread. Wenn die Anfrage endet, wird der Thread möglicherweise zerstört.
Vereinfacht ausgedrückt holt sich der Browser-Kernel den Seiteninhalt, organisiert Informationen (unter Anwendung von CSS), berechnet und kombiniert sie und gibt schließlich ein visuelles Bildergebnis aus, das üblicherweise auch als „ für die Rendering-Engine.
Der Browser-Kernel ist multithreaded. Unter der Kontrolle des Kernels kooperiert jeder Thread miteinander, um die Synchronisierung aufrechtzuerhalten. Ein Browser besteht normalerweise aus den folgenden residenten Threads:
GUI-Rendering-Thread
JavaScript-Engine-Thread
Zeitgesteuerter Trigger-Thread
Ereignisauslöser Thread
Asynchroner HTTP-Anfragethread
Hauptverantwortlich für die Seite Rendern und Analysieren von HTML, CSS, Erstellen eines DOM-Baums, Layout und Zeichnen usw.
Dieser Thread wird ausgeführt, wenn die Schnittstelle neu gezeichnet werden muss oder wenn durch einen Vorgang ein Reflow verursacht wird.
Dieser Thread und der JS-Engine-Thread schließen sich gegenseitig aus. Wenn der JS-Engine-Thread ausgeführt wird, wird das GUI-Rendering angehalten. Wenn die Aufgabenwarteschlange inaktiv ist, führt die JS-Engine die GUI aus Rendern.
Dieser Thread ist natürlich hauptsächlich für die Verarbeitung von JavaScript-Skripten und die Ausführung von Code zuständig.
ist auch hauptsächlich für die Ausführung von Ereignissen verantwortlich, die zur Ausführung bereit sind. Das heißt, wenn die Timer-Zählung endet oder die asynchrone Anforderung erfolgreich ist und korrekt zurückgegeben wird, wird sie in die Aufgabenwarteschlange eingegeben wiederum und warten Sie auf die Ausführung des JS-Engine-Threads.
Natürlich schließt sich dieser Thread mit dem GUI-Rendering-Thread gegenseitig aus. Wenn der JS-Engine-Thread zu lange JavaScript-Skripte ausführt, wird die Seitenwiedergabe blockiert.
Ein Thread, der für die Ausführung von Funktionen wie asynchronen Timern verantwortlich ist, wie zum Beispiel: setTimeout, setInterval.
Wenn der Hauptthread den Code nacheinander ausführt und auf einen Timer trifft, übergibt er den Timer zur Verarbeitung an den Thread. Wenn die Zählung abgeschlossen ist, wird der Thread zum Auslösen des Ereignisses fügt die gezählten Ereignisse hinzu. Gehen Sie zum Ende der Aufgabenwarteschlange und warten Sie, bis der JS-Engine-Thread ausgeführt wird.
ist hauptsächlich dafür verantwortlich, vorbereitete Ereignisse zur Ausführung an den JS-Engine-Thread zu übergeben.
Wenn beispielsweise die setTimeout-Timer-Zählung endet, sind asynchrone Anforderungen wie Ajax erfolgreich und lösen die Rückruffunktion aus, oder wenn der Benutzer ein Klickereignis auslöst, fügt der Thread die Ereignisse bereit Warten Sie am Ende auf die Ausführung des JS-Engine-Threads.
Ein Thread, der für die Ausführung von Funktionen wie asynchronen Anfragen verantwortlich ist, wie zum Beispiel: Promise, Axios, Ajax usw.
Wenn der Hauptthread den Code nacheinander ausführt und auf eine asynchrone Anforderung stößt, wird die Funktion zur Verarbeitung an den Thread übergeben. Wenn sich der Statuscode ändert, wird eine Rückruffunktion überwacht , fügt der ereignisauslösende Thread die Rückruffunktion zum Ende der Aufgabenwarteschlange hinzu und wartet auf die Ausführung des JS-Engine-Threads.
Es gibt zwei Arten von asynchronen Warteschlangen im Ereignis Schleife: Makro-Warteschlange (Makroaufgabe) und Mikro-Warteschlange (Mikroaufgabe). Es kann mehrere Makroaufgabenwarteschlangen geben, aber nur eine Mikroaufgabenwarteschlange.
Gemeinsame Makroaufgaben wie: setTimeout, setInterval, setImmediate, Skript (Gesamtcode), E/A-Vorgänge, UI-Rendering usw.
Gemeinsame Mikroaufgaben wie: process.nextTick, new Promise().then (Rückruf), MutationObserver (neue HTML5-Funktion) usw.
Ein vollständiger Ereignisschleifenprozess kann in die folgenden Phasen zusammengefasst werden:
Der Ausführungsstapel ist am Anfang leer. Wir können uns den -Ausführungsstapel als eine Stapelstruktur vorstellen, die Funktionsaufrufe nach dem First-in-Last-Prinzip speichert. Unser Prinzip. Die Mikrowarteschlange ist leer und in der Makrowarteschlange befindet sich nur ein Skript (der Gesamtcode).
Der globale Kontext (Skript-Tag) wird in den Ausführungsstapel verschoben, um die Codeausführung zu synchronisieren. Während des Ausführungsprozesses wird beurteilt, ob es sich um eine synchrone Aufgabe oder eine asynchrone Aufgabe handelt. Durch den Aufruf einiger Schnittstellen können neue Makroaufgaben und Mikroaufgaben generiert und in ihre jeweiligen Aufgabenwarteschlangen verschoben werden. Nachdem der Synchronisierungscode ausgeführt wurde, wird das Skript aus der Makrowarteschlange entfernt. Dieser Prozess ist im Wesentlichen der Ausführungs- und Entnahmeprozess der Makroaufgabe in der Warteschlange.
Was wir im vorherigen Schritt aus der Warteschlange entfernt haben, war eine Makroaufgabe, und in diesem Schritt beschäftigen wir uns mit einer Mikroaufgabe. Es ist jedoch zu beachten, dass Aufgaben einzeln ausgeführt werden, wenn eine Makroaufgabe aus der Warteschlange entfernt wird. Bei einer Mikroaufgabe hingegen werden Aufgaben einzeln ausgeführt. Wenn wir die Mikrowarteschlange verarbeiten, führen wir daher die Aufgaben in der Warteschlange einzeln aus und entfernen sie aus der Warteschlange, bis die Warteschlange geleert ist.
Rendering-Vorgänge ausführen und die Schnittstelle aktualisieren
Überprüfen Sie, ob eine Web-Worker-Aufgabe vorhanden ist, und wenn ja, führe es aus Verarbeitung
Der obige Prozess wird in einer Schleife wiederholt, bis beide Warteschlangen geleert sind
Lassen Sie uns zusammenfassen: Jeder Zyklus ist ein Prozess wie dieser :
Wenn eine Makroaufgabe ausgeführt wird, wird geprüft, ob eine Mikroaufgabenwarteschlange vorhanden ist. Wenn dies der Fall ist, werden alle Aufgaben in der Mikrotask-Warteschlange zuerst ausgeführt. Wenn nicht, wird die oberste Aufgabe in der Makrotask-Warteschlange gelesen. Wenn während der Ausführung der Makrotask eine Mikrotask auftritt, wird diese zur Mikrotask-Warteschlange hinzugefügt wiederum. Nachdem der Stapel leer ist, lesen Sie die Aufgaben in der Mikrotask-Warteschlange erneut usw.
Als nächstes schauen wir uns ein Beispiel an, um den obigen Prozess vorzustellen:
Promise.resolve().then(()=>{ console.log('Promise1') setTimeout(()=>{ console.log('setTimeout2') },0) }) setTimeout(()=>{ console.log('setTimeout1') Promise.resolve().then(()=>{ console.log('Promise2') }) },0)
Die endgültigen Ausgabeergebnisse sind Promise1, setTimeout1, Promise2, setTimeout2
Nachdem die Synchronisierungsaufgabe zum Ausführen des Stapels am Anfang (dies ist eine Makroaufgabe) abgeschlossen ist, wird geprüft, ob eine Mikrotask-Warteschlange vorhanden ist, die in der obigen Frage vorhanden ist (es gibt nur eine), und dann werden alle ausgeführt Aufgaben in der Mikrotask-Warteschlange, um Promise1 auszugeben, und gleichzeitig wird eine Makroaufgabe setTimeout2
generiert und dann die Makroaufgabenwarteschlange überprüft, bevor setTimeout2, die Makroaufgabe setTimeout1 ausgeführt wird Zuerst wird die Makroaufgabe setTimeout1 ausgegeben.
Wenn die Makroaufgabe setTimeout1 ausgeführt wird, wird die Mikroaufgabe Promise2 generiert und in die Mikroaufgabenwarteschlange gestellt Die Mikroaufgabenwarteschlange wird zuerst gelöscht und Promise2 wird ausgegeben.
Nach dem Löschen der Mikroaufgabe befinden sich alle Aufgaben in der Warteschlange und werden zur Makroaufgabenwarteschlange weitergeleitet ein weiteres. Diesmal wird setTimeout2
Der Betriebsmechanismus von Node.js ist wie folgt:
V8 引擎再将结果返回给用户。
其中 libuv 引擎中的事件循环分为 6 个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。
从上图中,大致看出 node 中的事件循环的顺序:
外部输入数据-->轮询阶段(poll)-->检查阶段(check)-->关闭事件回调阶段(close callback)-->定时器检测阶段(timer)-->I/O 事件回调阶段(I/O callbacks)-->闲置阶段(idle, prepare)-->轮询阶段(按照该顺序反复运行)...
timers 阶段:这个阶段执行 timer(setTimeout、setInterval)的回调
I/O callbacks 阶段:处理一些上一轮循环中的少数未执行的 I/O 回调
idle, prepare 阶段:仅 node 内部使用
poll 阶段:获取新的 I/O 事件, 适当的条件下 node 将阻塞在这里
check 阶段:执行 setImmediate() 的回调
close callbacks 阶段:执行 socket 的 close 事件回调
注意:上面六个阶段都不包括 process.nextTick()(下文会介绍)
接下去我们详细介绍timers
、poll
、check
这 3 个阶段,因为日常开发中的绝大部分异步任务都是在这 3 个阶段处理的。
(1) timer
timers 阶段会执行 setTimeout 和 setInterval 回调,并且是由 poll 阶段控制的。
同样,在 Node 中定时器指定的时间也不是准确时间,只能是尽快执行。
(2) poll
poll 是一个至关重要的阶段,这一阶段中,系统会做两件事情
回到 timer 阶段执行回调
执行 I/O 回调
并且在进入该阶段时如果没有设定了 timer 的话,会发生以下两件事情
如果 poll 队列不为空,会遍历回调队列并同步执行,直到队列为空或者达到系统限制
如果 poll 队列为空时,会有两件事发生
如果有 setImmediate 回调需要执行,poll 阶段会停止并且进入到 check 阶段执行回调
如果没有 setImmediate 回调需要执行,会等待回调被加入到队列中并立即执行回调,这里同样会有个超时时间设置防止一直等待下去
当然设定了 timer 的话且 poll 队列为空,则会判断是否有 timer 超时,如果有的话会回到 timer 阶段执行回调。
(3) check 阶段
setImmediate()的回调会被加入 check 队列中,从 event loop 的阶段图可以知道,check 阶段的执行顺序在 poll 阶段之后。
我们先来看个例子:
console.log('start') setTimeout(() => { console.log('timer1') Promise.resolve().then(function() { console.log('promise1') }) }, 0) setTimeout(() => { console.log('timer2') Promise.resolve().then(function() { console.log('promise2') }) }, 0) Promise.resolve().then(function() { console.log('promise3') }) console.log('end') //start=>end=>promise3=>timer1=>timer2=>promise1=>promise2
一开始执行栈的同步任务(这属于宏任务)执行完毕后(依次打印出 start end,并将 2 个 timer 依次放入 timer 队列),会先去执行微任务(这点跟浏览器端的一样),所以打印出 promise3
然后进入 timers 阶段,执行 timer1 的回调函数,打印 timer1,并将 promise.then 回调放入 microtask 队列,同样的步骤执行 timer2,打印 timer2;这点跟浏览器端相差比较大,timers 阶段有几个 setTimeout/setInterval 都会依次执行,并不像浏览器端,每执行一个宏任务后就去执行一个微任务(关于 Node 与浏览器的 Event Loop 差异,下文还会详细介绍)。
(1) setTimeout 和 setImmediate
二者非常相似,区别主要在于调用时机不同。
setImmediate 设计在 poll 阶段完成时执行,即 check 阶段;
setTimeout 设计在 poll 阶段为空闲时,且设定时间到达后执行,但它在 timer 阶段执行
setTimeout(function timeout () { console.log('timeout'); },0); setImmediate(function immediate () { console.log('immediate'); });
对于以上代码来说,setTimeout 可能执行在前,也可能执行在后。
首先 setTimeout(fn, 0) === setTimeout(fn, 1),这是由源码决定的
进入事件循环也是需要成本的,如果在准备时候花费了大于 1ms 的时间,那么在 timer 阶段就会直接执行 setTimeout 回调
如果准备时间花费小于 1ms,那么就是 setImmediate 回调先执行了
但当二者在异步 i/o callback 内部调用时,总是先执行 setImmediate,再执行 setTimeout
const fs = require('fs') fs.readFile(__filename, () => { setTimeout(() => { console.log('timeout'); }, 0) setImmediate(() => { console.log('immediate') }) }) // immediate // timeout
在上述代码中,setImmediate 永远先执行。因为两个代码写在 IO 回调中,IO 回调是在 poll 阶段执行,当回调执行完毕后队列为空,发现存在 setImmediate 回调,所以就直接跳转到 check 阶段去执行回调了。
(2) process.nextTick
这个函数其实是独立于 Event Loop 之外的,它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行。
setTimeout(() => { console.log('timer1') Promise.resolve().then(function() { console.log('promise1') }) }, 0) process.nextTick(() => { console.log('nextTick') process.nextTick(() => { console.log('nextTick') process.nextTick(() => { console.log('nextTick') process.nextTick(() => { console.log('nextTick') }) }) }) }) // nextTick=>nextTick=>nextTick=>nextTick=>timer1=>promise1
浏览器环境下,microtask 的任务队列是每个 macrotask 执行完之后执行。而在 Node.js 中,microtask 会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行 microtask 队列的任务。
接下我们通过一个例子来说明两者区别:
setTimeout(()=>{ console.log('timer1') Promise.resolve().then(function() { console.log('promise1') }) }, 0) setTimeout(()=>{ console.log('timer2') Promise.resolve().then(function() { console.log('promise2') }) }, 0)
浏览器端运行结果:timer1=>promise1=>timer2=>promise2
浏览器端的处理过程如下:
Node 端运行结果:timer1=>timer2=>promise1=>promise2
全局脚本(main())执行,将 2 个 timer 依次放入 timer 队列,main()执行完毕,调用栈空闲,任务队列开始执行;
首先进入 timers 阶段,执行 timer1 的回调函数,打印 timer1,并将 promise1.then 回调放入 microtask 队列,同样的步骤执行 timer2,打印 timer2;
至此,timer 阶段执行结束,event loop 进入下一个阶段之前,执行 microtask 队列的所有任务,依次打印 promise1、promise2
Node 端的处理过程如下:
浏览器和 Node 环境下,microtask 任务队列的执行时机不同
Node 端,microtask 在事件循环的各个阶段之间执行
浏览器端,microtask 在事件循环的 macrotask 执行完之后执行
Das obige ist der detaillierte Inhalt vonZusammenfassung der Unterschiede zwischen Browser- und Node-Ereignisschleifen (Ereignisschleife). Für weitere Informationen folgen Sie bitte anderen verwandten Artikeln auf der PHP chinesischen Website!