並行処理についての個人的思考のまとめ -処理の同期化編-
最近転職活動の一つとして何か形になるものを作ろうとツイッター関係のアプリを作成しているのですが、その際に並行処理化したい部分が出てきてあれこれと考えたので、その考えを整理する為にもまとめを書きたいと思います。コード例は全てJavaを使用していますが最後に他言語の機能も紹介していますし、考え方自体は他の言語にも適用できるかと思います。
また、文章の書き方が定まっていないので途中で文章が変わったりと読みにくかったらすみません。一応、週一で更新することを先週辺りから始めましたので、その内安定すると思いますからそれまで我慢してお待ちくださいw 書かなきゃいけない記事の宿題や自分の知識もある程度溜まってきましたしね^^;
【追記】
まとめ→並行処理の助けとなる考えへ変更
シンプル=とにかくメソッドを細かくわけて短いものにする的な意味にとれるという指摘を受けましたので補足を追記
並列処理と並行処理の違いについて
僕自身違いがよくわかっていなかったので悩んでいたのですが、教えて頂いたこちらの記事がわかりやすくまとめてあると思いますので一度御覧下さい。
並列と並行の違い - 氷雪の備忘録
と言っても最近は並列処理に並行処理を含めた意味で使われりもしているようですw
並行処理化する対象
まずは並行処理化する対象の決定からです。その判断に必要な情報は簡単に言ってしまえば下記の2つだと思います。
- 実行に時間のかかる処理
- 処理の実行される頻度
当たり前な情報だとは思いますがしっかりと計測して判断しましょう。大して処理に時間のかからないところを並行処理化しても、オーバーヘッドで逆に時間がかかるようになってしまうだけの可能性もあり無駄となってしまいます。
処理の実行される頻度については、個人アプリなどでとことん並行処理化したいとか仕事で欲求されていとかなら別ですが、納期等で時間には限りがあると全てを並行処理化する事は出来ませんので優先順位等決める為です。ただ、まだリリースもしていなかったりと利用者の情報がない状態では、自分が使う場合になって考えるなりのある程度の予測にはなってしまうのですが^^;
また、上記は後から何かしらの理由で並行処理化する場合の考える方法なのですが、並行処理化には設計の時点から考える事が必要とも言われております。理由としては後から並行処理化しようとしても出来ない部分が出てきてしまうからです。
ただ個人的には1つのクラスには1つの責任だけを持たせ、メソッドもシンプルにしてあれば出来無いものは限りなく0になるんじゃないかな?とも思ったり。まぁこれは僕が仕事などでチーム開発も大規模なプログラミングもした事ないからの甘い考えなのかも知れませんが^^;*1またはその0じゃない部分も考慮しての設計の時点でも考えましょうってことかも知れません。
対象処理に関係したデータに対する処理の同期化
対象が決まりましたら次はその処理に関係するデータにアクセスする全ての処理の同期化です。手順としては下記の通りとなります。
- 処理に関係する範囲と全てのデータの特定
- 特定したデータにアクセスする全ての処理の特定
- 同期化する方法の決定と適用
では順に考えることなどを説明していきましょう。
処理に関係する範囲と全てのデータの特定
まず最初に行う範囲とデータの特定は、処理には途中で他スレッドから割り込みが入ってはいけないという範囲がありますので、その範囲を明確にするということです。そして、この範囲を考える際に関係するデータというのが重要になります。ローカル変数のみを使用しているのでしたら、そのスレッドからしかアクセス出来ないので途中で別の値に変化することはありません。ですが、これがフィールドだと他のスレッドが途中で値を変えてしまい、データの辻褄があわなくなってしまう可能性があります。
例えば下記のようなコード(Java)があるとします。
public class Account { private long balance; public boolean withdraw(long amount) { if (balance >= amount) { balance -= amount; return true; } else { return false; } } }
上記の場合ですと前提条件であるbalanceがamount以上かチェックした後のbalanceからamountを引く前に他スレッドがbalanceの値を変えてしまいamount未満の値になる可能性があります。
さらに balance -= amount は balance = balance - amount と解釈され、balanceの値を取り出す→amountの値を引く→balanceに値を代入という複数の処理として実行されるので、balanceの値を取り出す時と代入する時の間にbalanceの値が変更され辻褄が合わなくなるという秘められた可能性もあります。さらにさらにこれが演算子をオーバーロード出来る言語だと、その処理も考えなければいけません。
また、今回はbalanceがlong型という値型の変数なので大丈夫ですが、これがBalanceクラスという参照型の変数でBalance#getBalanceというメソッドで値を取得する場合だと、そのメソッド内で使われているフィールドにも注意しなければなりません。
と、このような感じでその処理に必要なデータと範囲を考えて特定していきます。もちろんこれらの可能性に気づくにはある程度言語の知識が必要となると思うので勉強しておく事は大事だと思います。
特定したデータにアクセスする全ての処理の特定
次に行うのが特定したデータにアクセスする全ての処理の特定です。
なぜ必要かと言いますと、先にあげたコード例だと他にもAccount#getBalanceやAccount#setBalanceといったbalanceにアクセスするメソッドがあるかも知れないからです。そうなると単純にwithdrawメソッドだけ複数のスレッドが同時に実行できないように同期化しても、他の同期化されていないメソッドが変更してしまう可能性があるという事になりますので、これらも同期化の対象になるという事です。
これはもちろん先程例にあげたbalanceが参照型の場合だと、Balance#getBalanceメソッド内でアクセスしているフィールドにも気を付けないといけません。
並行処理化の方法の決定と適用
以上のような感じで全ての対象を特定出来ましたら並行処理化する方法を考えます。並行処理化する方法としては主に下記の二つがあります。
- イミュータブル(不変)なオブジェクトを使う
- データに対するアクセスを全て同期化する
まず先にイミュータブルなオブジェクトとは、オブジェクトの状態を設定する事が生成時の初期化でしか出来ず、状態を変更するメソッドを持たないオブジェクトの事を言います。ですが、そのオブジェクトのフィールドがミュータブル*2なオブジェクトでその参照を直接クライアント側に渡してしまうと、その参照に対してメソッドを呼び出す事により状態を変更できてしまうので、そのオブジェクトはミュータブルなオブジェクトとなりますからご注意ください。
イミュータブルなオブジェクトのフィールドはイミュータブルなオブジェクトのみにするか、ミュータブルなオブジェクトを使用していてもその参照を直接渡さないようにして下さい。また、もしそのオブジェクトの状態にある変更を加えたものが欲しいのであれば、ある変更を加えた新しいオブジェクトを生成して返すようなメソッドを用意するなどして下さい。
イミュータブルなオブジェクトを使用する利点は、複数のスレッドから同時にどのようにアクセスしても絶対に状態が変化しないので安全使えるという事です。例えばJavaの文字列を表現するクラスであるStringはイミュータブルなオブジェクトなので、文字列に対してどのようなメソッドを呼び出しても新しい文字列が生成されて返されるようになっております。
欠点としては先に説明した通り変化した状態のものが必要な場合に常に新しいオブジェクトが生成されますので、生成にコストのかかるものや頻繁に変化するものだと逆にコストがかかってしまうという事になりますので、使用する部分はよく考えてご注意ください。
次にデータに対するアクセスの同期化です。というわけでJavaの言語機能であるsynchronizedによるロックを使った同期化を行いたいと思います。これを行う際に気を付けなければいけない事は、先にコードを見た方が早いと思いますのでまずは下記のコードをご覧下さい。
// 先に上げたAccountクラスのbalanceが参照型だった場合の同期化の例 // 残高を表すクラス public class Balance { private long balance; public Balance(long balance) { this.balance = balance; } public synchronized long getBalance() { return balance; } public synchronized void setBalance(long balance) { this.balance = balance; } public synchronized void drop(long amount) { balance -= amount; } } // 銀行口座を表すクラス public class Account { private final String name; private final Balance balance; public Account(String name, Balance balance) { this.name = name; this.balance = balance; } public Balance getBalance() { return balance; } public boolean withdraw(long amount) { synchronized (balance) { if (balance.getBalance() >= amount) { balance.drop(amount); // 何かしらの処理 return true; } else { return false; } } } }
正直に悪い例でごめんなさいorz この例だとAccountのwithdrawの処理を全部Balanceに委譲して return balance.withdraw(amount); とかにした方がいいとか色々とツッコミどころがあるかと思いますが、とりあえず別の気を付けなければいけない点も説明したかったのあえて上記のように元のコードに近くしました。コメントにあるように何かしらのBalanceには委譲できない処理が他にも入るものだとでも思って下さい。
では、説明に入ります。
まず先にあげたようにAccountクラスのフィールドであるBalanceクラスはwithdrawメソッドで使用するdropメソッド以外にもsetBalanceという状態を変更するメソッドがあります。ですので、まずはこれらのメソッド全てにJavaのsynchronized修飾子をつけてメソッドにロックをかけ同時にアクセス出来ないようにしました。
そして次にAccountの同期化なのですが、なぜwithdrawメソッドにsynchronized修飾子をつけずに上記のようにしたかと言うと、withdrawにsynchronized修飾子をつけてもそれはwithdrawメソッドの呼び出しのみをロックするだけで、下記のコードのようにbalance.getBalance()とbalance.drop()の部分でそれぞれ別々にBalanceのロックを取得しているからです。なので、上記のようにsynchronizedブロックを使ってbalanceのロックを取得しています。
このようにフィールドの参照オブジェクト自体へのアクセスが同期化されていても、それはそのオブジェクトに対する1つの処理単位しか同期化されませんので、複数の処理で1つの意味がある処理は途中で必要なロックを手放さないようにまとめる事に注意して下さい。
public synchronized boolean withdraw(long amount) { if (balance.getBalance() >= amount) { // balanceのロックを取得 // この間にbalanceが変更される可能性あり balance.drop(amount); // balanceのロックを取得 return true; } else { return false; } }
Javaのsynchronizedについての補足と他言語のロック機能
Java自体を触った事がなかったりと知らない方にはコード例がわかりにくかったりすると思うので、ここで少し詳しく説明したいと思います。
synchronizedなのですがコード例にもありました通り修飾子とブロックの2種類があります。まず先にsynchronizedブロックの方を説明した方がわかりやすいと思うのでこちらから説明しますと、synchronizedの後の括弧()内には下記のコードように意味的には鍵となるオブジェクトを記述し、その後の括弧{}内にはロックする処理を記述します。この鍵となるオブジェクトは参照型のオブジェクトでないとコンパイル時にエラーが発生しますのでご注意ください。
synchronized (鍵となる参照オブジェクト) { // 鍵となるオブジェクトでロックする処理 }
例えて説明しますと、スレッドは人で鍵となるオブジェクトはそのまま鍵でその後の括弧{}はその鍵で入れる家だと思って下さい。つまりなぜか家の前に鍵が置いてあるのです。そして、人がその家の中で仕事をしたい時にはこの鍵を使ってドアを開け、鍵を持ったまま中に入り鍵をかけて仕事をするのです。鍵を持ったまま中に入っているので当然他の人(スレッド)が来ても中には入れませんよね?入れないから仕事したい人は仕方なく外で待っているのです。そして中に入っている人が仕事を終え、家の中から出てきて鍵を元の場所に戻したところでやっと待っていた人が中に入れるというわけです。
確かこんな感じの説明が「Java並行処理プログラミング」にも書いてあったと思うのですがおわかり頂けましたでしょうか?
では話を戻しまして、メソッドにつけるsynchronized修飾子はこの鍵となるオブジェクトに自身への参照であるthisを使ってメソッド全体をブロックするという意味となります。なので同期化の説明のコードのAccount#withdrawにsynchronized修飾子をつけてもBalanceとは別の鍵を使ってるので意味がないよね?というわけなのです。
また、おまけとしてsynchronizedブロックには下記のような使い方もあります。
public class Hoge { private final Object aLock = new Object(); private final Object bLock = new Object(); private int a; private int b; public void piyo(int val) { synchronized (aLock) { a += val; } } public void foo(int val) { synchronized (bLock) { b += val; } } }
このようにすれば各lockの参照が外に漏れない限りはHogeクラス内でしか鍵を取得できないので、同期化する際に注意する範囲が特定しやすくなりますし、aとbが無関係なのでしたらロックを細かくわける事も可能となります。
ロックの考え自体は並行処理の基本となる考えですので、他言語でもライブラリなり何かしら方法でロックによる同期化はサポートされています。例えばC++だとBoostのthreadライブラリにあるmutexクラス、C#だとlock 文、PythonだとthreadingモジュールにあるLockやRLockオブジェクトなどがあり、使い方は若干異なりますがほぼ同等の事が可能となっております。
並行処理の助けとなる考え
ここまでの流れを見て頂いたらある程度わかると思うのですが、並行処理に置いても小さくメソッドはシンプルにする事が大切です。
処理の範囲やデータを特定する際に、もしメソッドが長く複雑にあまり関係のない処理なんかが混じっていたらどうでしょうか?今回の例でもたった1つ参照型のオブジェクトが増えただけで範囲が広がり、ちょっと複雑になりましたがこれがさらに複雑になるという事です。そうなるととても特定し切れませんし、特定出来たと思っても漏れがあって不具合の発生する可能性が高くなります。そもそも並行処理化する意味のない大きな塊になってしまうという可能性もあります。
ですので、安全に効果的に並行処理化するには普段からメソッドを小さくシンプルにすることを心がけ、対象が長く複雑だった場合にはリファクタリングをして小さくシンプルにわかりやすくして下さい。そして、注意深く処理の影響する範囲を特定し、ちゃんと同期化できているのかよく考えて下さい。
※ ここで言っているシンプルにというのはメソッドを簡潔にという意味です。メソッドを過剰に小さく細かくわけてしまっても逆にわかりにくくなってしまい把握する範囲も広くなってしまいます。
といったところが僕が並行処理化する際に考えている事などです。僕の考え方ですので間違い等あるかも知れません。ですので、間違い等がありましたら修正したいのでコメントなどで教えて頂けたらなと思いますので是非よろしくお願いいたします。
また今回は処理の同期化について書きましたので、次は実際にスレッドに実行させる際の考え方についてまとめたいと思います。
参考文献
今回書いた内容のほとんどはこちらの本で学びました。タイトル通り内容はJavaの言語機能を使用しておりますが考え方はどの言語にも共通すると思いますので、並行処理を学びたい方にはオススメです。