Go言語入門:よくある質問 -Performance Vol.1-

スポンサーリンク
Go言語入門:よくある質問 -Performance Vol.1- ノウハウ
Go言語入門:よくある質問 -Performance Vol.1-
この記事は約18分で読めます。
よっしー
よっしー

こんにちは。よっしーです(^^)

本日は、Go言語のよくある質問 について解説しています。

スポンサーリンク

背景

Go言語を学んでいると「なんでこんな仕様になっているんだろう?」「他の言語と違うのはなぜ?」といった疑問が湧いてきませんか。Go言語の公式サイトにあるFAQページには、そんな疑問に対する開発チームからの丁寧な回答がたくさん載っているんです。ただ、英語で書かれているため読むのに少しハードルがあるのも事実で、今回はこのFAQを日本語に翻訳して、Go言語への理解を深めていけたらと思い、これを読んだ時の内容を備忘として残しました。

Implementation

なぜGoはベンチマークXで性能が悪いのか?

Goの設計目標の1つは、同等のプログラムに対してC言語の性能に近づくことですが、いくつかのベンチマークでは、golang.org/x/exp/shootoutのいくつかを含めて、かなり性能が悪いです。最も遅いものは、Goで同等の性能を持つバージョンが利用できないライブラリに依存しています。例えば、pidigits.goは多倍長精度演算パッケージに依存しており、C言語版は、Goとは異なり、GMP(最適化されたアセンブラで書かれています)を使用しています。正規表現に依存するベンチマーク(例えばregex-dna.go)は、本質的にGoのネイティブregexpパッケージと、PCREのような成熟した高度に最適化された正規表現ライブラリを比較しています。

ベンチマークゲームは、広範なチューニングによって勝利します。ほとんどのベンチマークのGoバージョンには注意が必要です。本当に比較可能なCとGoのプログラム(reverse-complement.goが一例です)を測定すれば、この一連のベンチマークが示すよりも、2つの言語の生のパフォーマンスがはるかに近いことがわかります。

それでも、改善の余地はあります。コンパイラは良いですが、もっと良くできます。多くのライブラリは大幅なパフォーマンス作業が必要で、ガベージコレクタはまだ十分に高速ではありません。(たとえそうであっても、不要なゴミを生成しないように注意することは、大きな効果をもたらす可能性があります。)

いずれにせよ、Goはしばしば非常に競争力があります。言語とツールが発展するにつれて、多くのプログラムのパフォーマンスが大幅に改善されています。有益な例については、Goプログラムのプロファイリングに関するブログ投稿を参照してください。かなり古いですが、今でも役立つ情報が含まれています。

解説

この問題は何について説明しているの?

「Goは遅い」という意見を聞いたことがあるかもしれません。特定のベンチマーク(性能測定プログラム)で、GoがC言語やRustより遅い結果が出ることがあります。

でも、これには理由があり、実際のアプリケーションではそれほど問題になりません。

この質問は:

  • なぜGoが一部のベンチマークで遅く見えるのか
  • それは本当に「Goが遅い」ことを意味するのか
  • 実際の性能はどうなのか

を説明しています。

基本的な用語
  • ベンチマーク: プログラミング言語やライブラリの性能を測定するテストプログラム
  • The Computer Language Benchmarks Game: 様々な言語の性能を比較するプロジェクト
  • GMP (GNU Multiple Precision): 高度に最適化された多倍長精度演算ライブラリ
  • PCRE (Perl Compatible Regular Expressions): 高度に最適化された正規表現ライブラリ
  • プロファイリング: プログラムのどの部分が遅いかを分析すること
  • ガベージコレクタ (GC): 自動メモリ管理システム
ベンチマークとは?
例: 単純な速度競争
問題: 1から1億までの素数を数える

C言語: 0.5秒
Rust: 0.6秒
Go: 0.8秒
Python: 15秒

結論: Goは遅い?

でも、これは特定の問題での結果です。

実際のWebアプリケーション
問題: 1秒間に10万リクエストを処理

Go: 10万リクエスト/秒 ✅
C言語: 12万リクエスト/秒 ✅
Python: 1万リクエスト/秒 ❌

Goは「遅い」? いいえ、実用的には十分高速!
なぜGoが一部のベンチマークで遅いのか?
理由1: ライブラリの成熟度の違い

例: 多倍長精度演算 (pidigits)

// Go版
import "math/big"

func calculatePi() {
    // Go標準ライブラリのbigパッケージ
    // Goで書かれている
    pi := new(big.Int)
    // ...計算
}

vs

// C言語版
#include <gmp.h>  // GNU Multi-Precision library

void calculate_pi() {
    // GMPライブラリ
    // 何十年も最適化されたアセンブリコード
    mpz_t pi;
    // ...計算
}

比較:

Go標準ライブラリ:
  - Goで書かれている(読みやすい)
  - 比較的新しい(2009年〜)
  - 汎用的な最適化

GMP:
  - アセンブリで最適化
  - 何十年もの歴史(1991年〜)
  - CPU命令レベルの最適化
  
結果: GMPの方が速い!
でも、Goが悪いわけではない
理由2: 正規表現ライブラリの違い

例: DNA配列マッチング (regex-dna)

// Go版
import "regexp"

func matchDNA(sequence string) {
    // Go標準のregexpパッケージ
    // RE2アルゴリズム(安全だが、やや遅い)
    re := regexp.MustCompile(`pattern`)
    matches := re.FindAllString(sequence, -1)
}

vs

// C言語版
#include <pcre.h>  // Perl Compatible Regular Expressions

void match_dna(char* sequence) {
    // PCREライブラリ
    // 何十年も最適化
    // バックトラッキング(速いが、悪意ある入力で脆弱性も)
    pcre* re = pcre_compile("pattern", ...);
    // ...マッチング
}

比較:

特徴Go regexpPCRE
アルゴリズムRE2 (線形時間保証)バックトラッキング
速度やや遅い通常は速い
安全性ReDoS攻撃に強い脆弱性の可能性
メモリ予測可能悪化する可能性

Goの選択:

  • 安全性を優先
  • 予測可能な性能
  • わずかに遅いが、実用的には十分
理由3: ベンチマークは「ゲーム」
ベンチマークゲームの実態:

1. 問題が公開される
2. 各言語の専門家が最適化競争
3. トリックや言語固有の最適化
4. 何週間もチューニング
5. 最速のコードが登録される

結果:
  各言語の「極限の最適化版」が比較される
  普通のコードではない!

例: C言語のベンチマーク

// 「普通の」Cコード
for (int i = 0; i < n; i++) {
    result += array[i];
}

// ベンチマーク用の「チューニング済み」Cコード
// SIMD命令、ループアンローリング、キャッシュ最適化...
__m256i sum = _mm256_setzero_si256();
for (int i = 0; i < n; i += 8) {
    __m256i v = _mm256_load_si256((__m256i*)&array[i]);
    sum = _mm256_add_epi32(sum, v);
}
// ...複雑な最適化が続く

Go版はまだチューニングが足りない:

C言語: 何十年も最適化の歴史
Rust: 性能重視のコミュニティ
Go: 実用性重視、ベンチマーク最適化は後回し
公平な比較: reverse-complement

問題: DNA配列の相補鎖を生成

// Go版(公平に書かれたバージョン)
package main

import (
    "bufio"
    "bytes"
    "os"
)

var complement = [256]byte{
    'A': 'T', 'T': 'A',
    'C': 'G', 'G': 'C',
    // ...
}

func reverseComplement(data []byte) {
    for i, j := 0, len(data)-1; i < j; i, j = i+1, j-1 {
        data[i], data[j] = complement[data[j]], complement[data[i]]
    }
}

C言語版(同様のアルゴリズム):

// 同じロジック
unsigned char complement[256] = {
    ['A'] = 'T', ['T'] = 'A',
    ['C'] = 'G', ['G'] = 'C',
    // ...
};

void reverse_complement(char* data, int len) {
    for (int i = 0, j = len-1; i < j; i++, j--) {
        char tmp = complement[(unsigned char)data[j]];
        data[j] = complement[(unsigned char)data[i]];
        data[i] = tmp;
    }
}

結果:

C言語: 1.2秒
Go: 1.3秒

差: わずか8%!

本当に比較可能なコードでは、GoとCは非常に近い!

Goの改善の余地
1. コンパイラ

現状:

Goコンパイラ(gc):
  - 高速なコンパイル重視
  - 最適化はそこそこ
  - 開発体験を優先

改善の余地:

可能性:
  - より積極的なインライン化
  - より良いループ最適化
  - SIMD命令の自動使用
  
トレードオフ:
  - コンパイル時間が増える?
  - コードサイズが増える?
2. 標準ライブラリ

現状:

// 例: 文字列操作
strings.Replace(s, "old", "new", -1)
// 実装: 汎用的、読みやすい、そこそこ速い

改善の余地:

可能性:
  - CPU固有の最適化
  - アセンブリによる高速化
  - アルゴリズムの改善
  
例:
  encoding/json パッケージ
  → 外部ライブラリ(json-iterator)の方が速い
  → 標準ライブラリに取り込む?
3. ガベージコレクタ

ガベージコレクション(GC)の影響:

func processData() {
    for i := 0; i < 1000000; i++ {
        // 毎回新しいスライスを作成
        data := make([]byte, 1024)  // GCの仕事が増える!
        process(data)
    }
}

影響:

大量の一時オブジェクト
  ↓
GCが頻繁に実行
  ↓
パフォーマンス低下

vs

C言語:
  手動メモリ管理
  GCなし
  → 速い(ただし、メモリリークのリスク)

Goの改善:

Go 1.5: GC停止時間 300ms
Go 1.6: 40ms
Go 1.8: 1ms以下
Go 1.12: サブミリ秒
Go 1.19: さらに改善

進化し続けている!
不要なゴミを生成しない工夫
悪い例: ゴミを大量生成
func badExample(data []string) string {
    result := ""
    for _, s := range data {
        result += s  // 毎回新しい文字列を作成!
    }
    return result
}

// 問題:
// 1万個の文字列 → 1万回のメモリ確保
// GCの負担が大きい
良い例: ゴミを減らす
func goodExample(data []string) string {
    var builder strings.Builder
    for _, s := range data {
        builder.WriteString(s)  // バッファに追加
    }
    return builder.String()  // 最後に1回だけ確保
}

// 改善:
// 1万個の文字列 → 数回のメモリ確保のみ
// GCの負担が小さい

性能比較:

badExample:  850ms
goodExample: 15ms

約57倍高速!
実際の性能: Webアプリケーション例
シナリオ: JSONを処理するAPIサーバー

Go版:

package main

import (
    "encoding/json"
    "net/http"
)

type Response struct {
    Message string `json:"message"`
    Count   int    `json:"count"`
}

func handler(w http.ResponseWriter, r *http.Request) {
    resp := Response{
        Message: "Hello",
        Count:   42,
    }
    json.NewEncoder(w).Encode(resp)
}

func main() {
    http.HandleFunc("/", handler)
    http.ListenAndServe(":8080", nil)
}

ベンチマーク結果:

wrk -t 4 -c 100 -d 30s http://localhost:8080/

Running 30s test @ http://localhost:8080/
  4 threads and 100 connections
  
Requests/sec:  85,432
Transfer/sec:  12.5MB

Node.js版(比較):

const http = require('http');

http.createServer((req, res) => {
    const resp = {
        message: "Hello",
        count: 42
    };
    res.writeHead(200, {'Content-Type': 'application/json'});
    res.end(JSON.stringify(resp));
}).listen(8080);

ベンチマーク結果:

Requests/sec:  42,156
Transfer/sec:  6.8MB

結論:

Go: 85,432 req/s
Node.js: 42,156 req/s

Goは約2倍速い!
実際のアプリケーションでは、Goは十分高速
プロファイリング: 遅い部分を見つける
Goの組み込みプロファイリング
package main

import (
    "fmt"
    "os"
    "runtime/pprof"
)

func slowFunction() {
    // 何か遅い処理
    sum := 0
    for i := 0; i < 1000000000; i++ {
        sum += i
    }
}

func main() {
    // CPUプロファイリング開始
    f, _ := os.Create("cpu.prof")
    pprof.StartCPUProfile(f)
    defer pprof.StopCPUProfile()
    
    slowFunction()
}

プロファイル解析:

go build -o myapp main.go
./myapp
go tool pprof cpu.prof

(pprof) top
Showing nodes accounting for 2.5s, 95% of 2.63s total
      flat  flat%   sum%        cum   cum%
     1.8s 68.44% 68.44%      1.8s 68.44%  main.slowFunction
     0.7s 26.62% 95.06%      0.7s 26.62%  runtime.memmove

結果: slowFunctionが遅い原因と判明!

ベンチマークの書き方
package main

import "testing"

func BenchmarkGoodExample(b *testing.B) {
    data := []string{"a", "b", "c", "d", "e"}
    
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        goodExample(data)
    }
}

func BenchmarkBadExample(b *testing.B) {
    data := []string{"a", "b", "c", "d", "e"}
    
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        badExample(data)
    }
}

実行:

go test -bench=. -benchmem

BenchmarkGoodExample-8   2000000   750 ns/op   80 B/op   2 allocs/op
BenchmarkBadExample-8     100000  15000 ns/op  500 B/op  20 allocs/op

解釈:

  • goodExampleは20倍速い
  • メモリ確保も10分の1
時間とともに改善されている
Go 1.0 vs Go 1.21 の性能比較
ベンチマーク: HTTPサーバー

Go 1.0 (2012):
  35,000 req/s

Go 1.5 (2015):
  50,000 req/s  (+43%)

Go 1.11 (2018):
  70,000 req/s  (+40%)

Go 1.18 (2022):
  85,000 req/s  (+21%)

Go 1.21 (2023):
  90,000 req/s  (+6%)

継続的に改善!
ガベージコレクタの進化
GC停止時間(Pause Time):

Go 1.5:  300ms  😱
Go 1.6:   40ms  😐
Go 1.8:    1ms  😊
Go 1.12: <1ms   😄
Go 1.19: <0.5ms 🎉

ほぼリアルタイムレベルに!
実用的な性能比較
言語別のユースケース
用途最適な言語理由
数値計算C/C++, Rust生の計算速度
WebサーバーGo, Rust並行処理、開発速度
システムツールGo, Rust単一バイナリ、クロスコンパイル
機械学習Python, C++ライブラリエコシステム
ゲームC++, Rust低レベル制御
マイクロサービスGo軽量、高速起動

Goが輝く場所:

  • ネットワークサービス
  • 並行処理
  • クラウドネイティブ
  • DevOpsツール
まとめ
ベンチマークの真実
  1. 特定のベンチマークでGoが遅い理由
    • ライブラリの成熟度
    • 最適化の歴史
    • チューニング不足
  2. 公平な比較では
    • GoとCは近い性能
    • 実用的には十分高速
  3. 改善は続いている
    • コンパイラの進化
    • GCの改善
    • ライブラリの最適化
実用的な観点
マイクロベンチマーク:
  「Goは遅い」

実際のアプリケーション:
  「Goは十分速い」
  
総合的な生産性:
  「Goは最高!」

考慮すべき要素:

  • 開発速度
  • メンテナンス性
  • デプロイの簡単さ
  • 並行処理の容易さ
  • 標準ライブラリの充実
性能最適化のベストプラクティス
  1. まず測定する go test -bench=. go tool pprof
  2. ボトルネックを特定 全体を最適化しない 遅い部分だけ最適化
  3. GCの負担を減らす // メモリプールを使う var bufferPool = sync.Pool{ New: func() interface{} { return make([]byte, 1024) }, }
  4. 適切なデータ構造 // スライスの事前確保 s := make([]int, 0, expectedSize)
  5. 並行処理を活用 // ゴルーチンで並列化 var wg sync.WaitGroup for _, item := range items { wg.Add(1) go func(i Item) { defer wg.Done() process(i) }(item) } wg.Wait()
現実的な結論
ベンチマーク競争で1位を取る?
  → C/C++、Rust を選ぶ

生産的で高速なアプリを作る?
  → Goは素晴らしい選択!

理由:
  ✅ 十分に速い(実用レベル)
  ✅ 書きやすい
  ✅ 読みやすい
  ✅ デプロイ簡単
  ✅ 並行処理が簡単
  ✅ 標準ライブラリ充実
  ✅ コミュニティ活発

有名企業の採用例:

  • Google(当然)
  • Docker(コンテナ技術)
  • Kubernetes(オーケストレーション)
  • Dropbox(ストレージ)
  • Uber(マイクロサービス)
  • Netflix(一部サービス)
  • Cloudflare(エッジコンピューティング)

これらの企業が選んだのは、ベンチマークの数字ではなく、実用的な生産性と性能のバランスです!

「完璧に速い」より「十分に速く、開発しやすい」方が、ビジネスでは重要なのです! 🚀

おわりに 

本日は、Go言語のよくある質問について解説しました。

よっしー
よっしー

何か質問や相談があれば、コメントをお願いします。また、エンジニア案件の相談にも随時対応していますので、お気軽にお問い合わせください。

それでは、また明日お会いしましょう(^^)

コメント

タイトルとURLをコピーしました