よく聞かれるのが、「Go言語の何がそんなに凄いの?」って話。特にRubyとか他の言語に慣れてる人からすると、goroutine(ゴルーチン)っていうのが、なんかもう魔法みたいに見えるらしいんだよね。正直、その気持ちはすごくわかる。
だって、並行処理って普通はもっとこう…面倒くさいじゃない?ロックとか、スレッドセーフとか、考えなきゃいけないことが山積みで。でもGoだと、それが「go」って書くだけで、え、終わり?みたいな感覚になる。今日はその「魔法」の正体が何なのか、特にRubyの並行処理と比較しながら、自分の頭の整理も兼ねて話してみようかなって思う。チャットで友達に説明するくらいのラフな感じでね。😉
先說結論
もう最初に言っちゃうと、Goの並行処理は「超軽量な実行単位(goroutine)と、安全なデータ受け渡し路(channel)が言語に組み込まれてる」から魔法みたいにシンプルで速い。一方で、Rubyは後付けの仕組みやGIL(グローバルインタプリタロック)っていう大きな制約があって、同じことをやろうとすると、どうしても複雑になったり、性能が出なかったりする。ただ、Rubyがダメって話じゃないから、そこは誤解しないでね!
APIサーバーで比べると、もう一目瞭然
百聞は一見にしかずって言うし、まずは具体的なコードを見てみるのが一番早い。例えば、同時にたくさんのリクエストを処理する簡単なAPIサーバーを作るとするじゃん?
Goで書くと、こんな感じになる。
package main
import (
"fmt"
"log"
"net/http"
"time"
)
// 各リクエストを処理する関数
func processRequest(reqID int) string {
// 時間がかかる処理をシミュレート
time.Sleep(100 * time.Millisecond)
return fmt.Sprintf("Processed request %d", reqID)
}
func handleRequest(w http.ResponseWriter, r *http.Request) {
log.Println("リクエスト受信!")
// ここがキモ!リクエストごとにゴルーチンを起動する
go func() {
result := processRequest(1) // 簡単のためIDは固定
fmt.Println(result)
// 本来は結果をクライアントに返すが今回は省略
}()
// ゴルーチンの完了を待たずに、すぐにレスポンスを返す
// これでサーバーは次のリクエストをすぐ受け付けられる
w.WriteHeader(http.StatusAccepted)
w.Write([]byte("Request accepted! Processing in background..."))
}
func main() {
http.HandleFunc("/process", handleRequest)
log.Println("サーバー起動 :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
見てほしいのはgo func() { ... }の部分。これだけ。たった2文字のgoを追加するだけで、processRequestの処理がバックグラウンドで(非同期で)実行されるんだよね。サーバー本体はリクエストの処理が終わるのを待たずに、すぐに「受け付けたよ!」って応答を返せるから、次のリクエストをどんどんさばける。めちゃくちゃ効率的じゃない?
じゃあ、これをRuby(Sinatraあたり)でやろうとするとどうなるか。
require 'sinatra'
require 'json'
# Pumaみたいなマルチスレッド対応サーバーが前提
post '/process' do
# 重い処理を別スレッドでやらないとサーバーが固まる
Thread.new do
# 時間がかかる処理をシミュレート
sleep 0.1
puts "Processed request in a thread!"
end
status 202
"Request accepted! Processing in background..."
end
見た目は似てるかもしれないけど、中身が全然違う。RubyのThread.newはOSのスレッドを使うから、Goのゴルーチンに比べてメモリ消費も大きいし、生成コストも高い。だから、リクエストのたびにホイホイ作るのは、ちょっと気が引ける。それに、もっと根本的な問題として、Ruby(特に標準のMRI)にはGILっていう制約があるせいで、CPUをたくさん使う処理は、スレッドをいくら作っても結局同時に1つしか動かないんだよね…。
もしベンチマークツールで両方に同時に1000リクエストとか送ったら、結果は火を見るより明らか。Goサーバーは平然とさばき続けるけど、Rubyサーバーはすぐに応答が遅くなって、頭打ちになるはず。まあ、これはちょっと極端な例だけど、この「手軽さ」と「性能」の差が、Goが「魔法」って言われる理由なんだ。
じゃあ、その「ゴルーチン」と「チャネル」って何者?
そろそろ魔法のタネ明かしをしようか。Goの並行処理の主役は、この2つ。
- Goroutine (ゴルーチン): めちゃくちゃ軽いスレッドみたいなやつ。OSのスレッドがメガバイト単位のメモリを使うのに対して、ゴルーチンはキロバイト単位。だから何千、何万個作っても全然平気。Goのランタイムが賢く管理してくれるから、プログラマは「これを並行で動かして」って言うだけ。
- Channel (チャネル): ゴルーチン同士が安全にデータをやり取りするための通り道。「このデータ、やっといて!」ってチャネルに投げとけば、別のゴルーチンがそれを受け取って処理する、みたいなことができる。ロックとかを使わずに済むから、コードがすごくクリーンになる。
さっきのAPIサーバーの例を、チャネルを使って少しリッチにしてみようか。
package main
import (
"fmt"
"sync"
"time"
)
// 複数のURLを同時に取得して、結果を返す関数
func fetchUrls(urls []string) {
// 文字列を送受信できるチャネルを作成
ch := make(chan string)
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1) // これからゴルーチンを1個増やすよ、と宣言
// 各URLの取得処理をゴルーチンで実行
go func(u string) {
defer wg.Done() // 終わったらカウンターを1個減らすよ、と宣言
// ここで実際にhttp.Get(u)とかする
time.Sleep(50 * time.Millisecond)
result := fmt.Sprintf("Fetched: %s", u)
// 結果をチャネルに送信
ch <- result
}(url)
}
// すべてのゴルーチンの完了を待つための、別のゴルーチン
// これがないと、結果を待たずにmainが終わっちゃう
go func() {
wg.Wait() // カウンターが0になるまで待機
close(ch) // 全部終わったらチャネルを閉じる
}()
// チャネルから結果が送られてくるたびに、それを取り出して表示
// チャネルが閉じられると、このループも終わる
for result := range ch {
fmt.Println(result)
}
}
func main() {
urls := []string{
"example.com",
"golang.org",
"google.com",
}
fetchUrls(urls)
}
ちょっと複雑になった?でもね、やってることはすごく合理的。複数のURLを同時に取得したいとき、一個ずつ待ってたら時間がかかるでしょ。だからURLの数だけゴルーチンを起動して、みんなで「よーいドン!」で取得しに行く。で、取得できた人から順番に「終わったよー!」ってチャネルに結果を報告する。メインの処理は、その報告をのんびり待ってればいい。これがGoの基本的な並行処理パターン。美しいよね。
この考え方、実は公式のGoブログとか、Goの設計者の一人であるRob Pikeのトークで何度も語られてる。「Don't communicate by sharing memory; share memory by communicating.(メモリを共有して通信するな、通信してメモリを共有しろ)」っていう有名な言葉があるんだけど、まさにチャネルのことを指してるんだよね。
GoとRuby、どう使い分ける?
じゃあ、もう全部Goで書けばいいじゃん!って思うかもしれないけど、まあ、そう単純な話でもない。やっぱり適材適所ってものがあるからね。個人的には、こんな感じで使い分けるのがいいかなって思ってる。
| 項目 | Go言語 | Ruby言語 |
|---|---|---|
| 得意なこと | APIサーバー、マイクロサービス、CLIツールとか、とにかく性能と並行処理が求められる場面。もう独壇場。 | Railsを使った爆速でのWebアプリ開発、プロトタイピング。いわゆる「開発者の幸せ」を最大化したいとき。 |
| 並行処理モデル | ゴルーチンとチャネルが言語機能としてビルトイン。軽くてシンプル。最初からそのために設計されてる感じ。 | OSスレッド(重い)が基本。GILの制約あり。concurrent-rubyとかライブラリで頑張るけど、ちょっと後付け感が…。 |
| 学習コスト | 言語仕様は小さいけど、ポインタとか、ゴルーチンとチャネルの「考え方」に慣れるのに少し時間がかかるかも。 | 直感的で書きやすい。初心者でもとっつきやすいし、Railsっていう強力なフレームワークがあるから、すぐに形にできる。 |
| エコシステム | WebフレームワークとかはまだRubyほど成熟してないかも。でも標準ライブラリがめちゃくちゃ優秀だから、大抵のことはそれで済む。 | Gemsっていうライブラリの宝庫がある。Railsを中心に、Web開発に必要なものは何でも揃ってる。歴史が長いだけある。 |
日本だと、例えばメルカリがマイクロサービス化を進める中でGoを大々的に採用した話は有名だよね。あれはまさに、大量のトラフィックを効率的にさばくためにGoの並行処理能力を活かした典型的な例。一方で、サービスの初期段階、アイデアを素早く形にしたいっていうフェーズなら、今でもRails(Ruby)は最高の選択肢の一つだと思う。
よくある誤解とアンチパターン
最後に、Goの並行処理を使い始めた人がやりがちなミスについて少しだけ。
- 何でもかんでも
goしちゃう:goをつけるのは簡単だからって、本当に短い処理までゴルーチンにすると、逆にスケジューリングのオーバーヘッドで遅くなることがある。使いどころの見極めは大事。 - チャネルのバッファを理解してない:
make(chan int)とmake(chan int, 10)は全然違う。バッファなしチャネルは送受信が同期するから、デッドロックの原因になりやすい。最初は少し戸惑うポイントかも。 - WaitGroupを忘れる: さっきのコードでも使った
sync.WaitGroup。これを忘れると、メイン関数がゴルーチンの処理完了を待たずに終了しちゃって、「あれ、何も実行されない?」ってなる。これは本当によくある。
まあ、このあたりは実際に書いてみないと、なかなか感覚がつかめない部分かもしれないね。でも、この「魔法」を一度体験しちゃうと、もう元には戻れないかも。それくらいGoの並行処理はパワフルで、プログラミングの新しい扉を開けてくれる感じがするんだ。🚀
あなたの意見も聞かせて!
もし次に何か新しいウェブサービスやツールを作るとしたら、パフォーマンスを重視してGoを選ぶ?それとも、やっぱり開発のスピード感をとってRuby(やRails)を選ぶ? よかったらコメントであなたの考えを教えてください!
