Article

Go言語でゲームサーバーを作って分かった、並行処理の本当の難しさ

高田晃太郎
Go言語でゲームサーバーを作って分かった、並行処理の本当の難しさ

Googleの“Tail at Scale”は、平均ではなく遅延の尾(p99、遅延の上位1%の境界)が体験を決めると指摘しています[1]。分散化が進んだオンラインゲームやリアルタイムWebでは、1%の遅いリクエストがマッチングの破綻や入力遅延として表面化し、結果的に継続率や課金に直結します。Go言語は軽量スレッド(goroutine)とチャネルで並行処理を書きやすくしてくれますが、ゲームサーバーの現場で分かったのは、書けることと運用に耐えることの間に深い溝があるという事実でした。ログとプロファイラを突き合わせると、スループットの壁はCPUではなく、キャンセルされないgoroutine、制御不能なバッファ、タイマの使い捨てといった“ささやかな実装”に潜むことが見えてきます[4]。ここではオンラインゲームサーバーやWeb APIにも通じる実装の要点に絞り、計測に基づく設計原則と具体的なコードを提示します。

遅延の尾を太らせるもの:goroutineの寿命と背圧

最初のバージョンでは接続ごとにread-loopとwrite-loopを起動し、ゲームロジックでさらにサブタスクを生成していました。プロファイル上はゴルーチン数がピークで数十万規模に達するケースがあり、GC時間がフレーム境界を越えて揺れが増幅される瞬間が生まれます。平均レイテンシーは許容範囲でも、p99が数倍に膨らむとプレイヤーの体感は明確に悪化します。根本には、キャンセルが伝播しないタスク、無制限のチャネル、そして負荷集中時に流量を抑えない設計(背圧の欠如)がありました[7,12]。

キャンセルを中心に据えたgoroutineの寿命管理

Goのコンテキストは“関数の寿命を確定させる”ための実用的な道具です。接続単位の親コンテキストからゲーム内タスクへと確実に伝播させ、終了条件を明示します[2]。以下はプレイヤー接続のライフサイクルを管理し、読み書きループとゲームロジックを中断可能にした例です。

package main

import (
    "bufio"
    "context"
    "errors"
    "log"
    "net"
    "time"
)

type Player struct{ ID string }

func handleConn(ctx context.Context, conn net.Conn, p Player) error {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()

    errCh := make(chan error, 2)

    go func() { errCh <- readLoop(ctx, conn) }()
    go func() { errCh <- writeLoop(ctx, conn) }()
    go func() { errCh <- gameLogic(ctx, p) }()

    select {
    case <-ctx.Done():
        return ctx.Err()
    case err := <-errCh:
        if err != nil && !errors.Is(err, context.Canceled) {
            log.Printf("player %s error: %v", p.ID, err)
        }
        return err
    }
}

func readLoop(ctx context.Context, conn net.Conn) error {
    r := bufio.NewReader(conn)
    for {
        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
            conn.SetReadDeadline(time.Now().Add(5 * time.Second))
            _, err := r.ReadByte()
            if err != nil {
                return err
            }
        }
    }
}

func writeLoop(ctx context.Context, conn net.Conn) error {
    ticker := time.NewTicker(50 * time.Millisecond)
    defer ticker.Stop()
    for {
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-ticker.C:
            if _, err := conn.Write([]byte("\n")); err != nil {
                return err
            }
        }
    }
}

func gameLogic(ctx context.Context, p Player) error {
    timer := time.NewTimer(200 * time.Millisecond)
    defer timer.Stop()
    for {
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-timer.C:
            // tick-based update
            // ...
            if !timer.Stop() { <-timer.C }
            timer.Reset(200 * time.Millisecond)
        }
    }
}

よくある“time.After”の使い捨てはゴルーチンやタイマ資源を毎回生成し続け、未消費のチャネルが残ると解放が遅れてメモリ使用量が増えるリスクがあります。上のようにtime.NewTimerを再利用するだけでメモリとスケジューラの圧迫を和らげられることが一般に知られています[3,7]。

背圧の欠如は雪だるま式のp99を招く

負荷が上がるときに入力を受け続ける設計は、内部キューにタスクが堆積して“古い仕事”を処理する時間の方が長くなるという逆説を生みます。一定数のワーカーに絞り、キューを有限にして溢れたら落とすかリトライ戦略に委ねると、遅延の尾を狭められます[5]。以下はドロップポリシー付きのワーカープールで、キャンセルと締切を尊重します。

package main

import (
    "context"
    "errors"
    "log"
    "time"
)

type Job struct{ Payload []byte }

var ErrQueueFull = errors.New("queue full")

type Pool struct {
    q chan Job
}

func NewPool(workers, capacity int) *Pool {
    p := &Pool{q: make(chan Job, capacity)}
    for i := 0; i < workers; i++ {
        go func(id int) {
            for job := range p.q {
                // do work; observe context via payload or ambient
                _ = job
            }
        }(i)
    }
    return p
}

func (p *Pool) Submit(ctx context.Context, j Job) error {
    select {
    case p.q <- j:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    case <-time.After(2 * time.Millisecond):
        return ErrQueueFull
    }
}

func main() {
    p := NewPool(256, 8192)
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
    defer cancel()
    if err := p.Submit(ctx, Job{Payload: []byte("x")}); err != nil {
        if errors.Is(err, ErrQueueFull) {
            log.Println("shed load")
        }
    }
}

負荷試験ツール(例:wrk、vegeta)で比較すると、無制限キュー版に比べてp95は同等でもp99が顕著に改善する傾向が観測されます[9,10]。ゲームでは視覚的な遅延が短縮されやすく、Web APIでもクライアント側のキュー肥大化を防げます。なお、改善幅はワークロードや環境に依存します。

共有状態と一貫性:ロックかアクタか

ゲームサーバーで難しいのは“同じプレイヤーの状態に対する同時更新”です。単純なRWMutexは読みの高いケースで効きますが、ホットキーが発生すると書き込み待ちの行列が伸び、ロックコンボイで尾を太らせます。ロックを細分化してもスケールの節目で再び現れるため、エンティティ単位の直列化、すなわちアクタ(メールボックス。1エンティティ=1専用ゴルーチンで順序処理)への転換が有効でした[6]。

package actor

import (
    "context"
)

type Msg interface{}

type Actor struct {
    inbox chan Msg
}

func NewActor(buf int, handler func(context.Context, Msg)) *Actor {
    a := &Actor{inbox: make(chan Msg, buf)}
    go func() {
        ctx := context.Background()
        for m := range a.inbox {
            handler(ctx, m)
        }
    }()
    return a
}

func (a *Actor) Tell(ctx context.Context, m Msg) error {
    select {
    case a.inbox <- m:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

単一ゴルーチンで順序保証されるため、同一エンティティの更新はロック不要で安全になります。シャードキーでアクタを分割し、クロスシャードのトランザクションは補償動作やイベント整合性で扱います。Web開発でもショッピングカートやウォレットの更新に同じ発想が直結します。ストレージはスナップショットとイベントログの組み合わせが現実的で、スナップショットを軽量KV(例:Redis)に置き、イベントは永続ログに書き出すと再構築が安定します。

マップとロックに頼る場合の“最後の一押し”

どうしても共有マップで戦う場合は、ホットキーの分割とフォールスシェアリング対策が効きます。値をパディングしてキャッシュライン競合を避け、配列シャーディングでロック粒度を固定化し、書き込みをバッチ化して待ち時間をまとめます。以下は簡略化したシャーディングマップの例です。

package shardmap

import (
    "hash/fnv"
    "sync"
)

type bucket struct {
    mu sync.RWMutex
    m  map[string]int64
}

type ShardedMap struct {
    b []bucket
}

func New(n int) *ShardedMap {
    s := &ShardedMap{b: make([]bucket, n)}
    for i := range s.b {
        s.b[i].m = make(map[string]int64)
    }
    return s
}

func (s *ShardedMap) getBucket(k string) *bucket {
    h := fnv.New32a()
    _, _ = h.Write([]byte(k))
    return &s.b[uint(h.Sum32())%uint(len(s.b))]
}

func (s *ShardedMap) Inc(k string, d int64) int64 {
    b := s.getBucket(k)
    b.mu.Lock()
    defer b.mu.Unlock()
    b.m[k] += d
    return b.m[k]
}

この手法はアクタよりもメモリ効率が良い一方で、順序保証や複合操作の安全性はコード側で意識する必要があります。どちらを選ぶかはドメインの一貫性要件とスループット目標で決めるのが妥当です。

タイマ、スケジューラ、GC:静かな三重奏の実務

タイマとスケジューラの相互作用は体感に直結します。tick駆動のゲームループにおける“selectのdefault分岐”は空回りを招き、CPUバウンド化してスケジューラのプリエンプションでさらに揺れが増します。タイマは再利用し、selectにdefaultを多用しないのが安定化の第一歩でした。もうひとつの実務はGCと割り当ての管理です。パケットのデコードや一時バッファを使い捨てると、ピークでヒープが広がりGCパウズが蓄積します。sync.Poolは使いどころを選びますが、ホットパスに限っては割り当て削減とGC負荷低減が期待できます[7]。

package bufpool

import (
    "sync"
)

var packetPool = sync.Pool{New: func() any { b := make([]byte, 0, 4096); return &b }}

func GetBuf() *[]byte  { return packetPool.Get().(*[]byte) }
func PutBuf(b *[]byte) { *b = (*b)[:0]; packetPool.Put(b) }

この最適化により、1パケットあたりの割り当て回数が減ってGCの停止時間が短くなる事例が知られています[7]。ただしPoolはゴルーチン間の移動で局所性を失う可能性があるため、サイズやライフタイムを観測しながら適用範囲を絞るのが安全です。

計測で語る:ベンチマークとp99

“速そうに見える実装”は信用せず、go testのベンチやwrk/vegeta等の負荷ツールでp50/p95/p99を常に可視化します。以下はジョブ投入の背圧有無を比較するためのマイクロベンチ例です(結果は環境依存で大きく変動します)。

package poolbench

import (
    "context"
    "testing"
    "time"
)

// assume Pool from previous snippet exists

func BenchmarkSubmit_WithBackpressure(b *testing.B) {
    p := NewPool(256, 8192)
    ctx := context.Background()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        _ = p.Submit(ctx, Job{Payload: []byte("x")})
    }
}

func BenchmarkSubmit_Unbounded(b *testing.B) {
    // naive queue for comparison
    q := make(chan Job)
    go func() { for range q {} }()
    b.ResetTimer()
    for i := 0; i < b.N; i++ { q <- Job{Payload: []byte("x") } }
}

マイクロでは差が僅かでも、エンドツーエンドで測ると背圧あり構成のp99が改善するケースが多く報告されています[9,10]。効果の大きさはQPS、ペイロード、CPU/メモリ、GOMAXPROCSなどの条件に左右されます。

プロトコル、実装、運用を貫く“締切”の設計

締切(デッドライン)とレート制御は“粗暴に速く”を避けるための安全装置です。コンテキストで締切を一本化し、I/O、ロジック、ストレージに渡り伝播させると、障害時の波及が劇的に小さくなります[2]。以下はgRPCハンドラでの締切伝播と、内部キュー投入の整合を保つ実装例です。

package rpc

import (
    "context"
    "errors"
    "log"
    "time"

    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

type Server struct{ P *Pool }

type Request struct{ Payload []byte }

type Response struct{ Ok bool }

func (s *Server) Do(ctx context.Context, in *Request) (*Response, error) {
    dctx, cancel := context.WithTimeout(ctx, 20*time.Millisecond)
    defer cancel()
    if err := s.P.Submit(dctx, Job{Payload: in.Payload}); err != nil {
        if errors.Is(err, ErrQueueFull) {
            return nil, status.Error(codes.ResourceExhausted, "overloaded")
        }
        if errors.Is(err, context.DeadlineExceeded) {
            return nil, status.Error(codes.DeadlineExceeded, "timeout")
        }
        log.Printf("submit error: %v", err)
        return nil, status.Error(codes.Internal, "internal")
    }
    return &Response{Ok: true}, nil
}

レート制御はトークンバケット(一定レートで許可トークンを発行する方式)が扱いやすく、golang.org/x/time/rateに頼るのが保守的で強い選択です。エッジの入口で落とせば、内側は落ち着きを取り戻します[8]。

package edge

import (
    "context"
    "net/http"
    "time"

    "golang.org/x/time/rate"
)

type Gate struct{ lim *rate.Limiter }

func NewGate(rps, burst int) *Gate { return &Gate{rate.NewLimiter(rate.Limit(rps), burst)} }

func (g *Gate) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 10*time.Millisecond)
    defer cancel()
    if err := g.lim.WaitN(ctx, 1); err != nil {
        w.WriteHeader(http.StatusTooManyRequests)
        return
    }
    w.WriteHeader(http.StatusOK)
    _, _ = w.Write([]byte("ok"))
}

この“締切を貫く”流儀はゲームサーバーにもWeb APIにも同じ効能をもたらします。タイムアウトはユーザー体験を守る最後の防波堤であり、サーバー資源を守る第一の盾でもあります。

観測可能性を先に入れる:壊れ方を早く知る

実装と同時にメトリクスを組み込み、遅延の尾とエラーの種別を分解してダッシュボード化します。OpenTelemetryでヒストグラムを使い、p90/p99を直接観測すると、最適化の方向を外しにくくなります[11]。

package obs

import (
    "context"
    "time"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/metric"
)

var meter = otel.Meter("game/server")

var reqDur metric.Float64Histogram

func init() {
    reqDur, _ = meter.Float64Histogram("request.duration.ms")
}

func Record(ctx context.Context, start time.Time, ok bool) {
    ms := float64(time.Since(start).Milliseconds())
    reqDur.Record(ctx, ms, metric.WithAttributes(attribute.Bool("ok", ok)))
}

インスツルメンテーションを最初から持っておけば、負荷テストのたびに“どこが遅いか”を議論ではなく数字で決められます。併せてk6による負荷試験の設計や、OpenTelemetryでの可観測性強化、gRPCのベストプラクティス、Kubernetes HPAとSLO設計などの関連ナレッジを整えておくと、技術と運用が一本の線でつながります。

コストとSLOの交差点:技術はビジネスを動かす

並行処理の修正は“動作の安定化”に見えますが、実際には明確なビジネス・インパクトを持ちます。背圧、タイマ再利用、メールボックス化(アクタ)の三点を同時に適用すると、レイテンシーSLOの違反率が下がり、オートスケールのスパイクが抑制され、ピーク帯の必要インスタンス数が減る傾向が一般的に見られます。結果としてクラウド費の抑制やオンコール負担の軽減に結びつくことが多く、Go言語の並行処理は“書きやすさ”の次に“止めやすさ”“限りやすさ”を手に入れて初めて戦力になります。Web開発のAPIや配信基盤でも、ここで紹介した実装はそのまま転用可能です。ドメインが違っても、締切伝播、背圧、状態の直列化、観測の四点セットは普遍の土台になります。

まとめ:ゴルーチンは増やすより、終わらせる

今回の教訓は単純です。ゴルーチンは増やすより確実に終わらせる。キューは詰め込むより早めに落とす。共有状態は広げるより局所化して直列化する。そして、最適化は平均ではなくp99で語る。これらを実装で表現するには、コンテキストの伝播、有限キューとレート制御、アクタ的モデリング、タイマ再利用、そして観測の埋め込みが不可欠でした。あなたのシステムでも、まずはひとつのホットパスに締切と背圧を入れ、p99のグラフを見てみませんか。数字が動いたとき、技術の改善がそのままビジネスの改善であることを、きっと実感できるはずです。

参考文献

  1. Dean J, Barroso LA. The Tail at Scale. Google Research. https://research.google/pubs/the-tail-at-scale/
  2. Go package documentation: context. https://pkg.go.dev/context
  3. Go package documentation: time (After, Timer). https://pkg.go.dev/time
  4. あそぼう社ブログ: goroutineリークの注意点. https://www.asobou.co.jp/blog/web/goroutine-leak
  5. Dan Slimmon. The most important thing to understand about queues. https://blog.danslimmon.com/2016/08/26/the-most-important-thing-to-understand-about-queues/
  6. Berb. Actor model overview (diploma thesis excerpt). https://berb.github.io/diploma-thesis/original/054_actors.html
  7. Cockroach Labs. How to optimize garbage collection in Go (using sync.Pool, etc.). https://www.cockroachlabs.com/blog/how-to-optimize-garbage-collection-in-go/
  8. Go rate limiter (golang.org/x/time/rate) docs. https://pkg.go.dev/golang.org/x/time/rate
  9. wrk HTTP benchmarking tool (GitHub). https://github.com/wg/wrk
  10. Vegeta HTTP load testing tool (GitHub). https://github.com/tsenart/vegeta
  11. OpenTelemetry Metrics: Histograms. https://opentelemetry.io/docs/specs/otel/metrics/data-model/
  12. ZOZO Tech Blog: goroutine活用とメモリ・実行時間への影響に関する事例. https://techblog.zozo.com/entry/improve-digdag-task-with-goroutine