Several processes reading and writing the same memory at once is how data
gets mixed up: a reader can catch a writer halfway, or a buggy process can
scribble over the layout everyone relies on. This page explains the two
habits that keep a box safe while other processes use
the same segment: a process never trusts the shared
copy of the layout, and it writes only under a
sequence lock.
Any process that can open the block can also write to it, header included.
So if the code read a field's offset from the header
every time it copied data, another process could change that offset between
the check and the copy, and make this process read or write outside the
block. This mistake has a name, "time of check, time of use".1
To rule it out, a process that opens an existing block copies line 0 of the
header and the field table once, checks those copies against the size of
the mapping as the operating system reports it (fstat on Linux,
VirtualQuery on Windows), and from then on uses only its own copies. The header's own size field must
match the mapping size, but the mapping size comes from the OS, not from
the block. The same idea applies to values arriving from Python: the
native write checks the size of every value before it takes the write
lock, so a bad value in a multi-field update changes nothing.
The lock has two jobs: a reader must never hold up a writer, and neither
should need a call into the operating system in the usual case. A single
counter in the header does both. The technique is called a sequence
lock:2 the counter is even when no write is in progress and odd
while one is running.
Step through a write that lands in the middle of a read, and point at a
shape to read what it does:
A read that meets a write
The reader notes the counter: 4 is even, so no write is running.
writerAny process that assigns a field or calls update.seq = 4The counter in the header. Even means no write is running, odd means one is.readerAny process reading a field. It never changes the counter, so it never holds up a writer.recordThe bytes of every field, which readers copy and writers overwrite.1 s = seq;2cas(seq, s, s +1);3copy(values, record);4cas(seq, s +1, s +2);1s=seq;2cas(seq,s,s+1);3copy(values,record);4cas(seq,s+1,s+2);1-> s = seq;2copy(record, out);3if(seq != s) retry;1->s=seq;2copy(record,out);3if(seq!=s)retry;notes 4swaps 4 for 5writescopiesAny process that assigns a field or calls update.The counter in the header. Even means no write is running, odd means one is.Any process reading a field. It never changes the counter, so it never holds up a writer.The bytes of every field, which readers copy and writers overwrite.
A writer takes the lock by swapping 4 for 5 in one atomic step.
writerAny process that assigns a field or calls update.seq = 5The counter in the header. Even means no write is running, odd means one is.readerAny process reading a field. It never changes the counter, so it never holds up a writer.recordThe bytes of every field, which readers copy and writers overwrite.1 s = seq;2-> cas(seq, s, s +1);3copy(values, record);4cas(seq, s +1, s +2);1s=seq;2->cas(seq,s,s+1);3copy(values,record);4cas(seq,s+1,s+2);1-> s = seq;2copy(record, out);3if(seq != s) retry;1->s=seq;2copy(record,out);3if(seq!=s)retry;notes 4swaps 4 for 5writescopiesAny process that assigns a field or calls update.The counter in the header. Even means no write is running, odd means one is.Any process reading a field. It never changes the counter, so it never holds up a writer.The bytes of every field, which readers copy and writers overwrite.
writerAny process that assigns a field or calls update.seq = 5The counter in the header. Even means no write is running, odd means one is.readerAny process reading a field. It never changes the counter, so it never holds up a writer.recordThe bytes of every field, which readers copy and writers overwrite.1 s = seq;2-> cas(seq, s, s +1);3copy(values, record);4cas(seq, s +1, s +2);1s=seq;2->cas(seq,s,s+1);3copy(values,record);4cas(seq,s+1,s+2);1-> s = seq;2copy(record, out);3if(seq != s) retry;1->s=seq;2copy(record,out);3if(seq!=s)retry;notes 4swaps 4 for 5writescopiesAny process that assigns a field or calls update.The counter in the header. Even means no write is running, odd means one is.Any process reading a field. It never changes the counter, so it never holds up a writer.The bytes of every field, which readers copy and writers overwrite.
The writer copies its values in while the reader copies the record out, so the reader's copy may mix old and new bytes.
writerAny process that assigns a field or calls update.seq = 5The counter in the header. Even means no write is running, odd means one is.readerAny process reading a field. It never changes the counter, so it never holds up a writer.recordThe bytes of every field, which readers copy and writers overwrite.1 s = seq;2cas(seq, s, s +1);3-> copy(values, record);4cas(seq, s +1, s +2);1s=seq;2cas(seq,s,s+1);3->copy(values,record);4cas(seq,s+1,s+2);1 s = seq;2-> copy(record, out);3if(seq != s) retry;1s=seq;2->copy(record,out);3if(seq!=s)retry;notes 4swaps 4 for 5writescopiesAny process that assigns a field or calls update.The counter in the header. Even means no write is running, odd means one is.Any process reading a field. It never changes the counter, so it never holds up a writer.The bytes of every field, which readers copy and writers overwrite.
writerAny process that assigns a field or calls update.seq = 5The counter in the header. Even means no write is running, odd means one is.readerAny process reading a field. It never changes the counter, so it never holds up a writer.recordThe bytes of every field, which readers copy and writers overwrite.1 s = seq;2cas(seq, s, s +1);3-> copy(values, record);4cas(seq, s +1, s +2);1s=seq;2cas(seq,s,s+1);3->copy(values,record);4cas(seq,s+1,s+2);1 s = seq;2-> copy(record, out);3if(seq != s) retry;1s=seq;2->copy(record,out);3if(seq!=s)retry;notes 4swaps 4 for 5writescopiesAny process that assigns a field or calls update.The counter in the header. Even means no write is running, odd means one is.Any process reading a field. It never changes the counter, so it never holds up a writer.The bytes of every field, which readers copy and writers overwrite.
The writer releases the lock by setting the counter to 6.
writerAny process that assigns a field or calls update.seq = 6The counter in the header. Even means no write is running, odd means one is.readerAny process reading a field. It never changes the counter, so it never holds up a writer.recordThe bytes of every field, which readers copy and writers overwrite.1 s = seq;2cas(seq, s, s +1);3copy(values, record);4-> cas(seq, s +1, s +2);1s=seq;2cas(seq,s,s+1);3copy(values,record);4->cas(seq,s+1,s+2);1 s = seq;2-> copy(record, out);3if(seq != s) retry;1s=seq;2->copy(record,out);3if(seq!=s)retry;notes 4sets 6writescopiesAny process that assigns a field or calls update.The counter in the header. Even means no write is running, odd means one is.Any process reading a field. It never changes the counter, so it never holds up a writer.The bytes of every field, which readers copy and writers overwrite.
writerAny process that assigns a field or calls update.seq = 6The counter in the header. Even means no write is running, odd means one is.readerAny process reading a field. It never changes the counter, so it never holds up a writer.recordThe bytes of every field, which readers copy and writers overwrite.1 s = seq;2cas(seq, s, s +1);3copy(values, record);4-> cas(seq, s +1, s +2);1s=seq;2cas(seq,s,s+1);3copy(values,record);4->cas(seq,s+1,s+2);1 s = seq;2-> copy(record, out);3if(seq != s) retry;1s=seq;2->copy(record,out);3if(seq!=s)retry;notes 4sets 6writescopiesAny process that assigns a field or calls update.The counter in the header. Even means no write is running, odd means one is.Any process reading a field. It never changes the counter, so it never holds up a writer.The bytes of every field, which readers copy and writers overwrite.
The reader looks at the counter again: 6, not the 4 it noted, so it throws its copy away and starts over.
writerAny process that assigns a field or calls update.seq = 6The counter in the header. Even means no write is running, odd means one is.readerAny process reading a field. It never changes the counter, so it never holds up a writer.recordThe bytes of every field, which readers copy and writers overwrite.1 s = seq;2cas(seq, s, s +1);3copy(values, record);4cas(seq, s +1, s +2);1s=seq;2cas(seq,s,s+1);3copy(values,record);4cas(seq,s+1,s+2);1 s = seq;2copy(record, out);3->if(seq != s) retry;1s=seq;2copy(record,out);3->if(seq!=s)retry;now 6: retrysets 6writescopiesAny process that assigns a field or calls update.The counter in the header. Even means no write is running, odd means one is.Any process reading a field. It never changes the counter, so it never holds up a writer.The bytes of every field, which readers copy and writers overwrite.
writerAny process that assigns a field or calls update.seq = 6The counter in the header. Even means no write is running, odd means one is.readerAny process reading a field. It never changes the counter, so it never holds up a writer.recordThe bytes of every field, which readers copy and writers overwrite.1 s = seq;2cas(seq, s, s +1);3copy(values, record);4cas(seq, s +1, s +2);1s=seq;2cas(seq,s,s+1);3copy(values,record);4cas(seq,s+1,s+2);1 s = seq;2copy(record, out);3->if(seq != s) retry;1s=seq;2copy(record,out);3->if(seq!=s)retry;now 6: retrysets 6writescopiesAny process that assigns a field or calls update.The counter in the header. Even means no write is running, odd means one is.Any process reading a field. It never changes the counter, so it never holds up a writer.The bytes of every field, which readers copy and writers overwrite.
On the next try the counter is 6 before and after the copy, so the copy holds one whole write.
writerAny process that assigns a field or calls update.seq = 6The counter in the header. Even means no write is running, odd means one is.readerAny process reading a field. It never changes the counter, so it never holds up a writer.recordThe bytes of every field, which readers copy and writers overwrite.1 s = seq;2cas(seq, s, s +1);3copy(values, record);4cas(seq, s +1, s +2);1s=seq;2cas(seq,s,s+1);3copy(values,record);4cas(seq,s+1,s+2);1 s = seq;2copy(record, out);3->if(seq != s) retry;1s=seq;2copy(record,out);3->if(seq!=s)retry;still 6: donesets 6writescopiesAny process that assigns a field or calls update.The counter in the header. Even means no write is running, odd means one is.Any process reading a field. It never changes the counter, so it never holds up a writer.The bytes of every field, which readers copy and writers overwrite.
writerAny process that assigns a field or calls update.seq = 6The counter in the header. Even means no write is running, odd means one is.readerAny process reading a field. It never changes the counter, so it never holds up a writer.recordThe bytes of every field, which readers copy and writers overwrite.1 s = seq;2cas(seq, s, s +1);3copy(values, record);4cas(seq, s +1, s +2);1s=seq;2cas(seq,s,s+1);3copy(values,record);4cas(seq,s+1,s+2);1 s = seq;2copy(record, out);3->if(seq != s) retry;1s=seq;2copy(record,out);3->if(seq!=s)retry;still 6: donesets 6writescopiesAny process that assigns a field or calls update.The counter in the header. Even means no write is running, odd means one is.Any process reading a field. It never changes the counter, so it never holds up a writer.The bytes of every field, which readers copy and writers overwrite.
Because the reader always checks the counter again after copying, it never
returns a mix of old and new bytes,2 and writes to several fields
(update(a=..., b=...)) are seen all at once
or not at all. The writer takes the counter from even to odd with one
compare-and-swap, so two writers can't both win. Sequence
lock gives the exact steps and
orderings, including why the unlock is a compare-and-swap rather than a
plain store.
The code needs memory_order arguments and fences because processors and
compilers are allowed to reorder memory accesses when a single thread
cannot tell the difference.3 Here another
process can tell. The fences forbid the reorderings that would break the
rule above: the writer's data copy may not move before it takes the lock or
after it releases it, and the reader's check of the counter may not move
before its copy. This placement of the fences, a release fence after the
writer takes the counter and an acquire fence between the reader's copy and
its second look at the counter, is the one Boehm shows to be correct for
C++.4
sharedbox does not use an ordinary mutex, for three reasons:
A reader never makes a writer wait. With a mutex, a slow reader delays
every writer.
A process that dies holding a mutex leaves it held in a way other
processes cannot always detect. A dead writer here leaves an odd counter
and its pid in writer_pid, so you can see who held the lock, and
force_unlock releases it.
Writers take turns. One lock per box is enough for records the size of a
dataclass, and it is what lets update change several fields at once.
The counter must be usable from several processes at once. C++ only
guarantees that for atomic operations the processor performs directly,
without a hidden lock inside the program,5 so the header
checks at compile time that the counter's operations are of that kind:
writing holds this same lock for as long
as its block runs, so a reader that starts in the meantime keeps retrying
until the block ends. If that takes longer than the reader's lock timeout,
the reader gives up with LockTimeoutError.
That trade is worth it for a large array a producer can fill in place,
because it saves a full copy, but only while the block stays shorter than
the readers' lock timeout.
A writer that waits too long for the lock, or a reader that keeps losing
to writers, gives up after lock_timeout seconds with
LockTimeoutError, whose message names the
process stored in writer_pid as the lock's holder. That process may have
died while holding the lock, and force_unlock releases it.
The wait for the lock spins, then yields, then sleeps for a doubling
interval of at most 1 ms. On Windows a sleep ends on a timer tick, so a
wait lasts at least one tick; the Notes of SharedBox
give its length. In the stress tests on GitHub Actions runners (CI runs
36440952244 and 36443594984), a 1 ms lock_timeout ended after 15.7 and
15.8 ms on Windows (median of 200 waits), against 1.18 and 1.16 ms on
Linux. A lock holder that is descheduled for one timer tick can also make
a short timeout expire: with a 10 ms lock_timeout and two contending
writers, the Windows runner timed out 1 of 4.3 million writes in the first
run and none of 2.9 million in the second.
Hans-J. Boehm, "Can Seqlocks Get Along With Programming Language Memory
Models?", HP Laboratories technical report HPL-2012-68, 2012: which
fences make a sequence lock correct in C++.
https://www.hpl.hp.com/techreports/2012/HPL-2012-68.pdf↩