Goの並行処理がRubyを圧倒する理由:Goroutineの仕組みと実行速度の違いを解説

Published on: | Last updated:

よく聞かれるのが、「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の処理がバックグラウンドで(非同期で)実行されるんだよね。サーバー本体はリクエストの処理が終わるのを待たずに、すぐに「受け付けたよ!」って応答を返せるから、次のリクエストをどんどんさばける。めちゃくちゃ効率的じゃない?

Goの並行処理のイメージ図
Goの並行処理のイメージ図

じゃあ、これを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のチャネルを使ったコードの例
Goのチャネルを使ったコードの例

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)を選ぶ? よかったらコメントであなたの考えを教えてください!

Related to this topic:

Comments

  1. Guest 2025-10-05 Reply
    - 大学の課題でAPIサーバー作成、言語はGoを選択。 - goroutine活用 → 同時処理が普通にできて、意外と手間もなかった。 - Rubyの時はGIL(グローバルインタプリタロック)があって、多分そのせいで速度面で結構イライラする。 - 実際使い比べたら体感が全然違う、まあ当然かもだけど驚いた。