C++协程库与嵌入V8的兼容性问题

2019-01-23 12:38:03 浏览数 (1)

环境介绍

因为业务需求,需要在 C 中调用 js 代码,这里选择使用 V8 引擎。

C 中使用了部门自研的有一定历史的 RPC 框架,所绑定的协程库是 GNU pth。

开发时,在 PC 上进行编码,然后将代码同步到编译机上进行编译,再然后将编译出来的 so 文件上传到另外一台开发机上运行。

Bug 现象

框架在初始化时,会调用业务的 Init 函数。然后在请求时,再调用相应的业务接口函数。碰到的第一个问题是:

Init 函数初始化 V8 实例的话,在业务函数中对 V8 的调用都会返回失败。 但是,如果在业务函数中再初始化 V8 实例的话,就可以成功调用 V8。

如问题所述,这不是问题,就这样跑了一段时间。

随着业务的发展,增加了第二个需要使用 V8 的接口。一开始很直接的思路,就是使用单例 V8 引擎,然后在调用业务函数的时候再初始化 V8。这时碰到了问题:某些时候,其中的一个接口调用 V8 会失败,但重启服务后,就有可能会调用成功。经过多次尝试,归纳出以下现象:

如果在接口 A 中完成 V8 的初始化,那么接口 A 和接口 B 中使用 V8 都不会有问题。 如果在接口 B 中完成初始化,那么以后在接口 B 中一直能成功调用 V8,但在接口 A 中调用 V8 会必现失败。

其实挺难总结到这样鬼畜的行为的,因为当时所执行的 js 脚本也在不断开发修改,接口又是那种时灵时不灵的行为

Debug 过程

// TODO: 一般这节不会有人看,随便写写就好

协程库的问题?

因为框架使用了协程库,这是一个会用上各种奇技淫巧的地方,而且框架选用的协程库又是没什么人用的 GNU pth,所以嫌疑很大。这个协程库太小众了,以致于很难找到相关的介绍实现的资料,所以准备直接啃代码。幸运的是,啃了一会发现看不懂,但是发现了作者在发布时其实带上了 paper:

1.png1.png

想想很刺激,论文是 .ps 格式的,我乡下来的完全不知道这是个文档。

此文介绍了怎么实现一个兼容性很强的协程栈(比如使用了软中断的回调创建协程……),然后得到的信息是:

协程库里用的是独立的协程栈。 没有移动协程栈的操作。

这是一个比较稳的协程栈实现方式,出问题的概率不大。

V8 的问题?

另一个方向是从 V8 下手。首先得到最小复现代码:v8::JSON::Parse(...) 。如果出问题了,那么这个简单的从 JSON 中构造 V8 对象的语句就会失败。

遗憾的是,英特网上的资料大多都是介绍 V8 怎么使用,很少介绍 V8 的实现。但是啃 V8 的代码不太现实,我稍微看了一下里面的代码后,就决定痛改前非,尽量不要使用宏实现控制流。

V8 的编译

然后很直接的思路就是跟着调试器走。但之前的哥们编译好的 V8 静态库里没有带符号表,这超出了我的调试水平。所以需要自己重新编译一份带符号表的。编译又是一个坑,给我的感觉是,凡是需要编译 V8 的,都是那种不需要资料也不屑于写资料的人。

  • 编译选项

如果编译选项没选对的话,也能成功编译,但运行时会报错。比如默认的选项是支持 snapshot,但是这样编出来的库,如果运行时不带相关的快照文件的话,就会初始化失败。再比如默认使用的 binutils,以及 thin lto 格式,编出来的静态库使用起来不太友好。

这里贴一下编译选项,万一有人也要踩这个坑的时候用得上(适用于 6.2.414.46 版本):

(见文末)

  • 符号表用的是相对路径

另一个坑是编译 V8 使用 ninja,编出来的库所带的 debug 源文件信息,用的都是相对路径。前面说到,我们又要使用编译机,又要使用开发机的。而且在这种代码量庞大而且不熟悉的请求下,在 PC 开着 IDE 使用远程 GDB 才是正确的选项。所以我们需要让 debug 信息里带上绝对路径。

当时没有找到什么比较简便的直接修改符号表的方法,所以选择了 wrap gcc 的调用,将调用 gcc 时的相对路径的参数转成绝对路径后,再真正调用 gcc。

这里写了一个通用的脚本实现这个转化:

(见文末)

使用的时候,建立一个所需文件名到这个脚本的链接,然后设置好 PATH 路径就好了。

这样编译好静态库之后,就可以正常与业务代码进行链接、调试了。可以进行 Debug 之后,对这种必现的单线程 Bug,问题不难发现。

结论及解决

如一开始说的,问题就是 V8 认为发生了栈溢出:

3.png3.png

结合前面说的协程栈实现,很容易就想到可能是因为协程栈地址的问题。这里再观察下 V8 成功、失败的协程栈地址就可以确认,不再赘述。

V8 和协程库,都不会想到还有这样的队友,导致了(我的)悲剧的发生。

但是还好这个兼容性问题要绕过不难。这里再解释下一开始说的 Bug 现象,即接口 A 里初始化 V8 的话,接口 A 和接口 B都能使用这个 V8 实例。原因和 AppPlatform 对协程的管理方式有关:

  • 每个接口对应一个协程数组,调度时,总是从数组的第一个元素(最低地址)开始,选择一个空闲的协程。
  • 接口 A 比接口 B 先分配协程数组,由于堆空间地址是往上生长的,导致接口 A 的协程栈地址均小于接口 B 的。

所以在接口 A 的第一个请求里初始化 V8 实例的话,其所记录下来的栈地址,一定是这两组接口里相对很低的地址。栈空间是向下生长的,V8 判断栈溢出的方法是判断当前栈顶地址是否小于初始栈地址 - 某个阈值。所以后面运行的时候都不会触发这个溢出判断。

根据这个特性,可以想到一个很简单的绕过方法:在每个接口里都分别初始化一个 V8 实例。使用这个方法也的确工作了一段时间。但这种依赖框架实现细节的做法是明显不靠谱的,所以需要更彻底的解决。

浏览 API 后发现其实 V8 有设置栈阈值的功能:

  • 方法 1

一个是在运行的时候,动态设置栈阈值:

代码语言:txt复制
v8::Locker isolateLock(isolate);
isolate->SetStackLimit(currentStackLimit)

这里又有个坑,是在调用这个函数的时候,需要加一个锁,否则它只会修改 C 栈阈值,而不会修改 js 的栈阈值,同样会导致栈溢出。但这里的锁可能会带来一定的性能开销。

  • 方法 2

另一个是在初始化 V8 实例的时候,设置一个非常低的栈阈值:

代码语言:txt复制
create_params.constraints.set_stack_limit((uint32_t*)0x1);
// ...

但这样就等于是放弃了 V8 的栈溢出检查。

  • 方法 3 使用 Copy Stack 的协程,如 libco 。

选哪种方法,就自行取舍吧。

V8 编译选项

代码语言:txt复制
## V8 编译时的 gn args

target_cpu = "x64"
# release
is_debug = false
symbol_level = 1

# debug
#is_debug = true
#v8_enable_backtrace = true
#v8_enable_slow_dchecks = true
#v8_optimized_debug = false
#use_debug_fission = false
#symbol_level = 2

# compile
is_clang = false
use_custom_libcxx = false
use_custom_libcxx_for_host = false
use_cxx11 = true
binutils_path = "/usr/bin"
use_sysroot = false
use_rtti = true
use_thin_lto = false
linux_use_bundled_binutils = false
is_component_build = false
v8_static_library = true

# features
is_desktop_linux = false
use_glib = false
use_aura = false
use_pic = true
use_gconf = false
use_ozone = false
use_udev = false
v8_enable_i18n_support = false
v8_use_snapshot = false
v8_enable_gdbjit = false
icu_use_data_file = false

相对路径参数转绝对路径

代码语言:txt复制
#!/bin/env python
# -*- coding: UTF-8 -*-
# FileName: AbsPathMap.py

import sys
import subprocess
import os

#--- exec name
exec_name = os.path.basename(sys.argv[0])
# print(exec_name)

#--- find real exec path
search_paths = os.environ['PATH']

cur_dir = os.path.normpath(os.path.realpath(os.path.dirname(sys.argv[0])))
# print('cur_dir: '   cur_dir)
real_exec_path = None
for path in os.environ['PATH'].split(':'):
  rpath = os.path.normpath(os.path.realpath(path))
  if rpath == cur_dir:
    continue

  fname = os.path.join(rpath, exec_name)
  if os.path.isfile(fname):
    real_exec_path = fname
    break

if real_exec_path is None:
  print ('Could not find true exec file: %s' % exec_name)
  exit(-1)

# print ('real_exec: '   real_exec_path)

#--- translate to absolute path
new_args = [real_exec_path]
for arg in sys.argv[1:]:
  if len(arg) == 0 or arg[0] == '-' or arg[0] == '@':
    new_args.append(arg)
    continue
  new_args.append(os.path.abspath(arg))

# print new_args
#--- call
subprocess.call(new_args)

0 人点赞