2018年2月4日日曜日

【Node.js】おまいらちゃんとリソース解放してますか

先月の記事で、クライアントからの接続が途中で切れてもレスポンスを返し終わっても、処理自体はまだ続くことがわかりました。つまり、こんな注意が必要。
  • (意図しない)無限ループのようにCPUを専有する処理があった場合、クライアントからの接続を切っても専有が解除されるわけではない。つまりそういう処理を作り込んでしまったらどうしようもない
  • DBの接続やファイルハンドルのようなリソースは、処理完了時にきちんと解放する必要がある
前者も設計やコードの時点で気をつけてもらわないとダメですが、問題なのが後者。

従来のスクリプト系言語では、リソースはリクエストの処理が終わったら勝手に解放されるから特に何もしなくてよかったりします。
その感覚でNode.jsを触ると、知らないうちにDBの接続数が異常に増えていたりします

さて、どう対処しましょうか。



1つのリソースを使いまわす

そのリソースが共有できるなら、グローバル変数に入れておいて全てのリクエストで共有するという方法もあります。

このとき注意するのは2点。

1つめは、リソースが無効化された場合の処理
たとえばDB接続であれば、DBサーバとの接続が切れた場合に再接続の処理を入れてやらなければいけません。MySQLでは一定時間アイドル状態が続くと強制的に切断されたりするので、そのへんも注意。

2つめは、それは本当に共有できるリソースなのかという点。
またDB接続の話になりますが、リレーショナルDBであればトランザクションが必要な場合があります。そのとき、接続オブジェクトをリクエスト間で共有していると、あるリクエストのトランザクション中に、全く関係ないリクエストのクエリが混じってしまうこともあります。

結論としては、リレーショナルDBではリソースを共有すべきではありません

必要になったら確保、不要になったら解放

愚直に確保と解放を繰り返す方法。まあ当然といえば当然の方法です。
JavaScriptにはデストラクタもファイナライザもないので、tryで確保、finallyで解放していきましょう。

そのときに気をつけるのは、確保や解放にコストがかかるリソースの場合。
たとえばネットワークを経由するものとかだと、都度確保・都度解放ではレスポンスに時間がかかってしまいます。

あとは、またまたDBの話になってしまいますが、複数の関数間でトランザクションを引き継ぎたい場合には、1つの関数内で接続が閉じているよりも関数に接続オブジェクトを渡せるような設計のほうがよかったりします。


結論としては、リソースはある程度共有すべきです

リクエストごとに共有しよう

ほならね、どうすればいいんですかって話ですよ。

このセクションのタイトルですでにネタバレしてますが、グローバル変数ほど粒度は低くなく、都度確保ほど高くもなく、リクエストごとに確保してやればいいんじゃないでしょうか。

Express.jsなら、ミドルウェア内でreq.someResourceのようにreqオブジェクトのプロパティにでもしてやればいいんじゃないでしょうか。

いつ解放する?

その場合に問題なのは、このリソースをいつ解放するの?ということ。


もちろんリクエスト処理が終わった後なのですが、この検出が意外と難しい。何回もハマりました。


調べて最初に出てきたのはresのfinishイベントを使うやり方。
Express.js の公式ドキュメントには書いてありませんが、これはNode.jsのhttp.ServerResponseにあるイベント。

なるほど、これなら処理が無事終了したときに呼ばれるので、ここで解放すればよさそう。

………。

お気づきの通り、このイベントは無事終了したときでないと呼ばれません。途中でクライアントからの接続が切れた場合には呼ばれないのでF5アタックには無力です。

次に出てきたのは、同じくhttp.ServerResponseのcloseイベント
これは先程とは 逆で、途中で切れた場合に呼ばれます。

この2つを併用すれば問題なさそう?

………………。

確かに一見問題なさそうですし、実際ほとんどの場合で問題ありません。
ただし、F5を超連打していると、ごくまれにどちらも呼ばれない場合がありました。

これは再現性がなくて原因の特定にかなり時間がかかりました。実際、試しに作るようなサンプルコード程度ではまずこういう問題は起こりません。

原因特定までの苦労話は置いておいて、結論から言うと、finish/closeのイベントハンドラを登録する時点でクライアントからの接続が切れていた場合にはイベントは発生しません。
具体的には、ここ以前に実行されたミドルウェアの処理中に接続が切れた場合です。

というわけで、これも条件に入れる必要があります。

コードはどうなる?

はい。それが問題。多分こんなかんじになります。

function middleware(req, res, next) {
    req.someResource = allocResource();

    res
        .on("close", () => {
            // 切断
            freeResource(req.someResource);
        })
        .on("finish", () => {
            // 完了
            freeResource(req.someResource);
        });
    if (res.socket.destroyed) {
        // すでに切断されていた
        freeResource(req.someResource);
    }
 
    next();
}
3箇所でチェックしないとダメです。気をつけましょう。

もっと簡単な方法ないの?

ありました。on-finishedというパッケージ。このパッケージはExpress.jsでも使われています。

import onFinished from "on-finished";

function middleware(req, res, next) {
    req.someResource = allocResource();

    onFinished(res, () => {
        freeResource(req.someResource);
    });
 
    next();
}
これだけで済んだ!先人の知恵(パッケージ)はありがたく使わせてもらいましょう。

さらなる工夫に向けて

お気づきの人もいるでしょうが、この方法だとリソースを使っても使わなくてもとりあえず確保されちゃうんですよね。リソースは必要なときにだけ確保するように工夫したいものです。

ん、簡単じゃね?と思った方は、試しにやってみてください。そしてハマってください

どんな工夫をすればいいか、そしてどうハマるか、どうやって抜け出すか…それは次回のお楽しみ。

0 件のコメント:

コメントを投稿