Lambda Web FunctionsのNode.jsでCPUバウンドな処理をすると、ほかのリクエストは待たされるか?

Lambda Web FunctionsのNode.jsでCPUバウンドな処理をすると、ほかのリクエストは待たされるか?

Lambda Web Functions で CPU バウンドなリクエストが同時実行されたとき、本当にほかのリクエストを止めるのか、実際に測定して確かめました。結果は、空いている環境があれば Lambda は処理中の環境を避けます。どの環境も処理中なら Node.js は待たされ、Go は待たされない、各言語の機構通りという結果でした結果でした。
2026.10.15

はじめに

Lambda Web Functions は、1 つの実行環境で複数のリクエストを同時に処理します。Event Functions は 1 つの実行環境で 1 リクエストずつ処理するので、CPU バウンドな処理がほかのリクエストを巻き込むことはありませんでした。

Web Functions のランタイムは Node.js だけです。ドキュメントにも、CPU を使う同期処理は同じ環境のすべてのリクエストを止めると書かれています。

Be aware that Node.js uses a single-threaded event loop. CPU-intensive synchronous operations block all concurrent requests in that environment. Use asynchronous I/O and avoid blocking the event loop.

https://docs.aws.amazon.com/lambda/latest/dg/web-functions-env-lifecycle.html#web-functions-env-lifecycle-concurrency

では、CPU バウンドなリクエストの最中に届いたリクエストは、本当に待たされるのでしょうか。Lambda がほかの実行環境に回すことはないのでしょうか。この記事では、ローカルと Lambda で測って確かめます。

比較のため、Go でも測ります。Go は goroutine を複数のスレッドで動かし、長く動き続ける goroutine はランタイムが途中で切り替えます。イベントループを持たないランタイムならどうなるかを見るための比較対象です。Web Functions では、Node.js から Go のバイナリを起動して動かしています。

結論は次のとおりです。

  • 実行環境が 1 つしかないローカルでは、Node.js は CPU バウンドな処理が終わるまで、ほかのリクエストを待たせました。Go は待たせていません。
  • Lambda では、空いている実行環境があると、たいてい処理中の環境を避けてリクエストが回されました。Node.js でも待たされていません。
  • Lambda でも、どの実行環境も処理中だと、Node.js は CPU バウンドな処理が終わるまで待たされました。Go は待たされていません。

検証の構成

CloudFront が計測画面の S3 と Node.js・Go の Web Function に振り分ける構成。各関数の実行環境は最大2つで、Node.js の同期ループと Go の HTTP サーバーを比較する

項目 設定
Web Function cf-lambda-web-functions-cpu-node(Node.js)と cf-lambda-web-functions-cpu-go(Go)
ランタイム nodejs24.x(Go は Node.js から起動する)
endpoint prod / HomeRegion / IamAuth
maxEnvironments 2(最小値)
実行環境の CPU 2(Node.js の os.availableParallelism()、Go の runtime.NumCPU() と GOMAXPROCS はどれも 2)
CloudFront / は計測用の画面(S3)、/node/* と /go/* はそれぞれの Web Function

CloudFront OAC の設定は、次の記事と同じです。

https://dev.classmethod.jp/articles/shuntaka-lambda-web-functions-cloudfront-oac/

ソースコードは次のリポジトリにあります。

https://github.com/shuntaka9576/cf-lambda-web-functions

CPU バウンドな処理

/cpu?ms=500 は、その言語で書いたループで、指定した時間だけ CPU を回します。

https://github.com/shuntaka9576/cf-lambda-web-functions/blob/20b689635a20d5feeef73440e65626b5afdbd937/app-cpu-node/index.mjs#L26-L40

https://github.com/shuntaka9576/cf-lambda-web-functions/blob/20b689635a20d5feeef73440e65626b5afdbd937/app-cpu-go/main.go#L31-L51

Node.js の組み込みの非同期 API(crypto.pbkdf2 など)は、libuv のスレッドプールで計算するのでイベントループを止めません。自分で書いた計算にはそうした逃げ道がないので、ループで再現しています。

Go は Node.js から起動する

Node.js は Go のバイナリを子プロセスとして起動するだけで、HTTP は Go が直接待ち受けます。

https://github.com/shuntaka9576/cf-lambda-web-functions/blob/20b689635a20d5feeef73440e65626b5afdbd937/app-cpu-go/lambda/index.mjs#L1-L20

実行環境の数を絞る

maxEnvironments の最小値は 2 なので、実行環境を 1 つには固定できません。最小値にして、同じ環境に当たりやすくしています。

https://github.com/shuntaka9576/cf-lambda-web-functions/blob/20b689635a20d5feeef73440e65626b5afdbd937/lib/cpu-web-function-stack.ts#L92-L101

計測方法

パターン 送り方 見たいこと
/ping だけ /ping だけを送る 比較の基準
空いている環境がある /cpu を 1 本送り、15ms 後に /ping を送る 処理中の環境を避けるか
どの環境も処理中 /cpu を 2 本(2 本目は 50ms ずらす)送り、15ms 後に /ping を送る 処理中の環境に入ると待たされるか

判定は次のようにしました。

  • 同じ環境か: アプリが起動時に作る envId と、Lambda が応答に付ける lambda-web-logstream ヘッダーで判定しました。全試行で 2 つの判定は一致しています。
  • 待たされたか: /ping の receivedAt(ハンドラーが動き始めた時刻)が、同じ環境の /cpu の finishedAt 以降なら「待たされた」としました。同じプロセスの時計どうしを比べるので、ネットワークの揺れの影響を受けません。

計測条件は次のとおりです。

項目 値
回数 各パターン 20 回
/cpu で CPU を回す時間 500ms
2 本目の /cpu をずらす時間 50ms
/cpu から /ping までの間隔 15ms
試行の間隔 500ms
maxEnvironments(Lambda のみ) 2

Lambda では maxEnvironments を最小値の 2 にしているので、/cpu を 2 本入れると、どの実行環境も処理中の状態を作れます。

計測画面では、試行ごとに /cpu と /ping のバーを並べ、右端に /ping のレイテンシとラベルを出しています。

計測画面の右端に出るラベル。「相乗り」と「片方に 2 本」が試行ごとに並んでいる

ラベル 意味
相乗り /ping が、/cpu を処理中の環境に入った
別の環境 /ping が、/cpu とは別の環境に入った
片方に 2 本 「どの環境も処理中」で、2 本の /cpu が同じ環境に入った

「片方に 2 本」になると、Lambda ではもう 1 つの環境が空いているので、/ping はそちらに回ります。ローカルは環境が 1 つしかないので、/ping も同じプロセスに入ります。/ping だけのパターンでは、ラベルの代わりに envId の先頭を出しています。

バーの色は、オレンジが /cpu、青が /ping、赤が /cpu の終了を待った /ping、灰色が別の環境に入った /ping です。

ローカルで測る

ローカルでは、Node.js と Go を 1 プロセスずつ起動します。手元の Mac は 10 コアなので、Go の GOMAXPROCS は Web Functions に合わせて 2 にしています。実行環境が 1 つしかないので、「空いている環境がある」でも /ping は /cpu と同じプロセスに届きます。

Node.js は /cpu が 1 本でも、/ping が /cpu の終了まで待たされました(p50 490ms)。/cpu が 2 本だと、2 本とも終わるまで待つので 941ms です。Go はどちらでも待たされておらず、/cpu が 2 本の最中でも p50 は 18ms でした。

/ping だけ

/ping だけを送ると、Node.js も Go も p50 は 4ms でした。

計測画面(/ping だけ)

ローカルの結果の一覧。/ping だけは Node.js、Go とも 4ms

ローカルの Node.js で /ping だけを 20 回送った結果。ほとんどが 2〜9ms

ローカルの Go で /ping だけを 20 回送った結果。どれも 3〜7ms

空いている環境がある

/cpu を 1 本送った最中に /ping を送ると、20 回とも同じプロセスに届きます(画面の「相乗り」)。Node.js は 20 回とも /cpu が終わるまで待たされ、約 490ms かかりました。Go は 20 回中 16 回が 10ms 未満で返っています。

計測画面(空いている環境がある)

ローカルの結果の一覧。空いている環境があるは Node.js が 490ms、Go が 4ms

ローカルの Node.js で /cpu の最中に /ping を送った結果。20 回とも /ping が /cpu の終了まで待たされ、約 490ms かかっている

ローカルの Go で /cpu の最中に /ping を送った結果。/ping はほとんどが 10ms 未満で返っている

どの環境も処理中

/cpu を 2 本送った最中に /ping を送ると、2 本とも同じプロセスに入ります(画面の「片方に 2 本」)。Node.js は 2 本とも終わるまで待たされ、約 940ms かかりました。Go は 2 本の /cpu で GOMAXPROCS の 2 つが埋まっていても、5〜41ms で返っています。ランタイムが goroutine を途中で切り替えるためです。

計測画面(どの環境も処理中)

ローカルの結果の一覧。どの環境も処理中は Node.js が 941ms、Go が 18ms

ローカルの Node.js で /cpu を 2 本送った最中に /ping を送った結果。20 回とも 2 本の /cpu が終わるまで待たされ、約 940ms かかっている

ローカルの Go で /cpu を 2 本送った最中に /ping を送った結果。/ping は 5〜41ms で返っている

結果のまとめ

/ping のレイテンシの p50 です。

/ping だけ 空いている環境がある どの環境も処理中
Node.js 4 ms 490 ms(別の環境へ 0/20) 941 ms(/cpu の終了待ち 20/20)
Go 4 ms 4 ms(別の環境へ 0/20) 18 ms(/cpu の終了待ち 0/20)

p95 と最大は次のとおりです。

/ping だけ 空いている環境がある どの環境も処理中
Node.js 12 / 37 ms 531 / 591 ms 972 / 997 ms
Go 6 / 7 ms 51 / 204 ms 38 / 41 ms
Go が待たされない理由(プリエンプション)

Go が待たされなかったのには、2 つの仕組みが効いています。

  • goroutine を複数のスレッドで動かす: Go は GOMAXPROCS(今回は 2)の数だけ goroutine を同時に動かせるので、/cpu が 1 つの CPU を使っていても、もう 1 つで /ping を処理できます。ローカルの「空いている環境がある」がこれにあたります。このあとの Lambda の「どの環境も処理中」も、/ping が入った環境の /cpu は 1 本なので同じです。
  • 動き続ける goroutine を途中で止める: Go 1.14 からは、10ms 以上動き続ける goroutine をランタイムが止めて、ほかの goroutine に切り替えます(プリエンプション)。ローカルの「どの環境も処理中」では、2 本の /cpu で CPU が 2 つとも埋まっていても、/ping は 5〜41ms で返りました。切り替えを待つぶん、少し遅くなっています。

Node.js の JavaScript は 1 つのスレッドで動き、実行中の処理を途中で止めて切り替えることもしません(ノンプリエンプティブ)。そのため、/cpu のループが終わるまで /ping は処理されません。

Lambda で測る

手元の Mac の Chrome から、CloudFront 経由で測りました(2026 年 10 月 15 日)。ms の値はネットワークの揺れを含むので、目安として見てください。待たされたかどうかは同じプロセスの時刻どうしで判定しているので、この揺れの影響を受けません。

空いている環境があると、Node.js も Go も /ping は 20 回とも別の環境に回され、待たされていません。どの環境も処理中だと、Node.js は /cpu と同じ環境に入った 16 回すべてで待たされ、p50 は 513ms でした。Go は一度も待たされていません。

/ping だけ

/ping だけを送ると、p50 は Node.js が 57ms、Go が 55ms でした。手元から CloudFront 経由で送っているので、ローカルより 50ms ほど遅く、ばらつきも大きくなっています。

Lambda の結果の一覧。/ping だけは Node.js が 57ms、Go が 55ms

Lambda の Node.js で /ping だけを 20 回送った結果。29〜159ms

Lambda の Go で /ping だけを 20 回送った結果。26〜169ms

空いている環境がある

/cpu を 1 本送った最中に /ping を送ると、Node.js も Go も 20 回とも /ping は別の環境に回されました(画面の「別の環境」)。Node.js でも待たされておらず、p50 は 93ms です。ローカルでは 490ms 待たされたのと対照的です。

Lambda の結果の一覧。空いている環境があるは Node.js が 93ms、Go が 84ms で、どちらも 20 回とも別の環境へ回された

Lambda の Node.js で /cpu の最中に /ping を送った結果。20 回とも /ping は別の環境に回され、/cpu の終了を待っていない

Lambda の Go で /cpu の最中に /ping を送った結果。20 回とも /ping は別の環境に回されている

どの環境も処理中

/cpu を 2 つの環境に 1 本ずつ入れてから /ping を送ると、/ping は /cpu を処理中の環境に入ります(画面の「相乗り」)。Node.js はそうなった 16 回すべてで /cpu の終了まで待たされ、p50 は 513ms でした。Go は一度も待たされておらず、p50 は 47ms です。1 回だけ 462ms かかった回がありますが、この回も /cpu の終了は待っていません。

残りの回(Node.js は 4 回、Go は 2 回)は、2 本の /cpu が同じ環境に入りました(画面の「片方に 2 本」)。このときは /ping が空いているもう 1 つの環境に回り、待たされていません。

Lambda の結果の一覧。どの環境も処理中は Node.js が 513ms で終了待ち 16/20、Go が 47ms で終了待ち 0/20

Lambda の Node.js で両方の環境に /cpu を入れてから /ping を送った結果。相乗りになった 16 回はすべて /cpu の終了まで待たされている

Lambda の Go で両方の環境に /cpu を入れてから /ping を送った結果。相乗りになっても /cpu の終了を待っていない

結果のまとめ

/ping のレイテンシの p50 です。

/ping だけ 空いている環境がある どの環境も処理中
Node.js 57 ms 93 ms(別の環境へ 20/20) 513 ms(/cpu の終了待ち 16/20)
Go 55 ms 84 ms(別の環境へ 20/20) 47 ms(/cpu の終了待ち 0/20)

p95 と最大は次のとおりです。

/ping だけ 空いている環境がある どの環境も処理中
Node.js 138 / 159 ms 154 / 230 ms 674 / 686 ms
Go 156 / 169 ms 123 / 144 ms 177 / 462 ms

わかったこと

空いている環境があれば、Lambda はたいてい処理中の環境を避ける

Lambda は、リソースの使用状況を見て、実行環境ごとの同時実行数の上限を調整します。

The dynamic per-environment concurrency limit is a Lambda-managed limit on the number of requests that a single execution environment handles at the same time. Based on your function's resource usage, Lambda automatically adjusts this limit to optimize efficiency and performance.

https://docs.aws.amazon.com/lambda/latest/dg/web-functions-scaling.html#web-functions-scaling-dynamic-concurrency

どの環境に振り分けるかの仕組みまでは、ドキュメントに書かれていません。今回、「空いている環境がある」では、/ping は 20 回とも処理中の環境を避けました。

ただし、いつも避けるわけではありません。「どの環境も処理中」で 2 本目の /cpu を送ったときは、もう 1 つの環境が空いていたのに、Node.js で 4 回、Go で 2 回、1 本目と同じ環境に入っています(画面の「片方に 2 本」)。

どの環境も処理中だと、Node.js は待たされる

今回は maxEnvironments を 2 にして、どの環境も処理中の状態を作りました。上限を設定しなければ、Lambda は余裕を持って実行環境を増やします。

ただし、Lambda が新しい実行環境を増やすのは、既存の環境が同時実行数の上限に近づいたときです。

Lambda scales up new environments when existing environments are close to their limit. When the limit prevents all existing environments from accepting more requests, requests queue, waiting for the next available environment.

https://docs.aws.amazon.com/lambda/latest/dg/web-functions-scaling.html#web-functions-scaling-dynamic-concurrency

この上限は、同時に処理するリクエストの数に対するものです。リソースの使用状況で調整されるとはいえ、今回の「どの環境も処理中」では、CPU を使い切っている環境にも /ping が回されました。各環境が抱えていたのは /cpu の 1 本だけで、上限には達していなかったと考えられます。

Web Functions は、1 つの環境で maxConcurrencyPerEnvironment(既定は 64)までリクエストを同時に処理する前提です。今回は測っていませんが、次のような場面では、空いている環境がなくなり、CPU バウンドなリクエストと同じ環境に入ると考えられます。

  • リクエストが増え、どの環境も複数のリクエストを抱えているとき
  • maxEnvironments やアカウントの vCPU のクォータに達したとき
  • 急にリクエストが増え、新しい環境の起動が追いつかないとき

Node.js でできる対策

Node.js で CPU バウンドな処理を書くときの対策は、次のようなものがあります。どれも今回は試していません。

  • 組み込みの非同期 API を使う: crypto.pbkdf2 や zlib.gzip などの非同期版は、libuv のスレッドプールで計算するので、イベントループを止めません。
  • worker_threads に逃がす: 自分で書いた計算は、ワーカースレッドで動かせばイベントループを止めません。実行環境の CPU は 2 つなので、もう 1 つの CPU を使えます。
  • maxConcurrencyPerEnvironment を下げる: 1 つの環境に入るリクエストが減るので、巻き込まれるリクエストも減ります。そのぶん環境が増え、コールドスタートも増えます。

ドキュメントにも、CPU バウンドなアプリケーションでは maxConcurrencyPerEnvironment を下げることが挙げられています。

For CPU-bound applications, setting maxConcurrencyPerEnvironment lower than the default may reduce CPU contention and improve steady state latency of warm environments, but may also result in more frequent cold starts.

https://docs.aws.amazon.com/lambda/latest/dg/web-functions-scaling.html#web-functions-scaling-optimization

さいごに

Lambda Web Functions は、リソースの使用状況を見て、実行環境ごとに同時に受け付けるリクエストの数を調整しています。

Based on your function's resource usage, Lambda automatically adjusts this limit to optimize efficiency and performance.

https://docs.aws.amazon.com/lambda/latest/dg/web-functions-scaling.html#web-functions-scaling-dynamic-concurrency

今回も、空いている環境があれば、Lambda はたいてい処理中の環境を避けてリクエストを回しました。Node.js でも、CPU バウンドな処理に巻き込まれていません。

ただし、どの環境も処理中で同じ環境に入ると、Node.js は CPU バウンドな処理が終わるまで待たされました。Node.js から起動した Go のバイナリは、ローカルと同じく Go のランタイムの仕様どおりに動き、同じ環境に入っても待たされていません。同じ環境に入ったときに待たされるかどうかは、その上で動くランタイムの仕組みで決まります。

リクエストが少ないうちは表に出にくい問題ですが、1 つの環境で多くのリクエストをさばくのが Web Functions の前提です。CPU バウンドな処理がある API を Web Functions に載せるなら、イベントループを止めない書き方か、maxConcurrencyPerEnvironment の調整を考えておくとよさそうです。

とはいえ、一般的な Web アプリのリクエストは、データベースや外部 API の応答を待つ I/O 待ちがほとんどです。I/O を待っている間はイベントループが止まらないので、今回のように CPU バウンドな処理で待たされる場面は、実際には起こりにくいと思われます。今回は、Web Functions でどんな挙動になるかを確かめるための検証でした。

この記事をシェアする

関連記事