diff --git a/README.md b/README.md index b5e0a163c..1609f32a8 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,18 @@

Quickstart · - Examples + Cheatsheet + · + Examples · Discord

[![Discord](https://img.shields.io/badge/Discord-Join-5865F2?logo=discord&logoColor=white)](https://discord.comma.ai) +[![PyPI](https://img.shields.io/pypi/v/msgq-ipc)](https://pypi.org/project/msgq-ipc/) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/commaai/msgq) [![Tests](https://github.com/commaai/msgq/actions/workflows/tests.yml/badge.svg?branch=master)](https://github.com/commaai/msgq/actions/workflows/tests.yml) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/commaai/msgq/blob/master/LICENSE) @@ -21,24 +25,22 @@ MSGQ lets programs on the same machine exchange messages. A publisher sends messages to a named endpoint, and subscribers listen on that same endpoint. Each endpoint supports one publisher and multiple subscribers. -MSGQ is a generic high performance IPC pub sub system with a single publisher and multiple subscribers. It uses a ring buffer in shared memory to efficiently read and write data. Each read requires a copy. Writing can be done without a copy, as long as the size of the data is known in advance. This library also provides a spoofed implementation that can be used for deterministic testing, and visionipc, an IPC system specifically for large contiguous buffers (like images/video). -

1 KiB cross-process ping-pong benchmark
- 1 KiB cross-process ping-pong on x86 Linux. Benchmark script. + 1 KiB cross-process ping-pong on x86 Linux. Benchmark script.

## Quickstart ```sh -python -m pip install git+https://github.com/commaai/msgq.git +python -m pip install msgq-ipc ``` -From a local checkout, run the [publisher](examples/publisher.py) and [subscriber](examples/subscriber.py) in separate terminals: +Run the included [publisher](https://github.com/commaai/msgq/blob/master/msgq/examples/publisher.py) and [subscriber](https://github.com/commaai/msgq/blob/master/msgq/examples/subscriber.py) examples in separate terminals: ```sh -python examples/publisher.py # terminal 1 -python examples/subscriber.py # terminal 2 +python -m msgq.examples.publisher --endpoint demo # terminal 1 +python -m msgq.examples.subscriber --endpoint demo # terminal 2 ``` The subscriber prints `Hello from MSGQ!` once per second. @@ -54,15 +56,65 @@ publisher.send(b"Hello from MSGQ!") print(subscriber.receive()) # b'Hello from MSGQ!' ``` +## Cheatsheet + +```python +import msgq + +# API reference; calls are not intended to run in sequence. + +# === Create sockets === +pub = msgq.pub_sock("demo") # One publisher per endpoint +sub = msgq.sub_sock("demo") # Receive messages from that endpoint +sub = msgq.sub_sock("demo", timeout=1000) # Wait up to 1000 milliseconds per receive +sub = msgq.sub_sock("demo", conflate=True) # Receive only the latest available message + + +# === Send & Receive === +pub.send(b"hello") # Send nonempty bytes; returns None +sub.receive() # Return bytes; block by default +sub.receive(non_blocking=True) # Return bytes immediately, or None +sub.setTimeout(1000) # Set receive timeout in milliseconds +sub.setTimeout(-1) # Restore indefinite blocking +msgq.drain_sock_raw(sub) # Return a list of all available messages +msgq.drain_sock_raw(sub, wait_for_one=True) # Wait for the first message, then drain + +# A receive timeout returns None; draining returns [] if no messages arrive. +# Slow subscribers can miss messages when the ring buffer wraps. + +# === Poll multiple subscribers === +poller = msgq.Poller() # Create a group of subscribers to watch +sub = msgq.sub_sock("demo", poller=poller) # Create and register a subscriber +poller.registerSocket(sub) # Alternatively, register an existing socket +poller.poll(1000) # Return readable sockets; timeout in milliseconds +poller.poll(0) # Check immediately; return [] if none are ready +poller.poll(-1) # Wait indefinitely for a readable socket + +# Register each socket once. Call receive() on the sockets returned by poll(). + +# === Reader synchronization and errors === +pub.all_readers_updated() # Check whether tracked readers have caught up +pub.wait_for_readers(timeout=1.0, interval=0.01) # Wait for that condition; times are in seconds +msgq.IpcError # Messaging failure exception +msgq.MultiplePublishersError # Publisher conflict; subclass of IpcError + +# wait_for_readers() raises TimeoutError if its deadline expires. +# Synchronization checks queue positions, not application processing. It requires +# at least one tracked reader and ignores readers invalidated by an overwrite. +``` + ## Contributing Issues and pull requests are welcome on [GitHub](https://github.com/commaai/msgq). Run `./test.sh` to build, lint, and test the package. ## License -MSGQ is available under the [MIT License](LICENSE). +MSGQ is available under the [MIT License](https://github.com/commaai/msgq/blob/master/LICENSE). -## Under the hood +
+Under the hood + +The message queue copies data on send and receive. A fake implementation is also available for deterministic testing. ### Storage The storage for the queue consists of an area of metadata, and the actual buffer. The metadata contains: @@ -113,3 +165,5 @@ If a writer overwrites the data while it's being copied out, the data will be in If at steps 2 or 5 the validity flag is not set, the reader is reset. Any data that was already read is discarded. After the reader is reset, the reading starts from the beginning. If a message with size -1 is encountered, step 3 and 4 are replaced by increasing the cycle counter and setting the read pointer to the beginning of the buffer. After that another read is performed. + +
diff --git a/examples/publisher.py b/examples/publisher.py deleted file mode 100644 index ae3bcd01d..000000000 --- a/examples/publisher.py +++ /dev/null @@ -1,16 +0,0 @@ -import time - -import msgq - - -publisher = msgq.pub_sock("msgq_example") - -print("Ctrl-C to exit") -try: - while True: - message = "Hello from MSGQ!" - publisher.send(message.encode("utf-8")) - print(f"Sent: {message}", flush=True) - time.sleep(1) -except KeyboardInterrupt: - pass diff --git a/examples/subscriber.py b/examples/subscriber.py deleted file mode 100644 index 4d48c4822..000000000 --- a/examples/subscriber.py +++ /dev/null @@ -1,13 +0,0 @@ -import msgq - - -subscriber = msgq.sub_sock("msgq_example", timeout=1000) - -print("Ctrl-C to exit") -try: - while True: - message = subscriber.receive() - if message is not None: - print(f"Received: {message.decode('utf-8')}", flush=True) -except KeyboardInterrupt: - pass diff --git a/msgq/examples/__init__.py b/msgq/examples/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/benchmark.py b/msgq/examples/benchmark.py similarity index 99% rename from examples/benchmark.py rename to msgq/examples/benchmark.py index 808fd2450..209793525 100644 --- a/examples/benchmark.py +++ b/msgq/examples/benchmark.py @@ -2,7 +2,7 @@ # requires-python = ">=3.11" # dependencies = ["msgq-ipc>=1.0", "pyzmq", "eclipse-zenoh>=1.10", "lcm", "matplotlib"] # [tool.uv.sources] -# msgq-ipc = { path = ".." } +# msgq-ipc = { path = "../.." } # [tool.ty.rules] # unresolved-import = "ignore" # /// @@ -234,31 +234,6 @@ def measure(name, size): connection.close() -def main(): - parser = argparse.ArgumentParser(description="Cross-process pub/sub ping-pong benchmark.") - parser.add_argument("--plot", type=Path, help="save a 1 KiB chart") - args = parser.parse_args() - if "CEREAL_FAKE" in os.environ: - parser.error("Unset CEREAL_FAKE to benchmark the real MSGQ backend") - print("Cross-process ping-pong; messages/sec counts requests and replies.", flush=True) - samples = {(name, size): [] for name in BACKENDS for size in (64, 1024, 65536)} - randomizer = random.Random(0) - for _ in range(REPEATS): - jobs = list(samples) - randomizer.shuffle(jobs) - for name, size in jobs: - sample = measure(name, size) - rate = 2 * sample["round_trips"] / sample["seconds"] - samples[name, size].append(rate) - print(f"{name:<12} {size:>6} bytes: {rate:>12,.0f} messages/sec", flush=True) - results = [(name, size, statistics.median(rates)) for (name, size), rates in samples.items()] - print("\nMedians:") - for name, size, rate in results: - print(f"{name:<12} {size:>6} bytes: {rate:>12,.0f} messages/sec") - if args.plot: - plot_results(results, args.plot) - - def plot_results(results, path): from matplotlib.ticker import EngFormatter, MaxNLocator @@ -293,4 +268,25 @@ def plot_results(results, path): if __name__ == "__main__": - main() + parser = argparse.ArgumentParser(description="Cross-process pub/sub ping-pong benchmark.") + parser.add_argument("--plot", type=Path, help="save a 1 KiB chart") + args = parser.parse_args() + if "CEREAL_FAKE" in os.environ: + parser.error("Unset CEREAL_FAKE to benchmark the real MSGQ backend") + print("Cross-process ping-pong; messages/sec counts requests and replies.", flush=True) + samples = {(name, size): [] for name in BACKENDS for size in (64, 1024, 65536)} + randomizer = random.Random(0) + for _ in range(REPEATS): + jobs = list(samples) + randomizer.shuffle(jobs) + for name, size in jobs: + sample = measure(name, size) + rate = 2 * sample["round_trips"] / sample["seconds"] + samples[name, size].append(rate) + print(f"{name:<12} {size:>6} bytes: {rate:>12,.0f} messages/sec", flush=True) + results = [(name, size, statistics.median(rates)) for (name, size), rates in samples.items()] + print("\nMedians:") + for name, size, rate in results: + print(f"{name:<12} {size:>6} bytes: {rate:>12,.0f} messages/sec") + if args.plot: + plot_results(results, args.plot) diff --git a/msgq/examples/publisher.py b/msgq/examples/publisher.py new file mode 100644 index 000000000..3c9360bb8 --- /dev/null +++ b/msgq/examples/publisher.py @@ -0,0 +1,21 @@ +import argparse +import time + +import msgq + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Publish a greeting once per second.") + parser.add_argument("--endpoint", default="msgq_example", help="endpoint name (default: %(default)s)") + args = parser.parse_args() + publisher = msgq.pub_sock(args.endpoint) + + print("Ctrl-C to exit") + try: + while True: + message = "Hello from MSGQ!" + publisher.send(message.encode("utf-8")) + print(f"Sent: {message}", flush=True) + time.sleep(1) + except KeyboardInterrupt: + pass diff --git a/msgq/examples/subscriber.py b/msgq/examples/subscriber.py new file mode 100644 index 000000000..9a17e844b --- /dev/null +++ b/msgq/examples/subscriber.py @@ -0,0 +1,19 @@ +import argparse + +import msgq + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Receive and print greetings from a publisher.") + parser.add_argument("--endpoint", default="msgq_example", help="endpoint name (default: %(default)s)") + args = parser.parse_args() + subscriber = msgq.sub_sock(args.endpoint, timeout=1000) + + print("Ctrl-C to exit") + try: + while True: + message = subscriber.receive() + if message is not None: + print(f"Received: {message.decode('utf-8')}", flush=True) + except KeyboardInterrupt: + pass diff --git a/pyproject.toml b/pyproject.toml index 6c2c3aa7d..c7e46e37d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta" [project] name = "msgq-ipc" -version = "1.0" -description = "Lock-free IPC pub/sub message queue" +version = "1.1" +description = "High-performance pub/sub messaging, made simple. For Python, C, and C++." readme = "README.md" requires-python = ">=3.11" license = "MIT"