前情提要
几个月前 y 同学开发时遇到了 Node.js 进程偶现的 CPU 占用 100% 排查 的问题, 当时排查的结论是大概率为 Node.js http2 的 bug
时间回到现在 d 同学反馈近期 CPU 占用 100% 非常频繁, 虽然上次排查下来可以通过使用 http1 代替 http2 解决, 但对于 toB 的巨石应用动辄 500+ 的文件请求, 还是希望使用 http2 强大的并发能力来大幅减少 bundless 模式下大量的文件请求时长问题
好巧不巧 d 同学也是 M2, 最后排查下来与 M2 无关…
添加日志
于是下载了 d 同学当前 Node.js 16.19.1 版本的代码, 在 lldb 线程堆栈回溯的最后一个函数 OnStreamClose 中加入了下面的日志, 目的是希望知道最后代码卡死的准确位置来做下一步的分析
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| int Http2Session::OnStreamClose(nghttp2_session* handle, int32_t id, uint32_t code, void* user_data) { Http2Session* session = static_cast<Http2Session*>(user_data); Environment* env = session->env(); Isolate* isolate = env->isolate(); HandleScope scope(isolate); Local<Context> context = env->context(); Context::Scope context_scope(context); Debug(session, "stream %d closed with code: %d", id, code); + std::cout << ">>> 1" <<< std:endl; BaseObjectPtr<Http2Stream> stream = session->FindStream(id); + std::cout << ">>> 2" <<< std:endl; // Intentionally ignore the callback if the stream does not exist or has // already been destroyed if (!stream || stream->is_destroyed()) return 0; + std::cout << ">>> 3" <<< std:endl;
// Don't close synchronously in case there's pending data to be written. This // may happen when writing trailing headers. if (code == NGHTTP2_NO_ERROR && nghttp2_session_want_write(handle) && !env->is_stopping()) { + std::cout << ">>> 4" <<< std:endl; env->SetImmediate([handle, id, code, user_data](Environment* env) { + std::cout << ">>> 5" <<< std:endl; OnStreamClose(handle, id, code, user_data); }); + std::cout << ">>> 6" <<< std:endl;
return 0; }
stream->Close(code); + std::cout << ">>> 7" <<< std:endl;
// ... return 0; }
|
接着把修改后的代码编译成功的二进制文件给 d 同学去复现。macOS 中编译 Node.js 运行下面两行命令即可。其他平台参考 Building Node.js
复现成功
最后 d 同学使用刚编译的 Node.js 很快复现了问题, 与此同时启动 nopack 命令的 VSCode 控制台在快速不断循环 ♻️ 打印 1 -> 2 -> 3 -> 4 -> 6 -> 5 -> 1 的日志, 因为这个死循环的日志输出把 VSCode 的 Code Helper 进程的 CPU 也拉到了 100%
1 2 3 4 5 6 7 8 9 10 11 12
| >>> 1 >>> 2 >>> 3 >>> 4 >>> 6 >>> 5 >>> 1 >>> 2 >>> 3 >>> 4 >>> 6 >>> 5
|
试图解决
因为上面复现时日志只能打印到 >>> 6, 仔细一看 >>> 6 上一行的代码 OnStreamClose 函数满足了 if(code == NGHTTP2_NO_ERROR && nghttp2_session_want_write(handle) && !env->is_stopping())
条件后会再次调用自身, 故造成了死循环 ❌
于是简单修改了一下 OnStreamClose 函数, 使得即使满足了 if 条件, 也不能调用自身超过 3 次。最后编译好发现未能再复现, 问题得到解决 ✅
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| int Http2Session::OnStreamClose(nghttp2_session* handle, int32_t id, uint32_t code, void* user_data, + int32_t retry_num) { //... // Don't close synchronously in case there's pending data to be written. This // may happen when writing trailing headers. if (code == NGHTTP2_NO_ERROR && nghttp2_session_want_write(handle) && !env->is_stopping() + && retry_num < 3) { + int32_t next_retry_num = retry_num + 1; + env->SetImmediate([handle, id, code, user_data, next_retry_num](Environment* env) { + OnStreamClose(handle, id, code, user_data, next_retry_num); });
return 0; } // ... }
|
最终结论
最后让 d 同学使用最新的 20.5.1 版本看能不能复现, 如果仍有问题, 就去提一个 Issue。结果是未能复现, 那么问题就锁定在了 16.19.1 ~ 20.5.1 版本之间。d 同学认真查阅了一下 Node.js Releases 日志 找到了该问题由 16.19.0 版本 node/pull/45153 改动引入, 在 16.20.0 版本 node/pull/46721 进行了回滚。故该问题为 Node.js 16.19.0、16.19.1 版本的 bug
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| int Http2Session::OnStreamClose(nghttp2_session* handle, int32_t id, uint32_t code, void* user_data) { Http2Session* session = static_cast<Http2Session*>(user_data); Environment* env = session->env(); Isolate* isolate = env->isolate(); HandleScope scope(isolate); Local<Context> context = env->context(); Context::Scope context_scope(context); Debug(session, "stream %d closed with code: %d", id, code); BaseObjectPtr<Http2Stream> stream = session->FindStream(id); // Intentionally ignore the callback if the stream does not exist or has // already been destroyed if (!stream || stream->is_destroyed()) return 0;
- // Don't close synchronously in case there's pending data to be written. This - // may happen when writing trailing headers. - if (code == NGHTTP2_NO_ERROR && nghttp2_session_want_write(handle) && - !env->is_stopping()) { - env->SetImmediate([handle, id, code, user_data](Environment* env) { - OnStreamClose(handle, id, code, user_data); - }); - - return 0; - }
stream->Close(code);
// ... return 0; }
|