Overview
Memory mapping establishes a correspondence between a file or device and a region of a process's virtual address space. This mechanism allows programs to access files as if they were part of memory, using pointer operations instead of explicit read and write system calls. The operating system handles the actual data transfer between disk and memory automatically through its virtual memory subsystem.
The kernel manages memory-mapped regions through its page cache mechanism. When a program accesses a memory-mapped address, the OS checks whether that page resides in physical memory. If not, a page fault occurs, and the kernel loads the required page from disk. Subsequent accesses to the same page occur at memory speed without additional system calls. Modified pages in writable mappings get written back to the underlying file either when explicitly requested or during normal page cache operations.
Memory mapping originated in early virtual memory systems as a way to implement demand paging for executable code. Modern operating systems extended this concept to arbitrary files, creating a unified mechanism for both code loading and data access. This abstraction eliminates the boundary between file I/O and memory access, allowing the same virtual memory infrastructure to handle both.
# Traditional file reading requires explicit I/O calls
File.open('data.bin', 'rb') do |f|
chunk = f.read(4096)
process_data(chunk)
end
# Memory mapping treats the file as a memory region
# (conceptual example - actual implementation varies)
mmap = MemoryMap.new('data.bin', :read)
data = mmap[0, 4096] # Access like memory
process_data(data)
The virtual memory system manages mapped regions identically to other memory allocations. Each mapping occupies address space but consumes physical memory only for pages actually accessed. The OS can discard clean pages at any time and reload them from disk when needed again, making memory mapping particularly efficient for large files where only portions get accessed.
Key Principles
Memory mapping operates on the principle that file contents can be accessed through memory addresses rather than file offsets. The operating system maintains a mapping table that associates virtual memory addresses with file offsets. When code dereferences a pointer into the mapped region, the memory management unit translates that virtual address into a physical address, triggering page loads as necessary.
The fundamental unit of memory mapping is the page, matching the system's virtual memory page size. On most systems, pages are 4KB, though larger page sizes exist for specific use cases. The OS maps files in page-sized chunks, meaning even small files consume at least one page of address space. File offsets used in mapping operations must align to page boundaries, though the mapping can expose any byte range to the application.
Memory mappings fall into two categories based on whether changes propagate to the underlying file. Shared mappings make modifications visible to other processes mapping the same file and write changes back to disk. Private mappings use copy-on-write semantics where modifications create private copies of pages, leaving the original file unchanged. The kernel creates new page frames for modified pages in private mappings, consuming additional memory.
# Conceptual representation of mapping types
class MemoryMapping
# Shared mapping - changes affect file and other processes
def self.shared(file, offset, length)
mapping = allocate_address_range(length)
link_to_file(file, offset, mapping, :shared)
mapping
end
# Private mapping - changes create copy-on-write pages
def self.private(file, offset, length)
mapping = allocate_address_range(length)
link_to_file(file, offset, mapping, :private)
mapping
end
end
Anonymous mappings create memory regions not backed by any file. These mappings provide private memory that starts zero-initialized, useful for dynamic memory allocation. The kernel allocates physical pages on demand as the program accesses the anonymous region. Many allocators use anonymous mappings for large allocations, bypassing the traditional heap.
The protection attributes of a mapping control access permissions. Mappings can be readable, writable, executable, or combinations thereof. The kernel enforces these permissions through the memory management unit, generating segmentation faults for violations. Protection attributes interact with the shared/private designation - shared mappings typically require the underlying file to have compatible permissions.
Synchronization determines when modifications to a shared mapping become visible to other processes and when they get written to disk. Asynchronous synchronization allows the kernel to flush changes at its discretion, optimizing for system-wide performance. Synchronous synchronization forces immediate writeback, ensuring durability but sacrificing performance. Most applications rely on asynchronous synchronization, explicitly requesting flushes only at consistency points.
# Synchronization control (conceptual)
class SharedMapping
def sync_async
# Kernel flushes pages when convenient
mark_for_eventual_writeback
end
def sync_sync
# Immediate flush to disk
flush_all_modified_pages
wait_for_disk_completion
end
def sync_range(offset, length)
# Flush only specific pages
flush_pages_in_range(offset, length)
end
end
Ruby Implementation
Ruby provides memory mapping capabilities primarily through external gems, as the core language lacks built-in memory mapping APIs. The mmap2 gem offers the most direct interface to system memory mapping facilities, wrapping the underlying POSIX mmap system call with Ruby objects. This gem exposes memory-mapped regions as string-like objects that support random access and modification.
require 'mmap2'
# Map an entire file for reading
mmap = Mmap.new('large_file.dat', 'r')
puts mmap.size # File size in bytes
# Access data at specific offsets
header = mmap[0, 512]
record = mmap[512, 128]
# Close the mapping
mmap.unmap
The mmap2 gem supports various mapping modes mirroring the underlying system capabilities. Read-only mode maps the file without modification capability. Read-write mode allows changes that propagate to the file. Copy-on-write mode creates a private mapping where modifications don't affect the original file. The gem handles page alignment requirements automatically, simplifying usage compared to raw system calls.
# Different mapping modes
read_map = Mmap.new('input.dat', 'r') # Read-only
write_map = Mmap.new('output.dat', 'w') # Read-write shared
private_map = Mmap.new('template.dat', 'c') # Copy-on-write private
# Modify a shared mapping
write_map[0] = 'X' # Changes visible in file
# Modify a private mapping
private_map[0] = 'Y' # Creates private copy of page
write_map.unmap
private_map.unmap
For more advanced control, Ruby's Fiddle and FFI libraries enable direct system call invocation. This approach provides access to all mmap flags and options but requires more code and platform-specific knowledge. Applications needing precise control over protection attributes, fixed addresses, or unusual mapping modes use this technique.
require 'fiddle'
require 'fiddle/import'
module MemMap
extend Fiddle::Importer
dlload Fiddle.dlopen(nil)
# Constants for mmap
PROT_READ = 1
PROT_WRITE = 2
MAP_SHARED = 1
MAP_PRIVATE = 2
extern 'void* mmap(void*, size_t, int, int, int, off_t)'
extern 'int munmap(void*, size_t)'
extern 'int msync(void*, size_t, int)'
end
# Map a file using raw system calls
fd = File.open('data.bin', 'r').fileno
size = File.size('data.bin')
addr = MemMap.mmap(nil, size,
MemMap::PROT_READ,
MemMap::MAP_PRIVATE,
fd, 0)
# Access mapped memory through addr pointer
Ruby's IO class supports memory-like access through positioning and buffering, though this doesn't use actual memory mapping. The IO#pread method reads from a specific offset without changing the file position, providing random access similar to memory mapping but still using system calls. This approach offers portability where memory mapping isn't available.
# IO-based random access (not true memory mapping)
File.open('data.bin', 'rb') do |io|
# Read from multiple positions
header = io.pread(512, 0)
footer = io.pread(512, io.size - 512)
# No need to seek between reads
middle = io.pread(1024, io.size / 2)
end
The tempfile library combined with memory mapping creates temporary shared memory regions for inter-process communication. A temporary file gets created, mapped into memory, and then unlinked from the filesystem. The mapping persists while processes hold references, providing shared memory that automatically cleans up when all processes exit.
require 'tempfile'
require 'mmap2'
# Create temporary shared memory
tmpfile = Tempfile.new('shared')
tmpfile.write("\0" * 4096) # Allocate space
tmpfile.flush
# Map into memory
shared = Mmap.new(tmpfile.path, 'w')
# Fork and share data
pid = fork do
shared[0, 5] = "child"
shared.msync # Ensure visibility
end
Process.wait(pid)
puts shared[0, 5] # Reads "child"
shared.unmap
tmpfile.close
tmpfile.unlink
For working with structured binary data in memory-mapped files, the bindata gem provides serialization and deserialization. Combine bindata definitions with memory-mapped regions to parse complex binary formats efficiently, reading only the required portions of large files.
require 'bindata'
require 'mmap2'
# Define binary structure
class Record < BinData::Record
endian :little
uint32 :id
uint32 :timestamp
string :payload, length: 256
end
# Map file and read specific record
mmap = Mmap.new('records.bin', 'r')
record_size = 264 # 4 + 4 + 256 bytes
record_data = mmap[record_size * 100, record_size]
record = Record.read(record_data)
puts record.id
puts record.timestamp
Implementation Approaches
File-backed memory mapping creates a bidirectional connection between a file and memory. The application opens a file descriptor, then requests the kernel to map a range of file offsets into virtual address space. The kernel creates page table entries pointing to the file's pages in the page cache. Reads and writes to mapped addresses operate on these cached pages, with the kernel handling synchronization to disk. This approach works best for files accessed non-sequentially where traditional I/O would require many seek operations.
Anonymous memory mapping allocates memory without file backing. The kernel initializes mapped pages to zero and allocates physical memory on demand as the application touches pages. These mappings serve as private memory regions for the process, equivalent to heap allocations but managed by the virtual memory system rather than a heap allocator. Large allocations often use anonymous mappings because they can be more efficiently managed at the page level.
# File-backed mapping pattern
class FileMapper
def initialize(path, size)
@file = File.open(path, 'r+')
@file.truncate(size) if @file.size < size
@mmap = Mmap.new(@file.path, 'w')
end
def read_record(index, record_size)
offset = index * record_size
@mmap[offset, record_size]
end
def write_record(index, record_size, data)
offset = index * record_size
@mmap[offset, record_size] = data
@mmap.msync # Flush to disk
end
def close
@mmap.unmap
@file.close
end
end
Shared memory through memory-mapped files enables inter-process communication. Multiple processes map the same file into their address spaces, creating a shared memory region visible to all. Changes made by any process appear to others after synchronization. The kernel maintains a single set of physical pages for the file, reducing memory usage compared to each process having private copies. This pattern suits situations where processes need to exchange large amounts of data efficiently.
# Shared memory communication pattern
class SharedMemoryQueue
def initialize(path, size)
File.write(path, "\0" * size) unless File.exist?(path)
@mmap = Mmap.new(path, 'w')
@size = size
end
# Producer writes data
def enqueue(data)
header_size = 8
data_len = [data.bytesize].pack('Q')
# Write length then data
@mmap[0, 8] = data_len
@mmap[8, data.bytesize] = data
@mmap.msync
end
# Consumer reads data
def dequeue
header_size = 8
data_len = @mmap[0, 8].unpack1('Q')
return nil if data_len == 0
data = @mmap[8, data_len]
# Clear the queue
@mmap[0, 8] = [0].pack('Q')
@mmap.msync
data
end
end
Memory-mapped I/O accesses hardware device registers by mapping physical device memory into the process address space. This technique bypasses traditional I/O port operations, treating device memory as ordinary memory. Reads and writes to the mapped region directly access device registers. Device driver development frequently uses memory-mapped I/O for performance-critical hardware interaction, though application-level code rarely needs this approach.
The streaming read pattern combines sequential access with memory mapping for processing large files. Map a window of the file into memory, process that window, unmap it, then map the next window. This approach limits memory consumption while maintaining efficient access patterns. The kernel's read-ahead mechanisms optimize sequential mapping operations, prefetching pages before they're accessed.
# Windowed mapping for large file processing
class WindowedFileProcessor
WINDOW_SIZE = 64 * 1024 * 1024 # 64 MB windows
def process_file(path)
file_size = File.size(path)
offset = 0
while offset < file_size
window_size = [WINDOW_SIZE, file_size - offset].min
# Map current window
# Note: Most Ruby mmap gems map entire files
# This is conceptual - would need lower-level control
process_window(path, offset, window_size)
offset += window_size
end
end
private
def process_window(path, offset, size)
# Process this portion of the file
# Implementation would use file positioning
# or a gem supporting partial mapping
end
end
The persistent data structure pattern uses memory-mapped files as durable storage for in-memory data structures. Hash tables, trees, or other structures get laid out in a file format that supports direct pointer access. The application maps the file and accesses structures through memory addresses. Modifications persist automatically as the kernel flushes dirty pages. This approach creates data structures that survive process restarts without serialization overhead.
Performance Considerations
Memory mapping eliminates data copying between kernel and user space. Traditional I/O requires the kernel to copy data from the page cache into application buffers, then the application processes that copy. Memory-mapped access operates directly on pages in the page cache, removing one copy operation. This reduction matters most for large data transfers where copy overhead dominates execution time.
Page fault handling adds latency to first access of each page. When code accesses an unmapped page, the processor triggers a page fault exception. The kernel must locate the corresponding page in the file, allocate physical memory, load the page contents, and update page tables. This process takes microseconds but can accumulate for workloads touching many pages. Sequential access patterns minimize this cost because the kernel's read-ahead prefetches pages before they're accessed.
# Comparison: traditional I/O vs memory mapping
require 'benchmark'
require 'mmap2'
file_size = 100 * 1024 * 1024 # 100 MB
File.open('testfile.dat', 'wb') { |f| f.write('x' * file_size) }
Benchmark.bm(20) do |x|
# Traditional sequential read
x.report('Traditional I/O:') do
sum = 0
File.open('testfile.dat', 'rb') do |f|
while chunk = f.read(4096)
sum += chunk.bytes.sum
end
end
end
# Memory-mapped sequential read
x.report('Memory mapped:') do
mmap = Mmap.new('testfile.dat', 'r')
sum = 0
(0...file_size).step(4096) do |offset|
chunk_size = [4096, file_size - offset].min
sum += mmap[offset, chunk_size].bytes.sum
end
mmap.unmap
end
end
Memory consumption patterns differ between traditional I/O and memory mapping. Traditional I/O uses explicit buffers whose size the application controls. Memory mapping commits virtual address space equal to the mapping size but consumes physical memory only for accessed pages. For sparse access patterns where small portions of a large file get read, memory mapping uses less physical memory. For dense sequential access, memory mapping may use more memory because the kernel keeps recently accessed pages cached.
The TLB (Translation Lookaside Buffer) limits memory mapping efficiency for scattered access patterns. The TLB caches virtual-to-physical address translations for recently used pages. When access spans many pages in a scattered pattern, TLB misses increase, requiring expensive page table walks. Sequential access patterns keep the TLB hot, while random access across a large file causes frequent TLB misses. Traditional I/O using large buffers can outperform memory mapping for truly random access because it maintains locality within buffers.
Write patterns affect performance differently for memory mapping versus traditional I/O. Memory-mapped writes mark pages dirty in place without immediate disk I/O. The kernel flushes dirty pages asynchronously, potentially batching many pages into efficient write operations. Traditional write system calls either copy data into kernel buffers (buffered I/O) or wait for disk completion (unbuffered I/O). Memory mapping provides the benefits of buffered writes without explicit buffer management, but applications lose control over write timing.
# Write performance comparison
require 'benchmark'
require 'mmap2'
file_size = 10 * 1024 * 1024 # 10 MB
data = 'x' * 4096
Benchmark.bm(20) do |x|
# Traditional buffered writes
x.report('Buffered writes:') do
File.open('write_test1.dat', 'wb') do |f|
(file_size / 4096).times do
f.write(data)
end
end
end
# Memory-mapped writes
x.report('Mapped writes:') do
File.open('write_test2.dat', 'wb') { |f| f.write("\0" * file_size) }
mmap = Mmap.new('write_test2.dat', 'w')
(file_size / 4096).times do |i|
mmap[i * 4096, 4096] = data
end
mmap.msync # Force flush for fair comparison
mmap.unmap
end
end
Cache coherency overhead affects shared mappings accessed by multiple processes. When one process modifies a shared page, the kernel must ensure other processes see the change. On single-processor systems, this happens automatically through the shared page cache. On multiprocessor systems, cache coherency protocols invalidate cached copies of modified cache lines across processor caches. This overhead increases with the number of processors and the frequency of modifications to shared pages.
File size changes interact poorly with memory mapping. If a file grows beyond its mapped size, the original mapping doesn't reflect the extension. Applications must unmap and remap to access new content. If a file shrinks below its mapped size, accessing now-invalid addresses causes segmentation faults. Traditional I/O handles size changes gracefully because each operation checks current file size. Applications using memory mapping for dynamic files must carefully manage mapping lifetime relative to file size changes.
Common Pitfalls
Forgetting to synchronize shared mappings leads to data loss. Modifications to memory-mapped pages remain in memory until the kernel decides to flush them. If the process terminates before flushing, changes may not persist. Calling msync forces dirty pages to disk, but applications must remember to call it at appropriate points. Unlike traditional I/O where write calls immediately copy data to kernel buffers, memory mapping delays durability.
require 'mmap2'
# Dangerous: changes may not persist
mmap = Mmap.new('important.dat', 'w')
mmap[0, 10] = "critical!"
mmap.unmap # Might lose data if process crashes
# Safe: explicit synchronization
mmap = Mmap.new('important.dat', 'w')
mmap[0, 10] = "critical!"
mmap.msync # Force flush to disk
mmap.unmap
Accessing memory beyond the mapping bounds causes segmentation faults rather than returning errors. Traditional file I/O returns error codes for reads or writes past end-of-file. Memory mapping crashes the process. Applications must carefully track mapping sizes and validate offsets before access. This behavior makes debugging harder because crashes occur at memory access sites rather than mapping creation sites.
# Bounds checking necessary for safety
class SafeMapping
def initialize(path, mode)
@mmap = Mmap.new(path, mode)
@size = @mmap.size
end
def read(offset, length)
raise ArgumentError, "Read beyond mapping" if offset + length > @size
@mmap[offset, length]
end
def write(offset, data)
raise ArgumentError, "Write beyond mapping" if offset + data.bytesize > @size
@mmap[offset, data.bytesize] = data
end
end
Platform differences in mapping behavior create portability issues. Windows and POSIX systems handle certain mapping operations differently. File mappings on Windows require files to remain open while mapped, whereas POSIX allows unmapping the file descriptor after mapping. Maximum mapping sizes vary by platform and process architecture. Code that works on 64-bit Linux may fail on 32-bit Windows due to address space limitations.
Private mappings consume more memory than expected when modified extensively. The copy-on-write mechanism creates private copies of pages as they're written. An application that maps a large file privately and modifies it extensively ends up with both the original pages and private copies in memory. This memory doubling surprises developers expecting memory mapping to reduce memory usage. Shared mappings avoid this issue but require careful synchronization.
# Private mapping memory consumption
mmap = Mmap.new('large.dat', 'c') # Private copy-on-write
# Modifying the entire mapping creates private copies
# Memory usage: original file size + modified page count
(0...mmap.size).step(4096) do |offset|
mmap[offset] = 'X' # Triggers copy-on-write for each page
end
# Memory usage now ~2x file size
Mapping alignment requirements cause confusion. The offset parameter to mmap must align to page boundaries. An application attempting to map starting at an arbitrary byte offset receives an error. The length parameter need not align, but the kernel rounds up to page boundaries internally. This means mapping a 1-byte file still consumes a full page of address space.
Signal handling during page faults creates race conditions. When a page fault loads data from disk, the process blocks waiting for I/O completion. If a signal arrives during this time, the signal handler executes before the memory access completes. Code in signal handlers that accesses the same mapped region can cause deadlocks or corruption. Applications must either avoid mapped memory access in signal handlers or use appropriate synchronization.
Mixing memory mapping with traditional I/O on the same file produces inconsistent views. The kernel caches file contents in the page cache for mapped access. Separate read or write system calls may use different cache mechanisms, leading to stale data. Changes through memory mapping might not appear in traditional I/O reads immediately, and vice versa. Applications should choose one access method per file or carefully synchronize between methods.
# Dangerous: mixed access modes
file = File.open('data.dat', 'r+')
mmap = Mmap.new('data.dat', 'w')
mmap[0, 5] = "hello"
mmap.msync
file.seek(0)
# May read stale data depending on platform
puts file.read(5)
mmap.unmap
file.close
Reference
Memory Mapping System Calls
| Function | Purpose | Key Parameters |
|---|---|---|
| mmap | Create a new mapping | addr, length, prot, flags, fd, offset |
| munmap | Remove a mapping | addr, length |
| msync | Synchronize mapping to disk | addr, length, flags |
| mprotect | Change protection on mapping | addr, length, prot |
| madvise | Give usage hints to kernel | addr, length, advice |
| mremap | Resize existing mapping | old_addr, old_size, new_size, flags |
Protection Flags
| Flag | Value | Description |
|---|---|---|
| PROT_NONE | 0 | Page cannot be accessed |
| PROT_READ | 1 | Page can be read |
| PROT_WRITE | 2 | Page can be written |
| PROT_EXEC | 4 | Page can be executed |
Mapping Flags
| Flag | Purpose | Effect |
|---|---|---|
| MAP_SHARED | Shared mapping | Changes visible to other processes |
| MAP_PRIVATE | Private mapping | Copy-on-write for modifications |
| MAP_ANONYMOUS | Anonymous mapping | Not backed by file |
| MAP_FIXED | Fixed address | Map at exact address or fail |
| MAP_POPULATE | Prefault pages | Load all pages immediately |
| MAP_LOCKED | Lock pages | Prevent swapping |
Synchronization Flags
| Flag | Behavior | Use Case |
|---|---|---|
| MS_ASYNC | Asynchronous flush | Schedule writeback, return immediately |
| MS_SYNC | Synchronous flush | Wait for writeback completion |
| MS_INVALIDATE | Invalidate caches | Force reload from disk |
Ruby mmap2 Gem Methods
| Method | Purpose | Example |
|---|---|---|
| Mmap.new | Create mapping | Mmap.new(path, mode) |
| Mmap[] | Read from mapping | mmap[offset, length] |
| Mmap[]= | Write to mapping | mmap[offset, length] = data |
| Mmap#size | Get mapping size | mmap.size |
| Mmap#msync | Synchronize to disk | mmap.msync |
| Mmap#munmap | Unmap region | mmap.munmap |
| Mmap#mprotect | Change protection | mmap.mprotect(mode) |
Mode Strings
| Mode | Access | Sharing | Equivalent Flags |
|---|---|---|---|
| r | Read-only | Shared | PROT_READ, MAP_SHARED |
| w | Read-write | Shared | PROT_READ + PROT_WRITE, MAP_SHARED |
| c | Copy-on-write | Private | PROT_READ + PROT_WRITE, MAP_PRIVATE |
Common Usage Patterns
| Pattern | Code Structure | When to Use |
|---|---|---|
| Read-only access | mmap = Mmap.new(path, 'r') | Processing large files |
| Random access | data = mmap[offset, size] | Non-sequential reads |
| Shared memory | mmap = Mmap.new(path, 'w') + fork | Inter-process communication |
| Persistent data | mmap[offset] = data + msync | Durable structures |
| Large allocations | MAP_ANONYMOUS mapping | Memory-intensive operations |
Performance Characteristics
| Operation | Memory Mapping | Traditional I/O | Winner |
|---|---|---|---|
| Sequential read | Fast (read-ahead) | Fast (buffered) | Tie |
| Random read | Very fast (page cache) | Slow (seeks) | Memory mapping |
| Sequential write | Fast (async writeback) | Fast (buffered) | Tie |
| Random write | Fast (in-place) | Slow (seeks) | Memory mapping |
| First access latency | High (page faults) | Low (immediate) | Traditional I/O |
| Memory overhead | Variable (accessed pages) | Fixed (buffers) | Depends on pattern |
Size Limitations
| Platform | Max Mapping Size | Address Space |
|---|---|---|
| 32-bit systems | ~2-3 GB | Limited by virtual address space |
| 64-bit systems | Terabytes | Limited by physical memory + swap |
| Windows | Per mapping limits apply | Handle limits per process |
| Linux | No practical limit | Per-process limit configurable |
Troubleshooting Checklist
| Symptom | Likely Cause | Solution |
|---|---|---|
| Segmentation fault | Access beyond mapping | Add bounds checking |
| EINVAL error | Unaligned offset | Round offset to page boundary |
| ENOMEM error | Address space exhausted | Reduce mapping size or use windowing |
| Data loss | Missing msync | Add explicit synchronization |
| Performance degradation | TLB thrashing | Reduce number of accessed pages |
| Memory bloat | Private mapping modifications | Use shared mapping or traditional I/O |