@gofsd/shmring

Cross-thread/cross-tab shared-memory ring buffer for the browser (SharedArrayBuffer-backed), compiled from the same Rust crate as the native backend via wasm-bindgen


Keywords
shared-memory, ring-buffer, wasm, webassembly, sharedarraybuffer, worker
License
MIT
Install
npm install @gofsd/shmring@0.3.0

Documentation

shmring

shmring is a fixed-capacity, single-producer/single-consumer byte ring buffer for Go, built on top of github.com/hidez8891/shm for cross-process shared memory.

One process creates the ring buffer and gets a Writer; another process opens the same named segment and gets a Reader. Bytes written on one side become readable on the other, in order, with no sockets, pipes, or copies through the kernel beyond the initial mmap.

Platform support

Platform Language Transport Blocking wakeup Status
Linux Go OS shared memory (direct /dev/shm + mmap, pure Go, no cgo) real futex(2) native, CreateShm/OpenShm, CI-tested
macOS, Windows Go OS shared memory (hidez8891/shm, cgo) polling (no portable futex-like primitive available here) native, CreateShm/OpenShm, CI-tested
Android Go ASharedMemory (cgo), fd-based, via gomobile bind real futex(2) (same kernel primitive as Linux) native, compiles against the real NDK and produces a real AAR, confirmed on a real device — see Android
Linux Rust OS shared memory (POSIX shm_open, direct FFI) real futex(2) independent implementation of the same wire format, create_shm/open_shm, confirmed with a real two-process round trip — see rust/README.md
macOS Rust OS shared memory (POSIX shm_open, direct FFI) polling (no public futex equivalent on macOS) same as above
Web (browser) Rust → wasm-bindgen SharedArrayBuffer + Atomics Atomics.wait/Atomics.waitAsync, woken by Atomics.notify same Rust crate as the desktop backend, compiled to wasm32-unknown-unknown; confirmed with a real headless-Chrome, cross-thread test — see Web

Same ring buffer wire format everywhere (64-byte header, SPSC head/tail protocol). Go covers desktop and Android; Rust is an independent implementation (not generated from Go) covering desktop and, via wasm-bindgen, the browser — the web build used to be compiled from Go, but was replaced by the Rust build (same functionality, verified end to end before the Go wasm code was removed). The storage backend and the surface exposed to the host language differ per platform: Go on desktop and Android (Kotlin/Java via gomobile); Rust on desktop (native shm_open) and on the web (JavaScript via wasm-bindgen-generated bindings).

Install

Go (desktop: Linux, macOS, Windows):

go get github.com/gofsd/shmring@v0.4.1

Gradle (Android) — the AAR is attached to each GitHub release rather than published to a Maven registry, so it resolves via a plain HTTP(S) ivy repository (no account/token needed to consume it):

// settings.gradle.kts
dependencyResolutionManagement {
    repositories {
        exclusiveContent {
            forRepository {
                ivy {
                    url = uri("https://github.com/gofsd/shmring/releases/download")
                    patternLayout { artifact("[revision]/[module]-[revision].[ext]") }
                    metadataSources { artifact() }
                }
            }
            filter { includeModule("dev.gofsd", "shmring") }
        }
    }
}
// app/build.gradle.kts
dependencies {
    implementation("dev.gofsd:shmring:v0.4.1@aar")
}

npm (web):

npm install @gofsd/shmring

Cargo (Rust, Linux/macOS):

cargo add shmring

Quick start

// process A (producer)
w, err := shmring.CreateShm("my-channel", 4096) // capacity must be a power of two
if err != nil {
    log.Fatal(err)
}
defer w.CloseStorage() // removes the OS shared-memory segment

w.Write([]byte("hello\n"))
w.Close() // signal EOF to the reader once done

// process B (consumer)
r, err := shmring.OpenShm("my-channel", 4096)
if err != nil {
    log.Fatal(err)
}
defer r.Close()

io.Copy(os.Stdout, r) // reads until the writer closes and the buffer drains

See examples/producer and examples/consumer for a runnable two-process demo:

go run ./examples/producer &
go run ./examples/consumer

Web

The browser build is compiled from the same rust/ crate as the native Rust backend, not from Go — rust/src/backend/wasm.rs implements Storage over a JavaScript SharedArrayBuffer, and rust/src/wasm_api.rs exposes it to JavaScript via wasm-bindgen (WasmWriter/WasmReader, createWriter/openReader). Each browser thread (main thread, or a Web Worker) that wants to be one side of a ring buffer loads its own independent wasm module instance — wasm32 code is single-threaded, so two instances can't literally share linear memory the way two native processes share an mmap'd segment. Instead, the ring buffer's storage lives in the SharedArrayBuffer, and head/tail coordination goes through real Atomics.load/Atomics.store. That's the web platform's actual cross-thread visibility guarantee — stronger than the "aligned access is coherent" argument the native OS-shared-memory backend relies on, not weaker.

web/shmring.js is a thin, hand-written ES module wrapper (loadShmring, Writer, Reader, createWriter, openReader) around the wasm-bindgen-generated bindings, mirroring the Rust/Go API as closely as JS idiom allows. See web/example for a working main-thread-Writer / Worker-Reader page.

Published to npm as @gofsd/shmring (npm install @gofsd/shmring) — see npm/README.md for package-specific usage. To build it from source instead:

mage web:build             # -> web/shmring_wasm.js, web/shmring_wasm_bg.wasm, web/example/shmring.wasm
mage web:serve              # http://localhost:8080/example/
mage npm:build              # -> npm/ (shmring.js, shmring_wasm.js, shmring_wasm_bg.wasm)

Requires cross-origin isolation. Browsers only expose SharedArrayBuffer on pages served with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp (web/devserver sets both for local testing; your production server needs to as well).

// main thread
import { loadShmring, createWriter, wasmURL } from "./shmring.js";
const raw = await loadShmring(wasmURL);
const { writer, sab } = createWriter(raw, 4096);
worker.postMessage({ sab, capacity: 4096 });
await writer.write(new TextEncoder().encode("hello\n"));
writer.close();

// worker.js
import { loadShmring, openReader, wasmURL } from "./shmring.js";
const raw = await loadShmring(wasmURL);
self.onmessage = async ({ data: { sab, capacity } }) => {
  const reader = openReader(raw, sab, capacity);
  const buf = new Uint8Array(64);
  const { n, eof } = await reader.read(buf);
  // ...
};

mage web:test builds the wasm module, runs the native Go test suite, then drives a real headless Chrome (web/e2e, via puppeteer-core) through the example page end to end — confirming actual data crosses the main-thread/Worker boundary, not just that things compile. Run npm install in web/e2e once first.

Android

GOOS=android picks up Go's linux build tag too (a long-standing special case in the toolchain's build-constraint matching), so the first version of this support just reused hidez8891/shm's Linux backend as-is. That does not work: bionic libc's own headers say so directly — sys/posix_limits.h defines _POSIX_SHARED_MEMORY_OBJECTS as __BIONIC_POSIX_FEATURE_MISSING, with the comment "mmap/munmap are implemented, but shm_open/shm_unlink are not." Confirmed by cross-compiling against a real NDK: it fails at the import "C" step, not at link time. backend/shm.go now explicitly excludes Android from the Linux/macOS/Windows build tag.

The real backend (backend/android.go) uses Android's actual shared-memory API, <android/sharedmem.h> (ASharedMemory_create + mmap, both available since API 26). The important shape difference from CreateShm/OpenShm: ASharedMemory has no name-based rendezvous — ASharedMemory_create's name argument is a debug label only, visible in /proc/<pid>/maps, not something a second call can open by name. Sharing a region means handing over its file descriptor directly: trivial within a process, and across processes normally means your Java/Kotlin layer sending it over Binder as a ParcelFileDescriptor (that plumbing is app-specific and outside this library's scope). Hence shm_android.go's constructors return/accept an fd rather than a name:

w, fd, err := shmring.CreateAndroidSharedMemory("my-buffer", 4096)
// hand fd to whoever should be the Reader
r, err := shmring.OpenAndroidSharedMemory(fd, 4096)

mobile/mobile.go wraps that for gomobile bind (gobind, gomobile's binding generator, doesn't support Go's multi-value returns beyond (value, error), so CreateSharedMemory returns a CreateResult{Writer, Fd} struct instead of a 3-tuple). The generated Java API:

Mobile.CreateResult result = Mobile.createSharedMemory("my-buffer", 4096);
Writer writer = result.getWriter();
long fd = result.getFd();
// ... send fd to another process via ParcelFileDescriptor ...
Reader reader = Mobile.openSharedMemory(fd, 4096);
go install golang.org/x/mobile/cmd/gomobile@latest
go get -tool golang.org/x/mobile/cmd/gobind   # records a tool dependency in go.mod
gomobile init

sdkmanager --install "ndk;28.2.13676358"
export ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/28.2.13676358

mage android:build   # -> bin/android/shmring.aar

Prebuilt AARs are attached to GitHub releases — see Install for the Gradle ivy repository snippet, no local NDK/gomobile build needed to just depend on it.

Verification status

Confirmed: backend/android.go and mobile/mobile.go cross-compile cleanly against a real NDK (28.2.13676358, targeting API 26, the ASharedMemory_create minimum — an API-24 target hides the declaration entirely and fails cgo's type-checking rather than giving a clear availability error) and link against the real bionic sysroot. gomobile bind produces a complete, real .aar: native libgojni.so for all four Android ABIs (armeabi-v7a, arm64-v8a, x86, x86_64) plus the generated Java bindings shown above.

Confirmed on a real device: ASharedMemory_create/mmap behave correctly at runtime. Two earlier attempts to verify this on an AVD (Pixel_9, both with and without KVM acceleration) had ended in the emulator itself segfaulting during boot (SIGSEGV, exit 139) — a crash in QEMU/the emulator, unrelated to this library. A physical device (Android 16, arm64-v8a) confirmed both the raw backend and the gomobile-bound AAR:

CC=$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android26-clang \
  GOOS=android GOARCH=arm64 CGO_ENABLED=1 \
  go build -o android-smoketest ./examples/android-smoketest
adb push android-smoketest /data/local/tmp/
adb shell /data/local/tmp/android-smoketest
# PASS: wrote and read back "hello from ASharedMemory\n" through real ASharedMemory-backed shared memory

That run surfaced one real bug, since fixed: OpenAndroidSharedMemory used to adopt the caller's fd number directly instead of dup-ing it. That's harmless across processes (a fd arriving over Binder is already a separate table entry), but any single-process caller reusing the literal fd for both CreateAndroidSharedMemory and OpenAndroidSharedMemory — exactly the pattern this doc and examples/android-smoketest show — got two Storages sharing one fd number, so the first side's Close/CloseStorage left the other closing an already-closed fd (EBADF). OpenAndroidSharedMemory now dups the fd on entry, so each side owns an independent descriptor.

API

  • CreateShm(name string, capacity int64, opts ...Option) (*Writer, error) creates a new shared-memory ring buffer.
  • OpenShm(name string, capacity int64, opts ...Option) (*Reader, error) opens one created by CreateShm.
  • *Writer implements io.Writer (Write), plus non-blocking TryWrite and a cancellable WriteContext.
  • *Reader implements io.Reader (Read), plus non-blocking TryRead and a cancellable ReadContext. Read returns io.EOF once the writer has closed and all buffered data has been drained.
  • Writer.Close marks the ring buffer closed (readable data already written is still drained normally); Writer.CloseStorage additionally releases the OS shared-memory segment and should be called once, by whichever side created it, after the other side is done.

This is the Go API (desktop/Android only, per the platform table above); see rust/README.md for the Rust API, which covers both desktop and the web build.

Design

Pluggable storage. The ring buffer algorithm never talks to OS shared memory directly — it depends only on the small backend.Storage interface (ReadAt/WriteAt/Size/Close), implemented by backend.ShmStorage (a direct, hand-rolled /dev/shm + mmap implementation on Linux, see backend/shm_linux.go; hidez8891/shm-backed on macOS/Windows, used by CreateShm/OpenShm on all three) and backend.AndroidSharedMemoryStorage (see Android). NewWriter/NewReader accept any backend.Storage, including backend.MemStorage, an in-process byte-slice backend used by this package's own tests. This is the extension point for the future: a new platform or transport means adding a new backend.Storage implementation, not touching the ring buffer logic — which is exactly how the Android backend was added, and how the Rust crate's own Storage trait (which mirrors this one) added its web backend without touching its own ring buffer logic either (see rust/README.md's Design section).

Platform support is summarized in the table at the top; CI (.github/workflows/ci.yml) runs the native test suite on Linux, macOS, and Windows with CGO_ENABLED=1. Only macOS and Windows actually need cgo here (the hidez8891/shm-backed ShmStorage they use is a cgo package); Linux's own direct /dev/shm + mmap + futex ShmStorage (see backend/shm_linux.go) is plain Go and doesn't import hidez8891/shm at all, so CGO_ENABLED=1 there is just the default, not a requirement.

Concurrency model. A ring buffer has exactly one Writer and one Reader, each used from a single goroutine at a time — this is a single-producer/single-consumer (SPSC) structure, not a general-purpose concurrent queue. Head/tail/closed are 32-bit counters rather than 64-bit (chosen so the same header format also fits a JavaScript Int32Array for the Rust web build's Atomics); correctness only depends on tail-head, which never approaches 2^31 as long as capacity does (enforced at construction). Coordination goes through plain, 4-byte aligned loads and stores on ShmStorage/MemStorage, because the underlying shm library only exposes copy-based ReadAt/WriteAt, not a raw pointer into the mapping. backend.AtomicStorage is an optional capability a Storage can implement for backends that need a real atomic load/store instead (Go doesn't currently ship one — the browser build now goes through the Rust crate's own equivalent Storage::load_u32_at/store_u32_at, not this interface; see rust/README.md). Plain aligned access mirrors how classic SPSC ring buffers over shared memory (e.g. Linux kfifo) work, and holds on every architecture Go currently targets. The in-process backend.MemStorage backend compensates for the weaker same-process guarantee with an internal mutex, since two goroutines in one process do need a Go memory-model-legal happens-before edge, unlike two OS processes sharing real mapped memory.

Blocking calls wait on a real wakeup where the platform has one. On Linux (desktop and Android), Write/Read (and their Context variants) block on a real futex(2) FUTEX_WAIT tied to the head/tail word they're waiting on, woken by a FUTEX_WAKE from the other side right after it updates that word (see backend.WaiterStorage, backend/futex_linux.go) — no busy-polling, and no wakeup latency beyond the syscall itself. On macOS and Windows there's no portable equivalent available to this package (no public futex-like primitive, and hidez8891/shm's Memory type doesn't expose the raw mapped pointer one would need), so those two platforms keep the original behavior: polling the shared counters with an exponential backoff, tunable via WithPollInterval. Either way, WithPollInterval's maxPoll also bounds how promptly a cancellable Context's cancellation is noticed on the futex-backed path, since a real wakeup only fires on the other side's activity, not on ctx.Done(). Use TryWrite/TryRead if you want to avoid blocking (and any polling that implies) entirely.

Development

This repo uses Mage instead of Make. The magefile lives in magefiles/ as its own Go module, so the mage dependency never leaks into shmring's own go.mod.

go install github.com/magefile/mage@latest

mage -l          # list targets
mage build
mage test
mage testRace
mage vet
mage lint        # requires golangci-lint
mage examples    # builds bin/producer and bin/consumer

The top-level targets above build/test using whatever platform you're already on. Alongside them, each supported platform has its own namespace (mage -l lists all of them):

mage linux:build    mage linux:test     mage linux:lint     mage linux:clean
mage darwin:build   mage darwin:test    mage darwin:lint    mage darwin:clean
mage windows:build  mage windows:test   mage windows:lint   mage windows:clean
mage android:build  mage android:test   mage android:lint   mage android:clean
mage web:build      mage web:test       mage web:lint       mage web:clean   mage web:serve

What actually runs depends on what's installed where you invoke it:

  • linux: fully native on a Linux host.
  • windows: cross-compiles from any host with mingw-w64 (x86_64-w64-mingw32-gcc); test additionally runs the suite if wine/wine64 is on PATH, otherwise it falls back to go vet (compiles and type-checks, including test files, without executing anything).
  • darwin: needs a real Apple toolchain for cgo (no practical open cross-compiler exists), so build/test are only expected to work when run on macOS itself — CI covers this on a macos-latest runner instead.
  • android: needs gomobile and an installed NDK (ANDROID_NDK_HOME); build/test fail with a specific, actionable error naming whichever is missing rather than a raw toolchain error. With both present, build genuinely produces bin/android/shmring.aar — see Android for what that does and doesn't confirm.
  • web: pure Go, no cgo, cross-compiles from anywhere; test additionally needs Node.js and a Chrome/Chromium binary (CHROME_PATH env var if it's not in a standard location) to run the real-browser check in web/e2e.

License

MIT, see LICENSE.