liveBook · Manning

Synchronizes-with relation

As explained in C++ Concurrency in Action, Second Edition

A C++ memory-model relation between operations that establishes inter-thread ordering and visibility.

The synchronizes-with relation establishes an ordering between operations in different threads. Its practical value is making earlier writes visible to a thread that observes a synchronization event. Combined with each thread’s own operation order, it creates a transitive happens-before path that can make access to non-atomic data safe. section 5.3

Publish data with an atomic flag

A writer appends 42 to a non-atomic vector, then sets an atomic readiness flag. A reader waits until it loads true before reading the vector. With the default atomic ordering, the flag store synchronizes with the load that reads true. The vector write precedes the store, and the vector read follows the load, so the write happens before the read. Loading false during an earlier loop iteration does not establish that path. section 5.3

Listing 5.2. Reading and writing variables from different threads
#include <vector>
#include <atomic>
#include <iostream>
std::vector<int> data;
std::atomic<bool> data_ready(false);
void reader_thread()
{
    while(!data_ready.load())                   #1
    {
        std::this_thread::sleep(std::chrono::milliseconds(1));
    }
    std::cout<<"The answer="<<data[0]<<"\n";    #2
}
void writer_thread()
{
    data.push_back(42);                         #3
    data_ready=true;                            #4
}


!@%STYLE%@!
{"css":"{\"css\": \"font-weight: bold;\"}","target":"[[{\"line\":7,\"ch\":48},{\"line\":7,\"ch\":50}],[{\"line\":11,\"ch\":48},{\"line\":11,\"ch\":50}],[{\"line\":15,\"ch\":48},{\"line\":15,\"ch\":50}],[{\"line\":16,\"ch\":48},{\"line\":16,\"ch\":50}]]"}
!@%STYLE%@!

Follow the four marked operations: vector write, flag store, flag load that reads true, then vector read. The atomic flag orders the two non-atomic accesses. section 5.3

The basic atomic case pairs a suitably ordered write with a read of the same atomic variable that observes its value. Ordering choices matter: a sequentially consistent store and observing load synchronize; a release store and observing acquire load can do so without imposing a total order on all atomic operations. Relaxed operations alone do not supply that cross-thread ordering. A consume load instead has a dependency-ordering relationship with an appropriate release sequence; do not treat it as an ordinary acquire load. section 5.3.1 section 5.3.3

The acquiring read need not see the release store directly. If successive atomic read-modify-write operations form a release sequence, an acquire load of a value in that sequence can synchronize with its initial release store. For example, consumers claiming items with acquire fetch_sub operations can each observe a queue buffer published before a release store to its item count—even when a later consumer reads a count written by an earlier consumer. section 5.3.4

Listing 5.11. Reading values from a queue with atomic operations
#include <atomic>
#include <thread>
std::vector<int> queue_data;
std::atomic<int> count;
void populate_queue()
{
    unsigned const number_of_items=20;
    queue_data.clear();
    for(unsigned i=0;i<number_of_items;++i)
    {
        queue_data.push_back(i);
    }

    count.store(number_of_items,std::memory_order_release);               #1
}
void consume_queue_items()
{
    while(true)
    {
        int item_index;
        if((item_index=count.fetch_sub(1,std::memory_order_acquire))<=0)  #2
        {
            wait_for_more_items();                                        #3
            continue;
        }
        process(queue_data[item_index-1]);                                #4
    }
}
int main()
{
    std::thread a(populate_queue);
    std::thread b(consume_queue_items);
    std::thread c(consume_queue_items);
    a.join();
    b.join();
    c.join();
}

The queue listing shows why the second consumer needs the release-sequence rule: its fetch_sub reads the first consumer’s update rather than the producer’s store. section 5.3.4

The same reasoning applies beyond an explicit flag. A mutex unlock can synchronize with a later lock on that mutex, ordering protected writes before the next holder’s accesses. Thread start and successful join also provide specified synchronization points. In a lock-free stack, a release update of the head and an acquiring compare-exchange can order node initialization before pop reads it; an unrelated relaxed load cannot replace that acquire. Release and acquire fences can also establish synchronization through appropriately placed atomic operations. section 5.3.7 section 7.2.5 section 5.3.5

How C++ Concurrency in Action, Second Edition covers it

How deep it goes
Works through observing loads, memory-order choices, release sequences, and acquire placement in a lock-free stack.
Examples
Traces a vector and readiness flag, a multi-consumer queue, and a lock-free stack.
What it assumes
Builds on atomic loads and stores and the risk of unordered non-atomic access; introduces ordering tags before elaborating release sequences.
What you can do after
Diagnose/debug with it whether an observed atomic value actually orders earlier writes before later reads.
How it connects
Connects happens-before, release-acquire ordering, release sequences, fences, mutexes, and lock-free data structures.

Also explained in

Compare how 3 books explain synchronizes-with relation →

Related concepts

This explainer was generated by Manning's knowledge graph from the text of C++ Concurrency in Action, Second Edition. The section links point to the parts of the book it draws on.