現在多くのコンピュータは、マルチコアCPUで動いています。
2000年初め頃にはシングルコア性能の成長に陰りが見え始め、極端なクロック周波数の増加が発熱と消費電力の増大を引き起こしました。
従来の性能向上手法は限界に達し、その代わりとして主流となったのが、複数のコアを使って並列に処理を実行するというアプローチです。
わかりやすく言えば、高性能なシングルコアは仕事の速い熟練の職人で、マルチコアは職人一人に代わり多人数で仕事を回すようなものです。
これまでは非常に難しい仕事をこなせる熟練の職人(クロック数が高いコア)を育ててきました。
ですが、ビジネスが求める要件は際限なく膨れ上がり、一人で抱えられる量を大きく超えてしまいました。
そこで、「一人でできないなら、みんなでやろう」というわけです。
ですが、「担当者が増えると問題が増える」というのは実務作業でもプログラム構造でも同じです。
複数のコアが同時に仕事を処理するようなプログラムは非常にバグが発生しやすく、繊細で難解な実装が求められます。
そのため次世代のプログラミング需要として、よりうまくマルチコアを扱えるマルチコアパラダイムなプログラミング実装手法が求められるようになりました。
そして、マルチコアパラダイムを最もうまく扱えるのが、新世代プログラミング言語であるGo言語です。
この記事ではGo言語が得意とする並列処理や並行処理について詳しく解説します。
2014年 大学在学中にソフトウェア開発企業を設立
2016年 新卒でリクルートに入社 SUUMOの開発担当
2017年 開発会社Jiteraを設立
開発AIエージェント「JITERA」を開発
2024年 「Forbes 30 Under 30 Asia 2024」に選出
Go言語の並列処理の基本
Go言語は、言語仕様として並行処理やCSP(Communicating Sequential Processes)モデルをサポートしています。
また、生まれながらにしてマルチスレッドに対応しており、自動で並列処理を行います。
難しい話を全て省くと要するに、Go言語の仕様に従う限り、「システム自体がマルチスレッド処理の面倒ごとを肩代わりしてくれる」という考えでも大まかには合っています。
そのため、Go言語は様々な制限があるものの、多くの利点とシンプルな構造で並列処理を実装することが可能です。
Go言語については、別の記事でも紹介しているのでもしよろしければご参照ください。
並列処理とは
並列処理は、複数の処理が同時に進行し、効率的にタスクを処理する手法です。
100ページの資料を整理するのに、1人が100ページ読むのではなく、10人が10ページづつ担当するようなものです。
現在のCPUには、処理を行うプロセッサコアが複数個搭載されているため、一つの仕事を分担して作業することで数倍のスピードでタスクを処理できます。
ですが、複数のコアを同時に動かし仕事を分担させる実装は非常に難しく、繊細な設計が必要となります。
マルチコア用に開発されたGo言語では、軽量スレッドのGoルーチン(Goroutine)を通じて、Goランタイムが自動的に手空きのコアに振り分けます。
つまりコアへの振り分けはシステムが効率良く自動的に運用してくれるので、本命の並行処理内容の設計に注力することができるのです。
Goルーチンとは
Goルーチンは、Go言語専用に開発されたネイティブスレッド並みに非常に軽量で効率的なスレッドです。
Go言語の最も大きなメリットの一つであり、
- 非常に軽量
- 通常のスレッドよりもメモリ消費量が遥かに少ないため、数百万単位のサブルーチン(Goルーチン)を同時に実行することができます。
- マルチコア対応、並列処理対応
- GoランタイムがGoルーチンを適切にマルチコアへ分散させてくれるため、Goルーチンさえ起動すれば自動的に効率的な並列処理が実行されます。
- 実装するソースコードがシンプル
- goキーワードと呼ばれるマルチスレッド用のシンプルな構文が用意されており、ソースコード自体の可読性が高いため、メンテナンスや改修が楽に行なえます。
といったマルチコア対応に必要な特徴を持っています。
Goルーチンの存在によって、「とりあえず、大量の計算処理をgo funcで投げれば、勝手に最適な並行処理を行い、Goランタイムがマルチコアへ並列処理を振り分けてくれる」という非常に都合のいい開発が可能となります。
チャネルとは
チャネル(channel)は、Goルーチン間のデータのやりとりを安全に行うための通信メカニズムです。
マルチスレッド処理を行う上で大きな問題となるのが、Goルーチン間のデータ通信です。
一旦とあるレストランのキッチンを思い浮かべてください。
キッチンにシェフが2人。
ホールにウェイターが2人。
注文表を使ってやり取りします。
ここで問題なのは、Goルーチン同士は別のスレッドで動いているためお互いを気遣えないということです。
つまり料理人同士もウェイターも、他の人が何をしているか知らないし、融通を利かせられません。
お互いにお互いを認識できないので、
- ウェイターがテーブルAの注文表を書いていたところに、追加注文を受けた別のウェイターが勝手に上書きし始める。
- 例えば、料理名「ペペロンチーノハンバーグライスありスパゲッティ(冷製)」みたいなオーダーが行われます。
- 同じ注文表を2人のシェフが同時に読んだことで、全く同じ注文を二重に作ってしまう。
といったことが起こります。
そのため、お互いに注文表を譲り合った結果、
- まずウェイター1さんがAテーブルの注文を用紙に書き、手空きのシェフ1にお願いします。
- シェフ1はAテーブルの料理を準備し始めます。
- シェフ2は、二重注文を防ぐためにAテーブルには関わらないようにします。
- ウェイター2さんがテーブルAから追加注文を受けたので、ウェイター1さんのあとに注文用紙へ書き込もうとします。
- シェフ1は注文用紙を受け取ろうとしましたが、ウェイター2さんが注文用紙を持ったため、返ってくるのを待ちます。
- ウェイター2さんは、手が開いているシェフを探しますが、誰もいませんのでシェフの手が空くのを待ちます。
- いつまでたってもテーブルAに料理は届きません。
みたいなことが起こります。
お互いに邪魔することを恐れてしまい、動けなくなるわけです。
そのためGoルーチン同士がデータを共有し合う仕組みが必要になります。
異なるGoルーチンで何かデータを共有したい場合、2通りの手段が存在します。
- 各プロセス(Goルーチン)が、メモリをロックしながら同じメモリにアクセスする。
- Shared-memory communicationと呼ばれます。
- レストランの例で言えば、注文表は常に一人だけで利用するようなものです。
- Go言語ではsyncパッケージのMutexなどを使います。
- 各プロセスがチャネルを通してメッセージを送り合う。メモリの内容は書き換えない。
- Message-passing communicationと呼ばれます。
- レストランの例で言えば、キッチンと厨房の間にデシャップを置くようなものです。
- Go言語ではチャネルとSelectを使います。
共有メモリのロックは非常に煩雑な実装が必要な上、バグが起きやすいため、Go言語では主にチャネルを通してメッセージを送り合う方法を採用します。
チャネルは、非常に大雑把に言えば「並行処理を阻害しない安全な通り道」のようなもので、Goルーチン同士がデータの送受信プロセスを同期化できます。
チャネルを通したデータはお互いの状態を気にせずに安全にやりとりできるようになります。
Go言語には、
Don’t communicate by sharing memory, share memory by communicating.
共有メモリで通信するな。通信でメモリを共有しろ。
という非常に有名な格言があるくらい、共有メモリでの通信を避けてチャネルを利用することが推奨されています。
Goルーチンとチャネルでよくあるトラブル
いくつかGoルーチンとチャネルを使う際に起こるトラブルについても紹介しておきます。
適切にGoルーチンを通信しないと、並行処理は性能を発揮できません。
不適切な設計によるデッドロック
別の処理が同じリソースを参照することで身動きが取れなくなるデッドロック問題は、従来の並行処理において最も多くのトラブルを引き起こしてきました。
Goルーチンは従来の並行処理に比べ、チャネルを利用することでデッドロック問題が発生しづらくなっています。
ですが、適当に処理をコーディングしてしまうと当然デッドロックが引き起こされます。
デッドロックを防ぐためには、Goルーチン間でのリソースの適切な共有と解放、つまりチャネルの適切な活用が必須となります。
そのため、例えば以下のような不明瞭な設計がデッドロックを引き起こします。
- Goルーチンの相互依存関係やデータ保持関係が明確でない
- むやみにデータを保持せず、必要な時にだけ受け渡すようにしましょう。
- チャネルの閉じ忘れ
- チャネルをどこで閉じるのか、明確に記述しましょう。
- select文が複数のチャネルを待つケース
- データの優先順位やタイムアウト設定は明確に記述しましょう。
- そもそも、select文の設計自体が間違っている場合もあります。
チャネルの偏り
特定のGoルーチンが頻繁にデータを送受信することで、特定のチャネルにデータが偏り、Goルーチンがうまく並行処理できなくなる問題があります。
チャネルの偏りは、処理の不均衡によってスループットの低下や致命的なボトルネックを引き起こします。
例えば、
- 特定の単一Goルーチンがチャネルへの書き込みを独占する
- 特定の単一Goルーチンがチャネルからの読み込みを独占する
- そもそも取り扱うデータに対してバッファが不足している
などが考えられます。
Goルーチンは非常に軽量なため、単一のGoルーチンに偏らないように複数のチャネルを利用し、select文の適切な使用などで偏りを軽減しましょう。
その他にも、スループットの低下を避けるために複数のGoルーチンを使ってワーカープールを実装する選択肢もあります。
ワーカープールについては、並列処理の実践の項目で紹介します。
競合状態
複数のGoルーチンが共有リソースへ同時にアクセスすることで、競合状態を引き起こします。
通常は、chanとselectを適切に利用することで、そもそも共有リソースを使うことは避けられます。
どうしても共有リソースを利用しなくてはいけない場合は、syncやmutexを利用して排他制御を行うことで回避しますが、 実装には最新の注意を払う必要があります。
また、ビルド時にraceオプションを利用して回避することもできますが、こちらはパフォーマンスが低下するため、動作検証時などの明確にraceオプションが利用可能と判断されたときのみ使用しましょう。
並列処理と並行処理の間違いに注意
並行処理と並列処理は、共に複数のタスクを効率的に処理するための手法ですが、その方法と目的に大きな違いがあります。
Go言語はGoルーチンによって並行処理を行い、複数のGoルーチンを複数のコアに自動で振り分けることで並列処理を実行しています。
並行処理とは
並行処理は、複数のタスクを独立して処理していくことです。
ある一瞬においては実行されているタスクは一つだけですが、それを高速に切り替え、リソース状況に合わせて最適な処理を行うことで、まるで複数のタスクが「同時に進行している」ように振る舞います。
並行処理は、パフォーマンスを最大限発揮してタスクを同時実行させることが目的となります。
並行処理は、例えば一人で何でも熟してしまう熟練のプロジェクト担当のようなものです。
関係者にメールを送りながら、必要に応じて電話し、会議室の空きをWebシステムで確認。
いくつかの資料を開いて目を通しながら、PowerPointの段落を考えつつ、メールの返答を確認。
そんな作業中にも、上司や部下からの話しかけに応答することもあるでしょう。
このとき、担当者は同時に複数の処理を行っています。
同時に、複数の資料を読みながら口は上司や電話と話し、手はメモを書いたり資料を作ったりしています。
ですが、目や手や口が4つや6つに増えたわけではありません。
ごく短時間でやることを切り替えながら、効率よくタスク処理をしています。
これが、並行処理です。
熟練のプロジェクトマネージャーは常に複雑に絡み合ったタスクを並行処理しますが、その実践には熟練の経験が必要になります。
Go言語の強みは主に並行処理に特化していることです。
Go言語では、従来では難易度が高く熟練の経験が必要だった非同期並行処理プログラムの開発を、Goキーワードを使って簡単に実装できるように高度な仕組みが導入されています。
並列処理とは
並列処理は、そもそも処理を行うCPUが複数存在し、複数の計算を同時に行います。
並行処理と異なり、時間的にも空間的にも物理的に複数のタスクを同時に処理します。
マルチコアまたはマルチプロセッサシステムで行われ、シングルコアでは果たせない物理的なパフォーマンスの向上を目指すことができます。
同じくプロジェクトマネージャーの例で言えば、複数人で手分けしてプロジェクト進行するイメージです。
資料をまとめる人、関係者に話を聞く人、スケジュールを組む人。
全員が一丸となって、プロジェクトを推進させます。
ですが、当然行き違いや認識のすれ違いが発生し、大型のプロジェクトにはケアレスミスや人的トラブルが絶えません。
プログラミングでも同じです。
並列処理の実装は複雑になりやすく、エラーが発生しやすいですし、エラーの原因を特定することが困難なケースが多いです。
Go言語は開発された段階で標準のマルチスレッドモデルを想定して設計されているため、多くの問題を引き起こす並列処理をシステムが自動処理してくれます。
ユーザーは高度に抽象化されたGoルーチンと並行処理実装だけを意識すれば良く、面倒な並列処理はGoが自動的に割り振ってくれます。
また、GOMAXPROCSやruntimeを利用することで使用するコア数の上限自体も独自に設定可能です。
両者の違い
まとめると、並行処理と並列処理には以下のような違いがあります。
項目 | 並行処理 | 並列処理 |
定義 | 複数の処理が独立して実行されること | 複数の処理を物理的に同時実行すること |
進行の形 | 同時に複数のタスクが進行するが、物理的な同時性は問わない | 同時に複数の処理が物理的に実行される |
目的 | 複数のタスクを並行して実行することで、効率的に処理を行い、パフォーマンスを向上させる | 複数のコアを同時に使うことで、物理的に従来の処理性能の限界を取り払う |
進行の例 | 複数のアプリケーションが同時に動作する | 複数のプロセッサが同時に異なるタスクを実行する |
タスクの進行形態 | 同じ時間帯に異なるタスクが進行 | 同じ時間帯に異なるタスクが同時に進行 |
パフォーマンスへの影響 | 同時に複数のタスクを進行させることで、効率的に処理が行える | 複数のCPUコアが物理的に処理を行うことで物理的な処理速度が向上する |
メリット | 複雑なマルチスレッド処理をシンプルに実装可能 マシンのリソースを効率的に利用できる |
コア数を増やすだけで物理的な性能向上が保証される
シングルコア性能の限界を打開できる |
デメリット | 物理的な同時性が保証されないため、旧来のプログラミング言語では実装が難しい
マルチコアの活用がシステムに依存する |
複雑でエラーが発生しやすい
プログラミング構成が複雑になるためデバッグが困難 様々なリソーストラブルが発生する |
Go言語は、マルチコア実装のデメリットを低減するように設計されており、言語仕様として並行処理が簡単に実装できるようにサポートしながら、ランタイム側で並列処理を自動的に振り分けます。
Go言語の並列処理の実践
Go言語では、Goキーワードを使って並行処理を行います。
のような関数を用意した場合、
とすれば、勝手にwokerの処理をGoルーチンに振り分けてくれます。
メソッドの処理の中で
のような無名関数を使っても良いです。
Goルーチンの処理はチャネルで受け渡しすることで、お互いのブロッキング問題も解決します。
別のGoルーチンの処理を待ちたいときは、そのGoルーチンからのチャネル通信を待てばいいわけです。
チャネルの通信を待たずに別の処理を行いたい場合は、selectを使います。
//dataでチャネルを受け取り、チャネルに何も通知されていないときはdefaultの処理を行う
select {
case data := <- ch:
#チャネルchに対する処理
default:
#チャネルに何も通信が来ていない場合の処理
}
のように、シンプルに場合分けをするだけで、ブロッキングを回避できます。
基本的にはgoとchanとselectを適切に使えばマルチスレッド処理が可能ですが、その他の実践的なテクニックとして、
- WaitGroupを利用した同期処理
- スループットを効率よく向上させるプール型のGoルーチン管理
の2つを紹介します。
WaitGroupを使用した同期
並行処理を設計する際には、同期タイミングの処理に気をつけなければなりません。
例えば、「サブルーチンが終わる前にメインルーチンを終わらさないようにする」という設計上の問題です。
各ルーチンは勝手に処理して勝手に終わるので、特定のGoルーチンの処理がまだ完了していないにも関わらず、メインの処理が終了してしまいます。
そのため、処理するべきタスクを残したままプログラムが終了する可能性が常に存在します。
通常はチャネルに値が返ってくるまで待てばよいわけですが、チャネルを使わないGoルーチンでは別の同期手段が必要です。
ですがルーチンは、自身の処理の終了を通知してくれないため、ルーチンが完了するまで待つための何らかのメカニズムが必要です。
Go言語で
- メイン処理が並行処理の結果を必要としないか気にしない
- 結果を収集する手段がチャネルを利用せず、終了タイミングだけ同期したい
という場合は、syncパッケージのsync.WaitGroupが便利です。
import (
“fmt”
“sync”
)
func worker(wg *sync.WaitGroup) {
defer wg.Done() // このルーチンが完了したときにwgが1つ終わったことを通知
#何らかの処理
}
func main() {
var wg sync.WaitGroup //WaitGroupを作る
for i := 1; i <= 10; i++ {
//wgの追加はgo func内ではなく、go funcの直前に行います。
//Goルーチンが開始するまでに若干の処理時間が必要なため、Goルーチン内で増やしても開始する前にメイン処理が終わってしまう場合があります。
wg.Add(1) // wgを1つ追加
go worker(&wg) // 新たなGoルーチンを起動
}
wg.Wait() // wgが全て完了するまで待機
fmt.Println(“All workers done”)//全てのGoルーチンが終了後にmainが終わります。
}
このコードでは、workerメソッドを10のGoルーチンで同時実行しています。
go workerする前にwg.Addを呼ぶことで、WaitGroupのカウントを1つ増やし、Goルーチンが処理が終了するとwg.Doneでカウントが1つ減ります。
プール型のGoルーチン管理
ある程度の規模のプログラムでは、大量のマイクロタスクGoルーチンの生成と破棄を何度も繰り返します。
そのため、ガーベッジコレクタ(GC)のオーバーヘッドが積み重なり、スループットが低下します。
わかりやすく言えば、Goルーチンの呼び出しと解放に手を取られて、処理が重くなってしまうわけです。
まず前提として、このスループット低下問題に対してGoルーチンは「自身がネイティブスレッド並みに非常に軽量で、ほとんど負荷を発生させない」という形で解決しています。
ですので、通常のアプリケーションでは、積極的にGoルーチンを生成しても、よっぽど無駄な設計をしない限り性能に大きな影響は起こりません。
ですが、小さくともオーバーヘッドは積み重なっていくため、大規模なプログラム実装などでは無視できないスループットの低下が発生します。
例えば、
- 膨大な非同期タスクの処理
- データベースへの大量の書き込みやAPIへの大量リクエストといった、数百万件の処理を一度に並行処理する場合など。
- リソースが極端に制限されている要件
- システムのリソース(CPU、メモリ、ディスクI/Oなど)が限られている場合、リソースの使用を慎重に制御しなくてはいけません。
といった要件においては、GCの負荷が大きな問題になります。
こういったスループットが重要な実装に対しては、「複数のGoルーチンを予め生成しておき、必要なタスクが発生した際にそのルーチンを再利用する仕組み」としてプール型のルーチン管理を行います。
※Goでは主導でメモリを確保、開放するarenaパッケージも用意されていますが、メモリの管理は非常に繊細な設計を要求するため、特別な事情がない限りarenaは非推奨です。
Go言語にもsync.Poolという標準ライブラリが用意されてはいますが、このライブラリはどちらかといえばオブジェクトの再利用に使用するもので、プール型のルーチン管理には向いていません。
そのため、通常はワーカープールと呼ばれるルーチン管理機能を自前で実装する必要があります。
とはいえ、様々な開発者が自身のワーカープール実装パターンを公開しているので、それを参照することで十分に実装可能です。
ワールプールの概念を理解することで、より大規模な実装であってもGo言語の強力な並列処理を最大限に活用することができます。
Go言語のまとめ
シングルコアの性能が頭打ちになり、CPUはマルチコアが当たり前になりました。
そのため、複雑な処理を行うシステムの実装には、複数のコアが扱える並列処理対応のプログラムが必要となります。
Go言語は、マルチコアに対応した次世代のプログラミング言語です。
並行処理をシンプルな構文で記述でき、ランタイムが並列処理をマルチコアへ自動的に割り振ります。
並列処理システムを導入する際には、ぜひGo言語での開発についても検討してみてください。
とはいえ、Go言語は比較的新しいプログラミング言語であり、並列処理や並行処理も設計や実装が難しい概念です。
株式会社Jiteraでは豊富な実績をもつエンジニアたちが、企業様のITシステムの悩みを解決しています。
並列処理にご興味の企業様。
システムの処理速度にお悩みの企業様。
ぜひ一度株式会社Jiteraにご相談ください。