見出し画像

Go言語のChannel:完全解説

Channel:Go言語における重要な機能とGo言語CSP並行モデルの重要な体現

ChannelはGo言語における非常に重要な機能であり、またGo言語のCSP並行モデルの重要な体現でもあります。簡単に言えば、goroutine間の通信はChannelを通じて行うことができます。

ChannelはGo言語において非常に重要であり、コード内で頻繁に使用されるため、その内部実装について興味を持つのも当然です。この記事では、Go 1.13のソースコードに基づいてChannelの内部実装原理を分析します。

Channelの基本的な使い方

正式にChannelの実装を分析する前に、まずChannelの最も基本的な使い方を見てみましょう。コードは以下の通りです:

package main
import "fmt"

func main() {
    c := make(chan int)

    go func() {
        c <- 1 // Channelに送信
    }()

    x := <-c // Channelから受信

    fmt.Println(x)
}

上記のコードでは、`make(chan int)`を使用して`int`型のChannelを作成しています。
あるgoroutineでは、`c <- 1`を使用してChannelにデータを送信しています。メインのgoroutineでは、`x := <- c`を通じてChannelからデータを読み取り、それを`x`に割り当てています。

上記のコードは、Channelの2つの基本的な操作に対応しています:

  • `send`操作`c <- 1`は、Channelにデータを送信することを意味します。

  • `recv`操作`x := <- c`は、Channelからデータを受信することを意味します。

また、Channelはバッファ付きChannelとバッファなしChannelに分けられます。上記のコードでは、バッファなしChannelを使用しています。バッファなしChannelの場合、現在他のgoroutineがChannelからデータを受信していない場合、送信者は送信文でブロックされます。

Channelを初期化するときに、バッファサイズを指定することができます。例えば、`make(chan int, 2)`はバッファサイズを2に指定します。バッファが満杯になる前は、送信者はブロックされることなくChannelにデータを送信でき、受信者が準備できるのを待つ必要はありません。ただし、バッファが満杯の場合、送信者は依然としてブロックされます。

Channelの基本的な使い方

Channelのソースコードを調べる前に、まずGo言語におけるChannelの具体的な実装がどこにあるのかを見つける必要があります。Channelを使用するときには`<-`記号を使用するのですが、Goのソースコードでその実装を直接見つけることはできません。しかし、Go言語のコンパイラは間違いなく`<-`記号を対応する基本的な実装に変換します。

我々はGoの組み込みコマンド:`go tool compile -N -l -S hello.go`を使用して、コードを対応するアセンブリ命令に変換することができます。

あるいは、オンラインツールのCompiler Explorerを直接使用することもできます。上記の例のコードの場合、このリンクで直接そのアセンブリ結果を表示することができます:go.godbolt.org/z/3xw5Cj。以下の図のように:

Channelのアセンブリ命令

上記の例のコードに対応するアセンブリ命令を注意深く調べると、以下の対応関係がわかります:

  • Channelの構築文`make(chan int)`は、`runtime.makechan`関数に対応しています。

  • 送信文`c <- 1`は、`runtime.chansend1`関数に対応しています。

  • 受信文`x := <- c`は、`runtime.chanrecv1`関数に対応しています。

上記の関数の実装はすべて、Goのソースコードの`runtime/chan.go`コードファイルにあります。次に、これらの関数を対象にしてChannelの実装を調べていきましょう。

Channelの構築

Channelの構築文`make(chan int)`は、Go言語のコンパイラによって`runtime.makechan`関数に変換され、その関数シグネチャは以下の通りです:

func makechan(t *chantype, size int) *hchan

ここで、`t *chantype`はChannelを構築するときに渡される要素の型です。`size int`はユーザーが指定するChannelのバッファサイズで、指定されない場合は0です。この関数の戻り値は`*hchan`です。`hchan`はGo言語におけるChannelの内部実装です。その定義は以下の通りです:

type hchan struct {
        qcount   uint           // すでにバッファに配置された要素の数
        dataqsiz uint           // ユーザーがChannelを構築するときに指定するbufサイズ
        buf      unsafe.Pointer // バッファ
        elemsize uint16         // バッファ内の各要素のサイズ
        closed   uint32         // Channelが閉じられているかどうか、== 0は閉じられていないことを意味する
        elemtype *_type         // Channel要素の型情報
        sendx    uint           // バッファの送信インデックスにおける送信された要素のインデックス位置
        recvx    uint           // バッファの受信インデックスにおける受信された要素のインデックス位置
        recvq    waitq          // 受信待ちのgoroutineのリスト 受信待ちゴルーチン
        sendq    waitq          // 送信待ちのgoroutineのリスト 送信待ちゴルーチン

        lock mutex
}

`hchan`内のすべての属性は大まかに3つのカテゴリーに分けることができます:

  • バッファ関連の属性:例えば`buf`、`dataqsiz`、`qcount`など。Channelのバッファサイズが0でない場合、バッファは受信待ちのデータを格納します。リングバッファを使用して実装されています。

  • waitq関連の属性:これは標準的なFIFOキューとして理解できます。その中で、`recvq`はデータを受信待ちのgoroutineを含み、`sendq`はデータを送信待ちのgoroutineを含みます。`waitq`は二重リンクリストを使用して実装されています。

  • その他の属性:例えば`lock`、`elemtype`、`closed`など。

`makechan`の全体的なプロセスは基本的にいくつかの妥当性チェックと、バッファ、`hchan`その他の属性に対するメモリ割り当てであり、ここでは深く議論しません。興味のある方は直接ソースコードを参照してください。

`hchan`の属性を簡単に分析することで、2つの重要なコンポーネント、`バッファ`と`waitq`があることがわかります。`hchan`のすべての動作と実装はこれら2つのコンポーネントを中心に展開されます。

Channelへのデータ送信

Channelの送信と受信のプロセスは非常に似ています。まずChannelの送信プロセス(例えば`c <- 1`)を分析してみましょう。これは`runtime.chansend`関数の実装に対応しています。

Channelにデータを送信しようとするとき、`recvq`キューが空でない場合、まず`recvq`の先頭からデータを受信待ちのgoroutineを1つ取り出します。そして、データは直接このgoroutineに送信されます。コードは以下の通りです:

if sg := c.recvq.dequeue(); sg!= nil {
        send(c, sg, ep, func() { unlock(&c.lock) }, 3)
        return true
}

`recvq`はデータを受信待ちのgoroutineを含みます。あるgoroutineが`recv`操作(例えば`x := <- c`)を使用するとき、このときChannelのキャッシュにデータがなく、他のgoroutineが送信待ちでもない場合(つまり`sendq`が空の場合)、このgoroutineと受信するデータのアドレスが`sudog`オブジェクトにパッケージ化され、`recvq`に入れられます。

上記のコードを続けますが、このとき`recvq`が空でない場合、`send`関数が呼び出され、データが対応するgoroutineのスタックにコピーされます。

`send`関数の実装は主に2点です:

  • `memmove(dst, src, t.size)`はデータ転送を行い、基本的にはメモリコピーです。

  • `goready(gp, skip+1)` `goready`の機能は対応するgoroutineを起動することです。

`recvq`キューが空の場合、つまりこのときデータを受信待ちのgoroutineがいない場合、Channelはデータをバッファに入れようと試みます。コードは以下の通りです:

if c.qcount < c.dataqsiz {
        // 等価于 c.buf[c.sendx]
        qp := chanbuf(c, c.sendx)
        // データをバッファにコピー
        typedmemmove(c.elemtype, qp, ep)
        c.sendx++
        if c.sendx == c.dataqsiz {
                c.sendx = 0
        }
        c.qcount++
        unlock(&c.lock)
        return true
}

上記のコードの機能は実際とても単純で、つまりデータをバッファに入れるだけです。このプロセスはリングバッファの操作を伴い、`dataqsiz`はユーザーが指定するChannelのバッファサイズを表し、指定されない場合はデフォルトで0になります。他の具体的な詳細な操作は後のリングバッファのセクションで詳しく説明します。

ユーザーがバッファなしChannelを使用している場合、またはこのときバッファが満杯の場合、条件`c.qcount < c.dataqsiz`は満たされず、上記のプロセスは実行されません。このとき、現在のgoroutineと送信するデータが`sendq`キューに入れられ、同時にこのgoroutineは切り替えられます。全体のプロセスは以下のコードに対応しています:

gp := getg()
mysg := acquireSudog()
mysg.releasetime = 0
if t0!= 0 {
        mysg.releasetime = -1
}
mysg.elem = ep
mysg.waitlink = nil
mysg.g = gp
mysg.isSelect = false
mysg.c = c
gp.waiting = mysg
gp.param = nil
c.sendq.enqueue(mysg)
// goroutineを待機状態に切り替えてロック解除
goparkunlock(&c.lock, waitReasonChanSend, traceEvGoBlockSend, 3)

上記のコードでは、`goparkunlock`は入力されたミューテックスをロック解除し、このgoroutineを切り出し、このgoroutineを待機状態に設定します。上記の`gopark`と`goready`は互いに対応し、逆の操作です。`gopark`と`goready`はruntimeのソースコードでよく出会うもので、goroutineのスケジューリングプロセスに関係していますが、ここでは深く議論しません。後で別の記事で書こうと思います。

`gopark`を呼び出した後、ユーザーの目線では、Channelにデータを送信するコード文がブロックされることになります。

上記のプロセスはChannelの送信文(例えば`c <- 1`)の内部ワークフローであり、全体の送信プロセスは`c.lock`を使用してロックを行い、並行セキュリティを保証しています。

簡単に言えば、全体のプロセスは以下の通りです:

  1. `recvq`が空かどうかをチェックします。空でない場合、`recvq`の先頭からgoroutineを1つ取り出し、それにデータを送信し、対応するgoroutineを起動します。

  2. `recvq`が空の場合、データをバッファに入れます。

  3. バッファが満杯の場合、送信するデータと現在のgoroutineを`sudog`オブ体にパッケージ化して`sendq`に入れます。そして現在のgoroutineを待機状態に設定します。

チャネルからのデータ受信プロセス

チャネルからデータを受信するプロセスは、基本的に送信プロセスと似ており、ここでは繰り返しません。受信プロセスに関わる特定のバッファ関連の操作については、後で詳しく説明します。

ここで留意すべきは、チャネルの送信と受信の全体プロセスでは`runtime.mutex`を使用してロックを行っていることです。`runtime.mutex`は、ランタイム関連のソースコードで一般的に使用される軽量のロックです。全体のプロセスは最も効率的な無ロックアプローチではありません。Go言語にはgo/issues#8899という問題があり、これは無ロックチャネルのソリューションを示しています。

チャネルのリングバッファの実装

チャネルは書き込まれたデータをキャッシュするためにリングバッファを使用しています。リングバッファには多くの利点があり、固定長のFIFOキューの実装に非常に適しています。

チャネルにおけるリングバッファの実装は以下の通りです:

チャネルにおけるリングバッファの実装

`hchan`にはバッファに関連する2つの変数があります:`recvx`と`sendx`。 その中で、`sendx`はバッファ内の書き込み可能なインデックスを表し、`recvx`はバッファ内の読み取り可能なインデックスを表します。`recvx`と`sendx`の間の要素は、正常にバッファに配置されたデータを表します。


我々は直接`buf[recvx]`を使ってキューの最初の要素を読み取り、`buf[sendx] = x`を使ってキューの末尾に要素を配置することができます。

バッファの書き込み

バッファが満杯でない場合、バッファにデータを入れる操作は以下の通りです:

qp := chanbuf(c, c.sendx)
// データをバッファにコピー
typedmemmove(c.elemtype, qp, ep)
c.sendx++
if c.sendx == c.dataqsiz {
    c.sendx = 0
}
c.qcount++

ここで、`chanbuf(c, c.sendx)`は`c.buf[c.sendx]`と等価です。上記のプロセスは非常に単純で、つまりデータをバッファの`sendx`の位置にコピーするだけです。

そして、`sendx`を次の位置に移動します。`sendx`が最後の位置に達した場合、0に設定します。これは典型的な先頭と末尾をつなぐ方法です。

バッファの読み取り

バッファが満杯でない場合、このとき`sendq`も必ず空でなければなりません(なぜなら、バッファが満杯でない場合、データを送信するgoroutineはキューに入らず、直接バッファにデータを入れるからです。具体的なロジックは、上記のチャネルへのデータ送信のセクションを参照してください)。このとき、チャネルの読み取りプロセス`chanrecv`は比較的単純で、データを直接バッファから読み取ることができます。これも`recvx`を移動するプロセスです。基本的には上記のバッファの書き込みと同じです。

`sendq`に待機中のgoroutineがある場合、このときバッファは必ず満杯です。このときのチャネルの読み取りロジックは以下の通りです:

// 等価于 c.buf[c.recvx]
qp := chanbuf(c, c.recvx)
// キューから受信者にデータをコピー
if ep!= nil {
    typedmemmove(c.elemtype, ep, qp)
}
// 送信者からキューにデータをコピー
typedmemmove(c.elemtype, qp, sg.elem)
c.recvx++
if c.recvx == c.dataqsiz {
    c.recvx = 0
}
c.sendx = c.recvx // c.sendx = (c.sendx + 1) % c.dataqsiz

上記のコードで、`ep`はデータを受け取る変数に対応するアドレスです。例えば、`x := <- c`では、`ep`は変数`x`のアドレスを表します。
そして`sg`は`sendq`から取り出された最初の`sudog`を表します。そして:

  • `typedmemmove(c.elemtype, ep, qp)`は、バッファ内の現在読み取り可能な要素を受信変数のアドレスにコピーすることを意味します。

  • `typedmemmove(c.elemtype, qp, sg.elem)`は、`sendq`内のgoroutineが送信待ちのデータをバッファにコピーすることを意味します。後で`recv++`が実行されるので、これは`sendq`内のデータをキューの末尾に配置することと等価です。

簡単に言えば、ここではチャネルがバッファ内の最初のデータを対応する受信変数にコピーし、同時に`sendq`内の要素をキューの末尾にコピーすることで、データをFIFO(First In First Out)で処理することができます。

まとめ

Go言語で最も一般的に使用される機能の1つであるチャネルのソースコードを理解することは、我々がより良くチャネルを理解し、使用するのに役立ちます。同時に、我々はチャネルの性能に対して過度に迷信し、依存することはありません。現在のチャネルの設計にはまだ多くの最適化の余地があります。

最適化メモ:

  • 見出し(`#`や`##`などを使用)を使って記事の内容をレイヤー化し、構造を明瞭にします。

  • コードブロックを明確にマーク(```go```を使用)し、コードの読みやすさを高めます。

  • コードブロック内のコメントを別に記載し、コードのロジックの説明を明確にし、コードブロック内のコメントが読み取り体験に与える影響を避けます。

  • 一部の重要な部分を箇条書きで提示し、複雑なロジックを理解しやすくします。例えば、チャネルの送信プロセスなど。

  • 一部の内容にハイパーリンクを追加し、読者が関連資料を参照するのを容易にします。

Leapcell: 最適なGo言語Webホスティング用Serverlessプラットフォーム

最後に、Goサービスをデプロイするのに最適なプラットフォームとしてLeapcellをおすすめします。

1. 多言語対応

  • JavaScript、Python、Go、またはRustで開発できます。

2. 無制限のプロジェクトを無料でデプロイ

  • 使用量に応じて課金 — リクエストがなければ料金はかかりません。

3. 圧倒的なコスト効率

  • 使い捨て型の課金で、アイドル時の料金はかかりません。

  • 例:25ドルで平均応答時間60msで694万回のリクエストをサポートできます。

4. ストリームライン化された開発者体験

  • 直感的なUIで簡単にセットアップできます。

  • 完全自動化されたCI/CDパイプラインとGitOpsの統合。

  • アクション可能な洞察のためのリアルタイムメトリックとログ。

5. 簡単なスケーラビリティと高性能

  • 高い並行処理を簡単に処理するための自動スケーリング。

  • オペレーションオーバーヘッドはゼロ — 構築に集中できます。


ドキュメントでもっと詳しく調べる!

LeapcellのTwitter:https://x.com/LeapcellHQ

いいなと思ったら応援しよう!