Goのミューテックスとのダンス

Goでデータ同期の「従来の」方法を使用できる場合と使用する必要がある場合とその理由に関する 、SendGridの開発者向けトレーニング記事の翻訳。



読解レベル: 中級 -この記事では、Goの基本と同時実行モデルに精通しており、少なくともロックおよびチャネルメソッドを使用したデータ同期のアプローチに精通していることを前提としています。



読者への注意 :この投稿は、私の良き友人に触発されました。 彼が彼のコードのいくつかのレースに対処するのを手伝い、できる限りデータ同期の技術を彼に教えようとしたとき、私はこれらのヒントが他の人に役立つかもしれないことに気付きました。 そのため、特定の設計決定が既に行われている継承されたコードベースであるか、Goの従来の同期プリミティブをよりよく理解したいだけであるかどうかにかかわらず、この記事は役に立つかもしれません。



Goプログラミング言語を初めて使い始めたとき、 「メモリの共有について話さないでください」というスローガンにすぐに恋をしました コミュニケーションによってメモリを共有します。」メモリを共有して通信するのではなく、通信によってメモリを共有します。 )私にとって、これはすべての同時コードを「常に」チャネルを使用して「正しい」方法で書くことを意味しました。 私は、チャネルの可能性を利用して、競争力、ブロッキング、デッドロックなどの問題を回避することを保証したと信じていました。



Goに進み、Goコードをより慣用的に記述し、ベストプラクティスを学ぶと、人々は定期的にsync / mutexプリミティブ、 sync / atomic 、その他いくつかの「低レベル」のものを使用する大きなコードベースにつまずきました「「オールドスクール」の同期のプリミティブ。 私の最初の考えは-まあ、明らかに間違っていますし、明らかに、トニー・ホアの通信シーケンシャルプロセスの仕事に基づいて設計について話すチャンネルを通して競争力のあるコードを実装することの利点についてロブ・パイクが話すのを見ませんでした。



しかし、現実は厳しいものでした。 go-communityはこのスローガンをあちこちで引用しましたが、 多くのオープンソースプロジェクトを見ると、mutexがいたるところにあり、それらがたくさんあることがわかりました 。 私はしばらくこのなぞなぞに苦労しましたが、最後にはトンネルの終わりに光が見えました。そろそろ袖をまくり、チャネルを脇に置きました。 さて、2015年に早送りして、約2.5年間Goで書いており、その間にmutexロックなどのより伝統的な同期プリミティブに関する洞察が得られました。 さあ、今、私に尋ねなさい、2015年に? 彼女、@ deckarep、あなたはまだチャンネルのみを使用して競争力のあるプログラムを書いていますか? 私は答えます-いいえ、それが理由です。



まず、プラグマティズムの重要性を忘れないでください。 ロックまたはチャネルメソッドを使用してオブジェクトの状態を保護する場合は、「どのメソッドを使用する必要がありますか?」 そして、判明したように、 この質問に素晴らしく答える非常に良い投稿があります

あなたの場合、最も表現力があり、シンプルな方法を使用してください。



Goの新しい一般的な間違いは、単にそれが可能であるため、および/またはそれが楽しいために、チャンネルとゴルーチンを再利用することです。 あなたの問題を最もよく解決するなら、 sync.Mutexを使うことを恐れないでください。 Goは、問題に対する最善の解決策を提供するという点で実用的であり、1つのアプローチだけを課すわけではありません。


この引用のキーワードに注意してください: 表現力豊かで、シンプルで、再利用でき、恐れず、実用的です。 ここで表明されたいくつかのことを正直に認めることができます。最初にGoを試したとき、私は恐れていました。 私はこの言語に完全に慣れていなかったので、すぐに結論を出す時間が必要でした。 おそらく、上記の記事とこの記事から、ミューテックスとさまざまなニュアンスの使用に関する一般的な慣行を掘り下げて、独自の結論を導き出すでしょう。 上記の記事は、ミューテックスとチャネルの選択に関する優れた洞察も提供します。



チャンネルを使用する場合:データの所有権の譲渡、コンピューティングの配布、非同期結果の送信。



ミューテックスを使用する場合:キャッシュ、状態。


最終的に、各アプリケーションは異なり、少し試行錯誤して、誤って起動する場合があります。 上記の指示は個人的には役立ちますが、もう少し詳しく説明します。 スライス、マップ、または独自のものなどの単純なデータ構造へのアクセスを保護する必要があり、このデータ構造へのアクセスインターフェイスが単純で簡単な場合は、mutexから始めます。 また、APIのロックコードの「汚れた」詳細を隠すのにも役立ちます。 構造のエンドユーザーは、内部同期の方法について心配する必要はありません。



ミューテックスでの同期が面倒になり始め、ミューテックス踊り始めたら 、これは別のアプローチに切り替える時です。 繰り返しになりますが、mutexは最小限の共有データを保護するための単純なスクリプトに便利であることを、与えられたとおりに受け入れます。 それらが必要なもののためにそれらを使用しますが、 それらを尊重し、制御不能にさせないでください 。 振り返って、プログラムのロジックを注意深く見てください。ミューテックスと戦っているなら、これはデザインを再考する機会です。 チャンネルに切り替えると、アプリケーションのロジックにはるかに適合したり、さらに良いことに、状態をまったく共有する必要がなくなったりします。



マルチスレッドは複雑ではありません-ロックは複雑です。


理解して、私はミューテックスがチャンネルより優れていると言っているのではありません。 両方の同期方法に精通している必要があります。チャネル上のソリューションが複雑すぎるように見える場合は、他のオプションがあることを知ってください。 この記事の例は、より優れた、よりサポートされた、より信頼性の高いコードを作成するのに役立ちます。 エンジニアとして、マルチスレッドアプリケーションで共有データと競合状態にどのようにアプローチするかを認識している必要があります。 Goを使用すると、高性能で競争力のあるアプリケーションや並列アプリケーションを非常に簡単に作成できますが、トリックがあり、適切なコードを作成して慎重に回避できる必要があります。 それらをさらに詳しく見てみましょう。



番号1 :mutexが1つ以上の値を保護する構造を定義する場合、アクセスを保護するフィールドの上にmutexを配置します。 Goソースコードでのこのイディオムの例を次に示します。 これは単なる配置であり、コードのロジックには影響しないことに注意してください。

var sum struct { sync.Mutex // <--    i int // <--     }
      
      





番号2 :実際に必要な時間以上ロックを保持しません。 例-可能であれば、IO呼び出し中にミューテックスを保持しないでください。 それどころか、必要最小限の時間だけデータを保護してください。 Webハンドラーでこのようなことを行うと、ハンドラーへのアクセスをシリアル化することにより、単に競争上の優位性を失います。

 //    ,  `mu`   //    cache // NOTE:    ,     //   ,    func doSomething(){ mu.Lock() item := cache["myKey"] http.Get() // -  IO- mu.Unlock() } //  ,  -  func doSomething(){ mu.Lock() item := cache["myKey"] mu.Unlock() http.Get() //    ,    }
      
      





番号3 :関数に複数の出口点がある場合、deferを使用して相互排他ロックを解除します。 あなたにとって、これは手作業のコードが少なくなることを意味し、誰かが3か月後にコードを変更し、新しい出口点を追加してロックを見失ったときにデッドロックを回避するのに役立ちます。

 func doSomething() { mu.Lock() defer mu.Unlock() err := ... if err != nil { //log error return // <--    } err = ... if err != nil { //log error return // <--   } return // <-- , ,   }
      
      





同時に、すべての場合において、盲目的に延期に依存しないようにしてください。 たとえば、次のコードは、関数の終了時ではなく、スコープの終了時に遅延が実行されると考える場合に陥りやすいトラップです。

 func doSomething(){ for { mu.Lock() defer mu.Unlock() // -   // <-- defer    ,  - **  } // <-- ()   ,     } //       !
      
      







最後に、複数の出口点のない単純な場合にはdeferをまったく使用できないことを忘れないでください。 遅延実行(遅延)のオーバーヘッドはわずかですが、多くの場合無視されます。 そして、それは非常に時期尚早で、しばしば最適化が多すぎると考えてください。



番号4 :粒度の細かいロックは、管理するためのより複雑なコードを犠牲にしてパフォーマンスを向上させることができます。一方、粗いロックは効率を下げることができますが、コードを簡単にします。 ただし、設計の見積もりについては、実際に考えてください。 あなたが「ミューテックスで踊っている」ことがわかるなら、これはおそらくリファクタリングとチャネル経由の同期への切り替えのための適切な瞬間です。



番号5 :上記のように、使用する同期方法をカプセル化することをお勧めします。 パッケージのユーザーは、コード内のデータを保護する方法を気にする必要はありません。



以下の例では、少なくとも1つの値がある場合にのみキャッシュからコードを選択するget()メソッドを導入していると想像してください。 また、コンテンツへのアクセスと値のカウントの両方をブロックする必要があるため、 このコードはデッドロックにつながります

 package main import ( "fmt" "sync" ) type DataStore struct { sync.Mutex // ←      cache map[string]string } func New() *DataStore { return &DataStore{ cache: make(map[string]string), } } func (ds *DataStore) set(key string, value string) { ds.Lock() defer ds.Unlock() ds.cache[key] = value } func (ds *DataStore) get(key string) string { ds.Lock() defer ds.Unlock() if ds.count() > 0 { // <-- count()  ! item := ds.cache[key] return item } return "" } func (ds *DataStore) count() int { ds.Lock() defer ds.Unlock() return len(ds.cache) } func main() { /*      ,    get()    count()      get()   */ store := New() store.set("Go", "Lang") result := store.get("Go") fmt.Println(result) }
      
      





Goのミューテックスは再帰的はないため、提案されるソリューションは次のようになります。

 package main import ( "fmt" "sync" ) type DataStore struct { sync.Mutex // ←      cache map[string]string } func New() *DataStore { return &DataStore{ cache: make(map[string]string), } } func (ds *DataStore) set(key string, value string) { ds.cache[key] = value } func (ds *DataStore) get(key string) string { if ds.count() > 0 { item := ds.cache[key] return item } return "" } func (ds *DataStore) count() int { return len(ds.cache) } func (ds *DataStore) Set(key string, value string) { ds.Lock() defer ds.Unlock() ds.set(key, value) } func (ds *DataStore) Get(key string) string { ds.Lock() defer ds.Unlock() return ds.get(key) } func (ds *DataStore) Count() int { ds.Lock() defer ds.Unlock() return ds.count() } func main() { store := New() store.Set("Go", "Lang") result := store.Get("Go") fmt.Println(result) }
      
      





このコードでは、エクスポートされていないメソッドごとに、同様のエクスポートされたメソッドがあることに注意してください。 これらのメソッドはパブリックAPIとして機能し、このレベルでロックを処理します。 次に、ロックをまったく気にしないエクスポートされていないメソッドを呼び出します。 これにより、外部からのメソッドへのすべての呼び出しが1回だけブロックされ、再帰的なロックの問題がなくなります。



番号6 :上記の例では、ロックとロック解除のみが可能な単純なsync.Mutexを使用しました。 sync.Mutexは、ゴルーチンの読み取りまたは書き込みにかかわらず、同じ保証を提供します。 ただし、 sync.RWMutexもあります 。これは、データにのみアクセスするコードに対してより正確なロックセマンティクスを提供します。 標準のMutexの代わりにRWMutexを使用する場合



回答:クリティカルセクションのコードが保護されたデータを変更しないことが確実な場合は、RWMutexを使用してください。


 //     RLock()  ,       func count() { rw.RLock() // <--   R  RLock (read-lock) defer rw.RUnlock() // <--   R  RUnlock() return len(sharedState) } //     Lock()  set(),    func set(key string, value string) { rw.Lock() // <-- ,    "" Lock (write-lock) defer rw.Unlock() // <--   Unlock(),  R sharedState[key] = value // <--  () }
      
      





上記のコードでは、 `sharedState`変数はオブジェクト、おそらくマップであり、その長さを読み取ることができます。 count()関数はオブジェクトが変更されないようにするため、任意の数のリーダー(goroutin)から安全に並行して呼び出すことができます。 いくつかのシナリオでは、これによりロック状態のゴルーチンの数が減り、読み取り専用のデータアクセスが多いシナリオでパフォーマンスが向上する可能性があります。 ただし、set()のようにデータを変更するコードがある場合は、rw.RLock()の代わりにrw.Lock()を使用する必要があります。



7番 :Goの地獄のようにクールで組み込みのレース検出器をご覧ください。 この検出器は、当時の標準のGoライブラリでも競合状態を見つけることで評判を得ています。 それがGoツールキットに組み込まれている理由であり、私よりも多くのスピーチや記事があります。



この記事で、Goでミューテックスをいつどのように使用するかについて、かなり包括的な図が提供されることを期待しています。 Goで低レベルの同期プリミティブを試し、ミスを犯し、そこから学び、ツールを評価して理解してください。 そしてまず第一に、コードを実用的にして、特定のケースごとに適切なツールを使用します。 最初は怖かったので、恐れないでください。 ロックについて言っているすべてのネガティブなことを常に聞いていたら、私はこのビジネスにはいられず、Goなどのクールなテクノロジーを使用して最もクールな分散システムを作成していました。

注:フィードバックが大好きなので、この資料が役立つと感じたら、pingを送信するか、ツイートするか、建設的なフィードバックをください。

ありがとう、良いコーディング!



All Articles