MicroAd Developers Blog

マイクロアドのエンジニアブログです。インフラ、開発、分析について発信していきます。

Atomic, volatile, synchronized について

はじめに

サーバサイドエンジニアの酒井です. 普段はマイクロアドが提供する広告配信プラットフォーム UNIVERSE Ads の DSP 部分の開発をしています. 今回は JVM 言語におけるマルチスレッドプログラミングに関するいくつかの用語の理解と整理を兼ねて記事にします.

背景

まず Real-Time-Bidding (RTB) についてですが, RTB では大量のリクエストが送られてくる中でレスポンスタイムの制約が 100 ms 以内と非常に制約が厳しいです. そのような環境の中マイクロアドの DSP 開発では, レスポンスタイムとスループットの二つのパフォーマンス指標の制約を常に満たすことが非常に重要になっています. この制約を満たせるようアプリケーションのパフォーマンスを上げるため, マルチスレッドプログラミングが用いられることはよくあります.

また, マイクロアドの DSP 開発では JVM 言語の一つである Scala を用いてアプリケーションの開発をしています. JVM 周りの言語を用いたマルチスレッドプログラミングをしているとよく出てくる単語として Atomic, Volatile, Synchronized などがあります. これらの機能はマルチスレッドプログラミングをする上で非常に重宝しますが, 各用語を理解せずに使っていると思わぬ障害になりがちです. マルチスレッドプログラミングによって引き起こされるバグは単純な単体テストでは気づくことが難しく, デバッグなどでの調査も困難になりがちなため障害原因の特定が難しくなります.

マルチスレッドプログラミングで発生する問題

まず各機能の挙動の確認のために以下のように何かを数えることができるクラスを考えます.

class Counter {

    var counter: Int = 0

    def increment(): Unit = {
        counter = counter + 1
        println(counter)
    }   
}

シングルスレッドで上記のクラスを用いて increment メソッドなどを用いることには何も問題はありません. 一方, マルチスレッドの場合, 上記のクラスでは counter 変数が各スレッドで共有されて使われることになります. マルチスレッドプログラミングにおいては, この変数が複数のスレッドで共有された状態というのが少し厄介です. 共有された変数が適切に管理されていないまま同時に対象の変数へアクセスがあると値が適切に管理されず, 誤った結果を生成することがよくあります.

例えば上記のプログラムを適当にマルチスレッドで何回か実行すると以下のようになる場合があります.

出力: 1, 3, 2, 3, 3, 4, 5, 4, 5, 6
重複: あり
順番: バラバラ

このようにマルチスレッドプログラミングの場合, 他のスレッドで increment メソッドが実行され, counter 変数が上書きされることによって予期せぬ値に書き換わっていることがあります. この問題をシンプルに解決しようとする場合, counter という変数はスレッドごとに生成されて用いるか, もしくは特定のスレッドが使っている間は他のスレッドに書き換えられたくありません. そこでこの問題の解決策としてよく用いられる機能が後に説明する Atomicvolatilesynchronized になります.

Atomic

まず, 一般的な用語としてアトミックとは不可分操作と呼ばれます. これは他のスレッドから割り込みをさせず, 今行っている変更操作が確実に終わった後に他のスレッドから実行されることを言います. 一般にこれをマルチスレッドで行うにはスレッドの制御が必要ですが, Java では AtomicInteger や AtomicBoolean などいくつかの Atomic 性を持つことを保証されたクラスが提供されています.

例えばこれを用いて上記の例を下記のように書き換えることで, マルチスレッドによる変数の値が上書きされてしまう問題は解決します.

class Counter {

    import java.util.concurrent.atomic.AtomicInteger
    val counter: AtomicInteger = new AtomicInteger(0) /* AtomicInteger 型を用いる */

    def increment(): Unit = {
        val newValue = counter.incrementAndGet()
        println(newValue)
    }
}

こちらを先ほどと同様にマルチスレッドで何度か実行をすると下記のように順番はバラバラですが重複がなくなります.

出力: 1, 3, 2, 4, 5, 6, 9, 8, 7, 10
重複: なし
順番: バラバラ

このように AtomicInteger などを用いることによってマルチスレッドでの実行においても変数の Atomic 性を保つことができます.

volatile

次に volatile についてです. JVM 上でのマルチスレッドプログラミングでは性能の向上を図るために, メインメモリ上の値をスレッド固有の領域にキャッシュしていることがあります. この機能のおかげで, 各変数が参照された際はメインメモリまで値を読みにいかず, スレッド上のキャッシュ値を読むだけで済むため速度の向上が図れます. 一方このキャッシュ機能は他のスレッドで値が変更されたかどうかなどは検知できないため, メインメモリの真の値と一致しない場合があります. そのため Java では volatile 修飾子が用意されており, この修飾子を用いることによりスレッドで値がキャッシュされるのを抑制し, メインメモリから値を読み込むように変更ができます. しかし当然ながら, スレッドによるキャッシュが使えなくなってしまうのでコンパイラによる最適化や速度の面での性能は落ちてしまいます.

実装としては, 下記のように scala ではアノテーションをつけるだけで機能します.

class Counter {

    @volatile  /* volatile のアノテーションを付ける */
    var counter: Int = 0

    def increment(): Unit = {
        counter = counter + 1
        println(counter)
    }
}

こちらを何度か実行すると, 以下のような結果が得られる場合があります.

出力: 1, 3, 2, 4, 5, 5, 7, 8, 9, 9
重複: あり
順番: バラバラ

ここで注意しないといけないのは volatile をつけた場合, 実メモリに各スレッドがアクセスしにいくので重複がなくなると感じるかもしれませんが increment 等の操作が atomic な操作ではない場合は重複や抜けが起こってしまう点です. 「値を取得するのみ」などである場合は常にメモリに保存されている最新の値を取得できるので問題はありません. しかし, increment の場合は「値の取得」に加え「値に加算し代入」をするふたつの操作があるためズレが生じます.

synchronized

最後に synchronized についてです. synchronized は synchronized で囲った箇所を処理できるスレッドを一つに制限できる機能です. 具体的にはオブジェクトに対してロックを掛け, スレッドがオブジェクトに対してアクセスをする際はロックを保持することにします. そしてアクセスしているスレッド以外のスレッドは, 今のスレッドがロックを開放するまで待つ必要があります. これにより, 今回の例のように複数のスレッドが同時に変数に対してアクセスすることを防ぎ, それぞれのスレッドが順番にアクセスするようになります.

class Counter {

    var counter: Int = 0

    def increment(): Unit = this.synchronized {  /* synchronized で囲う */
        counter = counter + 1
        println(counter)
    }
}

こちらの機能は上記のように他のスレッドから変更されたくない箇所を synchronized で囲むだけで機能します. こちらも同様に実行すると以下のような結果が得られるかもしれません.

出力: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
重複: なし
順番: ばっちり

synchronized では各スレッドから値へアクセスされる際に順番が制御されるため出力順も守られ重複などもなくなっています. 一方で, ここでは処理時間の計測は行っていませんが, 一般に synchronized は前のスレッドの処理が終わるまで処理を待つため処理時間が増える点は少し注意が必要です.

まとめ

今回はマルチスレッドプログラミングで重要になる Java の各クラスについて見ていきました. 各クラスを見ていくとそれぞれで処理と結果が異なり, どの場面で何を使うのが適切かを判断しながら使用することが大切だとわかります. また, それぞれの挙動を知っておくことによってバグを未然に防げたり, バグが起こった際に原因に気づくことができるようになるかもしれません.

募集

マイクロアドでは大量のデータを高速に捌く広告配信システムの開発にチャレンジしたいという仲間を募集しています. またサーバサイドエンジニアだけでなく, 機械学習エンジニア, フロントエンジニア, インフラエンジニアなど幅広く募集しています. 気になった方は以下からご応募いただけると幸いです.

recruit.microad.co.jp