#はじめに
Java8標準にストリーミングAPIが導入されて、煩雑になりがちな並行処理プログラミングがどんどんやりやすい環境になってきました。 自分自身もこの恩恵をすごく感じてます。そんな並行処理のコーディング手法がパワーアップする中で、残念ながら今回は最新技術とかじゃなくて、基本的なところをおさらいついでに書いてみたいと思います。知っている人はさらっと一読してください。Androidアプリ作っているけど並行処理とかよく知らない人はじっくり読んでみてもらえたらと思って書いてみました。
#スレッドセーフ
突然ですが、以下のクラスは複数のスレッドからアクセスされても問題がなさそうでしょうか?
public class SomeSequence {
private int value;
public int getNextValue() {
return value++;
}
}
答えはNOです。シングルスレッド環境では、とくに問題はありません。
しかし、マルチスレッド環境の場合だと、正しく動くことが保証ができません。
value++
のインクリメントは一つの操作でなく、value=1
だとすると
- 現在の値を読み、これがリターンされ、(tmp = value)
- リターン値に1が加えられ (tmp + 1)
- 新しい値を書き込む (value = tmp)
といった感じの処理をしています。
例えば、Aスレッド, Bスレッドがあり、AスレッドがgetNextValue
を呼び出し、不運なタイミング(例えばAスレッドが1〜2の処理中)でBスレッドもgetNextValue
を呼び出した場合、value
をインクリメントした同じ値がリターンされます。Bスレッドの期待結果はAスレッドのでの値をインクリメントすることなので、期待した結果を返すメソッドではなくなります。この現象のことを競合い状態(race condition)といいます。この場合、値が重複することを想定していない仕様で、1足りない状況がずっと続き、悲しい状態となります。
この問題について、どうすればいいかというと同期化を行います。
public class SomeSequence {
private int value;
public synchronized int getNextValue() {
return value++;
}
}
getNextValue()
にsynchronized
メソッドにすれば、上のような問題はなおります。簡単にいうとsynchronized
をつけることで、このメソッドは一度に一つのスレッドしか実行ができなくなります。A,BスレッドがgetNextValue
にアクセスしてもAスレッドが処理を抜けるまで, Bスレッドはクラスのメソッドにアクセスできません。結果的に値は正常な状態で更新ができます。このクラスは、複数スレッドからアクセスされたときでも、正しい動作を振る舞うことを保証します。これをスレッドセーフといいます。じゃあ、全部にsynchronized
つければいいじゃんと思います。が、落とし穴もあります。それは後ほど説明します。
#アトミック性
上記のサンプルコードでvalue++
のような操作がありますが、スレッドセーフでない場合、複数のスレッドから呼び出されると、正しい動作が保証されないことがわかりました。競合い状態を発生させず、この処理をスレッドセーフにするには、この操作をアトミックにする必要があります。アトミックとは不可分操作と呼ばれ、他のスレッドには割り込みをさせず、今行なっている変更操作が確実に終わった後に実行できることをいいます。value++
といった操作をリード・モディファイ・ライト(read-modify-write)と操作と言いますが、競合い状態を起こす可能性が高く、アトミックに実行する必要があります。
そのほかにも以下のような遅延初期化も、チェック・ゼン・アクト(check-then-act)と呼ばれ、競合い状態を起こす可能性が高いです。また、リード・モディファイ・ライト, チェック・ゼン・アクトなどの複数が合わさった状態を複合アクションと呼びます。
public class AppComponent {
private AppComponent instance = null;
public AppComponent getInstance() {
if (instance == null) {
instance = new Instance();
}
return instance;
}
}
前に述べたのと同じように、スレッドA, スレッドBがgetInstance
を実行した場合、不運なタイミングでは、instanceフィールドにセットされる前に実行され、二つのgetInstance
の呼び出しで別々のオブジェクトを受け取ることになります。これを陳腐化した観察(更新の一瞬前の古い値)とも言います。アトミックに実行するにはJavaの基本的な仕組みであるロックを検討します。また、以下のように既存にあるアトミック変数クラスを使ってもスレッドセーフを実現できます。getNextValue
にアクセスする全てのアクションが確実にアトミックになります。
public class SomeSequence {
private AtomicInteger value = new AtomicInteger(0);
public int getNextValue() {
return value.getAndIncrement();
}
}
#ロック
Javaは、アトミック性を強制させる仕組みを言語本体の機能として持ってます。先に書きましたsynchronized
がそうです。synchronized
には二つの役割があります。ロックとブロックです。下記の構文では、synchronized
で渡しているthis
をロック(鍵)の役割を果たすオブジェクトの参照、そのロックによって守られているコードブロックという構成になってます。 なお、先のサンプルコードではメソッド名の前にsynchronized
が付いているsynchronizedメソッド(同期化メソッド)と呼ばれ、下記はsynchronizedブロック(同期化ブロック)と呼ばれます。例ではコード全体をロックしているため、先のsynchronizedメソッドと意味は同じとなります。
public class SomeSequence {
private int value;
public int getNextValue() {
synchronized(this) {
return value++;
}
}
}
Javaのオブジェクト(シングルトンインスタンスやクラス全て)は全て、同期化のためのロックとして働き、言語がサポートしております。これを固有ロック(モニタロック)と呼びます。同期化ブロック(メソッド)を実行できるのはロックを取得できたスレッドのみで、同期化ブロックを終了するとロックを解放します(例外がスローされても解放します)。Aスレッドがロックを取得している場合、BスレッドはAスレッドがロックを解放するまで待ちます。これをミューテックス(相互排他ロック)と呼びます。Aスレッドがロックを解放しないとBスレッドは永遠に解放を待ちます(デッドロック)。
ちなみに以下のコードはデッドロックになるでしょうか?
public class Sequence {
public synchronized int getNextValue() {
return value++;
}
}
public class LoggerSequence extends Sequence {
public synchronized int getNextValue() {
log.d("[in] ", "-- calling getNextValue()");
return super.getNextValue();
}
}
AスレッドでLoggerSequenceは同期化メソッドであるgetNextValue
を呼び出す時に拡張元のSequenceクラスのロックを取得します。さらにブロックコード内でスーパクラスのメソッドでもSequenceクラスのロックを取得しようとします。LoggerSequenceでSequenceのロックを取得してしまっているので、絶対に取得できないロックを待つことになりそうです。が、実はデッドロックは発生しません。Javaの固有ロックは再入可(リエントラント)の特性を持っており、ロックを持っているスレッドが同じロックを取得しようとしたときリクエストは成功します。JVMはオーナースレッドを記録しており、ロックを取得するたびにカウントがインクリメント、ロックを抜ければデクリメントされます。同じスレッドが再度、同じロックを取得しようとした場合、ロックカウントが増減されます。
また、複数のスレッドがアクセスされるステート変数をロックする場合、ロックのオブジェクトは同じでなければいけません。例えば、以下のようなコードがある場合、全くスレッドセーフではありません。AスレッドがgetNextValue
にアクセスし処理を行った際、誰かがsetObject
にアクセスし、lockオブジェクトが変化したとすると、ロックの状態が変化し、別スレッドがコードブロック内にアクセスできる状態になってしまいます。
public class SomeSequence {
private Object lock = new Object;
private int value;
pubic void setObject(Object obj) {
lock = obj;
}
public int getNextValue() {
synchronized(lock) {
return value++;
}
}
}
先に安全にスレッドセーフを実現する場合、 全てにsynchronized
をつければいいのでは?と書いたと思います。例えば以下のようなコードがあるとします。先にsynchronized
がされたメソッド(ブロック)は一度に一つのスレッドのみ実行できます。Aスレッド, Bスレッド, CスレッドがこのクラスのaddNumber
にアクセスしたとき、BとCスレッドはAスレッドの処理を待ち、もしかすると長時間待たされるかもしれません。つまり単純ではありますが、実行性能が非常にいけてない状態になります。
public class SomeService {
private BigInteger number;
public synchronized BigInteger getNumber() {
return number;
}
public synchronized void addNumber() {
number.add(BigInteger.TEN);
someHeavyRequest();
}
}
以下のコードは、共有される可変なnumber
のみにsynchronized
をつけ、このブロックの中で更新するように修正されてます。ブロック外のコードはローカル変数のみにアクセスするものと仮定すると、それらは複数のスレッドが共有はしないので、同期化をする必要がありません。
AスレッドはロックをsomeHeavyRequest
の実行前に解放するので、並行性を損なわず、また、スレッドセーフも維持ができます。
public class SomeService {
private BigInteger number;
public synchronized BigInteger getNumber() {
return number;
}
public void addNumber() {
synchronized(this) {
number.add(BigInteger.TEN);
}
someHeavyRequest();
}
}
synchronized
ブロックを細かくするといった並行性は実行性能があがります(その分コードも煩雑化するのに注意)。ロックを使うときは常にsynchronized
ブロック内のコードがやることを意識すべきですが、ロックの取得と解放はオーバーヘッドを伴うため、細かく分割しすぎると実行性能が下がる可能性もあります。また、いくら実行性能をあげるためとはいえ、単純性(メソッド全体をブロック)を犠牲にすると安全性が損なわれる可能性もあるため、synchronized
ブロックの大きさを決定するときは設計目標のトレードオフも必要です。とはいえ、長時間かかる処理(重たい計算処理、通信とか)をしている間はロックを持つのはNGです。
#さいごに
基本的になぜかわからんけど起きるバグとか、1000回に1回おきるよなんで?っていったバグは大体においてここら辺の並行処理系が多いです。そんなときに基本的な並行処理の理解などがあれば、問題解決の近道になったり、ならなかったりするかもしれません。JavaのストリーミングAPIなどマルチスレッド関連がパワーアップしてはおりますが、ここらの基本を理解していれば、より迅速に取り入れることができるかもしれません。そして誰か自分に教えてください。
さて、だらだらとJavaの並行処理のおさらいを書いてみました。並行処理に関しては初歩中の初歩を書いております。まだまだ、たくさんあります。もっとよく知りたいって方は古いですが、以下の本を読むことをお勧めします。