Subscribed unsubscribe Subscribe Subscribe

GREEのCUFPでのHaskell事例紹介を見た

haskell

社内Haskellチュートリアルのススメ | GREE Engineers' Blogという記事からたどって、GREEの中の人がCUFPでHaskellの事例紹介をした動画を見た。


CUFP 2013: Yasuaki Takebe: A Mobile Gaming Platform Case Study

スライドはCommon Pitfalls of Functional Programming and How to Avoid Them: A Mobile Gaming Platform Case Study (PDF)

GREEでは

  • 内製のKVSデータベースの管理
  • 画像サーバのフロントエンド

みたいな用途でHaskellを使っているらしい。

プレゼンは(タイトルではFunctional Programmingとあるけど、実際のところは)Haskellでよくある落とし穴を紹介して、それに対するworkaroundを紹介している。例として

  • 遅延評価によるスペースリーク
  • 非同期例外による競合状態
  • 外部ライブラリの変更に追従できなかったために起こる不具合

の3つが取り上げられている。

遅延評価によるスペースリーク

これはHaskellをreal worldで使うときの障壁としてよく取り上げられる。例としてこんなコードが取り上げられている。

modifyTVar' requestThreads $ \threads -> filter (tid /=) threads

modifyTVar'を使うことで[ThreadId]に対するfilterがstrictに適用されていることを期待しているのだけど、実際にはWHNFまでしか評価されないのでthunkが積み上がっていく。あるあるだと思う。

この問題に対するworkaroundとして、プレゼンではseqとlengthを使ってspine strictなリストにしている。spine strictにしたいだけなのにlengthをして長さを求めるのはイヤだ、本質的なこと以外はしたくない、と言う場合はparallelパッケージのControl.Seqが使える。

modifyTVar' requestThreads $ \threads ->
  filter (tid /=) threads `using` seqList r0

実際にどのように評価されるかはghciで:printすれば確認できる。

Prelude> :set -XBangPatterns 
Prelude> let !a = filter (/= 5) [1..10]
a :: [Integer]
Prelude> let !b = let xs = filter (/= 5) [1..10] in length xs `seq` xs
b :: [Integer]
Prelude> let !c = filter (/= 5) [1..10] `using` seqList r0
c :: [Integer]
Prelude> :print a
a = 1 : (_t2::[Integer])
Prelude> :print b
b = [1,2,3,4,6,7,8,9,10]
Prelude> :print c
c = [1,2,3,4,6,7,8,9,10]

ここら辺のテクニックはSimon Marlowの並列平行本にまとまっているのでおすすめ。

Parallel and Concurrent Programming in Haskell: Techniques for Multicore and Multithreaded Programming

Parallel and Concurrent Programming in Haskell: Techniques for Multicore and Multithreaded Programming

非同期例外による競合状態

次に取り上げられるのは、STMなqueueであるTQueueからタイムアウト付きでpopしたときに、稀にデータがなくなるという話。これは文字通り受け取ってHaskellは安全じゃないという話とは考えて欲しくないのだけど、確かに非同期例外は難しい。

実際の問題のコードはスライド中には出てこないのだけれど、たぶんこういうことだろう。

readRequest :: TQueue Request -> IO (Maybe Request)
readRequest q = timeout 10 $ atomically $ readTQueue q

ここで、Control.Timeout.timeoutはスレッドを立ち上げ、指定時間経過した後に元のスレッドに非同期例外を投げる。問題はatomically $ readTQueue qというSTMのトランザクションを抜けた直後にタイムアウトして非同期例外が投げられた場合である。この場合、全体ではreadTQueueで取り出した値ではなくNothingが返り、取り出された値は失われる。

スライドではこんなworkaroundが示される。

readRequest q = do
  mRequest <- timeout 10 $ atomically $ do
    request <- peekTQueue q
    return request
  case mRequest of
    Just _ -> atomically $ tryReadTQueue q
    Nothing -> return Nothing

このコードは意図したコードなのかちょっとわからない。readRequestが複数のスレッドから呼ばれる場合を考えると、スレッドAがタイムアウト前にpeekTQueueで要素があることを確認したあと、スレッドBがtryReadTQueueで要素を取り出してしまった場合、スレッドAではあるはずだった要素がなくなってNothingが返る。

ちゃんとしたtimeoutを実装するにはControl.Concurrent.STM.TVar.registerDelayが使える。

readRequest q = do
  timer <- registerDelay 10
  atomically $ asum
    [ Just <$> peekTQueue
    , Nothing <$ waitTimeout timer
    ]
  where
    waitTimeout = readTVar >=> check

非同期例外の取り扱いの難しさはHaskellの鬼門の一つで、これもまたSimon Marlowの並列並行本に詳しくまとまっているのでおすすめ。

外部ライブラリの変更に追従できなかったために起こる不具合

これは簡単にまとめると、http-conduitの仕様変更で挙動が変わったのだけどコンパイルは通り、それに気がつかず放置していたらスレッドが沢山できて性能劣化するバグとなってしまったという話。これはHaskellに限らず、外部ライブラリを使うあらゆるコードで発生しうる現象だと思う。

Tsuru Capitalでは外部ライブラリはgitで管理された自家製Haskell Platformみたいなものか、コードのリポジトリにチェックインして使うため、自動で依存ライブラリがアップデートされて挙動がおかしくなることがないようにしている。アップデートする際はおかしな挙動や性能劣化などが起きていないかテストやベンチマークの結果を確認してから反映する。

Haskellは型の表現力が高いとはいえ、コンパイルをすり抜けるバグはいくらでもあるので、テストやベンチマークはやはり欠かせない。

まとめ

Simon Marlowの並列並行本のステマみたいになったけど、実際良い本なのでHaskellを実務で使う人は是非読んでみると良いと思う。ちなみにこの本はAmazonで買わなくとも、オンラインで読める

今後もHaskellを採用する会社が増えると僕が食いっぱぐれたときに救われるかもしれないのでみんなHaskellを使おう。