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 | 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).
Go (desktop: Linux, macOS, Windows):
go get github.com/gofsd/shmring@v0.4.1Gradle (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/shmringCargo (Rust, Linux/macOS):
cargo add shmring// 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 drainsSee examples/producer and
examples/consumer for a runnable two-process demo:
go run ./examples/producer &
go run ./examples/consumerThe 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.
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.aarPrebuilt 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.
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 memoryThat 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.
-
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 byCreateShm. -
*Writerimplementsio.Writer(Write), plus non-blockingTryWriteand a cancellableWriteContext. -
*Readerimplementsio.Reader(Read), plus non-blockingTryReadand a cancellableReadContext.Readreturnsio.EOFonce the writer has closed and all buffered data has been drained. -
Writer.Closemarks the ring buffer closed (readable data already written is still drained normally);Writer.CloseStorageadditionally 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.
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.
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/consumerThe 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:serveWhat 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);testadditionally runs the suite ifwine/wine64is on PATH, otherwise it falls back togo 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/testare only expected to work when run on macOS itself — CI covers this on amacos-latestrunner instead. -
android: needs
gomobileand an installed NDK (ANDROID_NDK_HOME);build/testfail with a specific, actionable error naming whichever is missing rather than a raw toolchain error. With both present,buildgenuinely producesbin/android/shmring.aar— see Android for what that does and doesn't confirm. -
web: pure Go, no cgo, cross-compiles from anywhere;
testadditionally needs Node.js and a Chrome/Chromium binary (CHROME_PATHenv var if it's not in a standard location) to run the real-browser check inweb/e2e.
MIT, see LICENSE.