🗺️ Analogy
Normal file I/O is like phoning the archive and asking a clerk to photocopy pages and mail them to you (each read copies bytes from the kernel into your buffer). Memory mapping is like getting the archive’s floor plan overlaid on your own office: the shelves now appear in your room, and a book only physically materializes the moment you reach for it (the kernel pages it in on demand). No copying, and you only pull in what you touch — but you’re now holding real shelves, so if the archive demolishes one while you’re reaching (the file is truncated), you don’t get a polite “not found” — you grab at thin air (SIGBUS).
🧪 What runs here
Anonymous mmap (memory not backed by a file) runs right here — the first playground below is a real mmap/munmap. File-backed mmap needs a real file descriptor, so those examples are fenced (correct for a real Linux/macOS machine).
What memory mapping is
mmap asks the kernel to map a file (or an anonymous region) directly into your process’s virtual address space. After that, the file’s bytes are memory: you read and write them like a []byte, and the kernel pages the actual data in from disk on demand and flushes writes back lazily.
graph LR F["file on disk"] -->|"mmap"| V["region of virtual memory"] V -->|"access page → page fault"| K["kernel pages it in (lazy)"] V -. "[]byte view, no read() per access" .-> APP["your Go code"]
The two payoffs: zero-copy (no read() into a user buffer) and laziness (only the pages you touch are loaded). For random access over a multi-gigabyte file — a database file, a search index, a column store — that beats read/seek, which would copy and often over-read.
A real mmap, runnable
The simplest mapping is anonymous — memory not backed by any file. It’s a real mmap(2)/munmap(2) and runs right here: the returned region is a []byte you read and write as ordinary memory, with no per-access syscall:
package main
import (
"fmt"
"syscall"
)
func main() {
const size = 4096 // one page
// Anonymous mmap: ask the kernel for memory directly (fd = -1, no file).
// MAP_SHARED means a forked child would see writes too — true shared memory.
region, err := syscall.Mmap(-1, 0, size,
syscall.PROT_READ|syscall.PROT_WRITE,
syscall.MAP_ANON|syscall.MAP_SHARED)
if err != nil {
fmt.Println("mmap failed:", err)
return
}
defer syscall.Munmap(region) // outside the GC heap — you must free it
// The region IS a []byte. Write to it like ordinary memory...
msg := "written straight into mapped memory"
copy(region, msg)
// ...and read it back — no read()/write() syscall per access.
fmt.Printf("read back: %q\n", region[:len(msg)])
fmt.Println("region length:", len(region), "bytes")
}
File-backed mappings are the same call with a real fd instead of -1 — that’s the next section.
Mapping a file in Go
There’s no standard os.Mmap; use golang.org/x/sys/unix (or syscall). The result is a []byte aliasing the file:
//go:build unix
package main
import (
"fmt"
"os"
"golang.org/x/sys/unix"
)
func main() {
f, _ := os.Open("big.dat")
defer f.Close()
info, _ := f.Stat()
size := int(info.Size())
// Map the whole file read-only into memory.
data, err := unix.Mmap(int(f.Fd()), 0, size,
unix.PROT_READ, unix.MAP_SHARED)
if err != nil {
panic(err)
}
defer unix.Munmap(data) // outside the Go heap — must free it yourself
// data is a []byte view of the file; no per-access read syscall.
fmt.Printf("first 4 bytes: %v\n", data[:4])
fmt.Printf("byte at 1<<20: %d\n", data[1<<20]) // pages in just that block
}
For writable mappings, add PROT_WRITE and MAP_SHARED, mutate the slice, and call unix.Msync(data, unix.MS_SYNC) to force changes to disk.
Shared memory between processes
Two processes that mmap the same file with MAP_SHARED see the same physical pages — the fastest IPC possible, because nothing is copied at all:
// Process A and Process B both:
data, _ := unix.Mmap(fd, 0, size, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
// A writes data[i] = x → B sees x at data[i]
The catch: the kernel gives you shared bytes but no synchronization. You must coordinate access yourself — a mutex in a shared header, atomic operations, or a separate signaling channel — or you’ll have data races across processes.
When mmap wins — and when it doesn’t
| Good fit | Poor fit |
|---|---|
| Random access over large files | Small files (overhead beats benefit) |
| Read-mostly shared datasets (indexes) | Sequential streaming (plain bufio is simpler/faster) |
| Zero-copy IPC between local processes | Files that may be truncated under you (SIGBUS risk) |
| Memory-mapped databases (BoltDB/bbolt) | Code that must stay portable with no cgo/x-sys |
🐹 You've probably used mmap without writing it
Memory mapping is most often something you benefit from, not code by hand. bbolt (the embedded KV store behind etcd) mmaps its database file for fast reads; many search indexes and caches do the same. Reach for raw unix.Mmap only when profiling shows that read/seek copying is your bottleneck on big, randomly-accessed files — and wrap it behind a small interface so the platform-specific, build-tagged code stays isolated (it’s the most “below the os package” thing in this track). For most file work, the os package and bufio are the right, portable choice.
⚠️ SIGBUS, manual Munmap, and portability
mmap drops below Go’s safety net. Truncation = SIGBUS: if the backing file shrinks (or a disk error hits) while you touch a mapped page, you get a signal, not an error return — far harder to recover from than io errors. The mapping is outside the GC heap, so you must Munmap (defer it); forgetting leaks address space. It’s platform-specific (x/sys/unix vs /windows, build tags required) and the byte slice aliases live kernel pages — don’t let it outlive the Munmap, or you’ve got a dangling slice. High reward, sharp edges: measure first.
See also
- Unix sockets — the other fast local-IPC mechanism.
- files & directories — the portable file API to prefer by default.
- the memory allocator (internals) — why mmap’d memory sits outside the Go heap.
- system calls —
mmap/munmap/msyncare syscalls behindx/sys.
Next: the foundations these primitives run on — the internals track.
Related topics
Fast local IPC — Unix domain sockets vs TCP, building a server over a Unix socket, and serving HTTP over one.
filesFiles & DirectoriesWorking the filesystem in Go — permissions, reading and walking directories with WalkDir, computing directory sizes, finding duplicates by hash, and symlinks.
Check your understanding
Score: 0 / 51. What does mmap do?
mmap creates a mapping between a region of virtual memory and a file. Reading the memory triggers the kernel to page in the corresponding file blocks lazily; writing (in shared mode) eventually flushes back. You treat a file as a []byte without explicit read/write syscalls per access.
2. What's the main performance advantage of mmap for large files?
Normal I/O copies file data kernel→user buffer on every read. mmap maps the kernel's page cache straight into your address space (zero-copy) and loads pages only when accessed. For random access over multi-GB files (databases, indexes), that's a big win over read/seek.
3. How can two processes share memory with mmap?
A MAP_SHARED mapping of the same file (or a shared anonymous mapping passed across fork) gives both processes views of the same physical pages — the fastest IPC there is, since there's no copying at all. The trade-off is you must synchronize access yourself (the kernel won't).
4. Why does mmap'd data NOT pressure Go's garbage collector?
Unlike a []byte you allocated, an mmap region lives outside the Go heap (it's backed by the file/page cache), so the GC neither scans nor counts it. That's part of mmap's appeal for huge datasets — but it also means you must Munmap explicitly; the GC won't free it for you.
5. What's a key risk of accessing a memory-mapped file?
Touching a page whose backing file shrank (or hit a disk error) raises SIGBUS — harder to handle than an error return. And because the mapping is outside the heap, forgetting Munmap leaks address space. mmap trades convenience and speed for these lower-level hazards.
Comments
Sign in with GitHub to join the discussion.