並行処理技術の最前線:GoとRubyの性能比較から見る次世代プログラミングの進化


Summary

並行処理って言葉は簡単そうに見えるけど、実際に実装してみると意外と罠が多いんですよね。この記事ではGoとRubyという2つの言語を比べながら、次世代のプログラミングでどうやってこの難題に対処していくかを探ってみました。特に最近よく聞くGoの並行処理機構について、実際に触ってみた感想も交えつつ解説しています Key Points:

  • Goのゴルーチンは軽量スレッドみたいなものらしくて、メモリ消費が少ないのが特徴。実際に並列処理を試してみたら、Rubyのスレッドより確かにサクサク動く印象だった
  • RubyのGIL(グローバルインタプリタロック)がボトルネックになるケースも。特にCPUバウンドな処理だと、並列化の効果が思ったより出ないことがあるみたい
  • チャネルを使ったGoの並行処理モデルは結構独特で、最初は戸惑うけど慣れると競合状態を防ぎやすい。個人的にはエラーハンドリングとの相性も良いと思う
結局のところ、言語選び一つで並行処理の悩みがだいぶ軽減される時代になってきたのかもしれない

現代のプログラミング界隈では、同時にいろんな作業をこなす能力――まあ「並行処理」ってやつ――が、もう特別なものじゃなくて、むしろ当たり前みたいになってきてる気がする。例えば、かなり多くのユーザー数を抱えるアプリだったり、生データをリアルタイムでやり取りしたり、ちょっと複雑めなワークフローをさばいたり。そんな場面だと、「同時進行」が求められることも増えてきた。

Go言語というと、最初から並行処理向けに設計されていて、小さくて軽いゴルーチンとかチャンネルなんかが特徴的だと言われる。でも一方で、Rubyは開発者に人気あるし書き味も気持ちいい言語だけど、この分野では少し遅れ気味と言われているようだ。

実際にはどうなのか。現場のコード例や簡単な比較などを通して見てみると、Goの並行モデル(特にゴルーチン)は思ったより手軽で扱いやすい印象もある。ただ、それが魔法みたいかは人によるかもしれない。Ruby側にも工夫はあるんだけど、大規模・高速化のニーズには十分応えきれてないと思う人もいるみたい。

結局、「何かを同時進行でやる」重要性は年々高まっていて、APIリクエストが一度に山ほど届くケースや、ずっと続く配信データの処理、それからバックグラウンドタスクなんかでも並行性への対応力が問われ始めた、と感じる人が増えているようだ。

並行処理って、まあ簡単そうに見えて案外手強い。共通のメモリを扱う時や、競合状態とかデッドロックみたいなトラブルが出てきて、一見単純なプログラムでもいつの間にか原因不明の不具合だらけになることもあるみたい。言語ごとに並行処理への対応もまちまちで、選ぶ言語次第でアプリの動作感や負荷耐性がかなり変わる場合も多いんじゃないかな。

例えばRuby。聞いた話ではグローバルインタプリタロック(GIL)という仕組みによってスレッドベースの同時実行が制限されるっぽい。それで重めの並列処理にはあまり向いていないという声も聞こえる。一方Goは…まあ随分前から「並行処理を意識して設計された」と言われてきたようだし、ゴルーチンとかチャネルっていう独特な機構のおかげでそこまで重くならない印象を持つ人もいる。

ゴルーチン、と呼ばれるものは軽量スレッドみたいな存在らしくて、本物のOSスレッドほどメモリ食わずに動いてくれるんだとか。普通スレッド使うと数十倍ぐらい余分にメモリ消費したりするイメージだけど、Goの場合はランタイム側が管理していて、その辺りがだいぶ違う感じがする。ただ、この辺り実際にどれくらい効率的なのかは運用環境によって体感差もあるかもしれないね。
Extended Perspectives Comparison:
言語並行処理の仕組みスレッド/ゴルーチンの特性パフォーマンス用途
Goゴルーチン、チャネルを使用軽量で効率的な実行数千リクエストを同時にさばける高トラフィックAPI、マイクロサービス
Ruby (Sinatra)スレッドを使用、GILの影響あり重く、切り替えに時間がかかる数百リクエストが限界になることが多いMVP開発、中規模ウェブアプリ
Goの利点高い同時性とスケーラビリティメモリ消費が少ないため資源効率良好
Rubyの利点
総合評価状況によって選択肢は変わるかもしれない。Goは高性能向け、Rubyは迅速なプロトタイピング向け。

Rubyが並行処理で苦戦する根本的な問題点とは

ゴルーチンって、ちょっとしたコマンド一つで、なんだか七千や八千どころか、気付けばとんでもない数が動き出していることもあるらしい。そういえば、こんな感じのコードがあった気がする。
package main

import (
"fmt"
"time"
)

func sayHello() {
for i := 0; i < 5; i++ {
fmt.Println("Hello from goroutine!")
time.Sleep(100 * time.Millisecond)
}
}

func main() {
go sayHello() // Launch goroutine
for i := 0; i < 5; i++ {
fmt.Println("Hello from main!")
time.Sleep(100 * time.Millisecond)
}
}

このサンプル、まあ実際にはメイン関数とsayHelloってやつが同時に動いてるっぽい。`go`って書いただけでね。メッセージも交互になったりして、「おお…」ってなる。スレッドの管理を意識しなくても済むから、仕組みとしてはかなり手軽かもしれない。

これに比べるとRubyでは並行処理のやり方が少し変わってくる気もする。たしかRubyの場合はスレッドやプロセスを使うことになるけど、MRI(Matz’s Ruby Interpreter)にはGIL(グローバルインタプリタロック)があるので、CPUガンガン使う系の処理は真の意味で同時に進めるのは難しいと言われていたような…。Fiberという軽量なコルーチン的機能も用意されているけれど、その規模感とか扱いやすさについてはGoとは違う印象を受けた人もいた。

たしか似たようなRubyコードもあったと思う。
require 'thread'

def say_hello
5.times do
puts "Hello from thread!"
sleep 0.1
end
end

thread = Thread.new { say_hello }
5.times do
puts "Hello from main!"
sleep 0.1
end
thread.join

このRuby例でも、一応並行っぽい挙動になる。ただ全体的に比べてみると、どうにも操作感というか記述量が増えたりする部分もあるみたい。それぞれ強みや制約はありそうだけど、一概に「絶対こっち!」とは言えないところかな…。

Rubyのスレッドって、あれですね、なんとなく重たく感じることが多い気がする。CPUに負荷がかかるような作業だと、GIL(グローバルインタプリタロック)が影響してしまって、本来の力を発揮しきれない場面もちらほら見かけるような。しかも手動でスレッド管理しようとすると、どうやらコードが複雑になりがちで、競合状態とか妙な罠にも出会いやすいみたい。

一方でGo言語だと、「ゴルーチン」だけじゃなくて「チャネル」っていう仕組みもあるらしい。ゴルーチン同士のやり取りや同期には、このチャネルが割と便利そうなんですよね。共有メモリまわりの悩みからある程度解放されるという声も聞いたことがある。「CSPモデル」…うろ覚えだけど、その考え方にヒントを得て設計されているっぽい?要は明示的なロックを書かずともデータをやり取りできる仕掛け。

例えばAPIリクエストの処理でも、十件前後(もう少し多かったかもしれない)のリクエストを同時進行でさばく場合、それぞれ別々のゴルーチンとして投げておいて、結果だけチャネルに流す感じ。何秒か(正確には覚えていない)遅延させて仕事を模擬してから、「リクエスト〇番完了!」みたいなのを送り返してくれるんです。その裏でWaitGroupという同期用の部品も使われたりするので、全部終わったタイミングでチャネルを閉じる流れになることが多いかな、と。

結果については…まあ並び順とか細かな部分にブレは出ますけど、それぞれ届いたものから順次表示したりできるので、一つ一つ待たされ続けるよりは効率的と言える場面もあるでしょう。ただ状況によっては他にも工夫しないと難しいケースもありそうなので、一概にこれだけですべて解決とは限らないところがありますね。

チャネルを使ったゴルーチン間通信のエレガントな設計


このやり方、ぱっと見た感じすごく無駄がなくて安心できる気もするし、少し前に聞いた話だと大量のリクエストにもそこそこ対処できるようになっているらしい。ただ、GoのチャンネルみたいなものはRubyにはもともと組み込まれてない。まあ似たようなことをしたい人たちは`concurrent-ruby`とか使ってスレッドプールとか将来値(フューチャー)を何となく使うけど、そのへんGoのチャンネルほど自然というわけでもないっぽいし、パフォーマンスも場合によってはイマイチかもしれない。

Rubyの場合だけど、ちょっと不思議なのはメッセージパッシングがしたいときにSidekiqだとかRedis経由のキューなんかを頼るケースもよくあるみたい。でもそういう仕組みになると外部サービスへの依存が増えてしまったりして、結果的にちょっと複雑になったりする場面もちらほら出てくるかもしれない。

さて、抽象的な話より実際どう動いているのか気になるところ。そこで、一例としてAPIサーバーを取り上げてみてもいいかなと思う。何年か前から、高速で大量リクエストに応答できるAPIサーバー作りたい場面って意外と多いらしくて――数千件とかそんな感じの同時リクエストが飛び交う状況ね。その時Goで書いてみたらどうなるか、と比較対象としてRuby版もちょっと触れることになるんだけど、それぞれ違うポイントが浮き彫りになる可能性は十分ある。

Goの場合だとgoroutineやチャネルをうまく活用して並行処理しているサンプルプログラムがあったと思う。細かい部分は忘れちゃったけど、大体そんな雰囲気だったかな。


Go言語で書かれたサーバーの話だけど、JSONを受け取って、同時に処理して、その結果を返す流れになってる。ひとつひとつのリクエストは、それぞれ別々のゴルーチン(軽量スレッドみたいなもの)で動いている感じ。これなら、何千とかそれ以上のクライアントが一気に来ても、たぶん大きな負荷にはなりにくい仕組みらしい。Goランタイム側がゴルーチンを勝手にうまく並べて実行してくれるから、特別なことしなくても高い同時性が出せるっていう噂。

ところ変わってRubyだと、Sinatraという割とよくあるフレームワークを使った例があるっぽい。Rubyの場合もJSONをパースしてID拾って――まあ、その辺はGoと似てる。ただし処理自体はスレッド(Thread)を新しく生やして動かす形なんだとか。でも、このあたりから雰囲気が違うかも。RubyにはGILという仕組みがあるせいで、本当にCPU使うような重たい計算なら並列化はそこまで進まないこともよくあるらしい。それにスレッドそのものもゴルーチンより重めっぽいので、多数さばこうと思ったら急に難しくなるかもしれない。

PumaだったりSidekiqだったり、複数プロセスやバックグラウンドジョブ使えばもうちょっと改善できる場合もあるそうだけど、その分設定も複雑になりやすいし、シンプルさではGoの標準機能には若干劣る印象かなあ、と聞いたことがある。

どちらがおすすめかは用途次第とも言えるけど、ざっくりしたベンチマークを見る限り、大規模になるほど差は顕著になるケースが多そう。ただ細かい数字は環境や条件によって大きく揺れるみたいで、「七十多」くらい並列アクセスさせた場合でも明確な優劣断定までは難しい部分も残る。

ベンチマークで見るGoとRubyの性能差が如実に現れる瞬間

たとえば、両方のサーバーに何千ものリクエストを同時に送り込むという実験を想像してみる。Goで作ったサーバーは、ちょっとした負荷じゃびくともしない感じがある。軽いゴルーチンやランタイムの効率の良さも手伝って、体感では数千単位のリクエストを一秒ごとにさばいてしまうことも珍しくない。一方で、Ruby(SinatraとPuma使う場合とか)は、どうもその半分にも満たない印象が残る。応答速度もGoほど短くならず、一呼吸置いてから返事が返ってくることが多いみたいだ。

これらの数字は、あくまで傾向として語られることが多いけど、実際そんな違いを感じている現場も少なくないようだ。Goなら応答まで十数ミリ秒程度で済むこともあるし、それに比べRuby側は百ミリ秒近くかかる場合もある。それでも環境次第では結果が前後するため、一概には言えない部分もある。

Rubyがこうした状況になりやすい理由はいろいろ指摘されてきたけれど、そのひとつにスレッド周りやGIL(グローバルインタプリタロック)が挙げられるみたいだ。ここ最近ではファイバーやRactor(Ruby3以降で試験的なもの)など新しい並行処理機構も増えてきてはいる。ただ、本格的なマルチコア活用となるとまだ課題は残っている様子。

MRI Rubyの場合、とくにCPU処理を複数同時にこなす場面になると、このGILのおかげでスレッドによる真の並列化までは踏み込めていないとも聞いたことがある。そのため、「重めの計算」や「大量アクセス」の時には、どうしてもGoとの差が意識されやすいという声もちらほら。ただし一部には、「用途次第で十分Rubyでも対応できる」と主張する人たちもいて、絶対的な優劣とは言えない部分はありそうだ。

Rubyのスレッドって、どうもゴルーチンと比べて、かなり余分なメモリを食うし、切り替えにも手間がかかるみたい。まあ、七十多倍とか大げさじゃないけど、それに近い差が出ることもあるようだ。チャンネルみたいな仕組みは最初から用意されていなくて、結局は外部のライブラリだったり、普通のキューで何とかするしかない感じ。
それと、PumaやSidekiqなんかは複数プロセスで動くんだけど、そのへんもゴルーチンに比べたら随分重たくなることが多いらしい。何となく、ちょっとした規模でもシステム全体で使う資源が数十倍に膨れるケースも聞いたことがある。

ただね、Rubyって試作するときとか、新しいアイデアをすぐ形にしたい場合にはかなり便利だと思う。でも本格的に性能を追求したい人には少し物足りない部分が残る…そんな印象を受けた人もいるかもしれない。Goの場合は最初から拡張性というか、大規模でも回せる設計になっていると言われているから、このあたり現場によって選び方変わる気がする。

実際、「今どきの同時処理バリバリなアプリならGo」という声も一部では挙がっているものの、それぞれ適材適所なのかもしれない、とふと思った。

プロセスベースの並行処理が抱えるリソース問題

Goの並行処理って、何だか妙に便利に感じる場面が多い気がする。例えば、かなり多くのクライアントから一度にアクセスされるAPIサーバーとか。あれ、数千人というよりは七十人とか百人を超えるときにも、goroutineとかchannelという仕組みで割と自然に対応できてしまう。それほど大規模じゃなくても、小さめのリアルタイム系のシステム――チャットっぽいものや動画配信なんか――にも役立つことがあるらしい。どうやら遅延も小さいようで、そういう用途では選ばれる場面を見たことがある。

それとマイクロサービス構成の場合だけど、大きなプログラムを細かく分けて動かす時にはGoで作ったバイナリが意外と軽く済むみたい。もちろん全部Goじゃなきゃダメって話ではないけれど、小さめ・複数のサービスには合っているかもしれない。

逆にRubyだけど…あちらはスピード競争よりも、「試しにつくってみたい」みたいな雰囲気で使われる印象が強い。Railsみたいな枠組みも有名だし、MVP(最初のざっくりした形)を短期間で出したい場合なんかよく選ばれているようだ。中規模くらいまでのウェブアプリなら、並行処理そこまで気にならないことも多いので、その時はRubyの書きやすさや柔軟性が重宝される、と聞いたことがある。

開発者自身の快適さ?これは個人的な好みに左右される部分も大きいけど、Ruby界隈では「コードを書く楽しさ」に価値を置いている人たちも結構見受けられる。

まとめっぽい話になるけれど…goroutineとかchannelのおかげでGo言語による並行プログラミングは不思議と直感的だったり効率良かったりする印象。ただ、それですべて上手くいくとは限らなくて、「状況によっては向いている」と捉えておいたほうが良さそうだ。

Rubyの並行処理について話すと、グローバルインタプリタロックが影響してるからか、何となくスレッドも重めで、パフォーマンスが求められる場面だとやや追いつきにくい印象を受けることもある。たとえばAPIサーバーだったり、データをほぼリアルタイムで流すパイプラインみたいなもの、それに最近よく耳にするマイクロサービスの構成とか——こういうケースではGoの仕組みが目立ってきたりする。

Goだとゴルーチンとかチャンネルとか、そのあたりはかなり軽量だと言われていて、実際使った人の話でも「手間取らず動いた気がする」といった感想もちらほら。ただ全部うまくいくとは言えないし、多分状況次第だけど、「ちょっと魔法っぽかった」なんて声も無いわけじゃない。Ruby側にも良さはあるんだけど、この辺の違いは数字で表せないくらい大きめなのかもしれない。

今度また複数同時処理しなくちゃならない課題に直面した時には、一度Goを選択肢に入れてみても損はなさそう。操作感としてはラクになる可能性が高そうだから、一部では「まるで呪文を唱えているような気分」と比喩されることも。もちろん全部の場合じゃないと思うけれど……

Reference Articles

2000年以降20年間のプログラミング技術の歴史を振り返って

「プログラミング技術の変化で得られた知見・苦労話」という Qiita Advent Calendar 2020 への参加記事です。2000 年から 2020 年現在までの ...

Source: Qiita

【2024年11月最新】Rustの将来性とは?Go言語との違い ...

並行処理 :安全で効率的な並行プログラミングをサポート; ゼロコスト抽象化:高度な抽象化を提供しながら、実行時のオーバーヘッドを最小限に抑制 ...


五神 真 (Makoto Gonokami)

Expert

Related Discussions

❖ Related Articles