はじめに
「同期処理、非同期処理」についての勉強会用に資料として作成した。
資料の公開場所としてQiitaがちょうどよかったので選択した。
本記事ではタイトルにある同期・非同期に加え、
- 並列処理
- 並行処理
を加えた4点をいくつかの技術と合わせて概念と特徴、それぞれの用途をつかむことを目指す。
用語説明
始めにそれぞれをどういうものか説明をしていきたいのだが、すでに素晴らしい記事が存在していた。
下記を参考にされたい。
とはいえ投げっぱなしもどうかと思うので、自分なりにどういうものかを書いてみる。
同期・非同期
処理を待つ・待たないの違い。
同期処理
プログラムの実行順序に沿って実行される。私の想定する一般的なコードなら書いた順番で実行がされると言い換えてもよい。
なんのページ開いても書いてあるのでn番煎じとなるが、途中に重たい処理が入ってもその処理が終わるまで待つため、実行時間はすべての処理の総和となる。
非同期処理
同期処理と異なり、処理が終わるのを待たずに先に進む。
これにより、これまで同期的にやる必要のあった重たい処理を待たなくて済むようになった。
とはいえ非同期処理の結果が欲しい場合もあり、この非同期の結果を取得したいときに用いられるデザインパターンがある。後述。
並列・並行処理
下記の図がわかりやすい
非同期処理の実現方法として並列処理、並行処理が存在する。
並列処理
実行単位はプロセスだったりスレッドだったりとあるが、同時に別処理が動く。
並行処理
あるタイミングにおいては1つの処理しかしていないが、複数の処理を高速に切り替えながら同時にやっているように見せる。
JavaScriptには非同期処理があるが、JavaScriptはシングルスレッドで動作しているため並行処理にあたる。
マルチプロセス・マルチスレッド
上記で実行単位としてプロセスやスレッドがあると記載した。それぞれ複数立ち上げることをマルチプロセス・マルチスレッドという。
マルチスレッド、マルチプロセスの概念図は以下の通り。
- マルチスレッド
プロセスが存在し、プロセスからスレッドが切られる。
各スレッドは同じメモリ空間を共有できるので、スレッドどうしのデータ共有が楽。だけども排他制御は考える必要があって面倒。
スレッドの切り替えがボトルネックとなり、プロセスが死んだらスレッドも根こそぎ死ぬ。
- マルチプロセス
マスタープロセスがプロセスを分割する。
プロセス独自のメモリ空間を持っているからマルチスレッドみたいに排他制御を考えなくていい。でもデータの共有は面倒。
プロセスはAWS lambdaをイメージするとわかりやすいかも。
プロセスを切るのがボトルネック。
- 使いどころさん
スレッドやプロセスを切らない方が速いこともある。
分割しているんだから速いんじゃないの?
そう思われるのも無理はないが、プロセスやスレッドを切るのにかかる時間がボトルネックとなって遅くなる場合があるためだ。
フューチャーパターン
非同期処理の部分で述べた、非同期処理の結果を受け取りたいときに用いられるデザインパターン。
フューチャーパターンの概念理解にはこれがわかりやすい
JavaScriptのPromiseやgoroutineのchannelがFutureオブジェクトに当たる認識で、何らかの非同期処理の結果を得る引換券となる。
まずはgoroutineで見てみよう。
package main
import "fmt"
func sum(s []int, c chan int) {
sum := 0
for _, v := range s {
sum += v
}
c <- sum // send sum to c
}
func main() {
s := []int{7, 2, 8, -9, 4, 0}
c := make(chan int)
go sum(s[:len(s)/2], c)
go sum(s[len(s)/2:], c)
x, y := <-c, <-c // receive from c
fmt.Println(x, y, x+y) // -5 17 12
}
go sum(/** param */)
でスレッドを切っており、x, y := <-c, <-c
でスレッドでの結果を得ている。
ここでの変数 c
はスレッドの結果が格納される変数として定義されており、mainスレッドから見れば別スレッドでの処理結果の引換券としてみることができる。
次にJavaScriptのfetch APIを見てみる。
const res = await fetch('https://example.com');
const data = await res.json();
data
Top level awaitは無視していただいて、ここでもfetchでAPIからの取得結果を受け取るresがある。
fetchの返却値はPromise型であり、awaitしなければPromise型として中身が入っていない状態で出力される。
それはPromise型がFutureオブジェクトだからであり、牛丼ができてから引換券と交換する処理として await
や .then()
で受け取る必要がある。
イベント駆動
あるイベントが起きたタイミングで任意の処理を発火させる。
AWS lambdaであればEventBridgeと合わせて任意の時間をトリガーに処理を動かせるし、API Gatewayと組み合わせればHTTP METHODでのリクエストがきたときに紐づくlambdaの処理を動かせる。
EventBridge - lambda
api gateway - lambda
WebではEventTarget.addEventListener()が有名だろう。
これはクリックイベントや読み込み完了イベント、スクロールイベントなどを検知して任意の処理を動作させる。
// clickで発火
htmlElement.addEventListener('click', (event) => {/** ... */});
// load時
window.addEventListener('load', (event) => {/** ... */});
// スクロール
document.addEventListener('scroll', (event) => {/** ... */});
バッチ処理
いろんな箇所で一括処理というような書き方がされており、実際その通りなのだが、ここでは別プロセスを切ってそこで大きな処理を行うものだと思ってもらいたい。
AWS lambdaを使ってやるんじゃダメなん? 上記しているイベント駆動的なものでしょ?
その通りなのだが、lambdaには時間制限があり、時間のかかる一括処理には向かない。
そこでECSを用いたバッチ処理を紹介する。
雑な絵だが、ECSでAPIを立ち上げており、何らかの重たい処理を行いたいとする。
しかしAPIの中で行うとフロントを待たせてしまいUXを著しく損なう。また、何らかの重たい処理は即時性は求めず、バッチ処理で行いたい。
となったとき、タスクAでAPI処理が行われ、タスクBをバッチ処理として立ち上げる。
タスクAはタスクBをrun_taskして満足しレスポンスを返却。
タスクBは非同期的にバッチ処理が行われる。
これはマルチプロセスに近い。
また、上記は最初ECSタスクが立ち上がっていないコールドスタンバイでタスクを立ち上げるまで時間がかかった。
ホットスタンバイの一例として、Celeryが挙げられる。
これまた雑な絵になってしまうが、バッチ処理で用いるECSタスクは立ち上げたまま、ElastiCache for Redisをブローカーとして、バッチ処理の指示を送る。
大まかな概要はrun_taskするのと大して変わらないが、ホットスタンバイかコールドスタンバイで異なる。
おわりに
同期・非同期処理を実際の使用方法が各機能とともに浅く広く舐めた。
発展
TypeScriptが得意なのでフロント厚め