我們知道JS語言是串列執行、阻塞式、事件驅動的,那麼它又是怎麼支援並發處理資料的呢?
"單執行緒"語言
#在瀏覽器實作中,每個單頁都是一個獨立進程,其中包含了JS引擎、GUI介面渲染、事件觸發、定時觸發器、非同步HTTP請求等多個執行緒。
行程(Process)是作業系統CPU等資源分配的最小單位,是程式的執行實體,是執行緒的容器。
執行緒(Thread)是作業系統能夠進行運算調度的最小單位,一條執行緒指的是一個單一順序在進程中的控制流。
因此我們可以說JS是"單執行緒"式的語言,程式碼只能按照單一順序進行串行執行,並在執行完成前阻塞其他程式碼。
【相關課程推薦:JavaScript影片教學】
#JS資料結構
#如上圖所示為JS的幾種重要資料結構:
● 堆疊(Stack):用於JS的函數巢狀調用,後進先出,直到堆疊被清空。
● 堆(Heap):用於儲存大塊資料的記憶體區域,如物件。
● 佇列(Queue):用於事件循環機制,先進先出,直到佇列為空。 事件循環
我們的經驗告訴我們JS是可以並發執行的,例如定時任務、並發AJAX請求,那這些是怎麼完成的呢?其實這些都是JS在用單線程模擬多線程完成的。
如上圖所示,JS串列執行主執行緒任務,當遇到非同步任務如定時器時,將其放入事件佇列中,在主執行緒任務執行完畢後,再去事件佇列中遍歷取出隊首任務執行,直到佇列為空。
全部執行完成後,會有主監控進程,持續偵測佇列是否為空,如果不為空,則繼續事件循環。 setTimeout定時任務
定時任務
會先交給瀏覽器的計時器模組,等延遲時間到了,再將事件放入到事件隊列裡,等主線程執行結束後,如果隊列中沒有其他任務,則會被立即處理,而如果還有沒有執行完成的任務,則需要等前面的任務都執行完成才會被執行。因此setTimeout的第2個參數是最少延遲時間,而非等待時間。
setTimeout(fn, 0);
然而考慮這麼一段程式碼會怎麼執行:<div class="code" style="position:relative; padding:0px; margin:0px;"><pre class="brush:php;toolbar:false">setTimeout(()=>{console.log(5)},5)
setTimeout(()=>{console.log(4)},4)
setTimeout(()=>{console.log(3)},3)
setTimeout(()=>{console.log(2)},2)
setTimeout(()=>{console.log(1)},1)
setTimeout(()=>{console.log(0)},0)</pre><div class="contentsignin">登入後複製</div></div>
了解完事件佇列機制,你的答案應該是0,1,2,3,4,5
,然而答案卻是
,這個是因為瀏覽器的實作機制是最小間隔為1ms。 <div class="code" style="position:relative; padding:0px; margin:0px;"><pre class="brush:php;toolbar:false">// https://github.com/nodejs/node/blob/v8.9.4/lib/timers.js#L456
if (!(after >= 1 && after </pre><div class="contentsignin">登入後複製</div></div>
瀏覽器以32位元bit來儲存延時,若大於
計時器的巢狀呼叫超過4層時,會導致最小間隔為4ms:
var i=0; function cb() { console.log(i, new Date().getMilliseconds()); if (i <blockquote>可以看到前4層也不是標準的立刻執行,在第4層後間隔明顯變大到4ms以上:</blockquote><pre class="brush:php;toolbar:false">0 667 1 669 2 670 3 672 4 676 5 681 6 685
對追蹤型腳本,如Google分析等,在目前頁面,依然是4ms的延時限制,而後台tabs為10s。 setInterval定時任務
此時,我們會知道,setInterval會在每個定時器延時時間到了後,將會一個新的事件fn放入事件佇列,如果前面的任務執行太久,我們會看到連續的fn事件被執行而感覺不到時間預設間隔。
因此,我們要盡量避免使用setInterval,改用setTimeout來模擬迴圈計時任務。 睡眠函數
###JS一直缺少休眠的語法,借助ES6新的語法,我們可以模擬這個功能,但同樣的這個方法因為借助了setTimeout也無法保證準確的睡眠延遲:###function sleep(ms) { return new Promise(resolve => { setTimeout(resolve, ms); }) } // 使用 async function test() { await sleep(3000); }
async函数是Generator函数的语法糖,提供更方便的调用和语义,上面的使用可以替换为:
function* test() { yield sleep(3000); } // 使用 var g = test(); test.next();
但是调用使用更加复杂,因此一般我们使用async函数即可。但JS时如何实现睡眠函数的呢,其实就是提供一种执行时的中间状态暂停,然后将控制权移交出去,等控制权再次交回时,从上次的断点处继续执行。因此营造了一种睡眠的假象,其实JS主线程还可以在执行其他的任务。
Generator函数调用后会返回一个内部指针,指向多个异步任务的暂停点,当调用next函数时,从上一个暂停点开始执行。
协程(coroutine)是指多个线程互相协作,完成异步任务的一种多任务异步执行的解决方案。他的运行流程:
● 协程A开始执行
● 协程A执行到一半,进入暂停,执行权转移到协程B
● 协程B在执行一段时间后,将执行权交换给A
● 协程A恢复执行
可以看到这也就是Generator函数的实现方案。
宏任务和微任务
一个JS的任务可以定义为:在标准执行机制中,即将被调度执行的所有代码块。
我们上面介绍了JS如何使用单线程完成异步多任务调用,但我们知道JS的异步任务分很多种,如setTimeout定时器、Promise异步回调任务等,它们的执行优先级又一样吗?
答案是不。JS在异步任务上有更细致的划分,它分为两种:
宏任务(macrotask)包含:
● 执行的一段JS代码块,如控制台、script元素中包含的内容。
● 事件绑定的回调函数,如点击事件。
● 定时器创建的回调,如setTimeout和setInterval。
微任务(microtask)包含:
● Promise对象的thenable函数。
● Nodejs中的process.nextTick函数。
● JS专用的queueMicrotask()函数。
宏任务和微任务都有自身的事件循环机制,也拥有独立的事件队列(Event Queue),都会按照队列的顺序依次执行。但宏任务和微任务主要有两点区别:
1、宏任务执行完成,在控制权交还给主线程执行其他宏任务之前,会将微任务队列中的所有任务执行完成。
2、微任务创建的新的微任务,会在下一个宏任务执行之前被继续遍历执行,直到微任务队列为空。
浏览器的进程和线程
浏览器是多进程式的,每个页面和插件都是一个独立的进程,这样可以保证单页面崩溃或者插件崩溃不会影响到其他页面和浏览器整体的稳定运行。
它主要包括:
1、主进程:负责浏览器界面显示和管理,如前进、后退,新增、关闭,网络资源的下载和管理。
2、第三方插件进程:当启用插件时,每个插件独立一个进程。
3、GPU进程:全局唯一,用于3D图形绘制。
4、Renderer渲染进程:每个页面一个进程,互不影响,执行事件处理、脚本执行、页面渲染。
浏览器的单个页面就是一个进程,指的就是Renderer进程,而进程中又包含有多个线程用于处理不同的任务,主要包括:
1、GUI渲染线程:负责HTML和CSS的构建成DOM树,渲染页面,比如重绘。
2、JS引擎线程:JS内核,如Chrome的V8引擎,负责解析执行JS代码。
3、事件触发线程:如点击等事件存在绑定回调时,触发后会被放入宏任务事件队列。
4、定时触发器线程:setTimeout和setInterval的定时计数器,在时间到达后放入宏任务事件队列。
5、异步HTTP请求线程:XMLHTTPRequest请求后新开一个线程,等待状态改变后,如果存在回调函数,就将其放入宏任务队列。
需要注意的是,GUI渲染进程和JS引擎进程互斥,两者只会同时执行一个。主要的原因是为了节流,因为JS的执行会可能多次改变页面,页面的改变也会多次调用JS,如resize。因此浏览器采用的策略是交替执行,每个宏任务执行完成后,执行GUI渲染,然后执行下一个宏任务。
因为JS只有一个引擎线程,同时和GUI渲染线程互斥,因此在繁重任务执行时会导致页面卡住,所以在HTML5中支持了Webworker,它用于向浏览器申请一个新的子线程执行任务,并通过postMessage API来和worker线程通信。所以我们在繁重任务执行时,可以选择新开一个Worker线程来执行,并在执行结束后通信给主线程,这样不会影响页面的正常渲染和使用。
总结
1、JS是单线程、阻塞式执行语言。
2、JS通过事件循环机制来完成异步任务并发执行。
3、JS将任务细分为宏任务和微任务来提供执行优先级。
4、浏览器单页面为一个进程,包含的JS引擎线程和GUI渲染线程互斥,可以通过新开Web Worker线程来完成繁重的计算任务。
最后给大家出一个考题,可以猜下执行的输出结果来验证学习成果:
function sleep(ms) { console.log('before first microtask init'); new Promise(resolve => { console.log('first microtask'); resolve() }) .then(() => {console.log('finish first microtask')}); console.log('after first microtask init'); return new Promise(resolve => { console.log('second microtask'); setTimeout(resolve, ms); }); } setTimeout(async () => { console.log('start task'); await sleep(3000); console.log('end task'); }, 0); setTimeout(() => console.log('add event'), 0); console.log('main thread');
输出为:
main thread start task before first microtask init first microtask after first microtask init second microtask finish first microtask add event end task
本文来自 js教程 栏目,欢迎学习!
以上是深入理解JavaScript的並發模型與事件循環機制的詳細內容。更多資訊請關注PHP中文網其他相關文章!