はじめに
この記事は、次の素晴らしい動画と参考書を元にして、自分の中でまとめ直したものになります。 まずstack領域とheap領域はどのようなものであって、Go言語ではどのように管理されているかをまとめます。それをもとに、Go言語の channel が並行処理を可能にしている仕組みと、各メモリ領域との関係性について言及しています。(動画では buffered channel について説明されていたので、この記事でもそちらについて説明しています。)
ぜひ元動画と書籍にも目を通してみてください。
- GopherCon 2017: Kavya Joshi - Understanding Channels - YouTube
- Understanding Allocations: the Stack and the Heap - GopherCon SG 2019 - YouTube
- Go言語 100Tips ありがちなミスを把握し、実装を最適化する - インプレスブックス
stack領域とheap領域
stack
デフォルトのメモリで、goroutine ごとに存在しています。goroutine のローカル変数を全て格納しておく LIFO のデータ構造です。Goで関数が呼び出されると、その関数だけがアクセスできるメモリ空間として stack frame が、確保された stack の中に作成されます。
ある関数が return して終了すると、再利用のために開放されます。関数の実行が終わると、その関数に割り当てられた stack frame は、後続の関数呼び出しで使用できるようになります。
まだ読んでないのですが、stack frame の挙動に関してはこの辺を読めば理解できそうです。 https://go.dev/src/runtime/stack.go
heap
全ての goroutine が共有するメモリのプールのようなもので、動的に割り当てられます。stack 上に存在できない変数は、heap に escape されます。「関数が return した後にその変数が参照されない」ということをコンパイラが証明できない場合、その変数は heap 上に割り当てられます。
heap は Garbage Collector (GC) によって解放される必要があります。heap への割り当てが増えれば増えるほど、GC に負担がかかり、アプリケーション全体としての CPU 使用率が上昇します。
何が heap に割り当てられるか
- 関数が return した後に参照される 可能性のある (つまり、ないと証明できない)値。例えば関数内で生成した pointer など
- コンパイラによって、stack に保存しておくにはサイズが大きすぎると判断された変数
- コンパイラがコンパイル時にサイズを判断できない場合。例えば interface や slice, string などは無限に大きくなる可能性があるため、heap 領域に確保される。
channel
channel が並行処理を可能にしている理由と heap
さて、ここで少し話題を変えて channel について触れていきたいと思います。channel を初期化する際、make(chan int, 1)
で初期化すると、channel は pointer として初期化され、 heap 領域に確保されます。さらに、channel の内部には lock するために mutex を持っています。この二つの実装により、channel は複数の goroutine からのアクセスを可能にしています。
type hchan struct { qcount uint // total data in the queue dataqsiz uint // size of the circular queue buf unsafe.Pointer // points to an array of dataqsiz elements elemsize uint16 closed uint32 elemtype *_type // element type sendx uint // send index recvx uint // receive index recvq waitq // list of recv waiters sendq waitq // list of send waiters // lock protects all fields in hchan, as well as several // fields in sudogs blocked on this channel. // // Do not change another G's status while holding this lock // (in particular, do not ready a G), as this can deadlock // with stack shrinking. lock mutex }
ref: go/src/runtime/chan.go at master · golang/go · GitHub
定義を見てわかるように、channel は内部にデータを保持するために buffer (queue) を持っています。ch <- data
のように channel にデータを送信すると、内部的にはこの buffer に対して enqueue することになります。興味深いのが、実際に enqueue されるデータは渡したデータそのものではなく、データのコピーであるという点です。
// send processes a send operation on an empty channel c. // The value ep sent by the sender is copied to the receiver sg. // The receiver is then woken up to go on its merry way. // Channel c must be empty and locked. send unlocks c with unlockf. // sg must already be dequeued from c. // ep must be non-nil and point to the heap or the caller's stack. func send(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int)
ref: go/src/runtime/chan.go at master · golang/go · GitHub
よって、受け取る側 variable <- ch
はコピーされたデータを受け取るため、thread safe にデータをやりとりできます。
また、channel は内部に recvq
, sendq
という waitq
を持っています。これは sudog と呼ばれる構造体を管理していて、channel を介してメッセージのやり取りをしている goroutine を保持しています。この情報を参照することによって、channel に送ったメッセージを適切な goroutine へと送信できているわけです。
channel のメッセージ送信と stack
goroutine 1 が channel に data を送信し、goroutine 2 が stack 上の変数 t
に channel からの値を読み込もうとしている時のことを考えます。
この時、goroutine 1 が channel を通して goroutine 2 に対して、値を送ったことを通知し、goroutine 2 を進めてもらうこともできます。Go言語ではそこを最適化していて、goroutine 1 から、goroutine 2 の stack 上にある変数 t
に対して直接値を書きみます。 stack は goroutine ごとに生成・管理されるものですが、この場合のみ、他の goroutine から操作されるようです。
終わりに
stackとheapの動作、そしてchannelを介したgoroutine間の通信の仕組みが少しだけ理解できた気がします。特にstack領域に他の goroutine が書き込めることには驚きました。
channel によってブロックされた goroutine がどのように管理されているかについては、自分が以前書いたブログも参考にしてみてください。