エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

ちょっとJavaのsynchronizedをGoに移植しようとしたはずが、なぜか1万文字の作文ができた

AI・機械学習チームのブログリレーも9日目になりました。同チームの横本@yokomotodです。

本日はJavaとGoを題材に並行プログラミングまわりの自由研究をしたお話をしてみたいと思います。

3部構成で、パート1では発端となった「排他制御」について、パート2では「メモリの可視化」について、それぞれJavaとGoを比べてみました。

最後にパート3では、それらの動作を規定する「メモリモデル」について、わかりやすく解説されているリソースを紹介します。

最近、Javaで書かれたコードをGoに移植、再現する機会がありました*1

言うまでもなく、提供されている言語の機能や標準ライブラリの違いが出てきます。

その中で、Javaの synchronized を使って排他制御を行っている部分をGoで再現する必要がありました。移植実装は簡単にできたのですが、裏側が気になって調べ始めるとどんどん楽しくなって深みにはまってしまったので、ここで紹介してみたいと思います。

普段Goだけ・Javaだけを書いていると当たり前に使っている機能も、隣と比べると見えてくるものがあるなと楽しんでいただけると幸いです。

また、もし間違っている点などあればご指摘お願いします。

長過ぎる! 3行で!!

  • 並行プログラミングでは、低機能過ぎず、かつ高機能過ぎもしない、適切な道具を使おう
  • 言語ランタイムの挙動に詳しくなるよりも、裏側を知らなくても使える道具を使おう。賢すぎるコードを書いてはいけない。
  • でも裏側がどうなっているか、知れば知るほど面白い!

パート1: synchronized = 「排他制御」?

Java synchronized vs Go sync.Mutex

Javaでは次のようにメソッドに synchronized 修飾子をつけたり synchronized { ... } ブロックを用いることで排他制御が可能です。

class A {
  synchronized void method1() {
    ...
  }
}

こうすることで複数のスレッドで同時に method1() が実行されることはなくなり、必ずスレッドごとに処理の完了が待たされるようになります。

では、 このような synchronized メソッドが使われているJavaのコードをGoに移植するにはどうすればいいでしょうか。

Goにはこのような修飾子はありません。代わりに、 sync パッケージの Mutex などを使って(Javaに比べるとよりプリミティブに)排他制御することになります。

import "sync"

type A struct {
    mux sync.Mutex
}

func (a *A) Method() {
    a.mux.Lock()
    defer a.mux.Unlock()

    ...
}

sync.Mutex のロックを獲得できるのは1つのgoroutineだけなので、こうすることで Method() が複数のgoroutineで同時に実行されることを防ぐことができます。

…が、実はこれではまだJavaの synchronized が完全に再現出来てはいません。

さてなぜでしょうか。実際に挙動の違いを見てみます。

次のJavaコードでは synchronized のついたメソッドが2つあり、methodA()methodB() を呼び出しています。

public class A {
    synchronized void methodA() {
        System.out.println("methodA");
        methodB();
    }

    synchronized void methodB() {
        System.out.println("methodB");
    }

    public static void main(String[] args) {
        A a = new A();
        a.methodA();
    }
}

実行してみると

methodA
methodB

と出力され終了します(当たり前に感じますか? 不思議に感じますか?)

次は同じことをGoで sync.Mutex を使ってやってみましょう*2

func (a *A) MethodA() {
    a.mux.Lock()
    defer a.mux.Unlock()
    fmt.Println("MethodA")
    a.MethodB()
}

func (a *A) MethodB() {
    a.mux.Lock()
    defer a.mux.Unlock()
    fmt.Println("MethodB")
}

func main() {
    a := &A{}
    a.MethodA()
}

これを実行すると

MethodA
fatal error: all goroutines are asleep - deadlock!

exit status 2

デッドロックとなってアボートしてしまいました。

MethodA() でロックを取得している状態で(同じgoroutine上で) MethodB() 内で再度ロックを取得しようとして、既にロック済みのため待機させられているためです。

こちらと合わせて見るとJavaのコードが動作完了したのがむしろ不思議に感じてきます。

Javaの synchronized では、ロックを取得したスレッドからであれば何度でもロックが取得出来ているということになります。

このような性質を持ったロックは「再入可能なロック (Reentrant Rock)」と呼ばれます *3

Javaの synchronized は再入可能(Reentrant)な性質を持っている一方、Goの sync.Mutex は再入可能ではない、というのが両者にある違い(の1つ)なのでした。

Goで再入可能なロック?

さて、ではGoでJavaのコードを再現するにはどうしたらいいでしょうか。

再入可能なロックを実装したパッケージを探して使う、というのも1つの選択肢ではあります。

しかし、Goの標準パッケージに再入可能なロックが実装されていないのは、単にGoが貧弱、というわけではありません。

以下は「 Recursive (aka reentrant) mutexes are a bad idea. 」から始まるRuss Cox氏の解説です。再入可能なロックはバグの温床であるとされています。

https://groups.google.com/g/golang-nuts/c/XqW1qcuZgKg/m/Ui3nQkeLV80J

また、Goに限らずマルチスレッドプログラミングで用いる概念について解説したこちらのページでも

zenn.dev

既に何度も言及しているとおり、より制約された通常ミューテックスで十分なケースにおいて、再帰ミューテックスをむやみに使うべきではありません。

のように言われています。

これらで主張されているように、より高度で複雑な排他制御ができる方法があったとしても、それを使うことが必ずしも正しいとは限りません。

並行プログラミングは特に難しい領域の1つで、動作を完全に把握することは人類には難しすぎます。そのため、むしろ出来るだけ低機能・シンプルな制御にした方が、意図しない動作に気づきやすくなります。

実際に、今回移植しようとしたJavaのコードの場合でも、(再入可能性を無視して)単純に sync.Mutex で実装してもほとんどの箇所では問題になりませんでした。一部そのままではデッドロックが発生した箇所も、ロックを取る箇所を調整することで解消できました。

仮にGoで再入可能なロックを実装するなら?

とはいえ、場合によっては再入可能なロックを使うことがふさわしい状況もあるかもしれません。またそうでなくても、どのように実装したらいいかは気になるところです。

例えばここで実装方法が紹介されています。

medium.com

実装の仕方としては、goroutineのidを認識しつつ、ロックの呼び出し回数をカウントすることになります。が、Goではgoroutine idを取得する方法は意図的に提供されていない*4ため、ダーティなハックが必要になるのが悩ましいところです。

Javaが再入可能を選択した理由

逆に、Javaの synchronized が再入可能に設計された理由も気になるところです。

公式チュートリアルでは

docs.oracle.com

Without reentrant synchronization, synchronized code would have to take many additional precautions to avoid having a thread cause itself to block. (意訳)再入可能な同期がなければ、同期コードはスレッドが自分自身をブロックしないようにたくさんの予防策を取らないといけなくなる。

と「利便性のため」のように見える文章がありました。

一方でこちらのページでは「既存の資産を活かすためにはやむをえなかった」と説明されており、こちらもとても納得できます。

yosuke-furukawa.hatenablog.com

パート1は以上です。ここまでは、Javaの synchronized の排他制御動作についてGoとの挙動の違い、設計理念の違いを紹介しました。

しかし、 synchronized が提供する機能は実は「排他制御」だけではありません。というわけでパート2に進みます。

パート2. sycnhronized = 「排他制御」+「メモリ可視性の保証」

パート2ではJavaの synchronized のもう1つの役割、「メモリの可視性の保証」について紹介します。そして最後にまた、Goではどうなっているかを見ていきます。

Javaの「メモリ可視性」

※ ここでのJavaのサンプルは 並行処理におけるメモリの可視性保証について - かとじゅんの技術日誌 から引用させて頂きました

次のコードでは、別スレッドでは stopRequestedtrue になるまで無限にループが回っており、メインスレッドで1秒後に stopRequested 変数を true に書き換えています。

public class StopThread {
    private static boolean stopRequested;

    public static void main(String[] args) throws InterruptedException {
        Thread backgroundThread = new Thread(new Runnable() {
            public void run() {
                int i = 0;
                while (!stopRequested) {
                    i++;
                }
            }
        });
        backgroundThread.start();
        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
    }
}

スレッドが開始したら1秒後に終了しそうに見えるコードです。 また各スレッドでは変数に一箇所でしか触っていないので synchronized などは不要そうに見えます。

ところが、実際にこのコードを実行するといつまでたっても終了しません。メインスレッドでの変数への変更が別スレッドからは見えてこないのです。何がどうなっているのでしょう。

この原因として「スレッドごとのローカルキャッシュが見えてしまっている」という説明がよくされていると思います(「スレッドごとのローカルキャッシュって具体的にはなんだっけ。どこにキャッシュされてるんだっけ」と気になった方はパート3までお待ちください)

スレッドごとのローカルキャッシュなどがあるために、並行プログラミングでは変数の値がスレッドをまたいで正しく読み取れるよう、注意して制御する必要があります。

1つの対処法として、変数 stopRequested にアクセスする部分を書き込み・読み込みともに synchronized ブロックで囲むと、期待通り変数 stopRequested の変化が見えるようになります。

このようにスレッドによって意図しない値を読むことなく、正しい値を読み取れるようにすることは「メモリの可視性の保証」などと言われます。

Javaの synchronized は排他制御、つまり「アトミック性」の保証だけではなく、「メモリの可視性」も同時に保証してくれる機能ということになっているのです。

Javaの volatile

synchronized に比べると見かける頻度はガクッと下がる気がしていますが、Javaには volatile という修飾子も存在しています。

synchronized が「アトミック性」「メモリの可視性」の2つを保証したのに対して、 volatile は「メモリの可視性」だけを保証してくれる機能です。

先ほどのコードで変数 stopRequested

private static volatile boolean stopRequested;

のように宣言することでも、 synchronized ブロックで囲んだときと同様に、別スレッド から変数 stopRequested の変更が見えるようになります。

volatile 修飾子をつけることでも、変数の可視性を保証されます。一方で排他制御についてはなにも行われません。

ロックなどの処理がない分 synchronized より軽量で、排他制御が不要なとき(変数へのアクセスが1箇所のみで最初から処理がアトミック、とか)などに利用が検討できます。

(が、本当に利用できるかはもっと複雑そうです。詳しくはこちらのページなどで詳しく解説されています)

blog.j5ik2o.me

Goで volatile

さて、もしもJavaの volatile をGoに移植したくなったらどうでしょうか? GoにJavaの volatile に相当する機能はあるのでしょうか?

はい、もちろん(?)ありません。

stackoverflow.com

TL;DR: Go does not have a keyword to make a variable safe for multiple goroutines to write/read it. Use the sync/atomic package for that. Or better yet Do not communicate by sharing memory; instead, share memory by communicating.

そしてここでGoの第1の格言が登場します。

Do not communicate by sharing memory; instead, share memory by communicating

Goでは人類には難しい並行プログラミングと戦うための設計として、「共有メモリで通信する」のではなく「通信でメモリを共有」することを理念としています。

チャネルを使いましょう!

またJavaについてもより安心・安全に使える機能は用意されています。 java.util.concurrent パッケージなどに用意されているクラスを積極的に活用していきましょう。

パート3. 「メモリモデル」への旅立ち。そして説明の脱落。

パート1では「排他制御」について、パート2では「メモリの可視性」について紹介・説明をしてみました。

最後のパート3は、それらがどのように動作するかが規定された「メモリモデル」のお話です。が、具体的な説明は既に素晴らしい解説がなされているので、ここでは独断と偏見に基づくオススメの解説リソースを紹介します。

詳細について本記事中で自分の言葉で解説するには紙面と私の知識が圧倒的に不足しているため割愛 脱落です。ここまで興味を持って読んで頂けた方は紹介するページについても是非見てみてください。

ざっくり「メモリモデル」

「メモリモデル」は、メモリの読み書きがどういう順序で実行されるか、どこまでは一貫性を保証するかを規定・約束するものです。ハードウェア(CPU)、プログラミング言語それぞれで定義されます。

メモリの読み書きは、CPUのL1, L2キャッシュによる影響や、プログラミング言語による最適化(命令の順序の入れ替え:アウト・オブ・オーダー実行)などにより直感に反する順序で実行されることがありえます。

ハードウェアはパフォーマンスを高めるために最適化を重視し(=一貫性の保証が弱い)、それでいて、その上で動作するプログラミング言語がより高い一貫性の保証を提供することで、実装の難易度を緩和しつつ高いパフォーマンスを実現することが出来るようになります。

こちらのページでより詳しく、かつ簡潔にまとめられています。

future-architect.github.io

次はGoのメモリモデルの公式な仕様書です。

go.dev

Introduction The Go memory model specifies the conditions under which reads of a variable in one goroutine can be guaranteed to observe values produced by writes to the same variable in a different goroutine.

Goの並行処理における動作が定めらたドキュメントです。

…なのですが、冒頭には次のように書かれています。

Advice Programs that modify data being simultaneously accessed by multiple goroutines must serialize such access.

To serialize access, protect the data with channel operations or other synchronization primitives such as those in the sync and sync/atomic packages.

If you must read the rest of this document to understand the behavior of your program, you are being too clever.

Don't be clever.

(訳)*5

アドバイス

同時にアクセスする複数のgoroutineによってデータを変更するようなプログラムでは、そのようなアクセスを直列化しなければなりません。

アクセスを直列化してデータを保護するには、チャネル操作またはsync、sync/atomicパッケージ内の要素などを使います。

プログラムの動作を理解するためにこのドキュメントの残りの部分を読まなければならない場合、あなたは頭が良すぎます。

賢くならないでください。

書かれているとおり、本来Goはチャンネルや sync パッケージなどを適切に使っていれば、裏側の詳細な制御を知らなくても正しい並行処理が書けるように意識して設計された言語です。 そのため通常の開発だけであればこのドキュメントは読む必要がない、むしろ読まずに済むほうが望ましいとされています。

が、読破できればGoを「完全に理解」することが出来るかもしれません。

ということで非常にわかりやすく勉強になった解説を紹介します。

Go Conference 2023で詳細な発表がありました。

zenn.dev

また上記ページにも記載のある、こちらの勉強会もオススメです。

www.youtube.com

これらの中で解説されている「逐次一貫性」「ハードウェアメモリーモデル」の解説を聞くことで、パート2の「スレッドによって異なる変数が見える」原因が具体的に理解できた気がします。

また、Javaでも言語モデル(Java Memory Model, JMM)が定められており、こちらの解説が勉強になりました。

blog.j5ik2o.me

おわり

以上、ちょっとJavaの synchronized をGoに移植しようとしただけだったはずが、気になって調べ始めると止まらなくなって生まれた作文でした。 長文を最後まで読んで頂きありがとうございます。

最後に、冒頭で書いた3行まとめを再掲して終わりたいと思います。

  • 並行プログラミングでは、低機能過ぎず、かつ高機能過ぎもしない、適切な道具を使おう
  • 言語ランタイムの挙動に詳しくなるよりも、裏側を知らなくても使える道具を使おう。賢すぎるコードを書いてはいけない。
  • でも裏側がどうなっているか、知れば知るほど面白い!

We are hiring !!

エムスリーではギークなエンジニアの仲間を募集しています。興味がある方はぜひカジュアル面談をご応募ください。

jobs.m3.com

*1:社内で「Database Design and Implementation」の輪読会/輪実装会を行っていて、書籍ではJavaのところをGoで実装してみています。完走できた暁にはこちらもブログにしようと思っているのでお楽しみに

*2:ここでは説明のために愚直にJavaのコードを真似る形にしています。「これじゃ駄目に決まってるでしょ」とすぐわかる方はしばし目を瞑ってください

*3:Javaでは concurrentパッケージに再入可能なロックを実装したずばり「ReentrantLock」という名前のクラス (java.util.concurrent.locks.ReentrantLock) が存在しています。本文中ではこのクラス自体のことではなく、一般の概念としての「再入可能なロック(Reentrant Rock)」について述べています。

*4:https://go.dev/doc/faq#no_goroutine_id

*5:https://future-architect.github.io/articles/20220808a/ の翻訳を引用させて頂きました