{"id":50897641,"url":"https://github.com/vladbpython/hel","last_synced_at":"2026-06-16T01:31:10.828Z","repository":{"id":363538101,"uuid":"1088941214","full_name":"vladbpython/hel","owner":"vladbpython","description":"High-performance channel that unifies blocking and async synchronization within a single architecture","archived":false,"fork":false,"pushed_at":"2026-06-09T09:24:23.000Z","size":939,"stargazers_count":7,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-09T11:10:58.249Z","etag":null,"topics":["async","blocking","channels","latency","mpmc","rust","spcs"],"latest_commit_sha":null,"homepage":"","language":"Rust","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/vladbpython.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2025-11-03T17:04:11.000Z","updated_at":"2026-06-09T09:31:21.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/vladbpython/hel","commit_stats":null,"previous_names":["vladbpython/hel"],"tags_count":3,"template":false,"template_full_name":null,"purl":"pkg:github/vladbpython/hel","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vladbpython%2Fhel","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vladbpython%2Fhel/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vladbpython%2Fhel/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vladbpython%2Fhel/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/vladbpython","download_url":"https://codeload.github.com/vladbpython/hel/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/vladbpython%2Fhel/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34387472,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-15T02:00:07.085Z","response_time":63,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["async","blocking","channels","latency","mpmc","rust","spcs"],"created_at":"2026-06-16T01:31:10.265Z","updated_at":"2026-06-16T01:31:10.810Z","avatar_url":"https://github.com/vladbpython.png","language":"Rust","funding_links":[],"categories":[],"sub_categories":[],"readme":"# HEL — Hybrid Event Link\n\nA high-performance sharded bounded MPMC/SPSC channel library for Rust with unified sync and async API.\n\nI was inspired by:\n\n - Crossbeam\n\n - Flume\n\n## Why HEL?\n\nHEL is designed around multi-core hardware.\n\nMost channel libraries make you choose: sync **or** async. hel gives you both on the same channel — a sync thread and an async task can share the same channel without bridges or wrappers.\n\nhel is also sharded. Instead of one ring buffer shared by all producers, hel creates N independent ring buffers. Producers route messages by key or round-robin. This eliminates the main bottleneck of classic MPMC channels: contention on a single tail pointer.\n\n### Version\n\n- Current version is - `1.1.0`\n- Changes are documented in `change_log/v{version}.md`\n\n## Benchmark results — X86 (MACOS)\n\n\u003e Benchmarks use 1 000 000 messages per run; payload is a Binance trade tick (Copy struct, \u0026'static str fields, ~80 B). Higher is better.\n\n### sync_mpmc\n\n![intel_mac_sync_mpmc](images/intel_mac_sync_mpmc.png)\n\n### async_sharded\n\n![intel_mac_async_mpmc](images/intel_mac_async_mpmc.png)\n\n### scaling\n\n![intel_mac_async_spsc_scaling](images/intel_mac_async_spsc_scaling.png)\n\n### batch\n\n![intel_mac_async_batch](images/intel_mac_async_batch.png)\n\n\n## Benchmark results — X86 (Ubuntu 26.04)\n\n\u003e Benchmarks use 1 000 000 messages per run; payload is a Binance trade tick (Copy struct, \u0026'static str fields, ~80 B). Higher is better.\n\n### sync_mpmc\n\n![intel_linux_sync_mpmc](images/intel_linux_sync_mpmc.png)\n\n### async_sharded\n\n![intel_linux_async_mpmc](images/intel_linux_async_mpmc.png)\n\n### scaling\n\n![intel_linux_async_spsc_scaling](images/intel_linux_async_spsc_scaling.png)\n\n### batch\n\n![intel_linux_async_batch](images/intel_linux_async_batch.png)\n\n\n\n## Benchmark results — ARM (MACOS)\n\n\u003e Benchmarks use 1 000 000 messages per run; payload is a Binance trade tick (Copy struct, \u0026'static str fields, ~80 B). Higher is better.\n\n### sync_mpmc\n\n![arm_mac_sync_mpmc](images/arm_mac_sync_mpmc.png)\n\n### async_sharded\n\n![arm_mac_async_mpmc](images/arm_mac_async_mpmc.png)\n\n### scaling\n\n![arm_mac_async_spsc_scaling](images/arm_mac_async_spsc_scaling.png)\n\n### batch\n\n![arm_mac_async_batch](images/arm_mac_async_batch.png)\n\n## Channel types\n\n### `round_robin` — even load distribution\n\n```rust\nlet (tx, rx) = round_robin::\u003cu64, 256\u003e(num_shards);\n```\n\nEach message goes to the next shard in sequence. No key needed.\n\n**Use when:** workers are stateless — HTTP handlers, log processors, task queues.\n\n### `shard_key` — ordering per key\n\n```rust\nlet (tx, rx) = shard_key::\u003cu64, 256\u003e(num_shards);\ntx.send(\"AAPL\", value)?;\n```\n\nRoutes by `hash(key) % num_shards`. The same key always reaches the same consumer.\n\n**Use when:** order per entity matters — trading symbols, user sessions, actor systems.\n\n### `shard_spsc` — sharded SPSC\n\n```rust\nlet ch = shard_spsc::\u003cu64, 256\u003e(num_shards);\nlet (shard_id, tx, rx) = ch.into_pairs().next().unwrap();\n```\n\nN independent SPSC channels. No shared state between shards at all.\n\n**Use when:** you know the routing upfront — sensor streams, per-thread pipelines.\n\n## API at a glance\n\n```rust\n// Sync\ntx.send(value)?;\ntx.send_timeout(value, Duration::from_millis(100))?;\nlet v = rx.recv()?;\n\n// Async\ntx.send_async(value).await?;\nlet v = rx.recv_async().await?;\n\n// Batch (1.5–2× faster than per-element)\ntx.send_batch(\u0026mut buf)?;\nrx.recv_batch(\u0026mut buf, 64);\n\n// Non-blocking\ntx.try_send(value)?;\nrx.try_recv()?;\n```\n\nSync and async work on the same channel simultaneously — no conversion needed.\n\n## Real world use cases\n\n### Trading Risk Manager\n\nRisk manager receives all orders from all strategies and checks limits before they reach the matching engine. Every order must be checked in symbol order — no two orders for the same symbol processed simultaneously.\n\n\n![trading_risk_manager_flow](images/trading_risk_manager_flow.png)\n\n```rust\n// Orders keyed by symbol → same symbol always hits same risk worker\n// Risk worker checks position limits, exposure, drawdown synchronously\nlet (order_tx, order_rx) = shard_key::\u003cOrder, 1024\u003e(8);\n\n// Risk workers — sync, CPU-bound limit checking\nlet workers: Vec\u003c_\u003e = order_rx.into_receivers()\n    .into_iter()\n    .map(|rx| thread::spawn(move || {\n        loop {\n            match rx.recv() {\n                Ok(order) =\u003e {\n                    if risk_check(\u0026order) {\n                        matching_engine_tx.send(order).unwrap();\n                    }\n                }\n                Err(RecvError::Disconnected) =\u003e break,\n                Err(RecvError::TimeOut(_)) =\u003e unreachable!(),\n            }\n        }\n    }))\n    .collect();\n\n// Strategy tasks — async, send orders with symbol as key\ntokio::spawn(async move {\n    let order = Order { symbol: \"AAPL\", qty: 100, price: 185.50 };\n    order_tx.send_async(order.symbol, order).await?;\n});\n```\n\n### Trading Matching Engine Core\n\nMatching engine receives pre-validated orders. Each symbol has its own order book — maintained by one dedicated thread with no locking. Orders for the same symbol are always serialized.\n\n![matching_engine_flow](images/matching_engine_flow.png)\n\n```rust\n// Inbound: orders routed by symbol to book workers\nlet (inbound_tx, inbound_rx) = shard_key::\u003cOrder, 512\u003e(num_shards);\n\n// Outbound: each book worker has its own SPSC to execution report handler\nlet exec_reports = shard_spsc::\u003cExecutionReport, 256\u003e(num_shards);\n\nlet handles: Vec\u003c_\u003e = inbound_rx\n    .into_receivers()\n    .into_iter()\n    .zip(exec_reports.into_pairs())\n    .map(|(order_rx, (_, report_tx, report_rx))| {\n        // Order book worker — owns one symbol group, zero lock contention\n        let worker = thread::spawn(move || {\n            let mut book = OrderBook::new();\n            loop {\n                match order_rx.recv() {\n                    Ok(order) =\u003e {\n                        let fills = book.process(order);\n                        for fill in fills {\n                            report_tx.send(fill).unwrap();\n                        }\n                    }\n                    Err(RecvError::Disconnected) =\u003e break,\n                    Err(_) =\u003e unreachable!(),\n                }\n            }\n        });\n\n        // Execution report consumer — async, sends fills to portfolio manager\n        let reporter = tokio::spawn(async move {\n            loop {\n                match report_rx.recv_async().await {\n                    Ok(report) =\u003e portfolio_tx.send_async(report).await.unwrap(),\n                    Err(AsyncRecvError::Disconnected) =\u003e break,\n                }\n            }\n        });\n\n        (worker, reporter)\n    })\n    .collect();\n```\n\n### Event-Driven System\n\nA typical event-driven backend: ingestion layer receives raw events, routes them to typed processors, processed results fan out to subscribers.\n\n![event_driven_flow](images/event_driven_flow.png)\n\n```rust\n#[derive(Debug)]\nenum Event {\n    Order(OrderEvent),\n    Payment(PaymentEvent),\n    User(UserEvent),\n}\n\n// Route events by type — each processor handles one event kind\nlet (event_tx, event_rx) = shard_key::\u003cEvent, 512\u003e(4);\n\n// Processors — async, one per event type\nlet processors: Vec\u003c_\u003e = event_rx.into_receivers()\n    .into_iter()\n    .enumerate()\n    .map(|(id, rx)| tokio::spawn(async move {\n        loop {\n            match rx.recv_async().await {\n                Ok(Event::Order(e)) =\u003e process_order(e).await,\n                Ok(Event::Payment(e)) =\u003e process_payment(e).await,\n                Ok(Event::User(e)) =\u003e process_user(e).await,\n                Err(AsyncRecvError::Disconnected) =\u003e break,\n            }\n        }\n    }))\n    .collect();\n\n// Notifiers — round_robin, stateless fan-out to N notification workers\nlet (notify_tx, notify_rx) = round_robin::\u003cNotification, 256\u003e(4);\n\nlet notifiers: Vec\u003c_\u003e = notify_rx.into_receivers()\n    .into_iter()\n    .map(|rx| tokio::spawn(async move {\n        loop {\n            match rx.recv_async().await {\n                Ok(n) =\u003e send_notification(n).await,\n                Err(AsyncRecvError::Disconnected) =\u003e break,\n            }\n        }\n    }))\n    .collect();\n\n// Ingester — reads from WebSocket, routes events by type string\ntokio::spawn(async move {\n    while let Some(raw) = ws_stream.next().await {\n        let event: Event = parse(raw);\n        let key = event.type_key(); // \"order\" | \"payment\" | \"user\"\n        event_tx.send_async(key, event).await.unwrap();\n    }\n});\n```\n\n### Game Engine\n\nhel fits game engines well for inter-system communication on multi-core hardware. Each game system (physics, AI, audio, rendering) runs on its own core and communicates via channels instead of shared mutable state.\n\n![game_engine_flow](images/game_engine_flow.png)\n\n\n```rust\n// Main loop → physics: SPSC, no contention, maximum throughput\nlet physics_ch  = SpscShard::\u003cPhysicsCmd, 512\u003e::new(1);\nlet ai_ch = SpscShard::\u003cAiCmd, 256\u003e::new(1);\nlet audio_ch = SpscShard::\u003cAudioCmd, 256\u003e::new(1);\n\n// Main loop → render workers: round_robin across N render threads\nlet (render_tx, render_rx) = round_robin::\u003cDrawCmd, 1024\u003e(num_render_threads);\n\nlet (_, physics_tx, physics_rx) = physics_ch.into_pairs().next().unwrap();\nlet (_, ai_tx, ai_rx) = ai_ch.into_pairs().next().unwrap();\nlet (_, audio_tx, audio_rx) = audio_ch.into_pairs().next().unwrap();\n\n// Physics worker — dedicated core, sync, deterministic timing\nthread::spawn(move || {\n    loop {\n        match physics_rx.recv_timeout(Duration::from_millis(16)) {\n            Ok(cmd) =\u003e physics_world.apply(cmd),\n            Err(RecvError::TimeOut(_)) =\u003e physics_world.step(FIXED_DT),\n            Err(RecvError::Disconnected) =\u003e break,\n        }\n    }\n});\n\n// AI worker — dedicated core, can use async for pathfinding futures\ntokio::spawn(async move {\n    loop {\n        match ai_rx.recv_async().await {\n            Ok(AiCmd::Think { entity }) =\u003e {\n                let decision = pathfinder.find(entity).await;\n                physics_tx.send(PhysicsCmd::Move(decision)).unwrap();\n            }\n            Err(AsyncRecvError::Disconnected) =\u003e break,\n        }\n    }\n});\n\n// Audio worker — sync, low latency\nthread::spawn(move || {\n    loop {\n        match audio_rx.recv() {\n            Ok(cmd) =\u003e audio_mixer.play(cmd),\n            Err(RecvError::Disconnected) =\u003e break,\n            Err(_) =\u003e break,\n        }\n    }\n});\n\n// Main loop — send commands each frame\nloop {\n    physics_tx.try_send(PhysicsCmd::Update(state)).ok();\n    ai_tx.try_send(AiCmd::Think { entity: player }).ok();\n    render_tx.send(DrawCmd::Frame(scene.snapshot())).unwrap();\n    frame_timer.wait_next();\n}\n```\n\n\u003e **Note:** for game engines with strict frame budgets, `try_send` is often preferable over blocking `send` — drop the message if the worker is behind rather than stalling the main loop.\n\n**Rule of thumb:**\n```\nnum_shards = num_producers = num_consumers = num_physical_cores / 2\n```\n\nEach shard runs on its own core with no shared state between shards. This means:\n\n- No cache line bouncing between cores for the common case\n- ARM `fetch_add` lets all producers write simultaneously\n- SPSC shards have zero atomic overhead between producer and consumer\n\n**Apple M2 Max (12 P-cores):**\n```\n8 shards → best throughput for MPMC (8 producers, 8 consumers)\n16 shards → best for SPSC scaling (349 M/s measured)\n```\n\n**Intel i9 8-core (16 logical):**\n```\n4 shards → best MPMC (1 producer per physical core, no HT contention)\n8 shards → acceptable, slight HT overhead\n```\n\n**General guidance:**\n```\nnum_shards \u003e num_physical_cores → context switch overhead, latency spikes\nnum_shards \u003c num_producers → contention on shared shards\nnum_shards = num_producers → optimal, each producer owns a shard\n```\n\n## Choosing the right channel\n\n| Use case                              | Channel         | Why |\n|---------------------------------------|-----------------|-----|\n| Task queue, stateless workers         | `round_robin`   | Even load, no key needed |\n| Trading, actors, sessions by key      | `shard_key`     | Ordering per entity |\n| Sensor streams, per-thread pipelines  | `SpscShard`     | Zero contention |\n| Physics / AI / audio in game engine   | `SpscShard`     | Dedicated core per system |\n| Event routing by type                 | `shard_key`     | Type as routing key |\n| Fan-out to N notification workers     | `round_robin`   | Stateless, even distribution |\n| Sync thread → async task pipeline     | any hel channel | Mix freely |\n| Async task → sync worker pipeline     | any hel channel | Mix freely |\n\n## Limitations\n\n**`num_shards` must be a power of two.** The ring buffer uses bitmasking for slot indexing.\n\n**`shard_key` does not guarantee even load.** Distribution depends on `hash(key) % num_shards`. With few unique keys, some shards may be empty. Use `round_robin` when even distribution matters more than ordering.\n\n**Bounded only.** No unbounded channel. Capacity is a compile-time constant. This is intentional — bounded channels make backpressure visible.\n\n**No native multi-channel select.** Fan-in via `recv_any()` works but at lower throughput than dedicated consumers.\n\n**`send_timeout` on ARM falls back to CAS.** The `fetch_add` optimization only applies to `send()` without a deadline.\n\n## When NOT to use hel\n\n- You need an **unbounded** queue → use `flume` or `async-channel`\n- You need **broadcast** (one message to all receivers) → use `tokio::broadcast`\n- You have **1–2 producers** with no real contention → `crossbeam` or `std::sync::mpsc` are simpler\n- You need **select over multiple channels** natively → `tokio::sync::mpsc` integrates better with `tokio::select!`\n\n## Quick start\n\n```rust\nuse hel::channel::mpmc::round_robin;\nuse hel::channel::errors::*;\n\nlet (tx, rx) = round_robin::\u003cString, 256\u003e(4);\n\nfor r in rx.into_receivers() {\n    tokio::spawn(async move {\n        loop {\n            match r.recv_async().await {\n                Ok(msg) =\u003e println!(\"got: {msg}\"),\n                Err(AsyncRecvError::Disconnected) =\u003e break,\n            }\n        }\n    });\n}\n\nstd::thread::spawn(move || {\n    for i in 0..1000 {\n        tx.send(format!(\"msg-{i}\")).unwrap();\n    }\n});\n```\n\n## Capacity guidelines\n\n```\nCAP = 128 — low latency, small bursts\nCAP = 256 — general purpose ← good default\nCAP = 1024 — batch workloads, large producers\nCAP = 4096 — high-throughput pipelines\n```\n\n`CAP` must be a power of two. Larger capacity reduces contention under load but uses more memory per shard: `size = CAP × sizeof(T) × num_shards`.\n\n## Target Environment\nhel is optimized for **bare metal and dedicated CPU** deployments.\nFor best results:\n- num_shards ≤ num_physical_cores\n- Dedicated CPU cores (no overcommit)\n- Linux with NUMA awareness or macOS\n\nFor cloud VMs with shared vCPUs, consider crossbeam or flume.","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fvladbpython%2Fhel","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fvladbpython%2Fhel","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fvladbpython%2Fhel/lists"}