Slide 1

Slide 1 text

sync.Condの使い所に ついて考えてみる DMM.go #11 合同会社DMM.comプラットフォーム開発本部 なずな (@na2na_chang)

Slide 2

Slide 2 text

自己紹介 なずな(@na2na_chang) ● 合同会社DMM.com(2024年新卒) ○ プラットフォーム開発本部 認可チーム ● 認可プロダクトを運用するお仕事 ● 最近の悩み: Goのエコシステムの中で完結するE2Eテストの作り方 2

Slide 3

Slide 3 text

はじめに OSSのコードリーディング、してますか? 私はGoにそこまで明るくないので頑張ってソースコードリーディングをして 「へー、こういうのあるんだー」と勉強をしていたりします。 今日はKubernetesのコードを読んでいた中で見つけた ある発見についてお話しします。 3

Slide 4

Slide 4 text

今日の発表をおいしく聞ける人 Goroutineは知っているけどsyncパッケージはよく知らない人 4

Slide 5

Slide 5 text

お品書き 1. 今回のお題を見つけたきっかけ 2. sync.Condとは? 3. チャネルとは? 4. 両者の特性の違い 5. 実際に使っている例を見て、なぜ sync.Condにしたのか考える 5

Slide 6

Slide 6 text

• sync.Condの存在を知る • チャネルの存在を知る • どういうときにチャネルを使うかを知る • どういうときにsync.Condを使うかを知り、使ってみたくなる 今日のゴール 6

Slide 7

Slide 7 text

見つけたきっかけ Kubernetesには、 リソースの変更を検知するための SharedIndexInformerという仕組みがあります。 昨年、チームでの業務中に見かけて存在は知っている状態でした。 名前がカッコいいのと、DMM.goのお題にちょうど良かったので深掘りをしました。 内部実装を見ていた時に、今回紹介する sync.Condを見つけた次第です。 以前の調査の結果は Kubernetes Meetup Tokyo #69 LT - PreStopによるSleep中に何が起きているか:~安全なRollingUpdateの実施のために~ で紹介しています。興味があればそちらもぜひ! 7

Slide 8

Slide 8 text

今日の登場人物 • sync.Cond • チャネル 8

Slide 9

Slide 9 text

sync.Condについて知る sync.Condは「条件変数」と呼ばれる同期プリミティブです。 sync.Condの主目的は、 「特定の条件が満たされるまで、ゴルーチンを効率的に待機させること 」です。 CPUを無駄遣いするポーリングをせずに、「準備ができたので確認をしてほしい」 と状態の変化を通知するための道具とイメージすると良いでしょう。 9

Slide 10

Slide 10 text

sync.Condについて知る sync.Condは、必ずsync.Mutexのような ロックと一緒に使います。 Wait()を呼ぶと、自動でロックを解除して 他のゴルーチンが状態を変更できるようにします。 起こされたら再度ロックを取得してから 処理を再開する、という一連の流れが重要です。 10

Slide 11

Slide 11 text

sync.Condについて知る sync.Condの実装はチャネルとは対照的で、 非常にシンプルです。 sync.Cond自体はロックを持ちません。 ユーザーが外部から渡したLockerを使い、 待機と通知の機能はランタイムの notifyListにほぼすべてを委譲しています。 11 https://github.com/golang/go/blob/6e676ab2b809d46623acb 5988248d95d1eb7939c/src/sync/cond.go#L40-L43

Slide 12

Slide 12 text

sync.Condについて知る Wait()メソッドの内部は、 その役割を明確に示しています。 ここにはデータコピーやバッファ管理の ロジックは一切ありません。 12 https://github.com/golang/go/blob/6e676ab2b809d46623acb598824 8d95d1eb7939c/src/sync/cond.go#L67-L73

Slide 13

Slide 13 text

sync.Condについて知る ユーザーが管理するロックと連携して、 ゴルーチンをWait・WakeUpさせることに 特化しています。 つまり、関心事としては状態の変化であり、 データのやり取りにないことがわかります。 13 https://github.com/golang/go/blob/6e676ab2b809d46623acb598824 8d95d1eb7939c/src/sync/cond.go#L67-L73

Slide 14

Slide 14 text

チャネルとは チャネルは ゴルーチン間で値を受け渡しするために使用する型です。 14

Slide 15

Slide 15 text

チャネルとは 調べ始める前のわたし 「チャネルって並行処理で出てくるよな、、、」 「sync.Condも並行処理の文脈で出てきたな、、、」 「なんでこの実装はチャネルにしなかったんだろう 🤔」 「どちらも同じような処理できるだろうし、 Easyに使えるチャネルでいいんじゃない?」 注) 調べ始めてからそもそも比較対象になるような代物ではないことに気づきました。あくまでも調べ始める前の認識です、、、 15

Slide 16

Slide 16 text

チャネルとは > 「どちらも同じような処理できるだろうし、 Easyに使えるチャネルでいいんじゃない?」 調べていく中で、そもそもチャネルと sync.Condは使用目的の異なる別物で、 並列で語るのは少し変だったことに気づきました。 16

Slide 17

Slide 17 text

チャネルとは ざっとこんな感じです sync.Cond: ひとつの変化しうる状態を多数のゴルーチンで共有するような用途で使う チャネル : ひとつのゴルーチンから別のゴルーチンにデータを送るための用途で使う 内部実装も見つつ説明をします 17

Slide 18

Slide 18 text

チャネルとは チャネルにおいて重要なのは、送受信のタイミングを同期させる点です。 基本となるバッファなしチャネルでは、 送受信の相手がいなければ処理がブロック(一時停止)します。 18

Slide 19

Slide 19 text

チャネルとは 一方、バッファ付きチャネルは、チャネル内に指定した数のデータを保持できるため、 バッファが満杯になるまで送信を、バッファが空になるまで受信をブロックしません。 これにより、送信者と受信者の処理をある程度非同期に進めることができます。 19

Slide 20

Slide 20 text

チャネルとは どちらのチャネルでも、このブロックする性質のおかげで、 開発者がロックを細かく制御しなくても、ゴルーチン間の同期が自然と成立します。 これはGoの哲学である『通信によってメモリを共有する』 を体現した機能と言えると思います。 20

Slide 21

Slide 21 text

チャネルについて知る チャネルの実体は、ランタイムにある hchanという巨大な構造体です。 https://github.com/golang/go/blob/6e676ab2b809d46623acb5988248d95d1eb7939c/src/runtime/chan.go#L34-L55 21

Slide 22

Slide 22 text

チャネルについて知る 送信処理(chansend)を例にデータ転送の仕組みを見ていきます ch <- dataというシンプルな操作の裏側で、 大きく分けて二つのパスを辿ります。 • 高速パス (Fast Path): ロック不要の受け渡し • 低速パス (Slow Path): ロック必須のデータ受け渡し 22

Slide 23

Slide 23 text

チャネルについて知る 高速パス (Fast Path): ロック不要の受け渡し バッファなしチャネル、またはバッファが空のチャネルに送信しようとした際、 すでに行列(recvq)で待っている受信ゴルーチンがいた場合に使われます。 https://github.com/golang/go/blob/6e676ab2b809d46623acb5988248d95d1eb7939c/src/runtime/chan.go#L229-L234 23

Slide 24

Slide 24 text

チャネルについて知る 低速パス (Slow Path): ロック必須のデータ受け渡し 高速パスが失敗した = すぐにはデータを受け取ってくれる相手がいない場合に使われます 以下の2パスに分岐します。 • バッファに空きがある場合 • バッファが満杯の場合 24

Slide 25

Slide 25 text

チャネルについて知る バッファに空きがある場合 データをリングバッファにコピーし、カウンタ (qcount)とインデックス(sendx)を更新します。 仕事は終わったので、unlock(&c.lock)でロックを解放して終了です。 https://github.com/golang/go/blob/6e676ab2b809d46623acb5988248d95d1eb7939c/src/runtime/chan.go#L229-L234 25

Slide 26

Slide 26 text

チャネルについて知る バッファも満杯の場合 送信ゴルーチンは自身の情報を送信待ちキュー (sendq) に追加し、 gopark を呼び出してスリープ状態に入ります。 この時、チャネルのロックは自動的に解放され、 他のゴルーチンがチャネルを操作できるようになります。 https://github.com/golang/go/blob/6e676ab2b809d46623acb5988248d95d1eb7939c/src/runtime/chan.go#L236-L309 26

Slide 27

Slide 27 text

チャネルについて知る ここまでの内容から、 チャネルはあくまでデータのやり取りが主たる関心事であることがわかります 27

Slide 28

Slide 28 text

わかること 28 sync.Cond (条件変数) chan (チャネル) 関心事 共有されている「状態」が変化したこと。 「データ」そのものを、あるゴルーチンから別のゴ ルーチンへ送ること。 役割 状態の変化を待っているゴルーチン全員に 「状態が変わったかもしれないから、確認してくだ さい」と知らせる。 データを特定のゴルーチンに 直接、安全に届ける。 その後 起こされたゴルーチンたちは、ロックを 再取得できたものから一つずつ処理をする データを受け取ったゴルーチンは、 そのデータに対する所有権を得て、 他のゴルーチンと競合することなく 処理を開始できる

Slide 29

Slide 29 text

わかること ここからは、実際にsync.Condを使っているアプリケーションコードを見ていきます。 今回はsync.Condを知るきっかけになったSharedIndexInformerをお題にします。 29

Slide 30

Slide 30 text

SharedIndexInformerとは k8s.io/client-goのSharedIndexInformerは、 Kubernetesリソースの変更を効率的に監視し、その状態を共有キャッシュとして 保持するための中心的な仕組みです。 30

Slide 31

Slide 31 text

SharedIndexInformerとは 例えばKubernetesを用いてデプロイされている Goで作られたバックエンドアプリケーションの更新するケースを考えてください Deploymentのイメージタグを更新後のアプリケーションのイメージタグに書き換えます 31

Slide 32

Slide 32 text

SharedIndexInformerとは 32 Kubernetesは、Deploymentの内容が書き変わったことを検知して、 最終的には更新後のアプリケーションのイメージを使った Podが立ち上がることになります SharedIndexInformerは、 この「Deploymentの内容が書き変わったイベントを検知」するための仕組みを支えています

Slide 33

Slide 33 text

SharedIndexInformerとは 今は例としてDeploymentを出しましたが、 基本的にはKubernetesで扱う全てのリソースの変更が この仕組みで検知されています。 33

Slide 34

Slide 34 text

SharedIndexInformerの仕組み おおまかに以下のようなコンポーネントで構成されています。 • APIサーバーとの通信 • キュー • ローカルキャッシュ 34

Slide 35

Slide 35 text

SharedIndexInformerの仕組み 以下を実現するためにキューの仕組み (k8s.io/client-goのcache.DeltaFIFO)を持っており、 この中でsync.Condを利用しています。 効率性: 無駄なイベントを集約し、コントローラーの負荷を減らす。 正確性: 特に削除イベントを確実に捉え、状態変化の「差分」を管理する。 信頼性: 監視が途切れても再同期によって状態の矛盾を防ぐ。 35

Slide 36

Slide 36 text

SharedIndexInformerの仕組み 今回はk8s.io/client-goのcache.DeltaFIFOを例に、 どう言った時にsync.Condを使うのがいいかを見ていこうと思います。 36

Slide 37

Slide 37 text

DeltaFIFO k8s.io/client-goのcache.DeltaFIFOは、キューの役割をします。 https://github.com/kubernetes/client-go/blob/master/tools/cache/delta_fifo.go 37

Slide 38

Slide 38 text

DeltaFIFO キューの実態は以下の二つです • items • queue これをlock(sync.RWMutex)で ロックしてデータの整合性を守っています 38 https://github.com/kubernetes/client-go/blob/master/tools/cache/delta_fifo.go

Slide 39

Slide 39 text

DeltaFIFO cond sync.Condの部分で、 チャネルではなく条件変数を使っています 39 https://github.com/kubernetes/client-go/blob/master/tools/cache/delta_fifo.go

Slide 40

Slide 40 text

DeltaFIFO forループの中で、 キューが空の間、f.cond.Wait()を 呼び出しています cond.Wait()はcond.Lockerを 自動的にアンロックします。 その後、通知が来るまでゴルーチンを ブロックします 40 https://github.com/kubernetes/client-go/blob/master/tools/cache/delta_fifo.go

Slide 41

Slide 41 text

DeltaFIFO データを追加する側では 処理の一番最後に f.cond.Broadcast()を呼んでいます 41 https://github.com/kubernetes/client-go/blob/master/tools/cache/delta_fifo.go

Slide 42

Slide 42 text

DeltaFIFO データを追加する側では 処理の一番最後に f.cond.Broadcast()を呼んでいます 42 https://github.com/kubernetes/client-go/blob/master/tools/cache/delta_fifo.go

Slide 43

Slide 43 text

DeltaFIFO f.cond.Broadcast()が呼ばれると、 cond.Wait()で待機していた全ての ゴルーチンがwake upします。 43 https://github.com/kubernetes/client-go/blob/master/tools/cache/delta_fifo.go

Slide 44

Slide 44 text

なぜDeltaFIFOはsync.Condを選んだのか? DeltaFIFOの内部を見てきたところで、 なぜDeltaFIFOはsync.Condを選んだのかを考えていきます 44

Slide 45

Slide 45 text

おさらい 45 sync.Cond (条件変数) chan (チャネル) 関心事 共有されている「状態」が変化したこと。 「データ」そのものを、あるゴルーチンから別のゴ ルーチンへ送ること。 役割 状態の変化を待っているゴルーチン全員に 「状態が変わったかもしれないから、確認してくだ さい」と知らせる。 データを特定のゴルーチンに 直接、安全に届ける。 その後 起こされたゴルーチンたちは、ロックを 再取得できたものから一つずつ処理をする データを受け取ったゴルーチンは、 そのデータに対する所有権を得て、 他のゴルーチンと競合することなく 処理を開始できる

Slide 46

Slide 46 text

なぜDeltaFIFOはsync.Condを選んだのか? そもそもKubernetesのリソース変更検知の仕組みは、複数のリソースが監視しています。 例えば、、、 「Podが作られた」というイベントひとつで以下のようなことが非同期に起こります →ネットワーク設定をするリソースに通知され、登録される →Podの総数を監視しているリソースに通知され、古い Podが削除される(こともある) →サービスメッシュのための仕組みを管理するリソースに通知され、コンテナが注入される(こともある) などなど 46

Slide 47

Slide 47 text

なぜDeltaFIFOはsync.Condを選んだのか? つまり、ひとつの変化しうる状態を n個のリソースが監視している というシチュエーションで使われるわけです 47

Slide 48

Slide 48 text

なぜDeltaFIFOはsync.Condを選んだのか? Goで実装されているのでGoに置き換えると 一つの「変化しうる状態」を複数のゴルーチンで監視している状況です。 複数のゴルーチンがアクセスするため、 データの整合性を守るsync.Mutexは、必須です。 48

Slide 49

Slide 49 text

なぜDeltaFIFOはsync.Condを選んだのか? 「ロックで守られた、たった一つのデータを複数のゴルーチンで共有している」 と言い換えることができます。 49

Slide 50

Slide 50 text

なぜDeltaFIFOはsync.Condを選んだのか? sync.CondはすでにあるMutexに後付けで 「待機/通知」の機能を追加するために設計されていると考えることができます。 DeltaFIFOは必須であるMutexにsync.Condを組み合わせるだけで、 効率的な待機メカニズムを最小限のオーバーヘッドで実装できます。 50

Slide 51

Slide 51 text

なぜDeltaFIFOはsync.Condを選んだのか? では、この設計でチャネルを使うとどうなるでしょう? 結局、キューとマップを守るための Mutexは必要です。 その上で、Popする側に「アイテムが追加されたこと」を知らせるためだけの 通知用チャネルを別途用意することになります。 その結果として、、、 51

Slide 52

Slide 52 text

なぜDeltaFIFOはsync.Condを選んだのか? DeltaFIFOのMutexと、チャネル内部のMutexという、 二重のロックが発生することになります データそのものではなく、ただの合図を送るためだけにチャネルを使うことになり、 不自然な設計になる可能性が高いです。 だからチャネルではなくsync.Condを選んだと考えられます。 52

Slide 53

Slide 53 text

まとめ • チャネルとsync.Condには明確な役割の違いがある • チャネルはデータの流れを作るのに適していて、 sync.Condは共有された状態を中心にゴルーチンを待機させるのに適している • k8s.io/client-go cache.DeltaFIFOはちゃんと理由があってsync.Condを選択した • その理由は、Mutexで保護された単一の共有キューという設計に、 sync.Condが極めて自然に、かつ効率的に統合できたから 53

Slide 54

Slide 54 text

おわり

Slide 55

Slide 55 text

参考文献 • k8s.io/client-go • Go1.25.1のソースコード • Goでの並行処理を徹底解剖! - Zenn • Goの並行処理入門 - Goroutine基礎編 • Goの並行処理入門 - syncパッケージ編 55