ソースコード分析
まずは受信に関するソースコードを見てみましょう。具体的な受付プロセスを明らかにした上で、実践例をもとに詳しく検討していきます。
受信操作を記述する方法は 2 つあります。1 つはチャネルが閉じているかどうかを反映する "ok" を使用する方法、もう 1 つは "ok" を使用しない方法です。この方法では、対応するタイプのゼロ値を受信すると、それが実際の送信者によって送信されたかどうかを知ることは不可能であり、値、またはチャネルが閉じられた後に受信者に返されるデフォルトのタイプのゼロ値です。どちらの記述方法にも独自のアプリケーション シナリオがあります。
コンパイラによる処理後、これら 2 つの記述メソッドは最終的にソース コード内の次の 2 つの関数に対応します。
// entry points for <- c from compiled code
func chanrecv1(c *hchan, elem unsafe.Pointer) {
chanrecv(c, elem, true)
}
func chanrecv2(c *hchan, elem unsafe.Pointer) (received bool) {
_, received = chanrecv(c, elem, true)
return
}
chanrecv1
関数が「ok」なしで状況を処理する場合、chanrecv2
チャネルが閉じているかどうかを反映する「受信」フィールドを返します。elem
受け取った値は特別で、パラメータが指すアドレスに「置かれ」ます。これは、C/C++ での書き込み方法とよく似ています。受け取った値がコードから省略された場合、ここでの elem は nil になります。
とにかく、最後にchanrecv
関数に目を向けます。
// 位于 src/runtime/chan.go
// chanrecv 函数接收 channel c 的元素并将其写入 ep 所指向的内存地址。
// 如果 ep 是 nil,说明忽略了接收值。
// 如果 block == false,即非阻塞型接收,在没有数据可接收的情况下,返回 (false, false)
// 否则,如果 c 处于关闭状态,将 ep 指向的地址清零,返回 (true, false)
// 否则,用返回值填充 ep 指向的内存地址。返回 (true, true)
// 如果 ep 非空,则应该指向堆或者函数调用者的栈
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
// 省略 debug 内容 …………
// 如果是一个 nil 的 channel
if c == nil {
// 如果不阻塞,直接返回 (false, false)
if !block {
return
}
// 否则,接收一个 nil 的 channel,goroutine 挂起
gopark(nil, nil, "chan receive (nil chan)", traceEvGoStop, 2)
// 不会执行到这里
throw("unreachable")
}
// 在非阻塞模式下,快速检测到失败,不用获取锁,快速返回
// 当我们观察到 channel 没准备好接收:
// 1. 非缓冲型,等待发送列队 sendq 里没有 goroutine 在等待
// 2. 缓冲型,但 buf 里没有元素
// 之后,又观察到 closed == 0,即 channel 未关闭。
// 因为 channel 不可能被重复打开,所以前一个观测的时候 channel 也是未关闭的,
// 因此在这种情况下可以直接宣布接收失败,返回 (false, false)
if !block && (c.dataqsiz == 0 && c.sendq.first == nil ||
c.dataqsiz > 0 && atomic.Loaduint(&c.qcount) == 0) &&
atomic.Load(&c.closed) == 0 {
return
}
var t0 int64
if blockprofilerate > 0 {
t0 = cputicks()
}
// 加锁
lock(&c.lock)
// channel 已关闭,并且循环数组 buf 里没有元素
// 这里可以处理非缓冲型关闭 和 缓冲型关闭但 buf 无元素的情况
// 也就是说即使是关闭状态,但在缓冲型的 channel,
// buf 里有元素的情况下还能接收到元素
if c.closed != 0 && c.qcount == 0 {
if raceenabled {
raceacquire(unsafe.Pointer(c))
}
// 解锁
unlock(&c.lock)
if ep != nil {
// 从一个已关闭的 channel 执行接收操作,且未忽略返回值
// 那么接收的值将是一个该类型的零值
// typedmemclr 根据类型清理相应地址的内存
typedmemclr(c.elemtype, ep)
}
// 从一个已关闭的 channel 接收,selected 会返回true
return true, false
}
// 等待发送队列里有 goroutine 存在,说明 buf 是满的
// 这有可能是:
// 1. 非缓冲型的 channel
// 2. 缓冲型的 channel,但 buf 满了
// 针对 1,直接进行内存拷贝(从 sender goroutine -> receiver goroutine)
// 针对 2,接收到循环数组头部的元素,并将发送者的元素放到循环数组尾部
if sg := c.sendq.dequeue(); sg != nil {
// Found a waiting sender. If buffer is size 0, receive value
// directly from sender. Otherwise, receive from head of queue
// and add sender's value to the tail of the queue (both map to
// the same buffer slot because the queue is full).
recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true, true
}
// 缓冲型,buf 里有元素,可以正常接收
if c.qcount > 0 {
// 直接从循环数组里找到要接收的元素
qp := chanbuf(c, c.recvx)
// …………
// 代码里,没有忽略要接收的值,不是 "<- ch",而是 "val <- ch",ep 指向 val
if ep != nil {
typedmemmove(c.elemtype, ep, qp)
}
// 清理掉循环数组里相应位置的值
typedmemclr(c.elemtype, qp)
// 接收游标向前移动
c.recvx++
// 接收游标归零
if c.recvx == c.dataqsiz {
c.recvx = 0
}
// buf 数组里的元素个数减 1
c.qcount--
// 解锁
unlock(&c.lock)
return true, true
}
if !block {
// 非阻塞接收,解锁。selected 返回 false,因为没有接收到值
unlock(&c.lock)
return false, false
}
// 接下来就是要被阻塞的情况了
// 构造一个 sudog
gp := getg()
mysg := acquireSudog()
mysg.releasetime = 0
if t0 != 0 {
mysg.releasetime = -1
}
// 待接收数据的地址保存下来
mysg.elem = ep
mysg.waitlink = nil
gp.waiting = mysg
mysg.g = gp
mysg.selectdone = nil
mysg.c = c
gp.param = nil
// 进入channel 的等待接收队列
c.recvq.enqueue(mysg)
// 将当前 goroutine 挂起
goparkunlock(&c.lock, "chan receive", traceEvGoBlockRecv, 3)
// 被唤醒了,接着从这里继续执行一些扫尾工作
if mysg != gp.waiting {
throw("G waiting list is corrupted")
}
gp.waiting = nil
if mysg.releasetime > 0 {
blockevent(mysg.releasetime-t0, 2)
}
closed := gp.param == nil
gp.param = nil
mysg.c = nil
releaseSudog(mysg)
return true, !closed
}
上記のコードにはさらに詳細なコメントが付けられており、ソース コードを 1 行ずつ確認することができます。
-
チャネルがヌル値 (nil) の場合、ノンブロッキング モードで直接返されます。ブロッキング モードでは、 gopark 関数が呼び出されて goroutine が一時停止され、ブロックが継続されます。チャネルが nil の場合、ブロックを避ける唯一の方法はチャネルを閉じることですが、nil チャネルを閉じると再びパニックが発生するため、目覚める機会はありません。closechan 関数をさらに詳しく見ることができます。
-
次に、送信関数と同様に、ロックを取得せずに、失敗を迅速に検出してノンブロッキング モードで返す操作を実行します。ちなみに、私たちが普段コードを書くとき、境界条件を見つけてすぐに戻ってコードのロジックを明確にすると、通常の状況が減って集中力が高まり、コードを読む人も集中力が高まるからです。コアコードロジックを見てください。
// 在非阻塞模式下,快速检测到失败,不用获取锁,快速返回 (false, false)
if !block && (c.dataqsiz == 0 && c.sendq.first == nil ||
c.dataqsiz > 0 && atomic.Loaduint(&c.qcount) == 0) &&
atomic.Load(&c.closed) == 0 {
return
}
チャネルが受信の準備ができていないことが確認された場合:
- バッファリングされていないため、送信キューで待機しているゴルーチンはありません。
- バッファリングされていますが、buf には要素がありません
その後、closed == 0、つまりチャネルが閉じられていないことが観察されました。
チャネルを繰り返し開くことはできないため、前回の観測時にチャネルが閉じられていないため、この場合は直接受信失敗を宣言してすぐに戻ることができます。選択されておらず、データも受信していないため、戻り値は(false, false)となります。
-
次の操作では、最初に比較的大きな粒度でロックが適用されます。チャネルが閉じており、ループ配列 buf に要素がない場合。非バッファリング クロージャとバッファリング クロージャに対応しますが、buf には要素がありません。対応する型のゼロ値が返されますが、受信フラグは false であり、チャネルが閉じられていることを呼び出し元に伝え、取り出した値は通常、送信者によって送信されるデータ。ただし、選択コンテキスト内にある場合は、この状況が選択されます。チャネルが通知信号として使用される多くのシナリオがここに当てはまります。
-
次に、送信を待っているキューがある場合、それはチャネルがいっぱいであることを意味します (バッファなしチャネルまたはバッファ付きチャネルのいずれかですが、buf がいっぱいです)。どちらの場合も正常にデータを受信できます。
そこで、recv 関数を呼び出します。
func recv(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
// 如果是非缓冲型的 channel
if c.dataqsiz == 0 {
if raceenabled {
racesync(c, sg)
}
// 未忽略接收的数据
if ep != nil {
// 直接拷贝数据,从 sender goroutine -> receiver goroutine
recvDirect(c.elemtype, sg, ep)
}
} else {
// 缓冲型的 channel,但 buf 已满。
// 将循环数组 buf 队首的元素拷贝到接收数据的地址
// 将发送者的数据入队。实际上这时 recvx 和 sendx 值相等
// 找到接收游标
qp := chanbuf(c, c.recvx)
// …………
// 将接收游标处的数据拷贝给接收者
if ep != nil {
typedmemmove(c.elemtype, ep, qp)
}
// 将发送者数据拷贝到 buf
typedmemmove(c.elemtype, qp, sg.elem)
// 更新游标值
c.recvx++
if c.recvx == c.dataqsiz {
c.recvx = 0
}
c.sendx = c.recvx
}
sg.elem = nil
gp := sg.g
// 解锁
unlockf()
gp.param = unsafe.Pointer(sg)
if sg.releasetime != 0 {
sg.releasetime = cputicks()
}
// 唤醒发送的 goroutine。需要等到调度器的光临
goready(gp, skip+1)
}
バッファリングされていない場合は、送信側のスタックから受信側のスタックに直接コピーされます。
func recvDirect(t *_type, sg *sudog, dst unsafe.Pointer) {
// dst is on our stack or the heap, src is on another stack.
src := sg.elem
typeBitsBulkBarrier(t, uintptr(dst), uintptr(src), t.size)
memmove(dst, src, t.size)
}
それ以外の場合、それはバッファリングされたチャネルであり、buf はいっぱいです。これは、送信カーソルと受信カーソルが重なっていることを意味するため、最初に受信カーソルを見つける必要があります。
// chanbuf(c, i) is pointer to the i'th slot in the buffer.
func chanbuf(c *hchan, i uint) unsafe.Pointer {
return add(c.buf, uintptr(i)*uintptr(c.elemsize))
}
そこにある要素を受信アドレスにコピーします。次に、送信側が送信するデータを受信側カーソルにコピーします。以上でデータの受信と送信の操作は完了です。その後、送信カーソルと受信カーソルをそれぞれ 1 つ進め、ラップアラウンドが発生した場合は 0 から開始します。
最後に、sudog で goroutine を取り出し、goready を呼び出してそのステータスを「実行可能」に変更し、送信者が目覚めるのを待ち、スケジューラのスケジューリングを待ちます。
-
そして、そのチャネルのbufにデータが残っていれば、比較的正常に受信できているということになります。ここでは、チャンネルが閉じている場合でもここにアクセスできることに注意してください。このステップは比較的単純で、通常は buf 内の受信カーソルにあるデータを、データが受信されるアドレスにコピーします。
-
最後の段階で、これまでの状況が阻止されることになります。もちろん、ブロックによって渡された値が false の場合、ブロックは行われず、直接返されます。
まず sudog を構築し、次にさまざまな値を保存します。なお、このフィールドには受信データのアドレスが格納されておりelem
、起動時にはこのフィールドが指すアドレスに受信データが保存されます。次に、チャネルのrecvqキューにsudogを追加します。goparkunlock 関数を呼び出して goroutine を一時停止します。
次のコードは、ゴルーチンが目覚めた後のさまざまな仕上げ作業です。
事例分析
次の例を使用して、チャネルからデータを送受信するプロセスを説明します。
func goroutineA(a <-chan int) {
val := <- a
fmt.Println("G1 received data: ", val)
return
}
func goroutineB(b <-chan int) {
val := <- b
fmt.Println("G2 received data: ", val)
return
}
func main() {
ch := make(chan int)
go goroutineA(ch)
go goroutineB(ch)
ch <- 3
time.Sleep(time.Second)
}
まず、バッファなしのチャネルが作成され、次に 2 つのゴルーチンが開始され、前に作成されたチャネルがそれらのゴルーチンに渡されます。次に、データ 3 がこのチャネルに送信され、プログラムは 1 秒間の最後のスリープ後に終了します。
プログラムの 14 行目は、バッファなしのチャネルを作成します。全体的なレベルから chan のステータスを確認するために、chan 構造内のいくつかの重要なフィールドのみを確認します。最初には何もありません。
そして、15行目と16行目でそれぞれゴルーチンを作成し、それぞれ受信動作を行っています。前回のソースコード解析により、これら 2 つのゴルーチン (以下、G1 と G2 と呼びます) が受信操作でブロックされることがわかりました。G1 と G2 はチャネルの req キュー内でハングし、双方向の循環リンク リストを形成します。
プログラムの 17 行目より前の chan の全体的なデータ構造は次のとおりです。
buf
長さ 0 の配列を指し、qcount は 0 であり、チャネル内に要素がないことを示します。recvq
とに注目してくださいsendq
, これらは waitq 構造体であり, waitq は実際には双方向リンク リストです. リンク リストの要素は sudog であり、これにはgoroutine を表すg
フィールドが含まれているg
ため、 sudog は goroutine とみなすことができます。recvq はチャネルを読み取ろうとするがブロックされるゴルーチンを保存し、sendq はチャネルに書き込もうとするがブロックされるゴルーチンを保存します。
この時点で、recvq 内に 2 つのゴルーチンがハングしていることがわかります。それらは、以前に開始された G1 と G2 です。受信するゴルーチンがなく、チャネルがバッファなしタイプであるため、G1 と G2 はブロックされます。sendq にはブロックされたゴルーチンがありません。
recvq
データ構造は次のとおりです。
chan のステータス全体を見てみましょう。
G1 と G2 は一時停止されており、ステータスは ですWAITING
。今日は goroutine スケジューラには焦点を当てませんが、もちろん、後で関連する記事を必ず書きます。ここで簡単に述べておきますが、ゴルーチンはユーザー モード コルーチンであり、Go ランタイムによって管理されますが、カーネル スレッドは OS によって管理されます。ゴルーチンはより軽量であるため、数万のゴルーチンを簡単に作成できます。
カーネル スレッドは複数のゴルーチンを管理できます。ゴルーチンの 1 つがブロックされても、カーネル スレッドは他のゴルーチンの実行をスケジュールでき、カーネル スレッド自体はブロックされません。M:N
これは私たちが通常モデルと呼ぶものです。
M:N
モデルは通常、M、P、G の 3 つの部分で構成されます。M は、ゴルーチンの実行を担当するカーネル スレッドです。P は、ゴルーチンの実行に必要なコンテキストを保存するコンテキストです。また、実行可能なゴルーチンのリストも維持します。G は、実行されるゴルーチンです。M と P は G 演算の基礎となります。
例に戻りましょう。M が 1 つだけあるとします。 G1 ( go goroutineA(ch)
) が に実行されるval := <- a
と、元の実行状態から待機状態 (gopark を呼び出した後の結果) に変わります。
G1 は M から分離されていますが、スケジューラは M をアイドル状態にしないため、別の goroutine の実行をスケジュールします。
G2でも同じような事がありました。現在、G1 と G2 は両方とも一時停止されており、送信者がチャネルにデータを送信するのを待ってから救出されます。