Javascript執行機制--單線程,同異步任務,事件循環

原创 Lin_Grady 教程 前端小知識 240阅读 2018-03-25 13:52:22 举报

總所周知,javascript是一門依賴宿主環境的單線程的弱腳本語言,這意味著什麽?

  • javascript的運行環境一般都由宿主環境(如浏覽器、Node、Ringo等)和執行環境(Javascript引擎V8,JavaScript Core等)共同構成;
  • 弱類型定義語言:數據類型可以被忽略的語言。例如計算時會在不同類型之間進行隱式轉換;
  • 在某一時刻內只能執行特定的一個任務,並且會阻塞其它任務執行;

本文主要講的就是第三點,從中引出下一個問題

單線程的設計原因?

Javascript當初誕生的目的其實就是因爲當年網絡技術十分低效,如表單驗證等個幾十秒才能得到反饋的用戶體驗十分糟糕,爲了給浏覽器做些簡單處理以前由服務器端負責的一些表單驗證。被Netscape公司指派花了十天就負責設計出一門新語言的Javascript之父就是Brendan Eich。盡管他並不喜歡自己設計的這作品,就有了大家都聽過的一句話:

"與其說我愛Javascript,不如說我恨它。它是C語言和Self語言一夜情的産物。十八世紀英國文學家約翰遜博士說得好:'它的優秀之處並非原創,它的原創之處並不優秀。'(the part that is good is not original, and the part that is original is not good.)"

作爲浏覽器腳本語言而誕生的JavaScript的主要用途是與用戶互動,以及操作DOM。這決定了它只需要是單線程就足以解決目的,否則會帶來很複雜的同步問題。但是沒想到的是之後的網絡越發的發達,這些年來的浏覽器大戰爲了爭奪地盤,反而讓Javascript被賦予了更多的職責跟可能性,今時今日的Javascript必須想方設法把自身的潛力激發出來,而單線程的弱點就被無限放大了,因爲在阻塞任務的過程中不一定是因爲CPU被占用了,而可能是因爲I/O太慢(如AJAX請求,定時器任務,Dom事件交互等並不消耗CPU的等待造成資源時間浪費)。

浏覽器中Javascript執行線程

我們一直都在說Javascript是單線程,但浏覽器是多線程的,在內核控制下互相配合以保持同步,主要的常駐線程有:

  • GUI渲染線程:負責渲染界面,解析HTML,CSS,構建DOM和Render樹布局繪制等。如果過程中遇到JS引擎執行會被挂起線程,GUI更新保存在一個隊列中等待JS引擎空閑才執行;
  • JS引擎線程:負責解析運行Javascript;執行時間過程會導致頁面渲染加載阻塞;
  • 事件觸發線程,浏覽器用以控制事件循環。當JS引擎執行過程中觸發的事件(如點擊,請求等)會將對應任務添加到事件線程中,而當對應的事件符合觸發條件被觸發時會把對應任務添加到處理隊列的尾部等到JS引擎空閑時處理;
  • 定時器觸發線程:因爲JS引擎是單線程容易阻塞,所以需要有單獨線程爲setTimeout和setInterval計時並觸發,同樣是符合觸發條件(記時完畢)被觸發時會把對應任務添加到處理隊列的尾部等到JS引擎空閑時處理;W3C標准規定時間間隔低于4ms被算爲4ms。
  • 異步http請求線程:XMLHttpRequest在連接後浏覽器新開線程去請求,檢測到狀態變化如果有設置回調函數會産生狀態變更事件,然後把對應任務添加到處理隊列的尾部等到JS引擎空閑時處理;
    好像鋪墊的有點多,往外偏了,接下來往回拉一點談談這些怎麽運行的。

什麽是堆(heap)和棧(stack)?

自己畫了一個醜醜的圖,大家將就看著吧。
圖片描述

以這個例子做說明。
當調用addOne時創建一個包含addOne入參和局部變量的幀並添加進去stack,當調用到addTwo時也同樣創建一個包含addTwo入參和局部變量的幀並添加進去在首部,執行完addTwo函數並返回時addTwo幀被移出stack,addOne執行完後addOne幀也被移除。
原理:當執行方法時都會建立自己的內存棧,在這個方法內定義的入參變量都會保存在棧內存裏,執行結束後該方法的內存棧也將自然銷毀了。

一般來說,程序會劃分有兩種分配內存的空間 -- 堆(heap)和棧(stack)。

內存空間分配方式結構大小存取速度釋放機制
stack靜態分配隨方法執行結束而銷毀
heap動態分配沒有系統的垃圾回收機制銷毀

因爲棧只能存放下確定大小的簡單數據,所以像變量(其實也就是一個記錄了指向複雜結構數據的地址指向,所以變量也是保存在棧裏的)和基本類型Undefined、Null、Boolean、Number 和 String等是按值傳遞的都會保存在棧裏,隨著方法執行完畢而被銷毀。
堆負責存放複雜結構的對象,數組,函數等創建成本較高並且可重用數據,即使方法執行完也不會被銷毀,直到系統的垃圾回收機制核實了沒有任何引用才會回收。
其實這只是棧的含義之一,Stack的三種含義

有時候我們代碼有問題導致棧堆溢出原因大概是這種情況:
棧溢出:無限遞歸死循環,遞歸越深層分配內存越多直至超過限制;
堆溢出:循環生成複雜結構數據;

好了,現在再看回上圖,除了heap和stack之外還有一個。。。

什麽是Queue(任務隊列)?

Javascript裏分兩種隊列:

  • 宏任務隊列(macro tasks):事件循環中可以有多個macro tasks,每次循環只會提取一個,包括script(全局任務), setTimeout, setInterval, setImmediate, I/O, UI rendering等.
  • 微任務隊列(micro tasks):事件循環中只有一個並且有優先級區別micro tasks,每次循環會提取多次直至隊列清空,包括process.nextTick, Promise, Object.observer, MutationObserver等.

例子過程,具體分析下面再說。
第一次執行事件打印:log start!, promise pending, log end!, promise resolve,promise pending then,promise resolve then;
第二次執行事件打印:setTimeout0,promise3 in setTimeout;
第三次執行事件打印:setTimeout300;

下面終于開始走到正題了

事件循環(Event Loop)!

我在上面鋪墊了這麽多東西,大家大概都能有個初步印象,然後所謂的Event Loop就是把這些東西串聯起來的一種機制吧,因爲這東西各有理解,比如兩位前端大牛之間就有分歧。
阮一峰:JavaScript 運行機制詳解:再談Event Loop
樸靈:樸靈評注
我看過他們很多的博客和書籍,對我幫助都很大,我就用自己的看法講講我眼中的Event Loop。

1,所有的任務都被放主線程上運行形成一個執行棧(execution context stack),其中的方法入參變量保存在棧內存中,複雜結構對象被保存在堆內存中;
2,同步任務直接執行並阻塞後續任務等待結束,其中遇到一些異步任務會新開線程去執行該任務(如上面提到的定時器觸發線程,異步http請求線程等)然後往下執行,異步任務執行完返回結果之後就把回調事件加入到任務隊列(Queue);
3,當執行棧(execution context stack)所有任務執行完之後,會到任務隊列(Queue)裏提取所有的微任務隊列(micro tasks)事件執行完;
4,一次循環結束,GUI渲染線程接管檢查,重新渲染界面;
5,執行棧(execution context stack)到宏任務隊列(macro tasks)提取一個事件到執行,接著主線程就一直重複第3步;

圖片描述
大概理解就這樣子,當然可能會有點偏差,歡迎指正!

特殊的定時器

我在上面線程說過

定時器觸發線程:因爲JS引擎是單線程容易阻塞,所以需要有單獨線程爲setTimeout和setInterval計時並觸發,同樣是符合觸發條件(記時完畢)被觸發時會把對應任務添加到處理隊列的尾部等到JS引擎空閑時處理;W3C標准規定時間間隔低于4ms被算爲4ms。

裏面有一些需要特別注意的地方:
1,計時完畢只是把對應任務添加到處理隊列,依然要等執行棧空閑才會去提取隊列執行,這個概念很重要,切記!即使設置0秒也不會立馬執行,因爲W3C標准規定時間間隔低于4ms被算爲4ms,具體看浏覽器,我個人認爲不管怎樣始終都會被放置到處理隊列等待處理;
2,setTimeout重複執行過程中每次時間誤差會影響後續執行時間,而setInterval是每次精確時間執行,當然這是指他們把對應任務添加到處理隊列的精確性;

但是setInterval也有一些問題:

  • 累計效應,如果執行棧阻塞時間足夠長以至于隊列中已經存在多個setInterval的對應任務的情況,執行時間會遠低于開發者期望的結果;
  • 部分浏覽器(如Safari等)滾動過程不執行JS,容易造成卡頓和未知錯誤;
  • 浏覽器最小化顯示時setInterval會繼續執行,但是對應任務會等到浏覽器還原再一瞬間全部執行;

结语

坦白讲,我原本时打算写一篇关于异步编程的文章,然后在铺垫前文的路上拉不回来了就变成了一篇梳理Javascript执行机制了,不过没关系,理解这些也是很重要的

评论 ( 0 )
最新评论
暂无评论

赶紧努力消灭 0 回复