🫢

【Go】単一マルチプレクサ vs 親子マルチプレクサ

2024/12/06に公開

この記事について

以下の記事では、強化されたマルチプレクサ(ルーティングハンドラ)を使用して、net/httpパッケージだけでも十分にwebサーバーを構築できる旨を書きました。

https://zenn.dev/yuta_kakiki/articles/768f2ff1fa38c1

この記事では、ルーティングのグループ化を図るということで、以下の構造をとっていました。

マルチプレクサ(*http.ServeMux)は、http.Handler型をとるので、技術的には可能です。

ただ、その後、記事を書いた後で、

マルチプレクサを分けると確かにいい感じにルーティングハンドラの責務を分けれた感はあるけど、これってパフォーマンス的にどうなんだ...?

と考えていました。
普通に考えて、経由するルーティングハンドラが多いという点でオーバーヘッドが生じるからパフォーマンスは落ちそうですよね。
でも、いや、実はその方がルーティングの探索が早くなったりするのか..?とも思いました。

推測するな、計測せよ

Goを開発した一人であるRob Pike氏の有名な言葉です。
考えてもわからないし、ネットで見ても該当する記事がなかった(これで迷ってるの自分だけだ😅)

Goは標準でベンチマークも取れますし、せっかくならこの際、単一のマルチプレクサ vs 親子構造のマルチプレクサで計測してみることにしました。

結論

ここでズバリ結論だけ先に見たい方のために書いておくと、

単一構造のマルチプレクサの方が圧倒的にパフォーマンスがいい

です。当然の結果だったのかも知れない。

計測

以下の二つを対象に、

  • 単一マルチプレクサ
  • 親子マルチプレクサ

ルーティングの数を

  • 小規模 :10
  • 中規模 :100
  • 大規模 :1000

として計測しています。
大規模、1000のルーティング数はやりすぎかも知れませんが、極端な例も見てみたいのでこれでよしとします。

単一のマルチプレクサの場合は、ルーティングの数だけ、ルーティングを登録するだけです。

親子構造のマルチプレクサの場合は、まず規模ごとに子マルチプレクサの「深さ」を指定し、一つの親マルチプレクサに幾つ子マルチプレクサを登録するかを決めます。
その後、子マルチプレクサごとにそれぞれいくつルーティングを登録するかを、「ルーティング数➗子マルチプレクサ数」で割り出し、決めています。

一回のリクエスト処理にかかった時間を計測します。

以下がベンチマーク計測に関するコードです。

const (
	smallRoutes  = 10
	mediumRoutes = 100
	largeRoutes  = 1000
)

// 単一構造マルチプレクサを作成
func createSingleMux(routes int) http.Handler {
	mux := http.NewServeMux()
	for i := 0; i < routes; i++ {
		path := "/route" + strconv.Itoa(i)
		mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {})
	}
	return mux
}

// 親子構造マルチプレクサを作成
func createParentChildMux(routes int, depth int) http.Handler {
	parent := http.NewServeMux()
	// ルートの数を深さで割ってグループ数(1つのこマルチプレクサに登録するハンドラ数)を決める
	groupSize := routes / depth
	// 深さ分子マルチプレクサを作成
	for i := 0; i < depth; i++ {
		child := http.NewServeMux()
		// グループサイズ分、ハンドラを子マルチプレクサに登録
		for j := 0; j < groupSize; j++ {
			// "group/深さ/route/0~グループサイズ分"
			path := "/group" + strconv.Itoa(i) + "/route" + strconv.Itoa(j)
			child.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {})
		}
		// 親マルチプレクサに子マルチプレクサを登録
		parent.Handle("/group"+strconv.Itoa(i)+"/", child)
	}
	return parent
}

// ベンチマーク
// 小規模ルーティング・単一構造
func BenchmarkSingleMux_Small(b *testing.B) {
	mux := createSingleMux(smallRoutes)
	benchMux(b, mux, "/route9")
}

// 小規模ルーティング・親子構造
func BenchmarkParentChildMux_Small(b *testing.B) {
	mux := createParentChildMux(smallRoutes, 2)
	benchMux(b, mux, "/group1/route4")
}

// 中規模ルーティング・単一構造
func BenchmarkSingleMux_Medium(b *testing.B) {
	mux := createSingleMux(mediumRoutes)
	benchMux(b, mux, "/route99")
}

// 中規模ルーティング・親子構造
func BenchmarkParentChildMux_Medium(b *testing.B) {
	mux := createParentChildMux(mediumRoutes, 3)
	benchMux(b, mux, "/group2/route33")
}

// 大規模ルーティング・単一構造
func BenchmarkSingleMux_Large(b *testing.B) {
	mux := createSingleMux(largeRoutes)
	benchMux(b, mux, "/route999")
}

// 大規模ルーティング・親子構造
func BenchmarkParentChildMux_Large(b *testing.B) {
	mux := createParentChildMux(largeRoutes, 5)
	benchMux(b, mux, "/group4/route199")
}

// 共通処理:リクエストを処理してベンチマーク
func benchMux(b *testing.B, handler http.Handler, path string) {
	req := httptest.NewRequest("GET", path, nil)
	w := httptest.NewRecorder()
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		handler.ServeHTTP(w, req)
	}
}

計測結果

以下のようになりました。

goos: darwin
goarch: arm64
pkg: playground
cpu: Apple M1
BenchmarkSingleMux_Small-8              14861487                80.17 ns/op
BenchmarkParentChildMux_Small-8          3008637               405.5 ns/op
BenchmarkSingleMux_Medium-8             14364777                87.58 ns/op
BenchmarkParentChildMux_Medium-8         1298642               932.3 ns/op
BenchmarkSingleMux_Large-8              13980805                86.19 ns/op
BenchmarkParentChildMux_Large-8          2627731               454.9 ns/op

表でまとめてみます。

ベンチマーク名 実行回数 1回あたりの実行時間 (ns/op)
単一マルチプレクサ(小規模) 14,861,487回 80.17 ns/op
親子構造マルチプレクサ(小規模) 3,008,637回 405.5 ns/op
単一マルチプレクサ(中規模) 14,364,777回 87.58 ns/op
親子構造マルチプレクサ(中規模) 1,298,642回 932.3 ns/op
単一マルチプレクサ(大規模) 13,980,805回 86.19 ns/op
親子構造マルチプレクサ(大規模) 2,627,731回 454.9 ns/op

圧倒的に単一マルチプレクサが効率いい。

現場からは以上です。

ルーティングの可視化(グループ化云々)の改善について

冒頭でもお伝えしたように、前回の記事では、マルチプレクサを親子構造にすると、責務わけできてて見通しがいいんだよなぁということでそうしていました。

今回の結果を受けて、単一で行う必要がありそうです。
じゃぁ、見通しが悪くなるのか??というとそれは違くて、書き方次第です。
前回書いた以下のコードは、このようにも書けます。

前回(親子構造)

func initRoutes() *http.ServeMux {
	// 親マルチプレクサを用意
	mux := http.NewServeMux()
	{
		// 子マルチプレクサ
		greetMux := http.NewServeMux()
		greetMux.Handle("GET /greet/hello/{name...}", loggingMiddleware(&helloHandler{}))
		greetMux.Handle("GET /greet/bye/{name...}", loggingMiddleware(&byeHandler{}))
		greetMux.Handle("POST /greet/custom", loggingMiddleware(&customGreetHandler{}))
		// 子マルチプレクサを親マルチプレクサに登録
		mux.Handle("/greet/", greetMux)
	}

	{
		// 子マルチプレクサ
		userMux := http.NewServeMux()
		userMux.Handle("GET /user/{id}", loggingMiddleware(http.HandlerFunc(fetchUser)))
		userMux.Handle("POST /user/{id}", loggingMiddleware(http.HandlerFunc(addUser)))
		// 子マルチプレクサを親マルチプレクサに登録
		mux.Handle("/user/", userMux)
	}

	return mux
}

単一構造に改善

// ルーティングを初期化する
func initRoutes() *http.ServeMux {
	// 親マルチプレクサを用意
	mux := http.NewServeMux()
	handleGreet(mux)
	handleUser(mux)

	return mux
}

func handleGreet(mux *http.ServeMux) {
	mux.Handle("GET /greet/hello/{name...}", loggingMiddleware(&helloHandler{}))
	mux.Handle("GET /greet/bye/{name...}", loggingMiddleware(&byeHandler{}))
	mux.Handle("POST /greet/custom", loggingMiddleware(&customGreetHandler{}))
}

func handleUser(mux *http.ServeMux) {
	mux.Handle("GET /user/{id}", loggingMiddleware(http.HandlerFunc(fetchUser)))
	mux.Handle("POST /user/{id}", loggingMiddleware(http.HandlerFunc(addUser)))
}

単一のマルチプレクサを使用しています。
どうでしょう、普通にみやすいですね。

(感想)計測は大事

今回の件でこれが痛く沁みました。
とはいえ、親子マルチプレクサを使った方が利点のあるユースケースも存在するのかも知れません。
思いつくのは、特定のパス以下にのみミドルウェアを適用させたい場合とかでしょうか。
/admin/配下のパスへのリクエストにはミドルウェアを適用させたいが、/member/配下には適用させる必要がない、とかならそれぞれマルチプレクサを二つ用意させて、ミドルウェアを片方のマルチプレクサにのみ適用させておくとか....。

数値もなしに頭で考えていましたが、計測してみたらパフォーマンスが雲泥の差でした。
Goでは便利なベンチマーク計測が標準でできます(素敵)。

一応、本記事のテーマは「単一マルチプレクサを使おう」ですが、「計測は大事」という裏テーマもありそうですね。

今後も迷ったら計測します。

Discussion