GoのTickerみたいなやつをHaskellで作った

github.com

作った。

GoのTickerはとてもシンプルな関数で、 指定した周期でチャネルに値を送るスレッドを生成する。 一定間隔で何かの処理を行いたいときに利用する。

今回作ったHaskell版もだいたい同じようなものを提供する。

実際のところパッケージにするには小さすぎる気もするけど、 Haskellパッケージ製作の練習をしたかったのでHackageのアカウントを取ってHackageに上げた。 練習のため、HaddockによるAPIドキュメントおよびhspecとdoctestによるテストも書いてある。 あとは何かCIを導入してGithubのページにCIのステータスを表示するようなやつもやってみたい。

小さいわりにHaskellパッケージに求められる要素は結構入っているので、 Haskellパッケージ作ったことがない人が作り方の参考にすることができるかもしれない。

ちなみに言語拡張は一つも使っていない。

パッケージの説明

(Haddock を結構まじめに書いたのでこちらも参照されたい)

現在は2つの関数を提供している。

newTicker
  :: Int                 -- ^ ticker rate by micro sec
  -> IO (Chan (), IO ()) -- ^ ticker channel and ticker stopper
withTicker
  :: Int               -- ^ ticker rate by micro sec
  -> (Chan () -> IO a) -- ^ handler function
  -> IO a              -- ^ result of handler

newTicker のインターフェースはGoの time.NewTicker に近づけている。

Haskellのチャネルは getChanContents を使うとリストに化けさせることができる。 (Haskellのリストは遅延リストなのでこれが可能)。 「forループでチャネルの受信をする」というGoっぽい書き方もできる。

import Control.Concurrent.Ticker (newTicker)
import Control.Concurrent.Chan (getChanContents)
import Control.Monad (forM_)
import System.Timeout (timeout)

main :: IO ()
main = do
  (chan, cancelTicker) <- newTicker $ 10^3 * 100
  chanStream <- getChanContents chan
  _ <- timeout (10^3 * 350) $ forM_ chanStream $ \_ -> do
    putStr "Tick!"
  -- Tick!Tick!Tick!
  cancelTicker

newTicker はチャネルとともにTickerを止める関数を返す。 Tickerの受信者がいなくなったらこれを呼ばないとリークになる(たぶんGoも同じ)。

もう一つの関数 withTicker はハンドラーが終わったら Ticker を停止するのでリークを気にする必要はない。

main :: IO ()
main = do
  withTicker (10^3 * 100) $ \chan -> do
    chanStream <- getChanContents chan
    _ <- timeout (10^3 * 350) $ forM_ chanStream $ \_ -> do
      putStr "Tick!"
    return ()

withTicker の実装は超単純で newTickerbracket でくるんでいるだけ。

withTicker microSec action = bracket
                             (newTicker microSec)
                             (\(_, cancelTicker) -> cancelTicker)
                             (\(chan, _) -> action chan)

こういう必要な関数がぱっと出てくるので Haskell は好き。 ハンドラーが例外を投げたときでも Ticker は停止される。

既知の問題

Githubissueにも書いたが、 newTicker は「一定周期」の実現のためにスリープ関数だけを使っている。 当然 newTicker のループの中にはスリープ関数以外の処理によって生じるオーバーヘッドがあり、 チャネルに値が積まれる速度は指定した時間よりわずかに長くなってしまうと思われる。

GoのTickerは現在時刻を毎ループで取得することで時間を厳守しているようだ。 でも現在時刻って NTP アクセスなどによって巻き戻ることがあると思うんだけど、 そこはどうしてるのだろう。

ともかくGoは詳しくないがHaskellでは getCPUTime というCPU時間を取得する関数があり、 これを使えば巻き戻らない時間が取得できそうだ、 と最初は思ったのだが……。

試したところ getCPUTime はどうやらスリープ中はカウントが止まってしまうようだった。 ループで何かを待つ場合、ふつうはビジーループを避けるために適当にスリープを入れると思うのだが、 スリープを入れるとめっちゃ時間が延びてしまった。 考えてみれば当たり前な気もするがCPUを使っていない時間はCPU時間として計測されないらしかった。

(おまけ)Hackageまわりの知見

  • Hackageのアカウントは実名で取っている人が多いが実名である必要はない。
  • .cabal ファイルの descriptionsynopsis より長く書いておかないとアップロードのときに警告される(アップロードはできる)。
  • Hackageにアップロードしたものは基本的に修正や削除はできない。
    • 修正のためには新しいバージョンをアップロードする。
    • ただし .cabal ファイルの軽微な修正ならバージョンを更新しなくても可能。
  • HaddockAPIに "Deprecated" の印をつけるにはHaskellのプラグマを用いる。
    • 該当部分
    • Haddockの記法ではないので注意。
    • deprecated な関数を使っていたら処理系が警告を出さないといけないので、よく考えると処理系レベルの記述になっているのは妥当だ。