JavaScriptの非同期に関して書いたからついでにGoの非同期に関して比較しながらあれこれ書いてみる

以前、JavaScriptの非同期に関して書いたので、それを踏まえてGoの非同期に関しても触れてみたい。

JSは非同期、Goは同期

まず第一に、I/O系のAPIにおいては JSは非同期がデフォでGoは同期がデフォである点に注意したい。

※JSの場合、I/O系とはfetch、setTimeout、ファイル読み込みなど、Goの場合はhttp.Get、ファイル読み込み、DB問い合わせなどを指す。

以下、処理を見てみよう。

// JavaScript
// fetch() は結果を待たずにPromiseを返す
const promise = fetch('/api');
console.log(promise); // Promise { <pending> }が返る
// Go
// http.Get() は結果が返るまで止まる
resp, err := http.Get("/api")
fmt.Println(resp) // ちゃんと実際のレスポンスが返る

JSでは一行目のfetchが非同期で走るため即座に二行目が実行される。この時点ではまだレスポンスが届いていないので、console.logはPromiseの内部状態である<pending>(処理中)を表示する。

JSはシングルスレッドなので、一つの処理がスレッドを占有してしまうと他のすべてが止まってしまう。故にI/O系のAPIは非同期に設計されているというわけだ。

そのため、コードスタイルは自ずと逆の意味を明示することとなる

JSは「待つ」ことを明示する。
Goは「並行に走らせる」ことを明示する。

JS:  放っておくと非同期 → awaitで「待て」を明示
Go:  放っておくと同期  → goで「別で走れ」を明示(channelで「どこを通って届けるか」を明示 ※ 後述)

Goはマルチスレッド

ではGoはどうやって並行処理するのか?

その前に、Goは複数のスレッドを利用できるため、特別な非同期の仕組みがなくても、スレッドごとに別々の処理を同時に進められる点に注目したい。

JS:   作業員(シングルスレッド)が1人しかいない
      → 1人が手を止めると全部止まる
      → だから「待たない」仕組み(非同期)が必須

Go:   作業員(OSスレッド)が数人いて、指示書は何万枚でも出せる
      → 1枚の指示書が待ち状態でも、別の指示書を別の作業員に回せる
      → だから「待つ」(同期)で問題ない

ただしOSスレッドは1つあたり1〜2MBのメモリを消費するため、大量には作れない。 そこで登場するのがgoroutineだ。

ゴルーチン!! (名前がかっこいい)🦍

goroutineとchannel

goroutineは 指示書とスタックのセットの塊(実行単位) であり、とても軽量だ。
これをスレッド内に大量に割り当てる。

OSスレッド1  ←  goroutine A, goroutine B, goroutine C ...
OSスレッド2  ←  goroutine D, goroutine E ...
OSスレッド3  ←  goroutine F ...

また、goroutineの管理は(OSスレッドではなく) Goのランタイムが管理する。
これによりスケジューリングのコストが小さくなるのだ。

goroutineを作る

go キーワードを関数の頭につけるだけで良い。
これでその関数は非同期で呼び出したことになる。

go doSomething()

channelで結果を受け取る

では値を受けるにはどうするか?channelを利用する。

func fetchUser() string {
    time.Sleep(1 * time.Second) // 重い処理を模擬
    return "Taro"
}

func main() {
    ch := make(chan string) // stringを送受信するチャネルを作る

    go func() {
        result := fetchUser()
        ch <- result // チャネルに結果を送る
    }()

    name := <-ch // チャネルから結果を受け取る(届くまで待つ)
    fmt.Println(name) // "Taro"
}

受け取り側は値が届くまでブロックする
JSで言えば、async関数がPromiseを返す → awaitで結果を待つ、という流れに対応する。


function fetchUser() {
    return new Promise(resolve => {
        setTimeout(() => resolve("Taro"), 1000);
    });
}

async function main() {
  const name = await fetchUser(); // ← "ここで待ってね"と宣言
  console.log(name);
}
JS:   async関数がPromiseを返す → awaitで結果を待つ
Go:   goroutineがchannelに送る → <-chで結果を待つ

複数のgoroutineで並行処理

ここまでのコードではgoroutineが1つだけだったので、実質的には直列処理だった。
ここからはgoroutineを複数立てて、並行処理を書いてみる。

// Go: 複数のgoroutine + channelで並行実行
func main() {
    chUsers := make(chan []User)
    chPosts := make(chan []Post)

    // 2つのgoroutineを同時に走らせる
    go func() {
        users, _ := fetchUsers()
        chUsers <- users
    }()
    go func() {
        posts, _ := fetchPosts()
        chPosts <- posts
    }()

    // 両方の結果を待つ
    users := <-chUsers
    posts := <-chPosts

    fmt.Println(users, posts)
}

前のセクションではgoroutineが1つだったので直列的に見えていたけど、ここではgoroutineを2つ立てて、それぞれが独立して走っている。<-chUsers<-chPostsで両方の完了を待つことで、Promise.allと同じ「全部揃うまで待つ」を実現する。

// JS: Promise.allで並行実行
const [users, posts] = await Promise.all([
  fetchUsers(),
  fetchPosts(),
]);

WaitGroup:全部終わるのを待つ(結果はいらない)

ここからは複数のgoroutineを利用する際のバリエーションだ。
channelは「結果を受け渡す」ための仕組みだが、「結果はいらないけど全部終わるのを待ちたい」場合にはWaitGroupを利用する。

func main() {
    var wg sync.WaitGroup

    urls := []string{"url1", "url2", "url3"}

    for _, url := range urls {
        wg.Add(1) // カウンターを+1
        go func(u string) {
            defer wg.Done() // 終わったらカウンターを-1
            fetch(u)
        }(url)
    }

    wg.Wait() // カウンターが0になるまで待つ
    fmt.Println("全部完了")
}

仕組みはシンプルで、内部にカウンターを持ち、Addで増やし、Doneで減らし、Waitでゼロになるまで待つ。
JSの結果を使わないPromise.allみたいなものだ。

select:最初に届いたものから処理する

select {
case user := <-chUsers:
    fmt.Println("ユーザーが先に来た:", user)
case post := <-chPosts:
    fmt.Println("投稿が先に来た:", post)
case <-time.After(3 * time.Second):
    fmt.Println("タイムアウト")
}

こちらはswitch文に似ている。ただ条件分岐ではなく「どのchannelに最初に値が届くか」で分岐する。
タイムアウトも自然に書けるのが便利だ。

JSで言えばPromise.raceである。

const result = await Promise.race([
  fetchUsers(),
  fetchPosts(),
  new Promise((_, reject) => setTimeout(() => reject('タイムアウト'), 3000)),
]);

まとめ

最後にここまでの参考となりそうなまとめをしてみた。

JSとGoの登場人物

JS:
  - 登場人物
    - スレッド → 唯一の作業員(OSのスレッドではあるのだがJSの場合、単にスレッドやメインスレッドと呼ぶ)
    - スタック → その作業員が使う唯一の作業台
    - イベントループ → 作業員の手元にあるタスクの順番表
  - 作業方法
    - 作業員が1人しかいないので、待ちが発生する処理は
      「終わったら教えて」と依頼して先に進む(非同期)
    - 順番表(イベントループ)を見て、次にやるべきことを拾い続ける

Go:
  - 登場人物
    - OSスレッド → 作業員。数人いる。
    - OSスタック → 各作業員に付属する作業台(ランタイムの内部管理用)
    - Goランタイム → 現場監督。指示書を作業員に振り分ける
    - goroutine → 指示書+小さな作業台(スタック)のセット。数万枚作れる
    - channel → goroutine間でデータを流すパイプ
  - 作業方法
    - 作業員が複数いて、指示書(goroutine)も軽いので、
      一つの指示書が待ち状態になっても他の指示書を別の作業員に回せる
    - だから各指示書の中身は素直に「上から順に待つ」書き方(同期)で問題ない
    - 現場監督(ランタイム)が空いた作業員に次の指示書を渡し続ける

複数のgoroutineを使った並行処理まとめ

用途JavaScriptGo
全部の結果を待つPromise.allchannel複数
全部終わるのを待つPromise.allWaitGroup
最初の1つを待つPromise.raceselect

Happy Hacking 🥥