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
を使用してロックを行い、並行セキュリティを保証しています。
簡単に言えば、全体のプロセスは以下の通りです:
-
recvq
が空かどうかをチェックします。空でない場合、recvq
の先頭からgoroutineを1つ取り出し、それにデータを送信し、対応するgoroutineを起動します。 -
recvq
が空の場合、データをバッファに入れます。 - バッファが満杯の場合、送信するデータと現在の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