業務で非同期処理を書いたときに「そのクラス、スレッドセーフですか?」と指摘を受けることが複数回ありました。
そこで、「そもそもスレッドセーフって何よ」「スレッドセーフじゃないと何が困るのよ」ということを勉強しました。本記事はそのまとめとなってます。
この本は思考させる系の練習問題がついてて、良い気がします。
自分もまだ読破できてませんが(笑)
以下は、この本の第1章「Single Threaded Execution」が基になっています。
##「スレッドセーフでない」とは
インスタンスまたはメソッドが、複数のスレッドから実行されたときに、期待した挙動をしなくなるような状態のこと。
####要件
・一度に一人しか通れない門が一つある。通行人が3人いて、それぞれが頻繁にこの門を通る。
・門が通行人を通るごとに、下記の3つの値を表示したい。
-それまで門を通った人数
-通った人の名前
-通った人の出身地
・通行人は下記の3人。
-名前:Alice 出身地:Alaska
-名前:Bobby 出身地:Brazil
-名前:Clice 出身地:Canada
####クラス設計
名前 | 役割 |
---|---|
Main | Javaのメインクラス。門を作り、人を通らせる |
Gate | 門を表すクラス。人が通る時に"それまでに通った人数""通った人の名前""通った人の出身地"を表示する |
UserThread | 人を表すクラス。Mainクラスが終了するまで門を通り続ける |
####実装
※こちら↓からDL可能です。
※筆者が作ったものではありません
https://www.hyuki.com/dp/dp2.html#download
public class Main {
public static void main(String[] args) {
System.out.println("Testing Gate, hit CTRL+C to exit.");
Gate gate = new Gate();
new UserThread(gate, "Alice", "Alaska").start();
new UserThread(gate, "Bobby", "Brazil").start();
new UserThread(gate, "Chris", "Canada").start();
}
}
public class Gate {
private int counter = 0;
private String name = "Nobody";
private String address = "Nowhere";
public void pass(String name, String address) {
this.counter++;
this.name = name;
this.address = address;
check();
}
public String toString() {
return "No." + counter + ": " + name + ", " + address;
}
private void check() {
if (name.charAt(0) != address.charAt(0)) {
System.out.println("***** BROKEN ***** " + toString());
}
}
}
public class UserThread extends Thread {
private final Gate gate;
private final String myname;
private final String myaddress;
public UserThread(Gate gate, String myname, String myaddress) {
this.gate = gate;
this.myname = myname;
this.myaddress = myaddress;
}
public void run() {
System.out.println(myname + " BEGIN");
while (true) {
gate.pass(myname, myaddress);
}
}
}
Gateクラスでは、checkメソッドで通過する人の名前と出身地の頭文字が異なっていた場合にBROKEN
を標準出力しています。
####動作確認
BROKEN
がたくさん出ました。なぜでしょう。
しかも、不思議なのは「名前と出身地の頭文字が合っている」「名前と出身地の頭文字が異なる」両方の場合にBROKEN
が出てしまっていることです。
"複数スレッド(=各UserThreadクラス)から実行されたときに期待した挙動しない"状態なので、ここではGateクラスがスレッドセーフでない状態になっています。
Testing Gate, hit CTRL+C to exit.
Alice BEGIN
Bobby BEGIN
Chris BEGIN
***** BROKEN ***** No.633: Bobby, Brazil //名前と出身地の頭文字が合っている
***** BROKEN ***** No.990: Bobby, Brazil
***** BROKEN ***** No.1511: Alice, Alaska
***** BROKEN ***** No.1928: Alice, Alaska
***** BROKEN ***** No.2512: Bobby, Brazil
***** BROKEN ***** No.2750: Chris, Canada
***** BROKEN ***** No.2998: Bobby, Brazil
***** BROKEN ***** No.3198: Bobby, Brazil
***** BROKEN ***** No.3499: Bobby, Brazil
***** BROKEN ***** No.4024: Bobby, Brazil
***** BROKEN ***** No.2198: Bobby, Brazil
***** BROKEN ***** No.4391: Bobby, Brazil
***** BROKEN ***** No.5290: Alice, Alaska
***** BROKEN ***** No.5514: Chris, Alaska //名前と出身地の頭文字が異なる
***** BROKEN ***** No.4901: Alice, Alaska
####なぜこうなる?
各UserThreadクラス(=Alice,Bobby,Chris)はpassメソッドを実行し、Gateクラスのクラス変数であるcounter
,name
,address
を次々と変更します。
Aliceさんは今Gateクラス変数がどのような状態になっているかなどお構いなしにpassメソッドを実行し続けます。BobbyさんもChrisさんも然りです。
もしかしたらタイミングによっては、次のような処理順になることもあります。
#####BROKEN出力 処理順例1(出力された名前と出身地の頭文字が異なっている場合)
- AliceさんがGateクラス変数
name
をAlice
に変更する - BobbyさんがGateクラス変数
address
をBrazil
に変更する - Gateクラスの
check
メソッドが実行される -
name
がAlice
,Address
がBrazil
なので、name.charAt(0) != address.charAt(0)
がtrueとなる -
BROKEN, Alice, Brazil
が出力される
#####BROKEN出力 処理順例2(出力された名前と出身地の頭文字が合っている場合)
- AliceさんがGateクラス変数
name
をAlice
に変更する - BobbyさんがGateクラス変数
address
をBrazil
に変更する - Gateクラスの
check
メソッドが実行される -
name
がAlice
,Address
がBrazil
なので、name.charAt(0) != address.charAt(0)
がtrueとなる - AliceさんがGateクラス変数
address
をAlaska
に変更する -
BROKEN, Alice, Alaska
が出力される
つまり、各スレッド(Alice,Bobby,Chris)がGateクラスに干渉する順番によって、得られる結果が異なる状態になっています。現状、干渉する順は制御できません。
##改善方法
Aliceさんは今Gateクラス変数がどのような状態になっているかなどお構いなしにpassメソッドを実行し続けます。BobbyさんもChrisさんも然りです。`
ココが良くないので、Gateクラスのメソッド側で「自分を使っていいのは一回に一人だけ、同時に実行しないでね」という制限をかけます。抽象的な言葉で言うと「排他制御」ですね。
具体的にはJavaのsyncronized
句を使います。
メソッドにsyncronized
句を付けることで、このような仕様が追加されます
- あるスレッドが
syncronized
メソッドを使う際、メソッドのロックを取得する - ロックを取得されたメソッドを他スレッドが使用すると、ブロックされる
- メソッドの使用が終わるとロックが解放され、他スレッドが使用可能になる
public class Gate {
private int counter = 0;
private String name = "Nobody";
private String address = "Nowhere";
//syncronizedで1度に1スレッドからしか使用できなくする
public synchronized void pass(String name, String address) {
this.counter++;
this.name = name;
this.address = address;
check();
}
//syncronizedで1度に1スレッドからしか使用できなくする
public synchronized String toString() {
return "No." + counter + ": " + name + ", " + address;
}
private void check() {
if (name.charAt(0) != address.charAt(0)) {
System.out.println("***** BROKEN ***** " + toString());
}
}
}
####動作確認
排他制御前の実行では数秒とまたずにBROKEN
していましたが、数秒待ってもBROKEN
しなくなりました。
(何時間もやってると壊れ始めるのかもしれませんが...)
Testing Gate, hit CTRL+C to exit.
Alice BEGIN
Bobby BEGIN
Chris BEGIN
####どうして治ったの?
passメソッドを使えるのが1度に1スレッドになり、下記のような処理順が実現されるためです。
#####非BROKEN出力 処理順例
- Aliceさんがpassメソッドのロックを取得する
- AliceさんがGateクラス変数
name
をAlice
に変更する - AliceさんがGateクラス変数
address
をAlaska
に変更する - Aliceさんがpassメソッドのロックを開放する
- Gateクラスの
check
メソッドが実行される -
name
がAlice
,address
がAlaska
なので、name.charAt(0) != address.charAt(0)
がfalseとなりBROKENしない - Bobbyさんがpassメソッドのロックを取得する
- BobbyさんがGateクラス変数
name
をBobby
に変更する - BobbyさんがGateクラス変数
address
をBrazil
に変更する - Bobbyさんがpassメソッドのロックを開放する
- Gateクラスの
check
メソッドが実行される -
name
がBobby
,address
がBrazil
なので、name.charAt(0) != address.charAt(0)
がfalseとなりBROKENしない(以下略)
##まとめ
- 「スレッドセーフでない」とは、メソッドやインスタンスが複数スレッドから使用された時に意図しない挙動をしてしまう状態のこと。
- 原因は、メソッドやインスタンスが複数スレッドで使用される際の使用順が制御されていないため。
- 防ぐには、排他制御が必要。
- 排他制御の一つとして、メソッドに「syncronized」句をつける方法がある。