匿名チャットの実装言語にGoを選んだ理由
はじめに
趣味で匿名チャットサービスを作っています。実装言語はGoです。作りはじめる前、「チャットみたいに通信待ちが多い処理には、Node.jsのイベントループの方が向いているのでは?」と迷いました。
結論から言うと、性能で圧勝したからGoにしたわけではありません。どちらでも作れます。それでもGoを選んだ理由を、プログラミング初心者の方にもわかるように書きます。
この記事の内容は次のとおりです。
- まず、作っているものの大きさ
- サーバーが1人の接続に対してやっていること
- お店のたとえで理解する
- 「待つ」性能そのものは、ほぼ同じ
- 「人数が増えると配信が重くなる」は、今回は起きない
- それでもGoにした理由
- Goにしたことで、自分が払っているコスト
- まとめ
まず、作っているものの大きさ
理由を話す前に、前提を置きます。ここが違うと結論も変わるからです。
| 1つのルームの人数 | 2人か3人 |
| 同時につながる人数の上限 | 1,000人 |
| サーバーの台数 | 小さいサーバー2台 |
1つのルームは最大3人です。ここが後で効いてきます。
サーバーが1人の接続に対してやっていること
チャットサーバーは、つながっている1人に対して、同時に3つのことを見張っています。
3つとも、ほとんどの時間は何かが起きるのを待っているだけです。ここが「チャットは待ち時間だらけ」と言われる部分です。
お店のたとえで理解する
Node.js(イベントループ)= 店員が1人だけのお店
店員が1人しかいません。でもこの店員はとても要領がよくて、お客さんAの料理が焼けるのを待っている間に、お客さんBの注文を聞いたり、お客さんCにお会計を出したりできます。「待ち時間」を無駄にしないのが得意です。
その代わり、1人の店員が全員のことを覚えておかないといけません。「Aさんは今どこまで進んだか」を店員の頭の中(コールバックやasync/await)で管理することになります。
Go(goroutine)= お客さん1人に専属の店員がつくお店
Goでは、お客さん1人につき専属の店員(goroutine)がつきます。専属なので、その店員は「Aさんのことだけ」を上から順に考えればよく、他のお客さんの事情を覚えておく必要がありません。
そして、この店員の人件費(メモリ)がとても安い。1人あたり数キロバイトから始まるので、1,000人 × 3係 = 3,000人の店員を雇っても、小さいサーバーで足ります。
「待つ」性能そのものは、ほぼ同じ
正直に書いておきます。お客さんが待っている間、店の裏側の仕組み(どの接続に動きがあったか通知するOSの機能)は、Node.jsもGoもほぼ同じものを使っています。「ただ待つ」性能で、両者に意味のある差はありません。
「Goは複数のCPUコアを使えるから速い」という説明もよく見ますが、今回のサービスには当てはまりません。理由は次で書きます。
「人数が増えると配信が重くなる」は、今回は起きない
チャットの説明でよく出てくるのが「1人が発言したら全員に配らないといけないから、人数が多いと大変」という話です。1つのルームに100人いるサービスなら、これは本当です。
でも今回のルームは最大3人です。1回の発言で送る先は多くて3つ。どんな言語でも一瞬で終わります。ここでGoが有利になることはありません。
さらに、今回はサーバーを2台に分けているので、別のサーバーにつながっている相手への配送は、Redisという別のソフトに任せています。つまり「配る仕事」の重い部分は、そもそもGoが担当していません。ここも言語選びの理由にはなりませんでした。
それでもGoにした理由
効いた順に4つ書きます。それぞれ「Node.jsならどう書くか」と並べます。
理由1: 1人分の処理を、起きる順番のまま1本に書ける(可読性)
さっきの「専属店員」がここで効きます。専属の店員は1人のお客さんだけを見ればいいので、コードも「メッセージを読む → 中身を確認する → ルームに配る」と、起きる順番のまま上から書けます。
// Go: 「発言があるまでここで待つ」と書ける
data, err := c.ws.ReadMessage()
c.svc.handleFrame(ctx, c, data) // 続きはすぐ下に書く
// Node.js: 「発言があったら、この関数を呼んで」と関数を預ける
ws.on("message", (data) => {
handleFrame(data); // 続きは、預けた関数の中に入る
});
違い: Goは「待つ」と書けます。Node.jsは待てないので、「起きたら呼んで」と関数を預ける形になり、処理の続きが別の場所に移ります。1つ2つならどうということはありませんが、1つの接続に読む係・書く係・生存確認係の3つがあり、それぞれが待ち受けを持つので、預ける関数がどんどん増えていきます。
「止まったまま待つ」と書いても、止まるのはこの人の専属店員だけで、他の999人は止まりません。素朴に書けるのに効率は落ちない、これが今回いちばん効きました。
理由2: 「どれか1つが起きるまで待つ」を1か所に書ける(可読性)
書く係(これも専属店員の1人です)は、3つのことを同時に待っています。「送るものが届いた」「生存確認の時間になった」「接続が終わった」。
// Go: 3つの待ち合わせが1か所に並ぶ
select {
case msg := <-c.out: // 送るものが届いた
case <-ping.C: // 生存確認の時間になった
case <-c.done: // 接続が終わった
}
// Node.js: 3つが別々の場所に散る
queue.on("message", ...); // 送るものが届いた
setInterval(ping, 30000); // 生存確認の時間になった
ws.on("close", ...); // 接続が終わった
違い: どちらも動きますが、Node.jsでは「3つのうちどれが先に起きたか」が1か所に見えません。終了処理をどれが担当するのかも、書く人が決めて散らばった場所に配ることになります。Goは1か所に並ぶので、読めば全部わかります。
チャットサーバーはこの「複数の待ち合わせ」だらけです。メッセージをまとめてデータベースに書く処理でも、「100件たまった」「200ミリ秒経った」「サーバーが終了する」を同じ形で待っています。
理由3: 自分が書ける言語だった
身も蓋もないですが、個人開発では、慣れた言語で早く動かすことが言語の性能差より効きます。
違い: ここに言語の優劣はありません。Node.jsに慣れている人なら、逆の結論になって当然です。
理由4: 単一バイナリで、1つのアプリにまとめやすい
今回は1つのアプリの中に、マッチング・チャット・トピック・通報といった機能を並べています(モジュラーモノリスといいます)。Goは全部まとめて1つの実行ファイルになるので、サーバーに置いて動かすだけで済みます。
違い: Node.jsはNode本体とnode_modulesを一緒に持っていく必要があります。大した手間ではないので、差は小さいです。
効いた順のまとめ
| 順位 | 理由 | どこで効いたか |
|---|---|---|
| 1 | 順番のまま書ける | コード全体の形。1接続 = 3係 という構造がそのままコードになった |
| 2 | select で待ち合わせ | 書く係・生存確認・一括保存と、同じ形が何度も出てきた |
| 3 | 自分が書ける | 着手の速さ。ただし言語の性質ではない |
| 4 | 単一バイナリ | デプロイのときだけ |
3位の「自分が書ける」は、選ぶ時点では実は1位でした。書き終えてみると、1位・2位の書きやすさの方が、毎日触るぶんだけ効いていたという順番です。
Goにしたことで、自分が払っているコスト
いいことばかりではありません。専属店員が何千人もいるということは、店員同士がぶつからないように気をつける必要があるということです。店員が1人しかいないNode.jsなら、そもそも起きなかった手間です。
- 共有するものに鍵をかける: 「どのルームに誰がつながっているか」の一覧は全店員が触るので、書き換えるときは他の店員を入れないようにしています。この仕組みを mutex(ミューテックス)と呼びます。mutual exclusion = 相互排他の略で、「鍵」ではなく「同時に入れるのは1人だけ」という意味の名前です。かけ忘れると、原因のわかりにくいバグになります。
- 店員を帰らせ忘れない: 接続が切れたのに専属店員が待ち続けると、その人数分メモリが残り続けます(goroutineリーク)。「もう終わり」を伝える専用の合図を用意して、3つの係が全員帰るようにしています。
- 遅いお客さんは切る: 送るものを受け取れない接続が1つあると、そこで詰まります。今回は「32件たまっても受け取れない接続は切断する」ことにしました。待ってあげる実装にすると、同じルームの他の人がその人を待つことになるからです。
- データベースへの書き込みは配信経路から外す: メッセージの保存を毎回その場でやると、保存の往復時間がそのまま会話の遅さになります。いったんバッファに積んで、100件たまるか200ミリ秒経つかで、まとめて1回で書いています。
まとめ
「チャットは通信待ちが多いから、待つのが得意なNode.jsの方がいいのでは?」という発想は、間違いではありません。実際、待つ性能自体はほとんど同じですし、今回のように1ルーム最大3人なら、配信の重さで差もつきません。Node.jsでも作れました。
それでもGoにしたのは、性能ではなく書き方の理由です。接続1本ごとに専属の担当を立てて、待ち受けを上から順に読めるコードで書ける。それが数千本あっても小さなサーバーに収まる。そして「複数の待ち合わせ」を言語の標準の書き方で表現できる。チャットサーバーは全体がこの形をしているので、コードが素直になりました。
言語選びは「どちらが速いか」より、「作ろうとしているものの形に、言語の書き方が合っているか」で決めていい、というのが今回の実感です。