ひらめの日常

日常のメモをつらつらと

Go言語のChannelとメモリ管理

はじめに

この記事は、次の素晴らしい動画と参考書を元にして、自分の中でまとめ直したものになります。 まずstack領域とheap領域はどのようなものであって、Go言語ではどのように管理されているかをまとめます。それをもとに、Go言語の channel が並行処理を可能にしている仕組みと、各メモリ領域との関係性について言及しています。(動画では buffered channel について説明されていたので、この記事でもそちらについて説明しています。)

ぜひ元動画と書籍にも目を通してみてください。

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 がどのように管理されているかについては、自分が以前書いたブログも参考にしてみてください。

hiramekun.hatenablog.com