初步了解 V8 引擎
V8 引擎本质上来说就是按照 ECMA 语法规范实现了堆内存管理 + 函数执行栈管理的高性能 JavaScript 引擎。
本文将会在上一节单独编译的 V8 引擎基础上借助于官方提供的 Hello World 例子来学习了解一些其内部的相关内容,以下内容基于 V8 版本为 7.7.299.13
。
I. 前言简介
V8 引擎本身其实只负责 JavaScript 的解析和执行,我们熟悉的不管是浏览器还是 Node.js 中的 Event Loop 以及随之附带的异步编程都不是引擎本身提供的。
另外还有一些非 ECMA 但是已经在 JavaScript 成为事实标准的必备 API,比如 setTimeout
和 setInterval
均不是 V8 引擎提供的(console
除外),下面是一个简单的例子:
将官方的 hello-world.cc
的测试代码
v8::Local<v8::String> source =
v8::String::NewFromUtf8(isolate, "'Hello' + ', World!'",
v8::NewStringType::kNormal)
.ToLocalChecked();
修改为:
v8::Local<v8::String> source =
v8::String::NewFromUtf8(isolate, "console.log('Hello' + ', World!')",
v8::NewStringType::kNormal)
.ToLocalChecked();
可以看到输出结果变成了:
undefined
3 + 4 = 7
返回了 undefined 说明 V8 引擎原生实现了 console.log
,可以继续测试下 setTimeout
:
v8::Local<v8::String> source =
v8::String::NewFromUtf8(isolate, "setTimeout(()=>{ }, 1000)",
v8::NewStringType::kNormal)
.ToLocalChecked();
编译后执行输出:
<unknown>:0: Uncaught ReferenceError: setTimeout is not defined
#
# Fatal error in v8::ToLocalChecked
# Empty MaybeLocal.
#
Illegal instruction: 4
这里可以看到 setTimeout
就不是由引擎实现的。
实际上以 Node.js 为例,上述 API 均是 Node.js 借助于 Libuv 实现的,同时暴露方法给 JavaScript 使用,而在 Chrome 等浏览器环境下,这一层则有浏览器内核来负责实现,因此对于这些非 ECMA 的 API,不同的 JS 宿主环境各自的实现细节会有些微差异,这是对大家构建跨端环境下的 JavaScript 项目时需要关注的地方。
II. 重要概念
Isolate
第一个非常重要的概念是 Isolate
,按照单词的意义可以理解为引擎的每一个 JS 运行时隔离环境,它本质上就是包含独立堆栈管理的 V8 运行时拷贝实例,也可以理解为相互独立的沙盒(SandBox),所以如果有两个 Isolate
,它们可以并行来运行 JavaScript 代码而不会相互干扰。
Context
每一个 Isolate
中 JavaScript 执行上环境,它允许独立无关的 JavaScript 代码在同一个 Isolate
实例中被正确的执行,也正为因为如此,开发者需要对每一段被执行 JavaScript 的代码来指定其执行上下文。
Local Handle
V8 实现了一套自动垃圾回收机制 (GC),因此堆对象都由 local handle
进行统一管理,这也意味着开发者必须通过 local handle
来创建或者访问 V8 对象。
local handle
由 handle scope
来统一进行管理,通常我们会在函数作用域顶部申明:
void test() {
v8::HandleScope handle_scope(isolate);
// ...
}
接下来 test
函数作作用域下创建的局部 local handle
变量都由这个申明的 handle_scope
来自动统一管理: 即当 test
函数执行完毕后会触发 HandleScope
析构,这一过程中会自动清理掉 test
函数内创建的 local handle
指向的 V8 对象。
其实从这个角度来说,handle scope
的设计完全遵循了 RAII
的原则,因为 C++ 的标准保证了创建的对象离开作用域时其析构函数一定会被调用,所以即使在 test
函数中发生了异常,其作用域内部创建的 local handle
依旧可以得到释放。
这个过程其实也意味着通常情况下 local handle
的生命周期完全跟随 handle scope
,因为如果我们想要在函数中返回一个 local handle
指向的 V8 不能直接 return
,而需要使用另一种 handle scope
来管理,它是 EscapableHandleScope
,下面是一个简单的例子:
void test() {
v8::EscapableHandleScope handle_scope(isolate);
v8::Local<v8::String> str =
v8::String::NewFromUtf8(isolate, "Hello world",
v8::NewStringType::kNormal)
.ToLocalChecked();
// Return the value through Escape.
return handle_scope.Escape(str);
}
Escape
顾名思义就是逃逸,这里就是让 local handle
指向的 V8 字符串对象 str
逃逸出 handle scope
的统一管理,那么其生命周期就得到了对应的延长。
Persistent Handle
与其他通用语言的全局变量类似,我们在编写代码时可能会需要一些生命周期更加长的 handle
来指向 V8 对象,这里的生命周期更长是相对上面提到的 local handle
仅在函数作用域内生效且受到作用域顶部申明的 HandleScope
约束。
在这种情况下就会使用到 persistent handle
,一个大家都见到过的例子就是 Chrome 中对 Document Object Model
(DOM) 节点的引用在底层都是由 persistent Handle
来操作的。
persistent handle
更长的生命周期带来的一个副作用就是 V8 的 GC 默认不会回收其引用的 V8 对象,需要开发者在代码中来手动判断其指向的 V8 对象不再使用时将其变为弱引用,调用的方法是: PersistentBase::SetWeak
,变为弱引用后,下一次 GC 时 V8 的垃圾回收器会自动将那些仅有 weak persistent handle
指向的堆对象回收掉。
Garbage Collector
V8 引擎的垃圾回收机制 (GC) 正是基于上面提到的两种 handle
来实现的,因为开发者创建的 V8 对象都只能通过 local handle
或者 persistent handle
来进行操作,那也意味着每次触发 GC 时扫描整个堆上的对象可以通过以下规则来判断这个堆对象是否还在使用:
- 有
local handle
引用 - 有非
weak
状态的persistent handle
引用
那么对于已经不再使用中的 V8 对象则可以清理掉以回收内存,限于篇幅这里对 GC 的描述比较简略,后面文章中会详细展开。
III. 单线程 or 多线程
面试的时候都会被问到 JS 是单线程还是多线程的问题,虽然 ECMA 已经实施的标准中没有关于 Thread 的定义,但是使用 JS 编写多线程其实已经在 Node.js 的 worker thread
和浏览器中的 web worker
实现了。
其中 Node.js 中的 worker thread
是基于 Libuv 提供的多线程库实现的 (uv_thread_create_ex
),它限制了每一个线程的 stack size
为 4MB (v12.x)。
不过我们仍然不能据此就认为 JS 已经支持了多线程,因为这些多线程实现都不是在引擎层面而是由第三方模块做的,实际上在 V8 引擎里面,负责 JS 函数执行栈处理的依旧只有一个线程,我们可以认为它是 JS 主线程。
不过 V8 引擎本身也提供了一些辅助线程来处理诸如 GC 这样的操作,我们可以 debug 下官方提供的 Hello World 来看下线程信息。
调试官方示例
首先为了 debug 的时候能看到官方示例中的源代码信息,我们需要编译一个 debug 版本:
g++ -g -I. -Iinclude samples/hello-world.cc -o hello_world -lv8_monolith -Lout.gn/x64.release.sample/obj/ -pthread -std=c++0x
其实就比之前多了一个参数 -g
,这里我们使用 lldb
进行调试,MacOS 安装 XCode
后自带,Windows 的同学可以参见之前的文章在子系统中自行编译 llvm
来使用 lldb
:
lldb -- hello_world
(lldb) target create "hello_world"
Current executable set to 'hello_world' (x86_64).
然后设置断点在 main
函数上:
(lldb) breakpoint set --name main
Breakpoint 1: where = hello_world`main + 34 at hello-world.cc:14:40, address = 0x0000000100000e02
最后开始运行:
(lldb) run
Process 27404 launched: '/Users/hyj1991/git/v8_base/v8/v8/hello_world' (x86_64)
Process 27404 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
frame #0: 0x0000000100000e02 hello_world`main(argc=1, argv=0x00007ffeefbff808) at hello-world.cc:14:40
11
12 int main(int argc, char* argv[]) {
13 // Initialize V8.
-> 14 v8::V8::InitializeICUDefaultLocation(argv[0]);
15 v8::V8::InitializeExternalStartupData(argv[0]);
16 std::unique_ptr<v8::Platform> platform = v8::platform::NewDefaultPlatform();
17 v8::V8::InitializePlatform(platform.get());
Target 0: (hello_world) stopped.
此时我们可以先看下当前的线程数量:
(lldb) thread list
Process 27404 stopped
* thread #1: tid = 0xba8804, 0x0000000100000e02 hello_world`main(argc=1, argv=0x00007ffeefbff808) at hello-world.cc:14:40, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
可以看到只有一个主线程,符合预期,继续 next
三次到执行完毕 v8::platform::NewDefaultPlatform()
方法:
(lldb) next
Process 27404 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = step over
frame #0: 0x0000000100000e83 hello_world`main(argc=1, argv=0x00007ffeefbff808) at hello-world.cc:17:39
14 v8::V8::InitializeICUDefaultLocation(argv[0]);
15 v8::V8::InitializeExternalStartupData(argv[0]);
16 std::unique_ptr<v8::Platform> platform = v8::platform::NewDefaultPlatform();
-> 17 v8::V8::InitializePlatform(platform.get());
18 v8::V8::Initialize();
19
20 // Create a new Isolate and make it the current one.
Target 0: (hello_world) stopped.
此时继续查看线程数量:
(lldb) thread list
Process 27404 stopped
* thread #1: tid = 0xba8804, 0x0000000100000e83 hello_world`main(argc=1, argv=0x00007ffeefbff808) at hello-world.cc:17:39, queue = 'com.apple.main-thread', stop reason = step over
thread #2: tid = 0xba8e86, 0x00000001018e4d30 dyld`ImageLoaderMachOCompressed::libImage(unsigned int) const, name = 'V8 DefaultWorke'
thread #3: tid = 0xba8e87, 0x00007fff7f659f02 libsystem_kernel.dylib`__psynch_mutexwait + 10, name = 'V8 DefaultWorke'
thread #4: tid = 0xba8e88, 0x00000001018dc428 dyld`ImageLoader::trieWalk(unsigned char const*, unsigned char const*, char const*) + 140
thread #5: tid = 0xba8e89, 0x00007fff7f659f02 libsystem_kernel.dylib`__psynch_mutexwait + 10
thread #6: tid = 0xba8e8a, 0x00007fff7f659f02 libsystem_kernel.dylib`__psynch_mutexwait + 10, name = 'V8 DefaultWorke'
thread #7: tid = 0xba8e8b, 0x00007fff7f659f02 libsystem_kernel.dylib`__psynch_mutexwait + 10
thread #8: tid = 0xba8e8c, 0x00007fff7f659f02 libsystem_kernel.dylib`__psynch_mutexwait + 10
可以看到线程数量变成了 8 个,也就是初始化完 platform
后多了 7 个后台线程,这是什么时候创建的呢,下面我们来看下这额外的 7 个线程是哪里创建的。
创建的额外线程
我们可以重新按照上面小节中的方法来进入示例的调试,这次我们直接 step
进入到 v8::platform::NewDefaultPlatform()
里面:
(lldb) step
Process 28136 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = step in
frame #0: 0x0000000100013c3a hello_world`v8::platform::NewDefaultPlatform(thread_pool_size=0, idle_task_support=kDisabled, in_process_stack_dumping=kDisabled, tracing_controller=unique_ptr<v8::TracingController, std::__1::default_delete<v8::TracingController> > @ 0x00007ffeefbff5f8) at default-platform.cc:38:32 [opt]
35 int thread_pool_size, IdleTaskSupport idle_task_support,
36 InProcessStackDumping in_process_stack_dumping,
37 std::unique_ptr<v8::TracingController> tracing_controller) {
-> 38 if (in_process_stack_dumping == InProcessStackDumping::kEnabled) {
39 v8::base::debug::EnableInProcessStackDumping();
40 }
41 std::unique_ptr<DefaultPlatform> platform(
Target 0: (hello_world) stopped.
这里可以看到 v8::platform::NewDefaultPlatform()
函数有一个参数 thread_pool_size
,其默认值为 0:
(lldb) frame variable thread_pool_size
(int) thread_pool_size = 0
继续 next
到 platform->SetThreadPoolSize(thread_pool_size)
这一行,这里显然是计算真正的线程池线程数的地方,step
进入一探究竟:
92 void DefaultPlatform::SetThreadPoolSize(int thread_pool_size) {
93 base::MutexGuard guard(&lock_);
94 DCHECK_GE(thread_pool_size, 0);
95 if (thread_pool_size < 1) {
96 thread_pool_size = base::SysInfo::NumberOfProcessors() - 1;
97 }
98 thread_pool_size_ =
-> 99 std::max(std::min(thread_pool_size, kMaxThreadPoolSize), 1);
100 }
Target 0: (hello_world) stopped.
好了这里就非常清晰了,如果 thread_pool_size
值小于 1,就默认将线程池大小设置为计算器的 CPU 逻辑核数 - 1,这里减去 1 显然是为了排除对主线程的干扰,毕竟不管如何 JS 主线程是一定会有的。
这些辅助线程是在计算完毕真正的 thread_pool_size
大小后, 早 platform->EnsureBackgroundTaskRunnerInitialized()
中被启动:
void DefaultPlatform::EnsureBackgroundTaskRunnerInitialized() {
base::MutexGuard guard(&lock_);
if (!worker_threads_task_runner_) {
worker_threads_task_runner_ =
std::make_shared<DefaultWorkerThreadsTaskRunner>(
thread_pool_size_, time_function_for_testing_
? time_function_for_testing_
: DefaultTimeFunction);
}
}
这里用了共享智能指针来创建 DefaultWorkerThreadsTaskRunner
,我们来看下其构造函数:
DefaultWorkerThreadsTaskRunner::DefaultWorkerThreadsTaskRunner(
uint32_t thread_pool_size, TimeFunction time_function)
: queue_(time_function),
time_function_(time_function),
thread_pool_size_(thread_pool_size) {
for (uint32_t i = 0; i < thread_pool_size; ++i) {
thread_pool_.push_back(base::make_unique<WorkerThread>(this));
}
}
可以看到这里正是使用了一个 for
循环来根据前面计算得到的 thread_pool_size
值来创建这些额外的辅助线程。
因为我的机器 CPU 逻辑核数为 8,这里就会创建 7 个额外的辅助线程,回到上一小节中的 thread list
结果,可以看到除去主线程确实多了 7 个辅助线程。
多提一句,负责管理和创建辅助线程的 WorkerThread
类其实已经实现了跨平台,在 posix
平台下直接使用 pthread_create
来创建线程;而在 win32
平台下则使用了 _beginthreadex
,详细的代码在 src/base/platform
下可以看到。