我第一篇發在這個網站上的部落格文章是關於 Haskell 的。我一直對函數式程式設計感興趣,但 Haskell 的複雜性似乎 並不值得。

我很早就模糊知道 OCaml,但從未真正嘗試過。 最近由於多核心支援、效應,以及 Jane Street 推出的更偏向系統導向的 OxCaml,OCaml 正經歷一波復興。現在似乎是學習它的好時機,而我對 Eio——一個並行框架——也感到好奇。它使用了上述效應,但對我來說更有趣的是它旨在具備確定性,這可能對測試有所幫助。

書籍與教學

首先要做的事是挑選一份教學或一本書。快速搜尋 Google 後,我找到了 Real World OCaml。假設它會與 Real World Haskell(我很喜歡,也很實務)類似,並假設我已有足夠的 FP 基礎,我就直接跳進去了。

我急著想看語法與語意,跳過了較為敘事性的序言。如果我有讀到序言,就會知道這本書選擇使用 Jane Street 的 Base 標準函式庫替代品。看來歷史上真正的標準函式庫相當貧乏,只為了滿足編譯器的需求。這已經不再是事實,而且它也在逐漸成長。不過,對我來說,一本名為 Real World OCaml 的書使用語言最大真實世界使用者的函式庫,似乎並不無道理。

無論如何,我確實學到教訓:要更仔細地閱讀內容,於是我回到開頭重新開始。這本書的前半部寫得很好,我跟著愉快地讀了下去。不過到了 GADTs 與一級模組時,我開始迷失。我認為這本書缺乏練習題,因此當遇到不熟悉的內容時,你無法充分掌握基礎知識來幫助自己脫困。

我找了些較簡單的東西,找到了 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 還使用特殊符號的年代,我也覺得滿意——所以這並不太困擾我。有命名參數(使用 ~)非常方便。

編譯器更困擾我。當遇到錯誤時,它似乎會放棄處理檔案的其餘部分。這使得迭代循環變得相當緩慢——寫程式碼、遇到錯誤、修復錯誤、重新建置。你幾乎沒有機會大量修復錯誤,尤其是當你專注於單一模組時。

Eio

Eio 是一個基於效應的 IO 並行函式庫。它相對較新,但有一些有趣的功能,例如 io_uring 支援。我最終的目標是實作 Raft 共識演算法並嘗試測試它。

Raft 論文提到要使用遠端程序呼叫(RPC)在伺服器之間進行通訊。一個簡單的 RPC 客戶端/伺服器似乎是一個很好的第一個專案。讓我們設計一個非常簡單的協定:一個程序呼叫有一個請求 ID(整數)、一個名稱(字串)和一個單一參數(字串)。整數是單一位元組,字串則序列化為一個位元組表示長度,後面接著資料。例如,要呼叫 Echo 程序,參數為 Hello,請求 ID 為 10,你會傳送:

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

回應只會包含請求 ID 和字串傳回值。

在開始之前,我們需要熟悉 Eio 用於其建置區塊的一些術語:

  • Fiber - 一個執行緒。不是系統執行緒!
  • Switch - 將多個 fiber 群組在一起的東西。有點像 Go 中的 sync.WaitGroup
  • Flow - 你可以讀取或寫入的東西
  • Stream - 一個有界、執行緒安全的佇列。就像 Go 的 channel
  • 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 上的操作是直接在底層資源(socket、檔案等)上執行。幸運的是,這些緩衝讀取器/寫入器有實用的輔助函式可以用來讀取/寫入型別,我們在這裡使用它們來傳送/接收請求 ID。

read_string/write_string 來自 Utils

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

接下來我們只需要一個輔助函式來取得緩衝 flow 並執行循環:

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⏎

太好了!

客戶端

對於客戶端,讓我們更進階一點,讓它可以安全地併發使用。為此,我們需要確保在任何時間只有一個程序呼叫被寫入 socket。這將使用兩個 fiber 來完成:一個用於讀取,一個用於寫入。寫入的 fiber 會從一個 stream 中取得工作,當方法被呼叫時,我們會將工作加入 stream:

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;

但我們如何將回應傳回給呼叫者呢?為此,我們會在 stream 上傳送一個 promise,並將其存放在雜湊表中,鍵值為請求 ID。當伺服器回應時,可以稍後解析它。注意在 Eio 中,promise 實際上被分成兩半——等待的一方(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

我們可以像這樣使用它,從多個不同的 fiber:

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 很有趣,也很容易使用。它處於多個甜蜜點——函數式程式設計,但你總是可以做命令式程式設計;已編譯但編譯器快速;在例外與錯誤值之間做選擇等。它的使用者群體相當小,我認為這很明顯。如何入門並不明確,而且沒有太多程式碼可以研究。

Eio 對任何使用過 Go 的人來說都會感到熟悉。在使用它時,我確實一直被 flow 被關閉的問題絆倒,後來發現是因為我應該阻塞時卻啟動了 fiber(就像在 run_server 的回呼中那樣)。

希望我很快就能帶著可運作的 Raft 實作回來。