Memory models in C++11 part 2 - several memory models supported by C++11

In the first part of this series, I introduced the basic concept of memory model. Next, I'll look at several memory models supported in C++11.

Several relational terms

Before continuing with the explanation, let's understand several relational terms.

sequenced-before

Sequential before is used to indicate the sequence of two operations between a single thread. This sequence is asymmetric and can be passed.

It not only represents the sequence between two operations, but also represents the visibility relationship between operation results. Two operations a and B, if there is a sequenced before B, not only indicates that the order of operation a is before B, but also indicates the result of operation A. operation B is visible.

happens-before

Different from sequential before, the happens before relationship represents the sequence of operations between different threads. Similarly, it is also an asymmetric and transitive relationship.

If A happens before B, the memory state of A will be visible before the execution of B operation. In the previous article, in some cases, A write operation simply writes to memory and returns. Operations on other cores may not see the operation results immediately. Such A relationship is not happy before.

synchronizes-with

The synchronizes with relationship emphasizes the propagation relationship after a variable is modified, that is, if the result of a thread modifying a variable can be seen by other threads, the synchronizes with relationship is satisfied.

Obviously, operations that satisfy the synchronizes with relationship must satisfy the happens before relationship.

Memory models supported in C++11

Since C++11, the following memory models have been supported:

enum memory_order {
    memory_order_relaxed,
    memory_order_consume,
    memory_order_acquire,
    memory_order_release,
    memory_order_acq_rel,
    memory_order_seq_cst
};

There are six types of enumerations related to the memory model, but they are actually divided into four types, as shown in the figure below. The requirements for consistency are gradually weakened, which will be explained below.

 

memory_order_seq_cst

This is the default memory model, that is, the sequential consistency memory model analyzed in the previous article. Since the related concepts in the previous article have been introduced in detail, they will not be described here. Only the sample code referenced from C++ Concurrency In Action is listed.

#include <atomic>
#include <thread>
#include <assert.h>

std::atomic<bool> x, y;
std::atomic<int> z;

void write_x() {
    x.store(true,std::memory_order_seq_cst);
}

void write_y() {
    y.store(true,std::memory_order_seq_cst);
}

void read_x_then_y() {
    while(!x.load(std::memory_order_seq_cst));
    if(y.load(std::memory_order_seq_cst)) {
        ++z;
    }
}

void read_y_then_x() {
    while(!y.load(std::memory_order_seq_cst));
    if(x.load(std::memory_order_seq_cst)) {
        ++z;
    }
}

int main() {
    x = false;
    y = false;
    z = 0;

    std::thread a(write_x);
    std::thread b(write_y);
    std::thread c(read_x_then_y);
    std::thread d(read_y_then_x);

    a.join();
    b.join();
    c.join();
    d.join();

    assert(z.load()!=0);
}

Due to the sequential consistency model, the final assertion cannot occur, that is, z is 0 at the end of the program.

memory_order_relaxed

This type corresponds to the loose memory model, which is characterized by:

  • Read and write operations on a variable are atomic operations;
  • The sequence of access operations to this variable between different threads cannot be guaranteed, that is, it may be out of order.

Look at the sample code:

#include <atomic>
#include <thread>
#include <assert.h>

std::atomic<bool> x, y;
std::atomic<int> z;

void write_x_then_y() {
    x.store(true,std::memory_order_relaxed);
    y.store(true,std::memory_order_relaxed);
}

void read_y_then_x() {
    while(!y.load(std::memory_order_relaxed));
    if(x.load(std::memory_order_relaxed)) {
        ++z;
    }
}

int main() {
    x = false;
    y = false;
    z = 0;

    std::thread a(write_x_then_y);
    std::thread b(read_y_then_x);

    a.join();
    b.join();

    assert(z.load()!=0);
}

In the above code, the access to atomic variables uses memory_ order_ In the relaxed model, the final assertion may fail, that is, z may be 0 at the end of the program.

Acquire-Release

  • memory_order_acquire: used to modify a read operation. It means that in this thread, all subsequent memory operations on this variable must be executed after this atomic operation is completed.

  • memory_order_release: used to modify a write operation. It means that this atomic operation can only be executed after all previous memory operations on this variable are completed in this thread.

 

  • memory_order_acq_rel: also contains memory_order_acquire and memory_order_release flag.

Look at the sample code:

#include <atomic>
#include <thread>
#include <assert.h>

std::atomic<bool> x, y;
std::atomic<int> z;

void write_x() {
    x.store(true,std::memory_order_release);
}

void write_y() {
    y.store(true,std::memory_order_release);
}

void read_x_then_y() {
    while(!x.load(std::memory_order_acquire));
    if(y.load(std::memory_order_acquire)) {
        ++z;
    }
}

void read_y_then_x() {
    while(!y.load(std::memory_order_acquire));
    if(x.load(std::memory_order_acquire)) {
        ++z;
    }
}

int main() {
    x = false;
    y = false;
    z = 0;

    std::thread a(write_x);
    std::thread b(write_y);
    std::thread c(read_x_then_y);
    std::thread d(read_y_then_x);

    a.join();
    b.join();
    c.join();
    d.join();

    assert(z.load()!=0);
}

In the above example, there is no guarantee that the final assertion of the program is Z= 0 is true because the synchronization of x and y variables in different threads does not guarantee the reading of x and y variables.

Thread write_x uses the write release model for variable x, which ensures read_ x_ then_ In the Y function, X is true before the load variable y; Similarly, thread write_y uses the write release model for variable y, which ensures read_ y_ then_ In the X function, y is true before the load variable x.

However, even so, the following similar situations may occur:

As shown in the figure above:

  • The initial condition is x = y = false.
  • Because in read_ x_ and_ In the Y thread, the acquire model is used for the load operation of X, so it is guaranteed to execute write first_ The X function came to this step; Similarly, execute write first_ Y to read_ y_ and_ Load operation for y in X.
  • However, even so, it may appear in read_ x_ then_ The load operation for y in Y is completed before the store operation of Y, because there is no sequential relationship between the y.store operation and this; Similarly, there is no guarantee that x will read the true value, so z = 0 will occur at the end of the program.

It can be seen from the above analysis that even if the release acquire model is used here, z=0 is still not guaranteed. The reason is that the initial write operations for x and y variables are written respectively_ x and write_y thread, and the order of execution cannot be guaranteed. Therefore, it is revised as follows:

#include <atomic>
#include <thread>
#include <assert.h>

std::atomic<bool> x, y;
std::atomic<int> z;

void write_x_then_y() {
    x.store(true,std::memory_order_release);
    y.store(true,std::memory_order_release);
}

void read_y_then_x() {
    while(!y.load(std::memory_order_acquire));
    if(x.load(std::memory_order_acquire)) {
        ++z;
    }
}

int main() {
    x = false;
    y = false;
    z = 0;

    std::thread a(write_x_then_y);
    std::thread b(read_y_then_x);

    a.join();
    b.join();

    assert(z.load()!=0);
}

As shown in the figure above:

  • The initial condition is x = y = false.
  • In write_ x_ then_ In the Y thread, the write operation to X is performed first, and then the write operation to y is performed. Because they are in the same thread, even if the relaxed model is used for the modification of X, the modification of X must be performed before the modification of Y.
  • In write_ x_ then_ In the Y thread, the acquire model is used for the load operation of Y, while the write model is used in the thread_ x_ then_ The release model is used for the read operation of variable y in Y, so it is guaranteed to execute write first_ x_ then_ Y function to read_y_then_x's load operation for variable y.
  • Therefore, the final execution sequence is shown in the figure above. At this time, z=0 is impossible.

From the above analysis, it can be seen that the release acquire operation for the same variable often plays the role of "synchronization of using a variable between threads". Due to the guarantee of this semantics, the order of inter thread operations is guaranteed.

Release-Consume

From the above analysis of the acquire release model, we can know that although this model can be used to synchronize with some operations between two threads, the granularity is too large.

In many cases, threads only want to synchronize dependent operations. In addition, the order of other operations in the thread doesn't matter. For example, in the following code:

b = *a;
c = *b;

The execution result of the second line of code depends on the execution result of the first line of code. At this time, the relationship between the two lines of code is called "carry-a-dependency". Memory introduced in C + +_ order_ The consume memory model limits the order of statements with clear dependencies between such codes.

Let's look at the following example code:

#include <string>
#include <thread>
#include <atomic>
#include <assert.h>

struct X {
    int i;
    std::string s;
};

std::atomic<X*> p;
std::atomic<int> a;

void create_x() {
    X* x = new X;
    x->i = 42;
    x->s = "hello";
    a.store(99,std::memory_order_relaxed);
    p.store(x,std::memory_order_release);
}

void use_x() {
    X* x;
    while(!(x=p.load(std::memory_order_consume))) {
        std::this_thread::sleep_for(std::chrono::microseconds(1));
    }

    assert(x->i==42);
    assert(x->s=="hello");
    assert(a.load(std::memory_order_relaxed)==99);
}

int main() {
    std::thread t1(create_x);
    std::thread t2(use_x);

    t1.join();
    t2.join();
}

In the above code:

  • create_ The store(x) operation in the x thread uses memory_order_release, and in use_ In the x thread, memory is used for X_ order_ For the load operation of the consumption memory model, there is a carry-a-dependency relationship between the two, so the order of execution can be guaranteed. Therefore, neither of the assertions X - > I = = 42 nor X - > s = = "hello" will fail.
  • However, create_ store operation using relax memory model for variable a in X, use_ The x thread also has a load operation using the relax memory model for variable a. The order of execution of the two is not affected by the previous memory_ order_ Because of the influence of the consumption memory model, the sequence cannot be guaranteed. Therefore, it is possible to assert that a.load(std::memory_order_relaxed)==99 is true or false.

By comparing the two memory models of acquire release and release consume, we can know that:

  • Acquire release can ensure the synchronizes with relationship between different threads, which also restricts the execution order of the previous and subsequent statements in the same thread.
  • Release consumption only restricts the execution order of statements with explicit carry-a-dependency relationship, and the execution order of other statements in the same thread is not affected by this memory model.

reference material

Tags: C++ Back-end

Posted on Fri, 03 Dec 2021 13:26:01 -0500 by dashti