概览

首先我们了解一下 YODAOS 的运行时:YODAOS 基于 ShadowNode 它采用事件驱动、非阻塞I/O模型;在设计之初,ShadowNode 的接口与 Node.js 兼容,因此在大部分场景下,开发者可以像 Node.js 一样使用 ShadowNode,了解这些有利于开发者更快速的进行 YODAOS 上的应用开发。

YODAOS 开发应用时,需要关注应用的性能与稳定性,包括但不限于以下:

  • 快速启动
  • 快速响应语音交互
  • 不出现异常与崩溃

启动

当一个应用被启动后, YODAOS 希望应用在5秒内完成启动的相关逻辑,如果5秒内没有完成启动应用将会被退出(kill) 。好的应用应该尽可能快的完成启动,以便更快的为用户提供服务。如果应用内部初始化逻辑包含 I/O 等阻塞操作,这些操作不应该阻塞启动过程,开发者可以维护一个内部的状态机来管理应用的初始化状态。

进程和线程

YODAOS 会为每个应用创建一个单独的进程,应用的代码将会 JerryScript 线程执行(主线程),当然开发者也可以在 此之后为应用创建单独的进程或者线程来执行某些工作。

应用的主线程主要负责接收并处理系统事件(NLP、按键等),因此主线程一般也叫做 UI 线程。由于主线程本身的特殊性,如果应用中包含I/O等阻塞操作将会导致应用无法及时响应系统的事件,这样会导致用户体验变得很差;同时 ShadowNode 不是线程安全的编程模型,因此不要在其它线程中操作 ShadowNode 及其相关的API。

在如之前所说,ShadowNode 采用事件驱动、非阻塞 I/O 模型,这种模型是通过 libtuv 实现,如果应用逻辑中包含 I/O 或者其它阻塞任务,开发者可以将任务放到 libtuv 的线程池中执行,执行完毕后会在主线程中回调,无需自己来处理线程同步等逻辑,示例请参考官方实现

需要指出的是,在 YODAOS 中如非必要,建议统一使用 libtuv线程池来处理多线程逻辑。使用 N-APIlibtuv 进行多线程处理。

JerryScript 是通过C编写的脚本语言,如果应用中包含大量密集计算的逻辑,建议把这些逻辑通过 N-API 的方式放到 C/C++ 中执行,这样可以加快处理速度。

ANRs

当应用的主线程由于某些原因被长时间阻塞时,应用将会出现 Application Not RespondingANRs)。这个过程对应用来说是透明的, YODAOS 使用下面的规则来判断和处理 ANRs

  • 应用底层将会每5秒会发送一个心跳给 YODAOS(无需开发者处理)
  • 当 YODAOS 连续3次(15秒)没有收到来自应用的心跳时,YODAOS 将会重启应用

处于 ANR 的应用将无法接收和处理用户的输入,这对于用户体验来说是非常糟糕的,所以应用应该避免这种情况的出现,下面列举了常见的可能导致ANR出现的情况:

  • 在主线程做同步 I/O
  • 在主线程做长时间的大量的密集计算
  • 主线程同步的等待其它线程的处理结果,而其它线程没有及时处理完成,比如thread.join()或条件变量等
  • 主线程和其它线程形成死锁

下面列举了常用的解决方式:

  • 对于大量密集计算的情况,可以通过系统提供的simpleperf等工具查看一段时间内应用函数调用情况(需要未strip的库);如果 CPU 大部分消耗在虚拟机内,则可以通过 ShadowNode 提供的CPU Profiler生成火焰图来查看脚本函数调用情况
  • 对于同步 I/O 、多线程同步或死锁等情况,可以通过 strace 查看系统调用情况,追踪等待前的最后一次调用或完成后的第一次调用来确定原因

总之,不要让主线程处于等待或者满载状态

内存管理

无论在什么样的环境中开发应用,内存管理都是时刻需要关注的点。 YODAOS 应用的内存由 JerryScript 虚拟机管理,虚拟机通过引用计数、标记清除算法来执行垃圾回收,在虚拟机的堆内存小于一定值的时候会自动的触发 回收机制,来保证内存可用。但是这并不意味着开发者不需要去关注应用的内存使用情况,以下是两种常见的内存泄露场景:

  • 对象被全局或闭包变量引用导致无法释放

    var obj = {}
    setInterval(() => {
      var timestamp = Date.now()
      obj[timestamp] = true
    }, 1000)
    
  • 使用 N-API 创建或获取对象后没有释放

    napi_value functionExportToJS(napi_env env, napi_callback_info info) {
      size_t argc = 1;
      napi_value argv[argc];
      napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr);
      napi_value value = argv[0];
      napi_ref ref = NULL;
      napi_create_reference(env, value, 1, ref);
      // balabalabala...
      // napi_delete_reference(env, ref);
      return NULL;   
    }
    
  • 多线程回调时没有开启 Handle Scope

    void handleAsyncCallbackFromOtherThread(uv_async_t* handle) {
      // napi_handle_scope scope;
      // napi_open_handle_scope(env, &scope);
      // balabalabala...
      napi_create_string_utf8(...);
      // napi_close_handle_scope(env, scope);
    }
    

所以无论是自带自动回收,或是需要自己管理内存的语言,在逻辑处理完后,对象都应该及时释放或解除引用,避免出现内存泄露。开发者可以通过process.memoryUsage()来获取进程的常驻内存大小rss、虚拟机的内存池大小heapTotal以及虚拟机内存池的使用大小heapUsed

当进程出现内存泄露时,可以定时打印heapUsed,如果heapUsed持续上涨代表是脚本内存泄露,这个时候可以通过 ShadowNode 提供的 Heap Profiler 来生成多次虚拟机内存的snapshot,通过比较进程快照(Snapshot)来确定是哪些对象泄露。

异常处理

脚本出现异常时导致进程退出时,开发者可以通过查看日志中的调用栈来定位原因:

TypeError: Expected a function.
    at main (/data/test.js:5:10)
    at anonymous (/data/test.js)

当脚本出现未捕获的异常(uncaughtException)时,ShadowNode 会将这个异常抛到全局对象process对象中,如果未监听process对象的异常,ShadowNode 将会强制退出应用。正常情况下开发者应该避免出现未捕获的异常的情况出现,除了常见的没有去处理错误导致未捕获的异常出现外,还有下面的情况也会导致:

try {
  setTimeout(function throwAnError () {
    console.log('Hello Yoda')
    throw new Error('intentionally throw an error')
  }, 1000)
} catch (err) {
  console.log('catched an intentional error')
}

事实上这个错误并不能被捕获,因为setTimeout是一个异步调用,1秒后throwAnError被调用时调用栈已经不是声明时的了,try/catch无法捕获到这个错误,因此也导致 ShadowNode 将会强制退出当前进程。可以通过下面的方式来避免进程被强制退出:

process.on('uncaughtException', function (err) {
  // balabala...
})

但是这通常不是一种好的做法,因为出现未捕获异常时代表这个错误不是开发者预期的,而这个异常后的开发者预期会执行的代码不会被执行,比如下面的例子,虽然没有导致进程退出,但是会导致内存泄露:

process.on('uncaughtException', function (err) {
  console.log('handled function exception.')
})
var funcs = {}
function main (funcName, func) {
  funcs[funcName] = func
  funcs[funcName]()
  delete funcs[funcName]
}
main('func1', 'this is a string, not a function')

上面的例子中,由于func的值并不是一个function而是string,导致在第七行执行的时候会出现错误,虽然在第一行捕获到了这个错来避免进程的退出,但是第八行的代码无法执行导致funcsfunc的引用无法解除进而导致func引用的泄露。所以在process对象上出现未捕获的错误时,更多常见的做法是友好的提示错误(如果有必要)和完成清理工作后主动退出进程。

性能提示

  • 常见的方法:
    • 缓存:将数据、计算结果等与上下文无关的缓存起来,加快处理速度
    • 延迟:将不必要的逻辑延迟处理,不阻塞当前交互流程,比如统计、埋点等逻辑
    • 批处理:当一次交互包含多个相同类型的处理时,将它们一次性处理完,比如批量 I/O
  • 不要依赖太多的外部库,很多库的实现从通用性的考虑会有一些额外的消耗
  • 做好弱网下的测试,很多问题可能只会在弱网的时候暴露出来
  • 当出现性能瓶颈时,优化业务流程带来的收益往往是最大的
  • 最重要的一点,在开发过程中保持对性能和稳定性的关注,不要在开发完成后再回头处理问题

results matching ""

    No results matching ""