Skip to content

High CPU usage with Windows backend for a two-way copy workload #837

@z-rui

Description

@z-rui

I was using eio to implement a proxy. The core logic is basically a two-way copy:

Fiber.both
  (fun () -> Eio.Flow.copy client_flow server_flow)
  (fun () -> Eio.Flow.copy server_flow client_flow)

I noticed that on Windows, the CPU usage is high when client keeps the connection open (typical behavior for a long-live TCP connection such as HTTP/1.1). I do not repro the same high CPU usage on Linux, so I suspect this is an issue with the Windows backend.

With the help of LLM, I was able to concentrate it into a MWE that demonstrates this behavior. This MWE spawns 3 fibers: a server that receives bytes and does not respond; a proxy that does the above; and a client that sends a few bytes and keeps the connection open. This program, on its own, uses about 100% single-core on Windows, whereas on Linux the CPU usage is negligible. If it is split into 3 individual programs, then only the process that runs the two-way copy uses 100% single-core.

MWE

open Eio.Std

let server_port = 1234
let proxy_port = 1235

(* --- 1. Mock Server ---
   Just accepts connections and reads data until EOF. *)
let run_server net =
  Eio.Switch.run @@ fun sw ->
  let addr = `Tcp (Eio.Net.Ipaddr.V4.loopback, server_port) in
  let socket = Eio.Net.listen ~sw net addr ~backlog:10 ~reuse_addr:true in
  traceln "[Server] Listening on 127.0.0.1:%d" server_port;
  Eio.Net.run_server socket ~on_error:(fun e -> traceln "[Server] Error: %a" Eio.Exn.pp e)
  @@ fun flow _addr ->
  traceln "[Server] Connection accepted.";
  try
    let buf = Cstruct.create 4096 in
    while true do
      let got = Eio.Flow.single_read flow buf in
      traceln "[Server] Received %d bytes." got
    done
  with End_of_file -> traceln "[Server] Client disconnected."

(* --- 2. The Proxy (The component that triggers the bug) --- *)
let run_proxy net =
  Eio.Switch.run @@ fun sw ->
  let addr = `Tcp (Eio.Net.Ipaddr.V4.loopback, proxy_port) in
  let socket = Eio.Net.listen ~sw net addr ~backlog:10 ~reuse_addr:true in
  traceln "[Proxy] Listening on 127.0.0.1:%d, forwarding to %d" proxy_port server_port;
  Eio.Net.run_server socket ~on_error:(fun e -> traceln "[Proxy] Error: %a" Eio.Exn.pp e)
  @@ fun client_flow _addr ->
  traceln "[Proxy] Connection received, connecting to server...";
  try
    Eio.Net.with_tcp_connect net ~host:"127.0.0.1" ~service:(string_of_int server_port) @@ fun server_flow ->
    traceln "[Proxy] Connected to server. Starting bidirectional relay.";

    (* Using Fiber.both and Eio.Flow.copy triggers the bug when connections
       are kept open but stuck in specific read/write waiting states on Windows. *)
    Fiber.both
      (fun () ->
        try Eio.Flow.copy client_flow server_flow
        with e -> traceln "[Proxy] Client -> Server stopped: %a" Eio.Exn.pp e;
        try Eio.Flow.shutdown server_flow `Send with _ -> ())
      (fun () ->
        try Eio.Flow.copy server_flow client_flow
        with e -> traceln "[Proxy] Server -> Client stopped: %a" Eio.Exn.pp e;
        try Eio.Flow.shutdown client_flow `Send with _ -> ())
  with e ->
    traceln "[Proxy] Session failed: %a" Eio.Exn.pp e

(* --- 3. Mock Client ---
   Connects to the proxy, sends data, and then KEEPS THE CONNECTION OPEN. *)
let run_client net clock =
  Eio.Switch.run @@ fun _sw ->
  Eio.Time.sleep clock 0.5; (* Wait for server and proxy to start *)
  traceln "[Client] Connecting to proxy at 127.0.0.1:%d..." proxy_port;
  Eio.Net.with_tcp_connect net ~host:"127.0.0.1" ~service:(string_of_int proxy_port) @@ fun flow ->
  traceln "[Client] Connected. Sending data...";
  Eio.Flow.copy_string "Hello, keeping the connection open!" flow;
  traceln "[Client] Data sent. Now keeping the connection open and waiting indefinitely...";

  (* Block forever reading, simulating a client waiting for a response
     or just holding a keep-alive connection open. *)
  try
    let buf = Cstruct.create 4096 in
    while true do
      let got = Eio.Flow.single_read flow buf in
      traceln "[Client] Received %d bytes from server." got
    done
  with End_of_file -> traceln "[Client] Server closed connection."
  | e -> traceln "[Client] Connection error: %a" Eio.Exn.pp e

(* --- Main --- *)
let () =
  Eio_main.run @@ fun env ->
  let net = Eio.Stdenv.net env in
  let clock = Eio.Stdenv.clock env in

  traceln "=========================================================";
  traceln " Eio Windows High CPU MWE (Minimal Working Example)";
  traceln "=========================================================";
  traceln "This program runs a Server, a Proxy, and a Client concurrently.";
  traceln "The client sends data and KEEPS the connection open.";
  traceln "Observe the CPU usage. On Windows with eio_windows, the proxy";
  traceln "shows 100%% core utilization.";
  traceln "=========================================================";

  Eio.Switch.run @@ fun sw ->

  Fiber.fork ~sw (fun () -> run_server net);
  Fiber.fork ~sw (fun () -> run_proxy net);
  Fiber.fork ~sw (fun () -> run_client net clock);

  (* Keep the program alive forever to observe CPU *)
  Eio.Time.sleep clock Float.infinity

Environment:

# Name      # Installed # Synopsis
eio         1.3         Effect-based direct-style IO API for OCaml
eio_main    1.3         Effect-based direct-style IO mainloop for OCaml
eio_windows 1.3         Eio implementation for Windows
ocaml       5.4.1       The OCaml compiler (virtual package)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions