makoto-developer's テックブログ

Goパフォーマンスチューニング実践

Goは標準的な実装でも十分に高速だが、適切な最適化を行うことで、さらに50-70%の性能向上が可能である。本記事では、実際のプロジェクトで効果があった手法を紹介する。

メモリ管理の最適化

高トラフィックなWebアプリケーションでは、メモリの使い方が性能に大きく影響する。

GoのGC(ガベージコレクタ)の特徴

Goのガベージコレクタは、他の言語と比較して独自の設計になっている。

一般的な言語のGC(Java、C#など)

  • 世代別GC(若い世代、古い世代)
  • メモリコンパクション(断片化解消)

GoのGC

  • 並行マーク&スイープ方式
  • スレッドローカルアロケーション

メモリアロケータの仕組み

Goのランタイムは、オブジェクトのサイズに応じて異なる戦略を使用する:

サイズ分類 範囲 割り当て方法
極小 < 16バイト Tiny アロケータ
≤ 32KB サイズクラス別プール
> 32KB 直接ヒープ割り当て

アロケーションの流れ

  1. P(プロセッサ)ローカルキャッシュから取得を試みる
  2. キャッシュが空ならサイズ別の共有プールから補充
  3. プールが空なら中央ヒープから新規割り当て
  4. ヒープが不足ならOSにメモリを要求

この多段階の仕組みにより、並行処理でもロック競合を最小限に抑えている。

高頻度アロケーションの問題

例えば、毎秒8,000リクエストを処理するAPIサーバーで、リクエストあたり平均6個のオブジェクトを生成すると:

  • 1秒間のアロケーション: 48,000回
  • 1時間のアロケーション: 1億7,280万回

これらすべてのオブジェクトがGCの監視対象となり、以下の影響が出る:

  • GCの実行頻度が増加
  • STW(Stop The World)時間が延びる
  • レスポンスタイムのばらつきが大きくなる

スタックとヒープの違い

Goコンパイラはエスケープ解析により、変数をスタックとヒープのどちらに配置するか決定する。

// ケース1: スタック割り当て
func calculateSum(x, y int) int {
    total := x + y
    return total  // totalは関数内で完結
}

// ケース2: ヒープ割り当て
func createBuffer() *[]byte {
    buf := make([]byte, 1024)
    return &buf  // bufのポインタを返すため、ヒープに配置される
}

エスケープ解析の確認方法

コンパイル時に -gcflags オプションを使うと、どの変数がヒープに移動されるか確認できる:

go build -gcflags='-m -l' main.go

出力例:

main.go:5:6: can inline calculateSum
main.go:10:14: make([]byte, 1024) escapes to heap

アロケーションを減らすテクニック

1. ジェネリクスを活用する

改善前: interface{}を使用

// interface{}への変換でアロケーションが発生
func display(value interface{}) {
    fmt.Println(value)
}

func main() {
    display(100)  // int → interface{} の変換でヒープ割り当て
}

改善後: ジェネリクスを使用(Go 1.18以降)

// 型パラメータを使用してアロケーションを回避
func display[T any](value T) {
    fmt.Println(value)
}

func main() {
    display(100)  // アロケーションなし
}

2. スライスの容量を事前確保

改善前: 動的に拡張

// スライスが成長するたびに再割り当てが発生
func collectNumbers() []int {
    var numbers []int
    for i := 0; i < 500; i++ {
        numbers = append(numbers, i)
    }
    return numbers
}

改善後: 容量を事前に指定

// 最初に必要な容量を確保
func collectNumbers() []int {
    numbers := make([]int, 0, 500)
    for i := 0; i < 500; i++ {
        numbers = append(numbers, i)
    }
    return numbers
}

性能差:

改善前: 約42,000 ns/op, 65,536 B/op, 12 allocs/op
改善後: 約15,000 ns/op,  4,096 B/op,  1 allocs/op

3. 小さな構造体は値渡し

改善前: ポインタ渡し

type Coordinate struct {
    Lat, Lon float64
}

// ポインタを返すとヒープに配置される
func newCoordinate(lat, lon float64) *Coordinate {
    return &Coordinate{Lat: lat, Lon: lon}
}

改善後: 値渡し

// 小さな構造体は値で返す
func newCoordinate(lat, lon float64) Coordinate {
    return Coordinate{Lat: lat, Lon: lon}
}

目安として、16バイト以下の構造体は値渡しの方が効率的である。

4. ループ変数のクロージャキャプチャに注意

改善前: ループ変数がエスケープ

func startWorkers() {
    for i := 0; i < 5; i++ {
        go func() {
            // iがクロージャにキャプチャされ、ヒープに配置
            fmt.Println(i)
        }()
    }
}

改善後: 引数として渡す

func startWorkers() {
    for i := 0; i < 5; i++ {
        go func(id int) {
            // 引数として受け取るため、エスケープしない
            fmt.Println(id)
        }(i)
    }
}

5. sync.Poolでオブジェクトを再利用

頻繁に生成・破棄されるオブジェクトは、sync.Poolで再利用できる。

var requestPool = sync.Pool{
    New: func() interface{} {
        return &Request{
            Headers: make(map[string]string, 10),
            Body:    make([]byte, 0, 1024),
        }
    },
}

func handleRequest(data []byte) error {
    // プールから取得
    req := requestPool.Get().(*Request)
    defer func() {
        // 使用後はリセットしてプールに返却
        req.Reset()
        requestPool.Put(req)
    }()

    // リクエスト処理
    return processRequest(req, data)
}

効果:

Pool使用前: 6,200 ns/op, 2,560 B/op, 6 allocs/op
Pool使用後: 4,100 ns/op,   640 B/op, 2 allocs/op

よくある性能低下のパターンと対策

実際のプロジェクトでよく見かける性能問題とその解決策を紹介する。

1. ループ内での文字列結合

問題のあるコード

// 文字列はイミュータブルなため、毎回新しい文字列が作成される
func joinItems(items []string) string {
    var output string
    for _, item := range items {
        output += item + ","
    }
    return output
}

+=操作で、既存の文字列と新しい文字列を連結した結果が新しいメモリ領域に作成される。

改善策: strings.Builderを使用

func joinItems(items []string) string {
    var builder strings.Builder
    // 概算サイズを事前に確保
    builder.Grow(len(items) * 15)

    for _, item := range items {
        builder.WriteString(item)
        builder.WriteString(",")
    }
    return builder.String()
}

この変更で、処理時間が10分の1以下になったケースもある。

2. HTTPクライアントの使い回し

問題のあるコード

// 毎回新しいクライアントを作成
func fetchData(url string) ([]byte, error) {
    client := &http.Client{
        Timeout: 5 * time.Second,
    }
    resp, err := client.Get(url)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    return io.ReadAll(resp.Body)
}

新しいクライアントを作るたびに、コネクションプール、DNSキャッシュなどが初期化される。

改善策: グローバルクライアントを使用

var defaultClient = &http.Client{
    Timeout: 5 * time.Second,
    Transport: &http.Transport{
        MaxIdleConns:        50,
        MaxIdleConnsPerHost: 5,
        IdleConnTimeout:     60 * time.Second,
    },
}

func fetchData(url string) ([]byte, error) {
    resp, err := defaultClient.Get(url)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    return io.ReadAll(resp.Body)
}

3. ループ内でのdefer

問題のあるコード

func processAllFiles(filenames []string) error {
    for _, name := range filenames {
        file, err := os.Open(name)
        if err != nil {
            return err
        }
        defer file.Close()  // 関数終了まで実行されない

        // ファイル処理
    }
    return nil
}

deferは関数終了時に実行されるため、ループ内で使うとファイルが閉じられずに蓄積する。

改善策: 関数に分離

func processAllFiles(filenames []string) error {
    for _, name := range filenames {
        if err := processOneFile(name); err != nil {
            return err
        }
    }
    return nil
}

func processOneFile(filename string) error {
    file, err := os.Open(filename)
    if err != nil {
        return err
    }
    defer file.Close()  // この関数終了時に確実に閉じられる

    // ファイル処理
    return nil
}

4. 大きな構造体の値コピー

問題のあるコード

type Session struct {
    UserID      string
    Username    string
    Permissions []string
    Settings    map[string]interface{}
    History     []Event
    // ... 他に30個のフィールド
}

// 構造体全体がコピーされる
func validateSession(s Session) bool {
    return s.UserID != "" && len(s.Permissions) > 0
}

改善策: ポインタ渡し

// ポインタを使用してコピーを回避
func validateSession(s *Session) bool {
    return s.UserID != "" && len(s.Permissions) > 0
}

この変更で、メモリ使用量が30%削減されたケースがある。

5. バッファなしチャネル

問題のあるコード

// バッファなしチャネルは送受信が同期的
func distributeWork(jobs []Job) {
    ch := make(chan Job)

    go func() {
        for job := range ch {
            execute(job)
        }
    }()

    for _, job := range jobs {
        ch <- job  // 受信側が処理するまでブロック
    }
    close(ch)
}

改善策: 適切なバッファサイズ

func distributeWork(jobs []Job) {
    ch := make(chan Job, 50)  // バッファを追加

    var wg sync.WaitGroup
    // 複数ワーカーで並列処理
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range ch {
                execute(job)
            }
        }()
    }

    for _, job := range jobs {
        ch <- job  // バッファがあるためブロックしにくい
    }
    close(ch)
    wg.Wait()
}

6. JSON エンコーダの再作成

問題のあるコード

func toJSON(data interface{}) ([]byte, error) {
    var buffer bytes.Buffer
    enc := json.NewEncoder(&buffer)
    if err := enc.Encode(data); err != nil {
        return nil, err
    }
    return buffer.Bytes(), nil
}

改善策: バッファプールを使用

var bufferPool = sync.Pool{
    New: func() interface{} {
        return new(bytes.Buffer)
    },
}

func toJSON(data interface{}) ([]byte, error) {
    buf := bufferPool.Get().(*bytes.Buffer)
    buf.Reset()
    defer bufferPool.Put(buf)

    enc := json.NewEncoder(buf)
    if err := enc.Encode(data); err != nil {
        return nil, err
    }

    // バッファを返却するため、コピーが必要
    result := make([]byte, buf.Len())
    copy(result, buf.Bytes())
    return result, nil
}

7. contextのキャンセルを無視

問題のあるコード

func fetchAllData(ctx context.Context, urls []string) []Result {
    results := make([]Result, len(urls))

    for i, url := range urls {
        // contextを確認していない
        results[i] = fetch(url)
    }

    return results
}

ユーザーがリクエストをキャンセルしても、処理が継続される。

改善策: contextを尊重

func fetchAllData(ctx context.Context, urls []string) ([]Result, error) {
    results := make([]Result, len(urls))
    errCh := make(chan error, len(urls))

    var wg sync.WaitGroup
    for i, url := range urls {
        wg.Add(1)
        go func(idx int, u string) {
            defer wg.Done()

            // contextを使ってリクエスト作成
            req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
            if err != nil {
                errCh <- err
                return
            }

            resp, err := defaultClient.Do(req)
            if err != nil {
                errCh <- err
                return
            }
            defer resp.Body.Close()

            results[idx] = parseResponse(resp)
        }(i, url)
    }

    wg.Wait()
    close(errCh)

    if len(errCh) > 0 {
        return nil, <-errCh
    }
    return results, nil
}

実用的なGoの最適化テクニック

1. マップとスライスの初期容量

// 改善前
func process(n int) map[string]int {
    m := make(map[string]int)  // 容量指定なし
    for i := 0; i < n; i++ {
        m[fmt.Sprintf("key%d", i)] = i
    }
    return m
}

// 改善後
func process(n int) map[string]int {
    m := make(map[string]int, n)  // 容量を指定
    for i := 0; i < n; i++ {
        m[fmt.Sprintf("key%d", i)] = i
    }
    return m
}

性能差:

改善前: 950,000 ns/op, 580,000 B/op, 15 allocs/op
改善後: 450,000 ns/op, 290,000 B/op,  3 allocs/op

2. ジェネリクスによる型安全

// Go 1.18以降
func findMax[T constraints.Ordered](items []T) T {
    if len(items) == 0 {
        var zero T
        return zero
    }

    max := items[0]
    for _, item := range items[1:] {
        if item > max {
            max = item
        }
    }
    return max
}

interface{}を使った実装と比較して、型安全性を保ちながら同等以上の性能が得られる。

3. []byte で処理を完結

string ⇔ []byte の変換はコストがかかるため、可能な限り[]byteで処理する。

// 改善前: 文字列で処理
func hashStrings(lines []string) [32]byte {
    hasher := sha256.New()
    for _, line := range lines {
        hasher.Write([]byte(line))  // 変換コスト
    }
    return [32]byte(hasher.Sum(nil))
}

// 改善後: バイトスライスで処理
func hashBytes(lines [][]byte) [32]byte {
    hasher := sha256.New()
    for _, line := range lines {
        hasher.Write(line)  // 変換なし
    }
    return [32]byte(hasher.Sum(nil))
}

性能差:

文字列版: 550,000 ns/op, 180,000 B/op, 40 allocs/op
バイト版: 280,000 ns/op,  40,000 B/op, 10 allocs/op

4. 並行処理の数を制限

func processURLs(ctx context.Context, urls []string, maxConcurrent int) error {
    sem := make(chan struct{}, maxConcurrent)
    var wg sync.WaitGroup
    errCh := make(chan error, len(urls))

    for _, u := range urls {
        wg.Add(1)
        sem <- struct{}{}  // セマフォ獲得

        go func(url string) {
            defer func() {
                <-sem  // セマフォ解放
                wg.Done()
            }()

            if err := processURL(ctx, url); err != nil {
                errCh <- err
            }
        }(u)
    }

    wg.Wait()
    close(errCh)

    if len(errCh) > 0 {
        return <-errCh
    }
    return nil
}

5. time.Ticker の適切な停止

func periodicTask(interval time.Duration, task func()) {
    ticker := time.NewTicker(interval)
    defer func() {
        ticker.Stop()
        // チャネルをドレイン
        select {
        case <-ticker.C:
        default:
        }
    }()

    for {
        select {
        case <-ticker.C:
            task()
        }
    }
}

実例:認証APIの最適化

問題の状況

あるプロジェクトの認証APIサーバーで以下の問題が発生:

  • ピーク時: 45,000 req/s
  • P99レイテンシ: 18ms(目標: < 5ms)
  • GC停止時間: 10-15ms(数秒ごと)

最適化前のコード

func (h *AuthHandler) Verify(w http.ResponseWriter, r *http.Request) {
    bodyBytes, _ := ioutil.ReadAll(r.Body)

    var payload AuthPayload
    json.Unmarshal(bodyBytes, &payload)

    // トークン検証
    tokenData := make(map[string]interface{})
    parsedToken, _ := jwt.ParseWithClaims(payload.Token, tokenData, h.keyFunc)

    // レスポンス生成
    response := map[string]interface{}{
        "valid":  parsedToken.Valid,
        "userId": tokenData["sub"],
    }

    json.NewEncoder(w).Encode(response)
}

リクエストあたりのアロケーション: 9回

最適化後のコード

type AuthHandler struct {
    bufferPool sync.Pool
}

func NewAuthHandler() *AuthHandler {
    return &AuthHandler{
        bufferPool: sync.Pool{
            New: func() interface{} {
                return bytes.NewBuffer(make([]byte, 0, 512))
            },
        },
    }
}

func (h *AuthHandler) Verify(w http.ResponseWriter, r *http.Request) {
    // バッファをプールから取得
    buf := h.bufferPool.Get().(*bytes.Buffer)
    defer func() {
        buf.Reset()
        h.bufferPool.Put(buf)
    }()

    buf.ReadFrom(r.Body)

    var payload AuthPayload  // スタック割り当て
    json.Unmarshal(buf.Bytes(), &payload)

    // 構造体を使用してアロケーション削減
    var claims JWTClaims  // スタック割り当て
    parsedToken, _ := jwt.ParseWithClaims(payload.Token, &claims, h.keyFunc)

    // 直接書き込み
    w.Header().Set("Content-Type", "application/json")
    fmt.Fprintf(w, `{"valid":%t,"userId":"%s"}`, parsedToken.Valid, claims.Subject)
}

リクエストあたりのアロケーション: 1回

最適化の結果

  • スループット: 78,000 req/s(+73%)
  • P99レイテンシ: 3.5ms(-81%)
  • GC停止時間: < 2ms(-80%)
  • メモリ使用量: 45%削減

プロファイリングツール

CPU プロファイリング

# ベンチマークでCPUプロファイル取得
go test -bench=. -cpuprofile=cpu.prof

# プロファイル確認
go tool pprof cpu.prof
(pprof) top10
(pprof) list functionName

メモリプロファイリング

# メモリプロファイル取得
go test -bench=. -memprofile=mem.prof

# プロファイル確認
go tool pprof mem.prof
(pprof) top10
(pprof) list functionName

トレース

# トレース情報を取得
go test -bench=. -trace=trace.out

# トレースビューアで確認
go tool trace trace.out

まとめ

Goアプリケーションの性能最適化では、以下のポイントが重要である:

  1. 測定してから最適化

    • 推測ではなく、プロファイラーで実測
    • ボトルネックを特定してから対処
  2. 正確性 > 可読性 > 性能

    • まず正しく動くコードを書く
    • 読みやすさを保つ
    • 必要な箇所だけ最適化
  3. アロケーション削減が最重要

    • GC負荷を減らすことが最も効果的
    • メモリプールやバッファの再利用
  4. 小さな改善の積み重ね

    • 一発で解決する方法はない
    • 地道な最適化の積み重ねが重要

これらの手法を組み合わせることで、実環境で50-70%の性能向上を達成できる。

ただし、過度な最適化はコードの保守性を損ねる。性能要件とのバランスを考えながら、必要な部分だけを最適化すべきである。

チェックリスト

最適化を行う際の確認事項:

- [ ] プロファイラーでボトルネックを特定済み
- [ ] マップ・スライスの容量を事前に指定
- [ ] 文字列連結には `strings.Builder` を使用
- [ ] 処理のメインパスは `[]byte` で完結
- [ ] 頻繁に生成されるオブジェクトは `sync.Pool` を検討
- [ ] 並行処理数を適切に制限
- [ ] すべての箇所で `context.Context` を伝播
- [ ] `time.Ticker` と `time.Timer` を確実に停止
- [ ] ホットパスでは `defer` を避ける
- [ ] 最適化前後で性能を測定・比較

References