Go: fasthttpはなぜ速い? 内部実装とVegetaベンチマークで徹底検証
TL;DR
- fasthttp はp50/p90 レイテンシで優位(p50 で約 15%、p90 で約 27%高速)
- 一方、mean(平均)や p99(テールレイテンシ)では net/http が優位なケースも
- 速さの理由: Worker Poolによる Goroutine 再利用、[]byteベースのアロケーション削減設計、sync.Poolによるオブジェクト再利用
- HTTP/2 非対応などの制約があるため、SLA 要件や負荷パターンに応じて選択が必要
はじめに
Go 言語で HTTP サーバーを実装する際、標準ライブラリのnet/httpを使うのが一般的です。しかし、高負荷環境ではvalyala/fasthttpという選択肢があります。fasthttp は「net/http より最大 10 倍速い」と謳われており、高性能 Web フレームワークであるFiberの基盤としても採用されています。
本記事では、fasthttp がなぜ速いのかを内部実装から解説し、Vegeta による負荷テストで実際の性能差を検証します。
net/http と fasthttp の内部実装の違い
1. Goroutine 生成モデルの違い
net/httpは、接続が確立された時点で新しい Goroutine を生成し、その接続の全リクエストを処理します:
// net/http の server.go より簡略化
func (srv *Server) Serve(l net.Listener) error {
baseCtx := context.Background()
ctx := context.WithValue(baseCtx, ServerContextKey, s)
for {
rw, err := l.Accept()
if err != nil {
return err
}
connCtx := ctx
if cc := s.ConnContext; cc != nil {
connCtx = cc(connCtx, rw)
if connCtx == nil {
panic("ConnContext returned nil")
}
}
tempDelay = 0
c := s.newConn(rw)
c.setState(c.rwc, StateNew, runHooks)
// 接続が確立された時点で新しいGoroutineを生成
// このGoroutineは接続が閉じられるまで、その接続の全リクエストを処理する
go c.serve(connCtx)
}
}
一方、fasthttpは Worker Pool パターンを採用し、Goroutine を再利用します:
// fasthttp の workerpool.go より簡略化
type workerChan struct {
lastUseTime time.Time
ch chan net.Conn
}
func (wp *workerPool) Serve(c net.Conn) bool {
// readyキュー(FILO)からアイドル状態のworkerを取得
ch := wp.getCh()
if ch == nil {
return false
}
// 既存のworkerに接続を渡す(新規Goroutine生成なし)
ch.ch <- c
return true
}
func (wp *workerPool) getCh() *workerChan {
var ch *workerChan
wp.lock.Lock()
ready := wp.ready
n := len(ready) - 1
if n >= 0 {
// FILO: 最後に使われたworkerを優先的に取得
// → CPUキャッシュの局所性を維持
ch = ready[n]
ready[n] = nil
wp.ready = ready[:n]
}
wp.lock.Unlock()
if ch == nil {
// プールにworkerがない場合のみ新規作成
vch := wp.workerChanPool.Get()
ch = vch.(*workerChan)
go func() {
wp.workerFunc(ch)
wp.workerChanPool.Put(vch) // 処理完了後にプールに返却
}()
}
return ch
}
この違いを図で表すと:
net/http の処理モデル
fasthttp の処理モデル
FILO(First In, Last Out)を採用する理由: 実装上の選択であり、メモリ効率や実装の簡潔さを考慮したものと考えられます。
2. メモリアロケーションの違い
net/httpでは、HTTP ヘッダーはmap[string][]stringにパースされます:
// net/http の Request 構造体
type Request struct {
Header Header // type Header map[string][]string
// ...
}
// リクエスト受信時の処理(簡略化)
// 1. []byte でヘッダーを読み取り
// 2. string に変換(メモリアロケーション発生)
// 3. map に格納(メモリアロケーション発生)
fasthttpでは、ヘッダーは[]byteのまま保持し、必要になるまでパースしません(遅延評価):
// fasthttp の RequestHeader 構造体
// 参照: https://github.com/valyala/fasthttp/blob/master/header.go
// 共通ヘッダー情報を持つ埋め込み構造体
type header struct {
h []argsKV // 汎用ヘッダーをkey-valueペアで格納
contentLength int // Content-Lengthの数値
contentLengthBytes []byte // Content-Lengthの生バイト列
contentType []byte // Content-Typeの値
// ...
}
type RequestHeader struct {
header // 共通ヘッダー情報を埋め込み
noCopy noCopy // コピー防止用
method []byte // HTTPメソッド (GET, POST等)
requestURI []byte // リクエストURI
host []byte // Hostヘッダー
userAgent []byte // User-Agentヘッダー
rawHeaders []byte // 受信したヘッダーの生データ
cookiesCollected bool // Cookie解析済みフラグ
// ...
}
設計のポイント: 頻繁に使用されるヘッダー(Host, User-Agent, Content-Type 等)は専用フィールドで高速アクセス。その他のヘッダーは[]argsKVスライスで動的に管理し、rawHeadersに生データを保持して必要時のみパースします。
// ヘッダーアクセス時(アロケーションなし)
func (h *RequestHeader) ContentType() []byte {
return h.contentType // []byteを直接返す
}
func (h *RequestHeader) Host() []byte {
return h.host // stringへの変換なし
}
3. sync.Pool によるオブジェクト再利用
fasthttp は多数のsync.Poolを使用して、頻繁に使用されるオブジェクトを再利用します:
// fasthttp での Request/Response のプール管理
var (
requestPool = &sync.Pool{
New: func() interface{} {
return &Request{}
},
}
responsePool = &sync.Pool{
New: func() interface{} {
return &Response{}
},
}
)
// オブジェクト取得(プールから再利用 or 新規作成)
func AcquireRequest() *Request {
return requestPool.Get().(*Request)
}
// オブジェクト返却(リセットしてプールに戻す)
func ReleaseRequest(req *Request) {
req.Reset()
requestPool.Put(req)
}
性能差が生まれる仕組みのまとめ
| 観点 | net/http | fasthttp |
|---|---|---|
| Goroutine | 接続確立時に新規作成 | Worker Pool で再利用 |
| ヘッダー格納 | map[string][]string |
[]byte(遅延パース) |
| Request/Response | 毎回新規作成 |
sync.Poolで再利用 |
| 文字列処理 |
string変換が多い |
[]byteのまま処理 |
fasthttp クライアントの実装
基本的な使い方
package main
import (
"fmt"
"log"
"github.com/valyala/fasthttp"
)
func main() {
// Requestを取得(プールから)
req := fasthttp.AcquireRequest()
defer fasthttp.ReleaseRequest(req)
// Responseを取得(プールから)
resp := fasthttp.AcquireResponse()
defer fasthttp.ReleaseResponse(resp)
// リクエストの設定
req.SetRequestURI("https://httpbin.org/get")
req.Header.SetMethod(fasthttp.MethodGet)
req.Header.Set("User-Agent", "my-app/1.0")
// リクエスト実行
if err := fasthttp.Do(req, resp); err != nil {
log.Fatal(err)
}
fmt.Printf("Status: %d\n", resp.StatusCode())
fmt.Printf("Body: %s\n", resp.Body())
}
高性能 HTTP クライアントの設計
本番環境ではfasthttp.Clientを適切に設定します:
package main
import (
"log"
"net"
"time"
"github.com/valyala/fasthttp"
)
func NewHTTPClient() *fasthttp.Client {
// TCPDialerの設定
dialer := &fasthttp.TCPDialer{
Concurrency: 4096, // 同時接続試行数
DNSCacheDuration: 5 * time.Minute, // DNSキャッシュ有効期間
}
return &fasthttp.Client{
// タイムアウト設定
ReadTimeout: 30 * time.Second,
WriteTimeout: 10 * time.Second,
MaxConnWaitTimeout: 10 * time.Second,
// 接続プール設定
MaxConnsPerHost: 1024, // ホストごとの最大接続数
MaxIdleConnDuration: 30 * time.Second,
// リトライ設定(冪等なリクエストのみ)
MaxIdemponentCallAttempts: 3,
// パフォーマンスチューニング
NoDefaultUserAgentHeader: true, // デフォルトUser-Agentを送信しない
DisableHeaderNamesNormalizing: true, // ヘッダー名の正規化を無効化
DisablePathNormalizing: true, // パスの正規化を無効化
// カスタムDialer(リトライ機構付き)
// 注意: Dialを使用する場合、Request timeoutが無視されます。
// Request timeoutを考慮する場合は、DialTimeoutを使用してください。
Dial: func(addr string) (net.Conn, error) {
const maxRetries = 3
var lastErr error
for i := 0; i < maxRetries; i++ {
conn, err := dialer.DialTimeout(addr, 5*time.Second)
if err == nil {
return conn, nil
}
lastErr = err
// タイムアウト以外のエラーはリトライしない
if netErr, ok := err.(net.Error); !ok || !netErr.Timeout() {
return nil, err
}
}
return nil, lastErr
},
}
}
各設定項目の解説:
| 設定項目 | 説明 | 推奨値の考え方 |
|---|---|---|
Concurrency |
同時 Dial 数の上限(0 で無制限) | CPU コア数 × 256〜1024 |
DNSCacheDuration |
DNS キャッシュの有効期間(デフォルト 1 分) | 1〜5 分(変更頻度による) |
ReadTimeout |
レスポンス読み取りタイムアウト(ボディを含む) | バックエンドの応答時間 + α |
WriteTimeout |
リクエスト書き込みタイムアウト(ボディを含む) | 通常は短め(5〜10 秒) |
MaxConnsPerHost |
ホストごとの最大接続数(デフォルト 512) | 想定同時リクエスト数に合わせる |
MaxIdleConnDuration |
アイドル接続の保持時間(デフォルト 10 秒) | サーバーの Keep-Alive 設定に合わせる |
エラーハンドリング
fasthttp には独自のエラー型があるため、適切にハンドリングが必要です。
import (
"errors"
"fmt"
"net"
"time"
"github.com/valyala/fasthttp"
)
func executeRequest(client *fasthttp.Client, req *fasthttp.Request, resp *fasthttp.Response) error {
err := client.DoTimeout(req, resp, 30*time.Second)
if err != nil {
switch {
case errors.Is(err, fasthttp.ErrTimeout):
return fmt.Errorf("request timeout")
case errors.Is(err, fasthttp.ErrNoFreeConns):
return fmt.Errorf("connection pool exhausted")
case errors.Is(err, fasthttp.ErrDialTimeout):
return fmt.Errorf("dial timeout: %w", err)
case errors.Is(err, fasthttp.ErrConnectionClosed):
return fmt.Errorf("connection closed by server: %w", err)
default:
// net.Error などのネットワークエラー
var netErr net.Error
if errors.As(err, &netErr) {
if netErr.Timeout() {
return fmt.Errorf("network timeout: %w", err)
}
return fmt.Errorf("network error: %w", err)
}
return fmt.Errorf("request failed: %w", err)
}
}
return nil
}
fasthttp サーバーの実装
基本的なサーバー
package main
import (
"encoding/json"
"log"
"time"
"github.com/valyala/fasthttp"
)
func main() {
handler := func(ctx *fasthttp.RequestCtx) {
switch string(ctx.Path()) {
case "/api/health":
ctx.SetStatusCode(fasthttp.StatusOK)
ctx.SetBodyString("OK")
case "/api/users":
ctx.SetContentType("application/json")
response := map[string]interface{}{
"users": []string{"alice", "bob", "charlie"},
}
if err := json.NewEncoder(ctx).Encode(response); err != nil {
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
return
}
default:
ctx.SetStatusCode(fasthttp.StatusNotFound)
}
}
server := &fasthttp.Server{
Handler: handler,
MaxConnsPerIP: 1000,
MaxRequestsPerConn: 1000,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 30 * time.Second,
}
log.Println("Starting server on :8080")
if err := server.ListenAndServe(":8080"); err != nil {
log.Fatal(err)
}
}
fasthttp/router を使ったルーティング
パスパラメータや HTTP メソッドごとのルーティングが必要な場合は、fasthttp/routerを使うと便利です。
package main
import (
"encoding/json"
"log"
"github.com/fasthttp/router"
"github.com/valyala/fasthttp"
)
func main() {
r := router.New()
// GETリクエスト
r.GET("/api/health", func(ctx *fasthttp.RequestCtx) {
ctx.SetStatusCode(fasthttp.StatusOK)
ctx.SetBodyString("OK")
})
// パスパラメータを使用({name}形式)
r.GET("/api/users/{id}", func(ctx *fasthttp.RequestCtx) {
userID := ctx.UserValue("id")
if userID == nil {
ctx.SetStatusCode(fasthttp.StatusBadRequest)
return
}
ctx.SetContentType("application/json")
json.NewEncoder(ctx).Encode(map[string]string{
"id": userID.(string),
"name": "example-user",
})
})
// POSTリクエスト
r.POST("/api/users", func(ctx *fasthttp.RequestCtx) {
ctx.SetStatusCode(fasthttp.StatusCreated)
ctx.SetBodyString("Created")
})
log.Println("Starting server on :8080")
if err := fasthttp.ListenAndServe(":8080", r.Handler); err != nil {
log.Fatal(err)
}
}
fasthttp/router では以下のパスパターンが使用できます:
| パターン | 説明 | 例 |
|---|---|---|
{name} |
名前付きパラメータ |
/users/{id} → id 取得 |
{name?} |
オプションパラメータ |
/users/{id?} → id は省略可 |
{name:*} |
キャッチオール |
/files/{path:*} → 残り全パス |
{name:[0-9]+} |
正規表現検証 |
/users/{id:[0-9]+} → 数字のみ |
RequestCtx の注意点
非同期処理で参照する場合は、必ず値をコピーしてください:
import (
"log"
"time"
"github.com/valyala/fasthttp"
)
// ❌ 間違い: handlerを抜けた後にRequestCtxを参照
func handler(ctx *fasthttp.RequestCtx) {
go func() {
time.Sleep(time.Second)
// ctx はhandler終了後に再利用される可能性がある
log.Println(string(ctx.Path())) // 危険!データ競合が発生
}()
}
// ✅ 正解: 必要な値をコピーしてから使用
func handler(ctx *fasthttp.RequestCtx) {
path := string(ctx.Path()) // コピー
userID := string(ctx.QueryArgs().Peek("user_id")) // コピー
go func() {
time.Sleep(time.Second)
log.Printf("path: %s, userID: %s\n", path, userID) // 安全
}()
}
Vegeta による性能比較
Vegetaは Go 製の負荷テストツールです。ここでは、net/http と fasthttp の性能を実際に比較します。
比較用サーバーの実装
net/http サーバー:
// benchmark/nethttp-server/main.go
package main
import (
"encoding/json"
"net/http"
)
func main() {
http.HandleFunc("/api/test", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Hello from net/http",
"server": "net/http",
})
})
http.ListenAndServe(":8080", nil)
}
fasthttp サーバー:
// benchmark/fasthttp-server/main.go
package main
import (
"encoding/json"
"github.com/valyala/fasthttp"
)
func main() {
handler := func(ctx *fasthttp.RequestCtx) {
if string(ctx.Path()) == "/api/test" {
ctx.SetContentType("application/json")
json.NewEncoder(ctx).Encode(map[string]string{
"message": "Hello from fasthttp",
"server": "fasthttp",
})
}
}
fasthttp.ListenAndServe(":8081", handler)
}
ベンチマーク結果
以下はローカル環境(macOS, Apple M2, Go 1.24)で計測した結果です。ベンチマークの実行方法は こちらのREADME を参照してください。
1000 req/sec (10 秒間) - 低負荷時
net/http:
Requests [total, rate, throughput] 10000, 1000.10, 1000.09
Latencies [min, mean, 50, 90, 95, 99, max]
63.209µs, 218.438µs, 153.485µs, 256.57µs, 298.281µs, 891.545µs, 12.896ms
Success [ratio] 100.00%
fasthttp:
Requests [total, rate, throughput] 10000, 1000.09, 1000.07
Latencies [min, mean, 50, 90, 95, 99, max]
56.833µs, 171.587µs, 133.227µs, 212.734µs, 252.216µs, 819.324µs, 17.536ms
Success [ratio] 100.00%
両者とも 100%成功。平均レイテンシは fasthttp がやや優位(net/http 218µs vs fasthttp 171µs)。
10000 req/sec (10 秒間) - 高負荷時
net/http:
Requests [total, rate, throughput] 100000, 10000.06, 9999.98
Latencies [min, mean, 50, 90, 95, 99, max]
26.292µs, 203.527µs, 44.352µs, 59.967µs, 123.443µs, 3.39ms, 65.117ms
Success [ratio] 100.00%
fasthttp:
Requests [total, rate, throughput] 100000, 10000.08, 10000.00
Latencies [min, mean, 50, 90, 95, 99, max]
22.875µs, 212.301µs, 38.607µs, 57.783µs, 125.328µs, 2.559ms, 72.081ms
Success [ratio] 100.00%
差が出るポイント:
- p50 レイテンシ: net/http 44µs vs fasthttp 38µs(fasthttp が約 13%高速)
- p99 レイテンシ: net/http 3.39ms vs fasthttp 2.56ms(fasthttp が約 25%高速)
20000 req/sec (10 秒間) - 超高負荷時
net/http:
Requests [total, rate, throughput] 200000, 19999.99, 19999.64
Latencies [min, mean, 50, 90, 95, 99, max]
24.792µs, 131.654µs, 45.249µs, 126.445µs, 197.214µs, 1.175ms, 51.057ms
Success [ratio] 100.00%
fasthttp:
Requests [total, rate, throughput] 200000, 20000.05, 19999.83
Latencies [min, mean, 50, 90, 95, 99, max]
21.584µs, 237.142µs, 38.119µs, 92.174µs, 187.581µs, 6.184ms, 52.674ms
Success [ratio] 100.00%
差が出るポイント:
- p50 レイテンシ: net/http 45µs vs fasthttp 38µs(fasthttp が約 15%高速)
- p90 レイテンシ: net/http 126µs vs fasthttp 92µs(fasthttp が約 27%高速)
ベンチマーク結果の考察
一部の指標では net/http が優位なケースがあります。
20000 req/sec での詳細比較
| 指標 | net/http | fasthttp | 優位 |
|---|---|---|---|
| min | 24.79µs | 21.58µs | fasthttp |
| mean | 131.65µs | 237.14µs | net/http ⭐ |
| p50 | 45.25µs | 38.12µs | fasthttp |
| p90 | 126.45µs | 92.17µs | fasthttp |
| p99 | 1.175ms | 6.184ms | net/http ⭐ |
| max | 51.06ms | 52.67ms | net/http |
fasthttp は p50 や p90 で優位ですが、mean(平均)や p99(テールレイテンシ)では net/http が優位という結果になっています。
なぜこのような結果になるのか
以下は、ベンチマーク結果から推測される要因です。実際の原因を特定するには、pprof などのプロファイリングツールを使用した詳細な分析が必要です。
1. Worker Pool の拡張コスト
負荷が急激に増加する際の新しい worker 作成による初期化コストが一部のリクエストに影響する可能性があります。
// fasthttp workerpool.go より(簡略化)
// readyキューが空かつ MaxWorkersCount 未満の場合に新規作成
if ch == nil && wp.workersCount < wp.MaxWorkersCount {
wp.workersCount++
ch = wp.workerChanPool.Get().(*workerChan)
go wp.workerFunc(ch) // ここで新規Goroutine生成のオーバーヘッド
}
2. sync.Pool の特性
sync.Poolに格納されたアイテムは、通知なく自動的に削除される可能性があります(公式ドキュメント: "Any item stored in the Pool may be removed automatically at any time without notification")。Go の実装では、各 GC cycle の開始時にプール内のオブジェクトがクリアされることが知られています。これは、sync.Poolの設計思想が「一時的なオブジェクトの再利用を最適化し、GC への圧力を軽減する」ことにあり、GC cycle の開始時にプール内のオブジェクトが解放されるためです。
この特性により、以下のような影響が発生します:
- GC cycle 開始直後: プール内のオブジェクトがクリアされるため、次のリクエストで新規オブジェクトのアロケーションが必要になる可能性が高まる
- 新規アロケーションのコスト: メモリ確保、初期化処理、GC 圧力の増加が発生し、レイテンシが増加する可能性がある
- テールレイテンシへの影響: 大多数のリクエストは高速ですが、GC cycle 開始直後のリクエストは新規アロケーションが必要となり、レイテンシが跳ね上がる可能性がある
特に高負荷時には GC の実行頻度が高くなる傾向があるため、この影響がテールレイテンシ(p99)に現れる可能性があります。
3. net/http の均一なオーバーヘッド
net/http は毎回新規 Goroutine を生成するため、オーバーヘッドが比較的均一に分散される傾向があり、テールレイテンシが比較的安定する傾向があります。一方、fasthttp は大多数のリクエストは高速ですが、Worker Pool 拡張や sync.Pool からのオブジェクト削除のタイミングで一部のリクエストが遅くなる可能性があります。
実運用での選択指針
- p50/p90 を重視する場合: fasthttp が有利(大多数のリクエストが高速)
- テールレイテンシ(p99)の SLA が厳しい場合: net/http の方が安定する可能性あり
- 平均レイテンシを重視する場合: 負荷パターンによって異なる
Tips
HTML レポートの生成
Vegeta はテスト結果を HTML グラフとして出力できます。
# 詳細なレポートを生成
vegeta attack -targets=targets_fasthttp.txt -rate=5000 -duration=30s | \
tee results.bin | \
vegeta report
# HTMLグラフを生成
vegeta plot results.bin > results.html
pprof によるプロファイリング
メモリアロケーションの差を確認するには、pprof を使用します。
import (
"net/http"
_ "net/http/pprof"
)
func main() {
// pprofエンドポイントを有効化
go func() {
http.ListenAndServe(":6060", nil)
}()
// メインのサーバー処理...
}
# 負荷をかけながらプロファイル取得
go tool pprof -http=:8082 http://localhost:6060/debug/pprof/heap
go tool pprof -http=:8083 http://localhost:6060/debug/pprof/allocs
fasthttp を使う際の注意点
1. net/http との API 非互換性
fasthttp はnet/httpと同じ API を提供していません:
| net/http | fasthttp |
|---|---|
http.Handler interface |
fasthttp.RequestHandler function |
http.Request |
*fasthttp.RequestCtx |
http.ResponseWriter |
*fasthttp.RequestCtx |
r.URL.Query().Get("key") |
ctx.QueryArgs().Peek("key") |
r.Header.Get("Key") |
ctx.Request.Header.Peek("Key") |
w.Write([]byte) |
ctx.Write([]byte) |
2. HTTP/2 非対応
3. 接続が予期せず閉じられる問題
サーバーが最初のレスポンスバイトを返す前に接続を閉じた場合、ErrConnectionClosedエラーが発生します。対処方法: サーバー側でConnection: closeレスポンスヘッダーを設定するか、クライアント側でリクエストにConnection: closeヘッダーを追加します。詳細は「エラーハンドリング」セクションを参照してください。
4. いつ fasthttp を使うべきか
適している場面:
- 毎秒数千〜数万リクエストを処理する
- 一貫した低レイテンシが求められる
- メモリ使用量を最小限に抑えたい
- HTTP/1.1 で十分
適していない場面:
- 標準的な負荷(〜数百 req/sec)
- 既存の net/http ミドルウェアエコシステムを活用したい
- HTTP/2、gRPC が必要
- WebSocket を多用する(fasthttp でも可能だが、gorilla/websocket の方が成熟)
まとめ
fasthttp が net/http より高速な理由は、Worker Pool パターンによる Goroutine 再利用、sync.Poolによるオブジェクト再利用、[]byte ベースのアロケーション削減設計、遅延評価によるメモリ効率化です。
ベンチマーク結果からの知見:
- fasthttp はp50/p90 レイテンシで優位(大多数のリクエストが高速)
- ただしmean(平均)や p99(テールレイテンシ)では net/http が優位なケースもある
選択の指針:
- p50/p90 を重視する場合 → fasthttp
- テールレイテンシ(p99)の SLA が厳しい場合 → net/http も検討
- HTTP/2 や gRPC が必要な場合 → net/http
自身のプロジェクトの要件(SLA、負荷パターン、プロトコル、エコシステム)を考慮して選択してください。
Discussion