自己团队(腾讯/看点/搜索)长期招聘web前端、c++后台、搜索/推荐算法,欢迎来撩,lucaspei@tencent.com。
---------------------------------------------------------------------
前段时间有同学咨询我一个问题:如何在nodejs中实现一个异步函数,但要求在js里面是同步的调用方式,不用await之类的来修饰?
先不纠结这个命题是不是靠谱,如果真要实现如何做?我当时的回答是:可以用nodejs的 c++ addon,在c++层面用promise实现,这样既能达到不阻塞线程,又能异步结果同步返回给js(未验证,只是当时灵光一现的方案)。
说到这里,有同学就会说:哪用那么麻烦,这个不是很简单吗,用while(true)阻塞一段时间不就行了!
别着急,这就要说到今天这篇文章要讨论的关键内容了,慢慢往下看。
随后,我把这个问题抛到群里面让小伙伴讨论,眼疾手快的同学在网上找到一个库,说不用这么麻烦,这里有一个现成的库可以实现这个功能,https://github.com/abbr/deasync
一测试,厉害了,还真能实现。看下了它的源码实现,还别说,挺有想法。
一方面通过 js 来阻塞线程:
另一方面通过c++ addon 来让event loop继续运行:
这样即阻塞了主线程的其他同步js逻辑,又让异步的事件得以执行,看似简单的代码反应出了作者对nodejs的八级掌握程度。
难道我是来介绍这个库的使用的吗?当然不是。咋一看这个库没啥问题,但仔细一看它用了while(true)来阻塞线程,从程序设计的角度来说这肯定有问题,虽然这个库以一个相对简单的方式实现了这个功能,但代价是很明显的,CPU负荷就这么上去了。
我否定了这个库的实用性,这时群里一同学提问:我记得看过nodejs的源码实现,它无论是在主循环还是在libuv里面的实现,都是通过类似于while(true)的方式来运行,如果这个库有问题,那是不是nodejs整体设计也不合理呢?
以前做hippy的时候其实处理过类似事情,在我看来回答这个问题其实很简单:虽然我们看似它是“死循环”,但其实它里面会在任务空闲时锁线程,有任务后又恢复。就跟人的心脏一样,虽然时时刻刻都在跳动,其实把时间单元放大后你会发现大部分时间它都在休息。
我们做技术的要严谨,不能信口开河。为了验证我的判断,拉下来nodejs源码。这里说下我遇到的一个坑,在windows上千万别下 LTS 的 node-v12.14.0 这个版本,源码对V8的兼容有问题,同时部分源码的编码处理也有问题,编辑通不过,网上也能找到解决办法。最简单的就是直接去下载Current版本即可,那些问题都修复了。这里再说一个编译的事,我刚开始在机械硬盘上重编nodejs大概需要花二三十分钟,换上SSD后速度提升好几倍,真香。
接下来开始举证自己,首先,在NodeMainInstance::Run里面的do while里面打了一行log。其次,也在uv_run里面的while循环里也打了一行log。
一共用了三个简单例子来验证:timer,网络IO,磁盘IO。
无论是timer还是磁盘IO,你都能发现并没有死循环,线程会阻塞,CPU使用率很低,都能很好的印证我上面的判断。直到跑了磁盘IO测试。。。while一直在跑,CPU负荷也上去了,这。。。刷新了我的认知,难道nodejs会犯这种低级错误?
到现在,真正的问题出现了:为什么磁盘IO时libuv的while一直在跑?难道就是这么设计的?不应该呀?没道理呀?
因此,我带着这个疑问,补充了nodejs与libuv交互部分的源码知识,虽然以前也看过部分nodejs源码,但没关注libuv里面的实现。
在看这种多线程模型的程序设计时,一定要特别注意你所看的代码到底在哪个线程运行,因为不同线程运行导致的结果完全不一样,也会有很多线程安全的问题出现。
这里是知识点,拿小本本记下来:虽然我们经常说nodejs是单线程多进程模式,但所说的单线程其实仅仅是指js逻辑线程,nodejs它本身是多线程的,一部分是鲜为人知V8自己创建的worker threads(据说主要用于GC处理,你也可以自己丢v8 task进去执行),另外就是libuv创建的IO处理线程。
刚开始我曾怀疑libuv是在另外一个独立线程执行,看了代码后发现它就是在主线程运行,js代码也是在这个线程运行(怎么看js在哪个线程执行的?看v8 isolate、context在哪个线程初始化,js就在哪个线程执行)。这里就能回答本文开始的第一个问题,为啥不能在js层用while(true)来阻塞线程了,除了我们说的CPU负荷上去的问题外,它还带来了event loop被阻塞不能执行的问题。
接下来,我们拿下面这行代码为例,解密它背后的故事。
fs.readFile('./test.txt', () => console.log(1))
- 首先调用 nodejs 内置 js 代码 /lib/fs.js 中的 readFile 去 open file,再调用 readFileAfterOpen 去 fstat,再调用 readFileAfterStat 去真正的 read file。
- 真正read是在 /lib/internal/read_file_context.js 这个文件中的 fsReadFileContext 这个类中实现,可以看到它调了internalBinding('fs')中的read。这个internalBinding是啥,它的作用是什么,后续我会有一篇文章专门讲讲我在做hippy时的设计,hippy的模块系统就参考了nodejs的实现,敬请期待。
- 接下来就调用到了 http://node_file.cc 中的 Read 的方法,然后通过 AsyncCall 这个方法把这个事件添加到了 libuv 中的 work queue 队列中去了,注意在调用这个 AsyncCall 方法时,绑定了 libuv 中 fs.c 的处理 file 的函数 uv_fs_read 以及对应的http://node_file.cc 中的回调函数 AfterInteger,而 uv_fs_read 又通过 uv__work_submit 绑定了两个钩子函数 uv__fs_work 和 uv__fs_done。
- 然后通过 uv_run 去遍历队列中的任务状态,需要丢到线程池去处理的IO就丢过去,当IO处理完后调用对应的回调函数 AfterInteger,req_wrap->Resolve 这里会调用到真正的js回调函数,输出日志,整个流程结束。
这里是知识点,拿小本本记下来:你会发现修改nodejs中的js源码后它不会被执行,需要你重新生成下工程才可以,因为nodejs中的 js 文件都会通过 node_js2c 这个命令把 js 转化为 http://node_javascript.cc,重新参与编译即可。
这里我们需要详细看下 libuv 的核心函数 uv_run 的实现来理解一些细节:
这个是windows版本的实现,linux有些不一样,但大同小异。首先可以看到里面有一个 while 循环,简单来说,它会根据事件队列里面事件的状态来觉得是否丢到线程池里面去处理 IO,然后设置一个很关键的 timeout 时间来阻塞线程,直达 fd 有返回,并把返回的 req 放到 loop 的 pending_reqs_tail 链表里面去,在下一次循环时调用 uv_process_reqs 执行 pending_reqs_tail 里面的回调。我们再来看一个关键函数 uv__poll。
这里面有一个很关键的系统函数 GetQueuedCompletionStatusEx,阻塞线程就是靠它,需要设置一个超时时间 timeout,在这个时间内 fd 有返回就继续往下执行,如果没返回,线程会一直阻塞到 timeout 时间到为止。js 的 timer 就是靠它来实现的,但是需要注意的是,当有很多任务时,这里每个任务的 timeout 设置就很讲究了,所以需要 uv_backend_timeout 这个计算时间的管家出现,它会最优的设置第一个被执行的 IO 的 timeout 时间,让每个任何合理被执行又不会相互影响,也有效的节省了 cpu 的开销。
到这里,是否可以解释为啥磁盘 IO 时 libuv 的 while 一直在跑了吗?还不能,上面只是介绍下背景,从源码来看符合我最开始的判断,那为什么 while 会一直跑,难道 libuv 做了什么优化处理?
接下来就是一顿 “断点 + log” 大法,发现越是深入越是神奇,每次循环都会有 fd 返回,并且都还执行 libuv 的 fs.c 里面的回调。那这就只能说明一个问题,是业务层的什么处理导致了这个现象,不能让 libuv 来被这个锅。方向明确后,我开始看 http://node_file.cc 的实现,又是一顿猛操作,发现每次都还执行了 js 的回调,直到这时,我似乎明白了些什么,我漏掉了 js 部分的逻辑实现。拉出 js 代码后发现了如下代码:
这下就破案了,原来是 js 层的逻辑,文件的读取是分片的,每次读取固定大小的片段,直到整个文件被读取完才会返回给 js。这个设计是一个很聪明的设计,防止了因为文件过大导致阻塞线程、占用线程资源的情况出现。
文章内容有点杂,信息量也有点大,嗯,确实如此,将就着看吧。