对js单线程的理解

时间:2023-01-20 18:18:14

对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的单线程有一个基本的了解了。

简单总结一下这几篇文章说的东西,关键就在于这个(此图取自阮老师博客)
对js单线程的理解
异步操作(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");
}, 200);

sleep(2000);//阻塞两秒钟

最后的输出是:

    延迟执行
test: 2006.686ms

如果以我的想法来看待这个事情,“代码块”指代的应该是sleep(2000)的执行,sleep函数完整的执行完之后才调用了timeout的回调。但是,这个函数本身执行的时间并不长,嵌套也不多,没法测试出“代码块”真正的限制是什么(原谅我对这个的脑洞)。

然后,为了搞清楚这个“代码块”的定义,我去看了chromium的源码,过程比较艰辛,下文再表,在这个过程中,我突然想起来,如果是嵌套的层级来决定“代码块”的执行的话,我可以直接用我们实际的代码来做测试,我们实际的代码嵌套的层级比较多,完全可以模拟这个情况。

    function init(){
console.time("test");
setTimeout(function(){
console.log("延迟执行");
}, 0);

//实际的逻辑
xxxxxx;//
console.log("实际逻辑结束");
console.timeEnd("test");
}

init();

最后的结果是,实际的逻辑全部执行结束之后,setTimeout的回调才执行,所以“代码块”跟函数的嵌套层级没有关系,再回到上边提到的执行时间,上边的测试代码里面是sleep了2秒钟,把这个时间调大呢。我把时间调整到了200秒,结果是

    延迟执行
test: 199998.553ms

这个时候我再去看我上边提到的那几篇文章,里面提到的主线程为空应该指的是当前线程正在执行的代码全部结束,对应上边我拿实际代码来做测试的情况,是我实际所有的逻辑都已经执行结束了,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、定时器)的响应只有在主线程代码都执行完的情况下,才能真正触发,触发之前进入事件队列排队。

本文可能有一些不对的地方,有一些我个人的猜测,因为环境的缘故没法验证,欢迎大家积极拍砖~