0. はじめに
Goでgoroutineやchannelの実装に出くわしたときに、雰囲気で見過ごしていたので基礎から整理する。
※ 2023/2/23追記: Goでの並行処理を徹底解剖! - Zenn が非常に分かりやすかったので、そちらを読んだ方がよさそうです。
1. プロセスとスレッド
プロセスとスレッドのイメージ図
参考:
- 【図解】CPUのコアとスレッドとプロセスの違い,コンテキストスイッチ,マルチスレッディングについて - SEの道標
- [非同期処理] プロセスとスレッド - Zenn
- Pythonのthreadingとmultiprocessingを完全理解 - Qiita
- 【OS/Linux】プロセス、スレッド、メモリ管理について - Qiita
プロセス
- 独立の仮想メモリ空間を保有している処理の単位。実行中のプログラム。
- プロセス間では基本的にメモリは共有されない。
- 1つ以上のスレッドから構成される。
スレッド(軽量プロセス)
- 1つのプロセスに割り当てられた仮想メモリ内で動作する処理の単位。
- 割り当てられた仮想プロセッサ(CPUコア)を占有する。
- スレッド間ではメモリが共有される。
- スレッド間では同じデータに簡単にアクセスできる。
ユーザースレッド(軽量スレッド)とカーネルスレッド
- ユーザースレッドとは、ユーザー空間上で実装されたスレッドのこと。
- ユーザーが書いたプログラムを実行する。
- カーネルスレッドとは、カーネル空間上で実装されたスレッドのこと。
- システムコール(OS/カーネルが提供する機能をアプリケーションが利用するための仕組み・API)を実行する。
※ ユーザープログラムの内部でシステムコールが呼び出された場合、ユーザースレッドからカーネルスレッドへのコンテキストスイッチが行われる。
シングルスレッドとマルチスレッド
- シングルスレッドとは、処理を上から順番に実行していくこと。
- マルチスレッドとは、処理効率を上げるなどの目的で、複数の処理を並行して行うこと。
- 例えば、時間のかかる操作を別スレッドで実行することで、ユーザー操作の受付を中断することなくプログラムを動かすことが可能になる。
- シングルスレッドのプロセスをシングルスレッドプロセス、マルチスレッドのプロセスをマルチスレッドプロセスと呼ぶ。
引用:
2. マルチスレッドとマルチプロセスの違い
- どちらも処理を並行して行う技術。
- マルチスレッドの場合、スレッド間ではメモリが共有される。スレッド間では同じデータに簡単にアクセスできる。
- マルチプロセスの場合、プロセス間では基本的にメモリは共有されない。異なるプロセスが同じメモリ上のデータにアクセスすることは基本的にはない。
引用:
3. スレッドセーフ
- マルチスレッドプログラミングにおいて、ある共有データへの複数のスレッドによるアクセスがあるとき、一度に1つのスレッドのみがその共有データにアクセスするようにして安全性が確保されているプログラムコードのこと。
- スレッドセーフなコードの場合、データアクセスの順序が予測通りになる。
- スレッドセーフでないコードの場合、競合状態が発生し、データに不整合が生じる場合がある。
- 関連語に、リエントラント、排他制御、アトミックなど。
引用:
競合状態(レースコンディション)
- 排他制御/ロックや同期の処理をしない場合に、複数のプロセスやスレッドが同時に処理をした場合に、処理結果が間違った状態(プロセスの実行順序に依存して結果が異なる状況)になってしまうこと。
- 単一のコンピュータで、単一のプロセスが動作しているような環境では、競合状態は発生しない。
- 例1: Unixで全てのユーザーが共有する一時的なファイル作成用途のディレクトリである /tmp、/var/tmp における競合状態の発生。
- 例2: データベースで複数のトランザクションが並行して実行されるときの競合状態(不整合)の発生。
引用:
- 競合状態 - kaworu.jpn.org
- データ競合(data race)と競合状態(race condition)を混同しない - Qiita
- 6.10. 競合状態を避ける - Secure Programming for Linux and Unix HOWTO
- [7-5.]Unix のレースコンディション - IPA 情報セキュリティ
排他制御
- 例えばデータベースにおいては、1つのトランザクションが終わるまで他のトランザクションが行えないように待ち状態にし、同じデータに対して更新処理が同時起こることを防ぐこと。ロック。
- ロックと言う時、データベース上の複数のユーザーの同時並行的要求に対応するための機構があり、更新をしそこなったり不正な情報を読み取らせることを防ぐこと。
- 1つ以上の処理が互いに相手を待ってしまい、どの処理も先に進めなくなってしまっている状態をデッドロックと呼ぶ。
引用:
4. 並行処理(Concurrency)と並列処理(Parallelism)の違い
並行処理(Concurrency)
- 処理を細切れに分割してコンテキストスイッチ(プロセスの切り替え)を繰り返しながら並行にタスクをこなしていく。
- ある時間の範囲において、複数のタスクを扱うこと。
並列処理(Parallelism)
- 複数の処理が同時に並列に実行される。
- ある時間の点において、複数のタスクを扱うこと。
引用:
5. コンテキストスイッチ
- あるプロセスがCPUの制御を別のプロセスに明け渡すこと。
- カーネルによって行われる。
- コンテキストとは、1つのCPUが複数のプロセスを並行処理する(処理するプロセスを切り替える)ためにそれまでの処理の内容を記録し、新しい処理の内容を復元すること。
- マルチプロセス(1リクエスト1プロセス方式)の場合
- リクエストが増えるとプロセスも増えるため、コンテキストスイッチ(特にメモリ空間の切り替え)のコストが高い。
- 例: Apache
- マルチプロセス(1リクエスト1プロセス方式)の場合
コンテキストスイッチのコスト問題を解決する方法
シングルプロセス・マルチスレッド
- メリットとして、コンテキストスイッチのコストは改善されるが、デメリットとして、ファイルディスクリプターを消費してしまう。
シングルプロセス・シングルスレッド(非同期・ノンブロッキングI/O)
- 処理すべきものがどんどんイベントキューに追加され、それらを全て1つのプロセスで捌く。
- ファイルアクセスや通信などのCPUを使わない処理は非同期で行われ、処理が終わったらコールバック関数が呼ばれる。
- 例: nginx、Node.js
引用:
- いまさら聞けないNode.js
- 非同期 IO について - Qiita
- ApacheとNginxについて比較 - Qiita
- Nginxのアーキテクチャを理解する - Qiita
- nginxについてまとめ(設定編) - Qiita
6. ジョブとワーカー
ジョブ
- 「コマンドやプログラムがまとまった、ひとかたまりの処理」のこと。
- 端末ごとに見た一連の処理。
- プロセスが1つもしくは複数集まって1つのジョブができているイメージ。
- プロセスと同様
kill
コマンドで停止できる。
- コンピュータはマルチタスクなため、ジョブは並列実行することが可能。
- AWSでは、 AWS Batch で呼び出される作業の単位を指す。
- ジョブは、ECSクラスターのAmazon ECSコンテナインスタンスで動作するコンテナ化されたアプリケーションとして呼び出すことができる。
- 多数の独立したシンプルなジョブを送信できる。
フォアグラウンドジョブ
- 今操作しているアプリケーション。
- 例: ChromeやFirefoxなどのブラウザ。
バックグラウンドジョブ
- フォアグラウンドジョブ以外のジョブ。
- 例: ChromeやFirefoxなどのブラウザに対する端末。
引用:
ワーカー
- nginxでは、マスタープロセスによって管理される(マスタープロセスとは別の)シングルスレッドのプロセスを指す。
- 「ワーカープロセス」とも呼ばれる。
- 1つのワーカープロセスは複数のリクエストを処理する。
- 「ワーカープロセス」とも呼ばれる。
- Railsでは、DelayedJob、Resque、Sidekiqなどのキューイングライブラリを指す。
- JavaScriptでは、 Web Workers APIのインターフェイス(後述)。
- スクリプトで生成することができるバックグラウンドタスクを表し、作成元にメッセージを送り返すことができる。
- Web Workersにおいて、メインスレッドとは別のスレッドのことを「ワーカースレッド」と呼ぶ。
- AWSでは、AWS Elastic Beanstalk上でウェブサーバー環境に対して作成された環境のことを指す。
applicationのリクエストサイクルの外で実行されるプロセスのこと
引用:
- Nginxのアーキテクチャを理解する - Qiita
- What is a worker in ruby/rails? - Stack Overflow
- workerとは
- Worker - MDN
- ワーカー環境 - AWS
7. Rubyの非同期処理/マルチスレッドプログラミング
時間がかかる処理は後回しにして早く応答だけしたいユースケース
- Active Jobと、DelayedJob、Resque、Sidekiqなどのキューイングライブラリを利用したバックグラウンドジョブの実行(非同期のキュー操作)。
- 例: CSVファイルのアップロードなど。
- ActionMailer#deliver_laterによるメールの非同期送信。
- Active Jobのメールキューにメールが登録され、コントローラはメール送信完了を待たずに処理を続行できる。
引用:
大量のデータを処理したいユースケース
- parallel gemを使うと、マルチスレッドでの処理が可能になる。
- マルチプロセスでの処理を行うことも可能。
引用:
8. JavaScriptのシングルスレッド/マルチスレッド
JavaScriptはシングルスレッド
JavaScriptはシングルスレッド(1度に1つのタスクしか実行できない)なので、非同期処理を動かせないはずだが、
- 優先度が高いMicro Task Queue(Promise callbackなど)と
優先度が低いMacro Task Queue(setTimeout、setIntervalなど)
という2種類のQueueを持ち、 - Event Loopに従って、
- Call Stackで優先度の高いQueueから処理を逐次実行する。
これにより、まるで非同期処理のように逐次処理(同期処理)をしている。
引用:
JavaScriptでのマルチスレッドプログラミング
-
Web Workerという仕組みを使うと、メインスレッドとは別のスレッド(バックグラウンド)でスクリプトの処理の実行が可能になる。
- これにより、UIを担当するメインスレッドの処理を中断・遅延させずに実行できる。
引用:
9. Goの並行処理/マルチスレッドプログラミング
goroutine
- 他のコードに対し並行に実行している関数のこと。
- Goのマルチスレッドのコア。
- Go言語のランタイムは、goroutineをスレッドとみなせば、OSと同じ構造であると考えることができる。
- 重い処理の非同期化に使われる。
-
go
キーワードを使ってマルチスレッドプログラミングを実現する。-
go
文は渡された関数を、同じアドレス空間中で独立した並行スレッド(ゴールーチン)の中で実行する。
-
- 同じアドレス空間で実行されるため、共有メモリへのアクセスは必ず同期する(=スレッドセーフである)必要がある。→ channelを使う。
- goroutineがスイッチされるタイミングは以下。
- アンバッファなチャネルへの読み書き
- システムコール呼び出し
- メモリアロケーション
- time.Sleep()が呼ばれる
- runtime.Gosched()が呼ばれる
package main
import (
"fmt"
"time"
)
func say(s string) {
for i := 0; i < 5; i++ {
time.Sleep(100 * time.Millisecond)
fmt.Println(s)
}
}
func main() {
go say("world") //新しいGoroutinesを実行する。
say("hello") //現在のGoroutines実行
}
// 上のプログラムを実行すると以下のように出力されます:
// hello
// world
// hello
// world
// world
// hello
// hello
// world
// world
// hello
channel
- 他の言語では「キュー(queue)」と呼ばれる、「First-in, First-out」(FIFO)型のデータ構造。
- goroutineの同期を可能にする。
- バッファ(一時的に記憶する場所)として使える。
- バッファ数の記述方法:
c := make(chan 型名, バッファ数)
- バッファが詰まった時は、チャネルへの送信をブロックする。
- バッファが空の時には、チャネルの受信をブロックする。
- バッファ数の記述方法:
package main
import "fmt"
func sum(a []int, c chan int) {
total := 0
for _, v := range a {
total += v
}
c <- total // 配列aの要素の合計値を、チャネル`c`に送信する
}
func main() {
a := []int{7, 2, 8, -9, 4, 0}
c := make(chan int)
go sum(a[:len(a)/2], c) // a[:len(a)/2] → a[0:3]
go sum(a[len(a)/2:], c) // a[len(a)/2:] → a[3:6]
x, y := <-c, <-c // チャネル`c`から受信したデータを`x`,`y`へ割り当てる(FIFO)
fmt.Println(x, y, x + y)
}
// 上のプログラムを実行すると以下のように出力されます:
// -5 17 12
sync.WaitGroup
- 複数のgoroutineの実行を待ってくれる。
今回のサンプルでは、待ち合わせにtime.Sleepを使っている箇所がいくつかあります。これは説明のためであり、本来はチャネルやsync.WaitGroupなどの「作業が完了した」ことをきちんと取り扱える仕組みを使って待ち合わせ処理を書くほうが望ましいでしょう。 さもないと、忘れた頃にコード改変でなぜか動かなくなって悩むことになったり、必要以上に待ちが発生してユニットテストの所要時間が無駄に伸びたりしてしまいます。
https://ascii.jp/elem/000/001/475/1475360/
- Railsエンジニア的には、System SpecでjQueryの実行完了を待つために、
sleep
を使わずにwait_for_ajax
を使うようにしていたイメージに近いかも。
引用: