体验 OCaml 与 Eio
Taking OCaml and Eio for a Spin

原始链接: https://mattjhall.co.uk/posts/taking-ocaml-eio-for-a-spin.html

这篇博文讲述了作者学习 OCaml 的经历。作者认为 OCaml 巧妙地平衡了函数式和命令式编程风格,令人乐在其中。虽然作者指出 OCaml 的语法有时稍显冗长,且编译器反馈循环不够迅速,但仍对其强大的实用性以及近期多核支持等工具带来的发展势头大加赞赏。 关于学习建议,作者推荐先从 CS3110 课程入手,通过实践练习打好基础,然后再深入阅读《Real World OCaml》。 作者此次探索的核心驱动力是 Eio 并发框架。为了评估该框架,作者构建了一个简化的远程过程调用(RPC)客户端和服务器。作者重点介绍了该框架对“效果”(effects)的应用,以及它与 Go 语言并发原语(纤程、流和承诺)的相似之处。尽管在纤程生命周期管理上遇到了一些小挑战,作者依然认为 OCaml 与 Eio 的组合极具吸引力,并计划通过实现 Raft 一致性算法来继续完善该项目。

这篇 Hacker News 帖子讨论了编译器错误处理在实际应用中的挫败感,特别是指 OCaml。原作者指出,OCaml 编译器在遇到错误后会停止处理,这拖慢了开发周期。 评论者们讨论了“批量修复错误”的可行性。一位用户认为,编译器很难在出错后进行有效的恢复以提供后续的有用信息,因此逐个修复错误并重新编译更为高效。虽然其他人认为理论上编译器应该能够绕过错误来检查其他函数,但由于技术限制(例如难以确定格式错误的函数体在何处结束),这往往不切实际。 这场讨论突显了对复杂错误报告的渴望与编译器设计现实之间的分歧。虽然 Biome 等现代工具利用基于 AST 的方法来更好地处理损坏的代码,但许多开发者仍然依赖 OCaml 等编译器的高速编译,以在错误恢复有限的情况下保持快速的迭代循环。
相关文章

原文

My first blog post on this website was about Haskell. I've always been interested in functional programming but the complexity of Haskell never seemed worth.

I have been vaguely aware of OCaml for a long time but never dipped my toes in. It has been having a bit of a renaissance recently with multicore support, effects and a more systems focussed spin called OxCaml from Jane Street. Now seems a great time to learn it and I was curious about Eio, a concurrency framework. It uses the aforementioned effects but more interesting to me is it is meant to be deterministic, which could be helpful for testing.

Books & Tutorials

The first thing to do is pick a tutorial or a book. A quick google search later I had found Real World OCaml. Assuming that it would be similar to Real World Haskell (which I enjoyed and was very hands on) and assuming I already had sufficient FP chops, I dived in.

Impatient to look at syntax and semantics I skipped over the more prosey Prologue. If I had read it I would have been told that the book chooses to use Jane Street's Base standard library replacement. It seems that historically the real standard library was quite anemic, only intended to cover the needs of the compiler. This is no longer true and it has slowly been growing. Still, it doesn't seem unreasonable to me that a book called Real World OCaml use the libraries from the biggest real world user of the language.

Anyway, I surely learnt my lesson to read things more carefully and went back to the beginning to start again. The first half of the book is well written and I happily followed along. I started to get lost around GADTs and first class modules though. I think the book lacks for exercises, so once something unfamilar comes up you don't have the full grasp of the basics to bail you out.

I went searching for something a bit easier and found CS3110 which has both videos and text to suit all tastes. It also had exercises which helped a lot. I would suggest to people that they start with this course and then graduate onto RWO for the more advanced features. I like RWO although if you are looking for a project based book like RWH, you'll be disappointed.

How It Feels In The Hand

Overall OCaml is very enjoyable to write code in. It has a nice mix of functional and imperative programming. For example, to loop through a list you are either using a recursive function or List.map and friends:

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"];


but you still have mutable variables and the like if you need them:

type client = { mutable request_id : int }

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

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


I will say that the syntax feels quite wordy, e.g. the let .. in, match .. with constructs. I think syntax is the least interesting part of a programming language to me - I was happy even back in the days where Rust had sigils - so it doesn't bother me too much. Having named arguments (with the ~) is very nice.

The compiler bothers me more. When it encounters an error it seems to give up on the rest of the file. This makes the iteration loop quite slow - write code, get an error, fix the error, rebuild. There's very little chance to fix errors in bulk, especially if you're focussed on one module.

Eio

Eio is an effects-based IO concurrency library. It's relatively new but it has some interesting features such as io_uring support. Ultimately my goal is to write an implementation of the Raft consensus algorithm and try test it.

The Raft paper says to use remote procedure calls (RPCs) to communicate between the servers. A dumb RPC client/server seems like a great first project. Let's come up with a very simple protocol: a procedure call has a request id (int), a name (string) and a single argument (string). Integers are a single byte and a string is serialised as a byte for the length and then the data. As an example, to call the Echo procedure with Hello as request 10, you would send:

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

A response will just be the request id and the string return value.

Before we start, we need to familiarise ourselves with some jargon Eio uses for its building blocks:

  • Fiber - a thread of execution. Not a system thread though!
  • Switch - something that groups fibers together. A bit like a sync.WaitGroup in Go
  • Flow - something you can read or write to
  • Stream - a bounded, thread safe queue. Like a Go channel
  • Promise - I'm sure you know this one.

Starting with the server we will need to take a Flow and loop, reading procedure calls off it, invoking them and sending a response:

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;

  
  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

We define a type to hold a connection, as a server can have many of them. Note that we aren't using the flow directly here, instead we are using Buf_read or Buf_write types. That's so we can use buffered reading/writing and be more efficient. Operations on a Flow are done directly on the underlying resource (socket, file etc). Happily these buffered reader/writers have useful helper functions for reading/writing types which we use here to send/receive the request id.

read_string/write_string come from 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

Next we just need a helper to get the buffered flows and run the loop:

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}

and a test executable:

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)

and we're ready to test it:

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

Nice!

Client

For the client let's get a bit more fancy and make it safe to use concurrently. To do that we need to make sure that we are only writing a single procedure call to the socket at any one time. This will be done using two fibers: one for reading and one for writing. The writing one will take its work off a stream that we add to when a method is invoked:

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

type t = {
  mutable request_id : int;
  writeq : request Eio.Stream.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
  write_request request;
  send_loop cli w

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

But how do we get the response back to the caller? To do that we will send a promise on our stream as well and store it away in a hash table, with the key being the request id. This can be resolved later when the server has responded. Note that in Eio promises are actually split into two halves - the side that waits (Promise.t) and the side that resolves (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

The receive loop can then just read responses and resolve promises:

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

We can use it like so, from multiple different fibers:

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

and see that it works:

> ./_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

Concluion

Overall OCaml has been fun to learn and easy to use. It sits at a number of sweet spots - FP but you can always do imperative, compiled but fast compiler, a choice between exceptions and errors as velues etc. It has quite a small userbase and I think it shows. It's not clear what is the best way to get started and there's not that much code out there to study.

Eio will feel familar to anyone who has used Go. Whilst hacking around with it I did keep getting tripped up with flows being closed which turned out to be because I was spinning up a fiber when I should have blocked (like in the callback to run_server).

Hopefully I'll be back soon with a working Raft implementation.

联系我们 contact @ memedata.com