One API for NNG and ZeroMQ — swap messaging backends without changing application code.
Like ActiveRecord abstracts databases, Nanowire abstracts messaging libraries.
- Migrating from ZMQ to NNG (or vice-versa) — change one line (
Nanowire.backend = :nng), not every socket call in your codebase. - Hedging your bets — evaluate both backends under real workloads before committing to one.
- Writing libraries — gem authors can offer messaging support without forcing a backend choice on users.
- Teaching — demonstrate messaging patterns once, run them on either stack.
NNG and ZMQ use incompatible wire protocols — they cannot talk to each other. Nanowire unifies the Ruby API, not the wire format.
You need at least one backend installed:
# NNG backend
gem install nng # + system: apt install libnng-dev / brew install nng
# ZMQ backend
gem install cztop # + system: apt install libczmq-dev / brew install czmqThen add Nanowire:
gem install nanowire
# or in Gemfile
gem 'nanowire'require 'nanowire'
# Pick one:
Nanowire.backend = :nng # in code
# ENV['NANOWIRE_BACKEND'] = 'zmq' # via environment
# Nanowire::Req.new(backend: :zmq) # per socketrequire 'nanowire'
require 'async'
Sync do |task|
rep = Nanowire::Rep.new
rep.listen('tcp://127.0.0.1:5555')
req = Nanowire::Req.new
req.dial('tcp://127.0.0.1:5555')
task.async do
msg = rep.receive
rep.send("re: #{msg.body}")
end
req.send('hello')
puts req.receive.body # => "re: hello"
endSync do |task|
pub = Nanowire::Pub.new
pub.listen('ipc:///tmp/pubsub.sock')
weather = Nanowire::Sub.new(prefix: 'weather.')
weather.dial('ipc:///tmp/pubsub.sock')
sleep 0.05
task.async do
pub.send('weather.rain')
pub.send('sports.goal')
end
puts weather.receive.body # => "weather.rain"
# "sports.goal" is filtered out
endRuntime topic management:
sub.subscribe('sports.')
sub.unsubscribe('weather.')Sync do |task|
pull = Nanowire::Pull.new
pull.listen('tcp://127.0.0.1:5556')
push = Nanowire::Push.new
push.dial('tcp://127.0.0.1:5556')
task.async { push.send('work item') }
puts pull.receive.body # => "work item"
endSync do |task|
a = Nanowire::Pair.new
a.listen('ipc:///tmp/pair.sock')
b = Nanowire::Pair.new
b.dial('ipc:///tmp/pair.sock')
task.async { a.send('ping') }
puts b.receive.body # => "ping"
task.async { b.send('pong') }
puts a.receive.body # => "pong"
end| Method | Description |
|---|---|
#listen(url) |
Bind/listen on an address (returns self) |
#dial(url) |
Connect to an address (returns self) |
#send(data) |
Send a string (returns self) |
#receive |
Receive a Nanowire::Message |
#close |
Close the socket |
req.recv_timeout = 5.0 # seconds (Float), nil = no timeout
req.send_timeout = 2.0req.wait_readable(timeout) # poll for readability
req.wait_writable(timeout) # poll for writabilityBoth backends support FD-based IO waiting for integration with async schedulers.
msg = req.receive
msg.body # => String (binary-safe)
msg.to_s # => alias for body
msg.to_str # => implicit String coercion (works with interpolation, puts, etc.)req.native # => NNG::Socket::Req0 or CZTop::Socket::REQDrop down to the underlying socket when you need backend-specific features like TLS, raw mode, or ROUTER/DEALER.
| Nanowire | NNG | ZMQ |
|---|---|---|
Nanowire::TimeoutError |
NNG::Error::TimedOut |
IO::TimeoutError |
Nanowire::ClosedError |
NNG::Error::ObjectClosed |
SystemCallError |
Nanowire::StateError |
NNG::Error::IncorrectState |
ArgumentError |
| Pattern | Classes | Direction |
|---|---|---|
| Request/Reply | Req, Rep |
bidirectional |
| Publish/Subscribe | Pub, Sub |
unidirectional |
| Pipeline | Push, Pull |
unidirectional |
| Exclusive pair | Pair |
bidirectional |
Some features are too different across backends or exist in only one:
- ROUTER/DEALER (ZMQ) vs raw mode (NNG)
- TLS, pipe introspection,
abstract://(NNG-only) - XPUB/XSUB, STREAM (ZMQ-only)
- Survey/Respondent, Bus (NNG-only)
NNG vs ZMQ through the Nanowire abstraction layer. Ruby 4.0.1, benchmark-ips (warmup: 1, time: 3):
| ipc | tcp | |
|---|---|---|
| NNG async | 11.5k | 7.3k |
| ZMQ async | 12.1k | 12.0k |
| NNG threads | 11.4k | 6.2k |
| ZMQ threads | 13.4k | 9.9k |
| ipc | tcp | |
|---|---|---|
| NNG async | 263 µs | 246 µs |
| ZMQ async | 147 µs | 156 µs |
| NNG threads | 117 µs | 173 µs |
| ZMQ threads | 200 µs | 222 µs |
ZMQ has higher throughput (especially for larger messages). NNG wins threaded latency. See bench/ for full results and scripts.
Switching a cztop codebase to Nanowire:
# Before (ZMQ-specific)
req = CZTop::Socket::REQ.new
req.connect('tcp://127.0.0.1:5555')
req << 'hello'
parts = req.receive # => Array<String>
puts parts.last
# After (backend-agnostic)
req = Nanowire::Req.new # backend: :zmq or :nng
req.dial('tcp://127.0.0.1:5555')
req.send('hello')
msg = req.receive # => Nanowire::Message
puts msg.bodyKey differences:
bind/connectbecomelisten/dial(NNG terminology, clearer semantics)<<becomes#send(no operator overloading)receivereturns aMessageobject, notArray<String>- Timeouts are always in seconds (
Float), never milliseconds
Nanowire ships with NNG and ZMQ adapters, but the adapter interface is a simple duck type — any class that implements initialize, listen, dial, send, receive, close, timeout accessors, and wrap_error can be registered:
Nanowire::Adapter.register(:my_backend, MyAdapter)
sock = Nanowire::Req.new(backend: :my_backend)The landscape of broker-less messaging libraries with ZMQ-style scalability protocols is small (realistically: ZMQ, NNG, and nanomsg classic), so this is less about a plugin ecosystem and more about keeping the door open — for in-process test fakes, for nanomsg legacy codebases, or for whatever comes next.
bundle install
bundle exec rake test # runs against both backends
bundle exec ruby bench/async/latency.rb