對js單線程的了解
前言
事情的起因是醬紫的,某一天我在寫一段代碼,我突然間對js的單線程機制産生了疑惑,并且對我寫的代碼可能的運作結果産生了懷疑,感覺完全不能保證代碼的正确性。這段代碼用僞代碼寫的話大概是這樣子的。
/*邏輯入口*/
loginFn();//登入邏輯
logicFn();//某個業務邏輯
/*登入邏輯定義*/
function loginFn(){
function dispatchEvent(eventName){//派發事件
dispatcher.dispatchEvent({//派發對應的事件
type:eventName
});
}
loginHandle(function(){
//登入的回調
user_login = true;
dispatchEvent("login");//派發登入的事件
}, function(){
//未登入的回調
user_login = false;
dispatchEvent("unlogin");//派發未登入的事件
});//登入
}
/*業務邏輯定義*/
function logicFn(){
function loginHandle(){
//登入的處理
}
function unloginHandle(){
//未登入的處理
}
//判斷全局的登陸狀态
if(user_login){
//已經登入
}else if(user_login === false){
//未登入
}
//監聽登入事件
this.on("login", loginHandle);//登入
this.on("unlogin", unloginHandle);//未登入
}
我大概解釋一下這段代碼的執行的業務背景吧,簡單來說就是登入和一部分與之相關的邏輯。登陸的話,依賴于某次異步操作,但不是每次都依賴于這個操作,上邊這段話可能有點繞,但這個是一個很重要的前提。使用者打開浏覽器,第一次請求網站的時候,會發出這個異步請求,成功之後會種一個cookie(session的),那麼在這個浏覽器打開的狀态下,繼續通路網站的其他頁面就不需要發出這個請求了。是以在業務邏輯的定義裡,我在監聽自定義事件之前,會先判斷一次使用者登入的狀态,在不走異步邏輯的情況下,會直接觸發對應的處理。
我對此産生的疑惑,就是業務邏輯代碼的定義的這部分。假設走了異步邏輯,那麼我沒法控制ajax請求傳回的時機,代碼是一行一行解釋運作的,假設比較巧,資料在判斷登入狀态和監聽登入事件之間傳回了呢?假設傳回之後立即觸發了回調的函數,那麼登入的事件派發了出來,但是還沒有事件的監聽,那就沒有邏輯來處理這個了。
其實這個問題,最簡單的解決方法,應該是把logicFn的調用放到loginFn的調用上邊,這樣子,所有的處理都會通過事件的監聽來觸發,而不會出現一些模棱兩可的情況。但是,不好意思,我要說但是了,我們的一些其他的業務邏輯的特點要求這兩個的調用順序必須是這樣的。是以…
js的單線程機制
當然,實際上我的擔心是沒有發生的,代碼的運作邏輯是正常的,這裡面來保證邏輯正常的就是js的單線程機制。在我寫這篇部落格之前,我對“js是單線程”的了解僅限于js代碼是一行一行執行的,不會出現同時執行兩行的代碼的情況,在這個簡單的前提下,其實并不能保證代碼的正确性,我上邊擔憂的情況還是有可能會發生。
當然,我對js單線程的了解還是比較淺層的,那麼這句話真正的了解是什麼樣子的呢,看一下Philip Roberts: Help, I’m stuck in an event-loop,以及The JavaScript Event Loop(英文不太好的,直接看裡面的ppt就可以了,我就是這樣子的)中文的資料可以看一下阮一峰老師談論event loop,如果看完這幾個大概就對js的事件循環以及js的單線程有一個基本的了解了。
簡單總結一下這幾篇文章說的東西,關鍵就在于這個(此圖取自阮老師部落格)
![](https://img.laitimes.com/img/9ZDMuAjOiMmIsIjOiQnIsIyZwpmLxADOwATM0EDMycmYvwFNxAjMvw1ZvxmYvwVbvNmLht2alVmYuU2Zh1Wavw1LcpDc0RHaiojIsJye.jpg)
異步操作(UI操作、ajax、定時器|延遲器)觸發之後,會進入到事件隊列裡面,隻有主線程(js的執行線程,或者說主棧statck)為空的情況下才會檢查事件隊列,并且取出事件隊列的事件來進行相應的操作。那麼下一個問題是,主線程為空對應的是什麼情況,聰明的你肯定可以想到,那肯定是js代碼全部執行完的情況啊。實際上是這樣子的,那在我糾結這個問題的時候,我是怎麼去認定這個問題的呢?
實際上,我最開始看的資料跟上邊我舉的這些差不多,隻不過我一開始是這樣認為的,異步觸發之後,不是當時觸發,而是進入事件隊列,在主js的statck處于某種狀态下,才會執行事件隊列裡面的處理。聽起來好像沒什麼差別,那換一種說法,等js執行的線程執行完一個“代碼塊”之後,再執行事件隊列裡面的處理。等等,好像有什麼奇怪的東西出現了,什麼叫做“代碼塊”呢?這是我自己提出的一個概念,大概意思是浏覽器來保證js代碼的執行的“原子性”,每次保證執行一個完整的功能不受異步的影響,當然我是圖樣,但是我最開始的時候确實是這麼想的。
這樣的想法就導緻了我後來的糾結,這個“代碼塊”應該怎麼去定義。是完整的一個函數?好像不是,這個沒法保證原子性。是一定程度上的函數的嵌套?還是代碼執行的時候有一個最小的執行時間,在這個執行時間裡面都是一個“代碼塊“?天真的我,并不是沒有寫一些測試的代碼,類似于這樣子(僞代碼)。
function sleep(numberMillis) {
var now = new Date();
var exitTime = now.getTime() + numberMillis;
while (true) {
now = new Date();
if (now.getTime() > exitTime)
return;
}
}
console.time("test");
setTimeout(function(){
console.log("延遲執行");
console.timeEnd("test");
}, );
sleep();//阻塞兩秒鐘
最後的輸出是:
延遲執行
test: 2006.686ms
如果以我的想法來看待這個事情,“代碼塊”指代的應該是sleep(2000)的執行,sleep函數完整的執行完之後才調用了timeout的回調。但是,這個函數本身執行的時間并不長,嵌套也不多,沒法測試出“代碼塊”真正的限制是什麼(原諒我對這個的腦洞)。
然後,為了搞清楚這個“代碼塊”的定義,我去看了chromium的源碼,過程比較艱辛,下文再表,在這個過程中,我突然想起來,如果是嵌套的層級來決定“代碼塊”的執行的話,我可以直接用我們實際的代碼來做測試,我們實際的代碼嵌套的層級比較多,完全可以模拟這個情況。
function init(){
console.time("test");
setTimeout(function(){
console.log("延遲執行");
}, );
//實際的邏輯
xxxxxx;//
console.log("實際邏輯結束");
console.timeEnd("test");
}
init();
最後的結果是,實際的邏輯全部執行結束之後,setTimeout的回調才執行,是以“代碼塊”跟函數的嵌套層級沒有關系,再回到上邊提到的執行時間,上邊的測試代碼裡面是sleep了2秒鐘,把這個時間調大呢。我把時間調整到了200秒,結果是
延遲執行
test: ms
這個時候我再去看我上邊提到的那幾篇文章,裡面提到的主線程為空應該指的是目前線程正在執行的代碼全部結束,對應上邊我拿實際代碼來做測試的情況,是我實際所有的邏輯都已經執行結束了,setTimeout的回調才觸發。正是因為這樣的機制,才能保證我一開始提到的代碼能夠以我們預期的方式來執行,登入的異步不會提前幹擾我們判斷邏輯的執行。
這個問題的結論就是這樣子的,有興趣的同學可以看一下下面我研究chromium源碼的“成果”。
chromium源碼的探索
說是“成果”,其實真的沒有什麼成果,chromium的源碼是C++的,雖然理論上來說看得懂,但隻是理論上,如果對源碼的目錄結構不熟,又沒有什麼文檔可以參考的情況,我覺得還是不要輕易嘗試了。如果你要嘗試,以下是我的一些心得,僅供參考。
首先你需要下一份源碼,可以去chromium的官網下,當然你需要挂一個代理。嫌麻煩的話,可以看一下這一篇文章,作者分享了幾個打好的包,我自己用的源碼就是裡面39.0.2132.2的版本,至于資料,我覺得比較好的是《Webkit 技術内幕》的作者朱永盛老師的一系列文章,還有這個部落客總結的一系列文章,最後是侯炯的《webkit研究報告》(一共兩篇,百度可以搜到,代碼的解釋是在第二篇)。基本上,我看代碼參考的就是這些資料,但是這些資料相對來說都比較老,先不說最新的代碼了,就是我用的39版本的代碼,很多檔案夾的命名已經跟文章的裡面的不一緻的,不過大體上還是可以對的上的。我下面提到的一些檔案夾的路徑也是指我現在用的39版本的路徑。
\src下面我關注的有兩個比較重要的檔案夾,一個是\src\V8,這個是V8的代碼,還有一個是\src\third_party\WebKit,這個是webkit的代碼。在這個檔案夾下面,\Source\core(對應的是上邊有些文章提到的叫webcore的檔案夾)是webkit核心的代碼,core下面的\frame\裡面的是頁面的直接控制器,\html\parser\裡面的是頁面的解析器,dom的建構都是在裡面,\loader\是加載器,\page\是頁面控制器的上一層,我了解的是frame上邊的一層封裝。
一些很重要的類和成員變量,
Page::m_mainFrame
LocalFrame::m_script //V8
HTMLDocumentParser::m_treeBuilder //dom樹建構,HTMLTreeBuilder
HTMLDocumentParser::m_scriptRunner //腳本的運作容器,HTMLScriptRunner
ScriptLoader //core/dom/ScriptLoader.cpp
實際調用的時候,ScriptLoader調用LocalFrame的m_script來執行代碼。而最開始調用js解析的函數實作是這樣子的。
void HTMLDocumentParser::runScriptsForPausedTreeBuilder()
{
ASSERT(scriptingContentIsAllowed(parserContentPolicy()));
TextPosition scriptStartPosition = TextPosition::belowRangePosition();
RefPtrWillBeRawPtr<Element> scriptElement = m_treeBuilder->takeScriptToProcess(scriptStartPosition);
// We will not have a scriptRunner when parsing a DocumentFragment.
if (m_scriptRunner)
m_scriptRunner->execute(scriptElement.release(), scriptStartPosition);
}
在這個函數裡面,先拿到腳本的開始的位置,然後再轉成一個對應的對象,然後執行。關鍵在于,假設傳進去的腳本是完整的js代碼(在HTMLDocumentParser.cpp的注釋中看到是以script的結束标簽來做辨別的,ps:不太确定),而不是一行一行的代碼傳進去執行(這應該是V8來做的事情),以上假設成立的話,以代碼的完整執行來作為一個階段結束的标志,那就合情合理了。當然,以上都是假設,因為沒有編譯的環境,是以沒法驗證,不過估計與實際誤差不大。
再多說一點的是webkit的架構,webkit是浏覽器的核心-負責dom樹的建立和渲染,V8是js的解析引擎-負責js的執行,而webkit預設的解析引擎是JavaScriptCore,因為解析引擎可能不一樣的緣故,解析引擎和核心之間的耦合程度很低,各司其職。
那麼事件循環是在哪一層上實作的呢?
可以參考一下這一篇誰提供了node的消息循環,跟這個類似,事件循環不是在V8上邊實作的,那麼我們來思考一下這麼一個過程,V8在執行一段代碼,代碼觸發了一個ajax的請求,通過中間層的調用,網絡請求通過外層的某些函數發出,請求傳回之後的操作是什麼?首先,肯定是外層首先來處理網絡請求的,處理完了之後判定目前js還沒有執行完(對應線程是否為空)的是webkit這一層還是V8這一層呢?我比較傾向于前者(事實上,我沒有找到這部分對應的代碼…)。我做這個判斷的依據是HTMLDocumentParser::isWaitingForScripts的存在,這個函數作用就是html解析過程中判斷是不是在解析執行js,那麼我猜測在其他的子產品中,應該也有類似的代碼,而這個函數是處在webkit這一層的。
實際上,從消息循環的角度上來講,浏覽器層面有很多的消息循環,跟js執行相關的循環很有可能是存在于浏覽器比較外的層面(在webkit之外)的,通過一層一層的傳遞,把消息(事件)通知給具體的子產品來處理。chromium消息循環的代碼的基類應該是在(頂層)\src\base\message_loop裡面,有興趣的童鞋,可以找一下跟webkit這一層相關的消息循環的位置,然後告訴我。
總結
這個問題其實糾結了還挺長時間的,尤其是後面去看chromium的源碼花了很長時間,當然最後也隻是看了一個一知半解(甚至離這個程度還差了很遠),不過在合理的推測之下,還是(大概)了解了這個問題比較深層次的原因。
具體的總結如下:
- js是一個單線程的執行環境,代碼是一行一行執行的,不存在同時執行兩行的情況。
- 異步(網絡、ui、定時器)的響應隻有在主線程代碼都執行完的情況下,才能真正觸發,觸發之前進入事件隊列排隊。
本文可能有一些不對的地方,有一些我個人的猜測,因為環境的緣故沒法驗證,歡迎大家積極拍磚~