はじめに
「学生ひよこ界隈が送るGo/Javaで実現する「はじめてのバックエンド」Advent Calendar 2025」17日目の記事は、Go言語での特色である「並行処理」についてです。
「Goは並行処理が得意で、パフォーマンスが良い」という話をよく耳にするのですが、それが具体的にどういうことで、API開発にどう役立つのか、正直ピンと来ていなかったので、今回のテーマとしました。
この記事では、Goのゴルーチン(goroutine)とチャネル (channel)の基本的な考え方を、おさらいしつつ、APIのパフォーマンス向上に関わる使い方を学ぶハンズオンを行います。
- Goで基本的なAPIは書けるが、パフォーマンスを改善したいと考えている方
- Go言語の大きな特徴である並行処理の概念と、その実用的な使い方を学びたい方
この記事で、APIにおける実践的な使い方を学んで、並行処理にメリットを感じられるように頑張りましょう!
なぜAPIに並行処理が必要なのか?
APIサーバーがリクエストを受け取ったとき、ハンドラ内部で複数の重い処理を実行するケースはよくあります。例えば、
- 複数のマイクロサービスから情報を取得する
- 複数のテーブルに対して、それぞれ重いクエリを発行する
- 外部のAPI(天気情報、株価情報など)を複数呼び出す
これらの処理を逐次処理(一つずつ順番に実行)すると、APIの応答時間は各処理時間の合計になってしまいます。
逐次処理のイメージ:
[ 処理A (2秒) ]---->[ 処理B (3秒) ]---->[ レスポンス ]
| |
|<------------------ 合計 5秒 ------------------>|
これでは、ユーザーを5秒も待たせてしまいます。
もし、処理Aと処理Bが互いに依存していないなら、並行処理でこれらを「同時に」実行することで、全体の処理時間を短縮できます。
並行処理のイメージ:
[ 処理A (2秒) ]----->|
[ 完了を待つ ]---->[ レスポンス ]
[ 処理B (3秒) ]---------->|
| |
|<----------------- 合計 3秒 ---------->|
全体の処理時間は、最も時間のかかる処理Bの時間(3秒)まで短縮されました。
これがAPI開発において並行処理が実践的に活用できる理由です。
ゴルーチンとチャネルとは?
Goは、この並行処理を簡単に書くための仕組みを言語レベルで提供しています。
ゴルーチン (Goroutine)
ゴルーチンは、Goのランタイムによって管理される、非常に軽量なスレッドのようなものです。goキーワードを関数の前に付けるだけで、その関数を現在の処理とは別の流れ(非同期)で実行できます。
// 普通の関数呼び出し(終わるまで待つ)
myFunction()
// ゴルーチンとして実行(待たずに次の行へ進む)
go myFunction()
OSのスレッドよりも遥かに少ないメモリで動作するため、数千、数万といったゴルーチンを気軽に起動できるのが大きな特徴です。
チャネル (Channel)
ゴルーチンは「実行を始める」のは簡単ですが、その処理が終わったことを知ったり、処理結果を受け取ったりするために使われるのがチャネルです。
チャネルは、ゴルーチン間で安全にデータを送受信するための「パイプ」のようなものです。
// int型の値を送受信できるチャネルを作成
ch := make(chan int)
// ゴルーチン側で、チャネルに値を「送信」する
go func() {
result := 1 + 1
ch <- result // パイプにデータを入れる
}()
// メイン側で、チャネルから値を「受信」する
// チャネルに値が送られてくるまで、この行で処理がブロック(待機)される
value := <-ch
fmt.Println(value) // -> 2
【実践】ゴルーチンとチャネルでAPIを高速化する
それでは、実際にAPIの処理を並行化してみましょう。
複数の外部APIから情報を集約して返す/dashboardというAPIを作成する想定で行います。
APIの処理としては以下のケースを想定します。
-
fetchWeather(): 天気情報を取得するのに2秒かかる -
fetchNews(): 最新ニュースを取得するのに3秒かかる
STEP1: 準備と逐次処理バージョンの作成
まずは、これらの処理を順番に実行する、遅いバージョンのAPIサーバーを準備します。
main.go (Before)
package main
import (
"encoding/json"
"log"
"net/http"
"time"
)
// 外部APIから天気情報を取得する(という想定のダミー関数)
func fetchWeather() string {
time.Sleep(2 * time.Second)
return "晴れ"
}
// 外部APIからニュースを取得する(という想定のダミー関数)
func fetchNews() string {
time.Sleep(3 * time.Second)
return "Goの新しいバージョンがリリース!"
}
func dashboardHandler(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// 逐次処理
weather := fetchWeather() // 2秒待つ
news := fetchNews() // さらに3秒待つ
response := map[string]string{
"weather": weather,
"news": news,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
log.Printf("処理時間: %v", time.Since(start))
}
func main() {
http.HandleFunc("/dashboard", dashboardHandler)
log.Println("サーバーが以下のポート番号で起動しています:ポート番号:8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
このサーバーを起動して/dashboardにアクセスすると、コンソールに「処理時間: 5.00...s」のように表示され、レスポンスが返ってくるまでに約5秒かかることがわかります。
STEP2: 並行処理バージョンの実装
このdashboardHandlerを、ゴルーチンとチャネルを使って高速化します。
main.go (After)
// ... fetchWeather, fetchNews, main関数は同じ ...
func dashboardHandler(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// 結果を受け取るためのチャネルを作成
weatherChan := make(chan string)
newsChan := make(chan string)
// 天気情報取得をゴルーチンで実行
go func() {
weatherChan <- fetchWeather()
}()
// ニュース取得をゴルーチンで実行
go func() {
newsChan <- fetchNews()
}()
// 両方のチャネルから結果を受信する
// この受信処理は、データがチャネルに送られてくるまで待機する
weather := <-weatherChan
news := <-newsChan
response := map[string]string{
"weather": weather,
"news": news,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
log.Printf("処理時間: %v", time.Since(start))
}
修正点はdashboardHandlerの中だけです。
-
string型の値を受け取るためのチャネルweatherChanとnewsChanを2つ作成します。 -
fetchWeatherとfetchNewsをそれぞれgoキーワードを付けてゴルーチンとして起動し、結果をそれぞれのチャネルに送信(<-)します。 - メインの処理は、
weather := <-weatherChanとnews := <-newsChanで、それぞれのチャネルから値が送られてくるのを待ちます。
このサーバーを起動して/dashboardにアクセスすると、今度はコンソールに「処理時間: 3.00...s」と表示され、全体の処理時間が最も時間のかかるfetchNewsの3秒に短縮されたことが確認できます。
並行処理の注意点
Goの並行処理は強力ですが、いくつか注意すべき点もあります。
- デッドロック: すべてのゴルーチンがチャネルからの受信を待っているなど、互いに待ち合ってしまいプログラム全体が停止してしまう状態
- レースコンディション: 複数のゴルーチンが、ロックなどの保護なしに同じ変数に同時に書き込みを行い、データが壊れる可能性
- ゴルーチンのリーク: 起動したゴルーチンが、チャネルの受信側がいないなどの理由で永遠に終了せず、メモリを消費し続ける問題
これらの問題を避けるため、sync.WaitGroupや、context、sync.Mutexといった機能を活用する、さらに高度なテクニックが存在します。
おわりに
この記事では、Goの並行処理について、そしてAPIでの利活用方法について少し触れました。
今回は、2つのゴルーチンを待ち合わせるシンプルな例でしたが、実際のアプリケーションでは、もっとたくさんのゴルーチンを動かしたり、もし処理がタイムアウトしてしまったらどうするか、といったエラーハンドリングも重要になってきそうです。
これからは、そのあたりをまた詳しく掘り下げてみたいです。
最後までお読みいただき、ありがとうございました!
この「学生ひよこ界隈が送るGo/Javaで実現する「はじめてのバックエンド」Advent Calendar 2025」では、GoやJavaを使い、APIの作り方、データベースとの接続、テストやDockerといった気になったバックエンド技術の基本を振り返った学びを共有しています。
ぜひ他の記事もチェックして、筆者がこのひとりアドカレを完遂することができるか、確認してみてください(^^)
学生ひよこ界隈が送るGo/Javaで実現する「はじめてのバックエンド」Advent Calendar 2025
それでは、明日の「学生ひよこ界隈が送るGo/Javaで実現する「はじめてのバックエンド」Advent Calendar 2025」の記事もお楽しみに!
参考文献