2012年8月10日金曜日

クラウド温泉3.0 (18) / Promise

クラウド温泉3.0@小樽のセッション「Monadicプログラミング・マニアックス」で使用するスライドのネタ検討その18です。

関数型プログラミングが期待されている大きな理由の一つは並列プログラミングです。今後プロセッサのコア数が急激に増えるのは確実で、複数コアを活用して高速に動作するプログラムを空気のように作ることが必須になります。

また、メモリ容量の増大やSSDの採用でデータベースもインメモリ化されてくると、従前のようなI/Oのボトルネックが解消してきます。このためアプリケーション側の並列処理にかかる比重が高まってくることになります。

このため、並列プログラミングは気合を入れて行う特別なものではなく、毎日のように接するコモディティとなるでしょう。普通に書くものが普通に並列動作するようになっていなくてはならないわけです。しかし、Javaなどの既存のオブジェクト指向言語では、並列プログラミングの難易度は高く、並列プログラミングのコモディティ化はとても達成できそうにありません。

関数型プログラミングの場合も並列プログラミングには色々なアプローチがありますが、有力なアプローチとなるのがモナドによる並列処理の隠蔽です。通常のMonadicプログラミングをしておくと、モナドの裏側で自動的に並列処理をしてくれます。この方法であれば、日常的に並列プログラミングしても、プログラマにかかる負担は多くありません。(そのかわりMonadicプログラミングをマスターするというハードルが追加されます。)

ScalazのPromiseは並列処理を隠蔽したモナドです。今回はPromiseによる並列プログラミングについて見ていきます。

準備

準備として動作時間を取得するための関数goを定義します。

def go[T](a: => T): (T, Long) = {
  val start = System.currentTimeMillis
  val r = a
  val end = System.currentTimeMillis
  (r, end - start)
}

テストに使用する関数fは引数に取ったInt値を100msウエイト後にそのまま返します。

val f = (x: Int) => {
  Thread.sleep(x * 100)
  x
}

f関数をgo関数上で動作させると以下になります。10×100msで一秒後に値10が返ってきています。

scala> go(f(10))
res176: (Int, Long) = (10,1000)

PromiseのKleisli

Monad活用のコツの一つはクライスリ関数です。Int値を取りMonadであるPromiseを返すクライスリ関数をKleisliでくるんだものを定義しておきます。

scala> val fp = f.promise
fp: scalaz.Kleisli[scalaz.concurrent.Promise,Int,Int] = scalaz.Kleislis$$anon$1@9edaab8

Applicative

まず普通にf(1)、f(2)、f(3)を順に足していく処理です。先頭から順に関数の実行時間も足し込まれるので600msかかります。

scala> go(f(1) + f(2) + f(3))
res212: (Int, Long) = (6,603)

PromiseのKleisliとApplicativeを使って同様の処理を行うと300msで実行できました。これは、fp(1)、fp(2)、fp(3)の処理が平行実行されたため、最も長いfp(3)の300msが全体の実行時間になったためです。

scala> go((fp(1) |@| fp(2) |@| fp(3))(_ + _ + _).get)
res215: (Int, Long) = (6,302)

ここで重要な点は、平行処理に対する同期処理はまったく記述していない点です。普通のMonadicプログラミングを行うだけで、Promiseモナドが自動的に同期処理を行なってくれます。

上記が普通のMonadicプログラミングというのは、以下のOptionに対する処理と見比べてみるとよくわかります。

scala> go((1.some |@| 2.some |@| 3.some)(_ + _ + _).get)
res237: (Int, Long) = (6,1)

つまり普通のMonadicプログラミングをすれば自動的に並列プログラミングになるわけです。Monadicプログラミングのハードルは高いものの、このハードルだけクリアしておけば自動的に並列プログラミングがついてきます。Javaで並列プログラミングを学習するコストと、実際のプログラミング時の手間やデバッグ効率を考えると、Monadicプログラミングのハードルの方がはるかに低いといえます。

Listに対する並列プログラミング

Listに対して普通に関数fを適用すると、関数fの実行時間が全て足し込まれたものが全体の処理時間になります。以下では1.2秒かかっています。

scala> go(List(1, 2, 3).map(f).map(f))
res221: (List[Int], Long) = (List(1, 2, 3),1205)

Promise(+Kleisli)を使うと以下のように600msで処理が完了しました。一段目のmap処理の300msと二段目のmap処理(内部でflatMap)の300msを足して600msになります。

scala> go(List(1, 2, 3).map(fp).map(_.flatMap(fp)).sequence.get)
res220: (List[Int], Long) = (List(1, 2, 3),602)

Kleisli関数の合成

Kleisliの威力を見るために、もう少し処理を複雑にしてみます。

関数fと連続実行した時に処理の合計が400msになる関数fxを定義します。同時にこれをPromiseのKleisli化したものも定義します。

val fx = (x: Int) => {
  val t = math.abs(x - 4)
  Thread.sleep(t * 100)
  x
}

val fxp = fx.promise

関数fと関数fxをListに対して普通にmapで連続実行した場合ですが、以下のように1.2秒かかりました。

scala> go(List(1, 2, 3).map(f).map(fx))
res223: (List[Int], Long) = (List(1, 2, 3),1205)

関数fと関数fxを合成した場合も1.2秒は変わりません。

scala> go(List(1, 2, 3).map(f >>> fx))
res232: (List[Int], Long) = (List(1, 2, 3),1205)

関数fと関数fxをPromiseのKleisli化した関数fpと関数fxpを連続実行したものが以下になります。内部で並列処理が行われ400msで実行が完了しました。

scala> go(List(1, 2, 3).map(fp).map(_.flatMap(fxp)).sequence.get)
res222: (List[Int], Long) = (List(1, 2, 3),402)

また、Klisliのfpとfxpを合成したものをmapで適用しても同様に400msで実行が完了しました。

scala> go(List(1, 2, 3).map(fp >=> fxp).sequence.get)
res230: (List[Int], Long) = (List(1, 2, 3),402)

PromiseのKleisliでは、Kleisliをパイプラインの各段に適用しても、Kleisliを合成したものを一度に適用しても、同じように並列処理が行われました。どちらのアプローチでも同じように並列処理が行われるというのは、プログラミングのしやすさからは非常に重要な性質です。

バージョン

セッションで用いるScalaとScalazはそれぞれ2.9.2と6.0.4です。

ScalaとScalazとも新バージョン(Scala: 2.10, Scalaz: 7)がリリースされる直前になっています。Scala 2.10では並列プログラミング周りが大きく変更されます。Scalaz 7もこの影響を受けるものと考えられます。

このため、本セッションで扱う内容はすぐに古くなってしまうことが確実ですが、基本的な考え方は引き続き有効だと思います。

0 件のコメント:

コメントを投稿