このサイトでの最初のブログ記事は Haskellについてでした。関数型プログラミングには以前から興味がありましたが、 Haskellの複雑さは価値に見合わないと思っていました。

OCamlについては長い間ぼんやりと知っていましたが、実際に触れたことはありませんでした。 最近ではマルチコア対応やエフェクト、Jane Streetによるよりシステム寄りの OxCamlというスピンオフにより、少しルネサンスを迎えています。今が学ぶのに最適な時期だと思われ、 Eioという並行処理フレームワークにも興味がありました。Eioは前述のエフェクトを使用していますが、より興味深いのは、テストに役立つ可能性のある決定論的設計であることです。

書籍とチュートリアル

最初にやるべきことは、チュートリアルや書籍を選ぶことです。Googleで検索したところ、 Real World OCamlを見つけました。 Real World Haskell(楽しめて実践的だった)と似ているだろうと思い、すでにFPの十分な知識があると仮定して、読み進めました。

構文や意味論を早く見たいと思い、より散文的なPrologueを飛ばしました。もし読んでいれば、 本書ではJane StreetのBase標準ライブラリ置き換え版を使用していると知らされたでしょう。歴史的に、本当の標準ライブラリはコンパイラのニーズを満たすためのものとして非常に貧弱でした。これはもはや真実ではなく、徐々に成長してきています。それでも、Real World OCamlという本が言語の最大の実世界ユーザーからのライブラリを使用するのは合理的だと思います。

とにかく、もっと注意深く読むべきだったと学び、最初に戻ってやり直しました。 本の前半はよく書かれており、楽しく読み進めました。ただし、GADTや一級モジュールあたりでつまずき始めました。本には演習問題が不足しているように思います。馴染みのない概念が出てくると、基本を十分に理解していないため、つまずいてしまうのです。

もう少し簡単なものを探したところ、動画とテキストの両方があり好みに応じて学べる CS3110を見つけました。演習問題も多く役立ちました。まずはこのコースから始め、その後より高度な機能のためにRWOに進むことをお勧めします。RWOは好きですが、RWHのようなプロジェクトベースの本を求めているなら、期待外れに終わるでしょう。

実際に使ってみた印象

全体として、OCamlは非常に楽しくコーディングできます。関数型と命令型のプログラミングがうまく混在しています。例えば、リストをループ処理する場合、再帰関数を使うか、List.mapなどの関数を使うかのどちらかです:

let print_recursive list =
  let rec loop i list =
    match list with
    | [] -> ()
    | x :: xs ->
      Printf.printf "[%d] => %i";
      loop (i+1) xs
  in
  loop 0 list

let print_fn = List.iteri (fun i x -> Printf.printf "[%d] => %i\n" i x)

let () =
  print_recursive ["foo"; "bar"; "baz"];
  print_fn ["foo"; "bar"; "baz"];

(*
[0] => foo
[1] => bar
[2] => baz
[0] => foo
[1] => bar
[2] => baz
*)

ただし、必要に応じて可変変数なども使えます:

type client = { mutable request_id : int }

let dispatch cli ~msg =
  let request_id = cli.request_id in
  cli.request_id <- request_id + 1; (* update request_id *)
  
  Printf.printf "Dispatching %d/%s to server\n" request_id msg

let () =
  dispatch_cli ~msg:"Hello";
  dispatch_cli ~msg:"World"

(*
  => Dispatching 0/Hello to server
  => Dispatching 1/World to server
*)

構文がかなり冗長に感じる点は指摘しておきます。例えば、let .. inmatch .. withなどの構文です。構文はプログラミング言語で最も興味の薄い部分だと考えているので(Rustがまだシジルを使っていた時代でも満足していました)、あまり気になりません。名前付き引数(~付き)は非常に便利です。

コンパイラの方が気になります。エラーに遭遇すると、ファイルの残りの部分を処理するのを諦めてしまうようです。これにより、イテレーションループがかなり遅くなります — コードを書く、エラーを得る、エラーを修正する、再ビルド。エラーを一括で修正する余地はほとんどなく、特に1つのモジュールに集中している場合はなおさらです。

Eio

EioはエフェクトベースのIO並行処理 ライブラリです。比較的新しいですが、io_uringサポートなど興味深い機能があります。最終的な目標は Raft合意アルゴリズムの実装を書いてテストすることです。

Raftの論文では、サーバー間の通信にリモートプロシージャコール(RPC)を使用することを推奨しています。シンプルなRPCクライアント/サーバーは素晴らしい最初のプロジェクトのように思えます。非常にシンプルなプロトコルを考えてみましょう:プロシージャコールにはリクエストID(int)、名前(string)、単一の引数(string)があります。整数は1バイトで、文字列は長さを表す1バイトとそのデータとしてシリアライズされます。例えば、リクエスト10としてEchoプロシージャをHelloで呼び出す場合、以下を送信します:

000im0000: 0a04 4563 686f 0548 656c 6c6f            ..Echo.Hello

レスポンスは単にリクエストIDと文字列の戻り値になります。

始める前に、Eioの構成要素に関する用語をいくつか理解する必要があります:

  • Fiber - 実行のスレッド。システムスレッドではありません!
  • Switch - ファイバをグループ化するもの。sync.WaitGroup in Goのようなもの
  • Flow - 読み書き可能なもの
  • Stream - 境界付きスレッドセーフキュー。Goのチャネルのようなもの
  • Promise - これはご存知でしょう。

サーバーでは、Flowを受け取り、プロシージャコールを読み取り、呼び出してレスポンスを送信するループが必要になります:

open Utils

type conn = {
  src : Eio.Buf_read.t;
  sink : Eio.Buf_write.t;
}

let rec conn_loop conn =
  let request_id = Eio.Buf_read.uint8 conn.src in

  let procedure_name = read_string conn.src in
  let body = read_string conn.src in
  
  traceln "rx %d/%s(%s)" request_id procedure_name body;

  (* In a real RPC system we might dispatch the invocation to a worker pool *)
  let res = match procedure_name with
  | "Echo" -> body
  | "Capitalise" -> String.capitalize_ascii body
  | _ -> failwith "unknown procedure"
  in

  traceln "tx %d/%s(%s) => %s" request_id procedure_name body res;

  Eio.Buf_write.uint8 conn.sink request_id;
  write_string conn.sink res;

  conn_loop conn

接続を保持する型を定義します。サーバーは複数の接続を持つことができるためです。ここではFlowを直接使用せず、Buf_readBuf_write型を使用していることに注意してください。これはバッファリングされた読み書きを使用し、より効率的になるためです。Flowでの操作は基盤となるリソース(ソケット、ファイルなど)で直接行われます。幸いなことに、これらのバッファリングされたリーダー/ライターには、ここでリクエストIDの送受信に使用する型を読み書きするための便利なヘルパー関数があります。

read_string/write_stringUtilsから来ています:

let read_string src =
  let len = Eio.Buf_read.uint8 src in
  Eio.Buf_read.take len src
  
let write_string sink s =
  Eio.Buf_write.uint8 sink (String.length s);
  Eio.Buf_write.string sink s

次に、バッファリングされたフローを取得してループを実行するヘルパーが必要です:

let handle_conn flow =
  let src = Eio.Buf_read.of_flow ~max_size:1024 flow in
  Eio.Buf_write.with_flow flow @@ fun sink ->
  conn_loop {src; sink}

そしてテスト用の実行ファイル:

open Dumb_rpc
open Eio.Std

let port = 1470

let () =
  Eio_main.run @@ fun env ->
  Eio.Switch.run @@ fun sw ->
  let net = Eio.Stdenv.net env in

  let handle_client flow addr =
    traceln "Accepted connection from %a" Eio.Net.Sockaddr.pp addr;
    Dumb_rpc.Server.handle_conn ~sw flow
  in

  let addr = `Tcp (Eio.Net.Ipaddr.V4.loopback, port) in
  let socket = Eio.Net.listen net ~sw ~reuse_addr:true ~backlog:5 addr in
  Eio.Net.run_server socket handle_client
    ~on_error:(traceln "Error handling connection: %a" Fmt.exn)

これでテストの準備ができました:

> printf '\x00\x04Echo\x05Hello' | nc localhost 1470 -q0
Hello⏎
> printf '\x01\x0aCapitalise\x07matthew' | nc localhost 1470 -q0
Matthew⏎

素晴らしい!

クライアント

クライアントでは少し凝って、並行して安全に使用できるようにしてみましょう。 そのためには、任意の時点でソケットに単一のプロシージャコールのみを書き込むようにする必要があります。これは2つのファイバーを使用して行います:1つは読み取り用、もう1つは書き込み用です。書き込み用のファイバーは、メソッドが呼び出されたときに追加するストリームから作業を取得します:

type request = { procedure : string; arg : string; }

type t = {
  mutable request_id : int;
  writeq : request Eio.Stream.t; (* Read right to left: stream of request *)
}

let rec send_loop cli w =
  let req = Eio.Stream.take cli.writeq in
  let request_id = take_and_inc_request_id cli in
  write_request request;
  send_loop cli w

let invoke cli ~procedure ~arg =
  let req = {procedure; arg;} in
  Eio.Stream.add cli.writeq req;

しかし、レスポンスを呼び出し元に返すにはどうすればよいでしょうか?そのために、ストリームにPromiseも送信し、リクエストIDをキーとしてハッシュテーブルに保存します。これはサーバーがレスポンスを返した後に解決できます。Eioでは、Promiseは実際には2つの半分に分割されていることに注意してください — 待機する側(Promise.t)と解決する側(Promise.u)です。

type waiter = { resolver : string Eio.Promise.u; }

type request = {
  procedure : string;
  arg : string;
  resolver : string Eio.Promise.u;
}

type t = {
  mutable request_id : int;

  writeq : request Eio.Stream.t;
  waiters : (int, waiter) Hashtbl.t;
}

let rec send_loop cli w =
  let req = Eio.Stream.take cli.writeq in
  let request_id = take_and_inc_request_id cli in
  Hashtbl.add cli.waiters request_id { resolver = req.resolver };
  
  traceln "tx %d/%s(%s)" request_id req.procedure req.arg;
  write_request request;

  send_loop cli w

let invoke cli ~procedure ~arg =
  let (promise, resolver) = Eio.Promise.create () in
  let req = {procedure; arg; resolver; } in
  Eio.Stream.add cli.writeq req;

  Eio.Promise.await promise

受信ループはレスポンスを読み取り、Promiseを解決するだけです:

let rec recv_loop cli r =
  let (request_id, body) = read_response
  
  traceln "rx %d => %s" request_id body;
  let waiter = Hashtbl.find cli.waiters request_id in
  Promise.resolve waiter.resolver body;
  
  recv_loop cli r

複数の異なるファイバーから以下のように使用できます:

open Eio.Std

let port = 1470

let () =
  Eio_main.run @@ fun env ->
  Eio.Switch.run @@ fun sw ->
  let net = Eio.Stdenv.net env in

  let addr = `Tcp (Eio.Net.Ipaddr.V4.loopback, port) in
  let flow = Eio.Net.connect ~sw net addr in

  let cli = Dumb_rpc.Client.create () in
  Eio.Fiber.fork_daemon ~sw (fun () -> Dumb_rpc.Client.run cli ~flow; `Stop_daemon);
  
  for i = 0 to 5 do
    Eio.Fiber.fork ~sw (fun () ->
      let arg = Printf.sprintf "arg-%d" i in
      traceln "Invoking Echo(%s)" arg;
      let res = Dumb_rpc.Client.invoke cli ~procedure:"Echo" ~arg in
      traceln "Got response Echo(%s) => %s" arg res
    )
  done

そして動作を確認できます:

> ./_build/default/bin/client.exe
+Invoking Echo(arg-0)
+Invoking Echo(arg-1)
+Invoking Echo(arg-2)
+Invoking Echo(arg-3)
+Invoking Echo(arg-4)
+Invoking Echo(arg-5)
+tx 0/Echo(arg-0)
+tx 1/Echo(arg-1)
+tx 2/Echo(arg-2)
+tx 3/Echo(arg-3)
+rx 0 => arg-0
+Got response Echo(arg-0) => arg-0
+tx 4/Echo(arg-4)
+rx 1 => arg-1
+Got response Echo(arg-1) => arg-1
+rx 2 => arg-2
+tx 5/Echo(arg-5)
+Got response Echo(arg-2) => arg-2
+rx 3 => arg-3
+rx 4 => arg-4
+Got response Echo(arg-3) => arg-3
+Got response Echo(arg-4) => arg-4
+rx 5 => arg-5
+Got response Echo(arg-5) => arg-5

結論

全体として、OCamlは楽しく学びやすく使いやすいものでした。いくつかのスイートスポットに位置しています — FPですが、いつでも命令型プログラミングができ、コンパイルされますがコンパイラは高速で、例外と値としてのエラーの選択肢などがあります。ユーザー基盤はかなり小さく、それが表れていると思います。始めるのに最適な方法が明確ではなく、研究すべきコードもそれほど多くありません。

EioはGoを使ったことがある人には馴染みがあるでしょう。いじっている間、run_serverのコールバックのようにブロックすべき時にファイバーを起動していたため、フローが閉じられる問題に何度もつまずきました。

近いうちに動作するRaft実装で戻ってこられることを願っています。