• Using std::promise
s and futures dodges these issues, but the approach uses heap memory for shared states, and it's limited to one-shot communication.
Item 40: Use std::atomic
for concurrency, volatile
for special memory.
Poor volatile
. So misunderstood. It shouldn't even be in this chapter, because it has nothing to do with concurrent programming. But in other programming languages (e.g., Java and C#), it is useful for such programming, and even in C++, some compilers have imbued volatile
with semantics that render it applicable to concurrent software (but only when compiled with those compilers). It's thus worthwhile to discuss volatile
in a chapter on concurrency if for no other reason than to dispel the confusion surrounding it.
The C++ feature that programmers sometimes confuse volatile
with —the feature that definitely does belong in this chapter — is the std::atomic
template. Instantiations of this template (e.g., std::atomic
, std::atomic
, std::atomic
, etc.) offer operations that are guaranteed to be seen as atomic by other threads. Once a std::atomic
object has been constructed, operations on it behave as if they were inside a mutex-protected critical section, but the operations are generally implemented using special machine instructions that are more efficient than would be the case if a mutex were employed.
Consider this code using std::atomic
:
std::atomicai(0); // initialize ai to 0
ai = 10; // atomically set ai to 10
std::cout << ai; // atomically read ai's value
++ai; // atomically increment ai to 11
--ai; // atomically decrement ai to 10
During execution of these statements, other threads reading ai
may see only values of 0, 10, or 11. No other values are possible (assuming, of course, that this is the only thread modifying ai
).
Two aspects of this example are worth noting. First, in the “ std::cout << ai;
” statement, the fact that ai
is a std::atomic
guarantees only that the read of ai
is atomic. There is no guarantee that the entire statement proceeds atomically. Between the time ai
's value is read and operator<<
is invoked to write it to the standard output, another thread may have modified ai
's value. That has no effect on the behavior of the statement, because operator<<
for int
s uses a by-value parameter for the int
to output (the outputted value will therefore be the one that was read from ai
), but it's important to understand that what's atomic in that statement is nothing more than the read of ai
.
The second noteworthy aspect of the example is the behavior of the last two statements — the increment and decrement of ai
. These are each read-modify-write (RMW) operations, yet they execute atomically. This is one of the nicest characteristics of the std::atomic
types: once a std::atomic
object has been constructed, all member functions on it, including those comprising RMW operations, are guaranteed to be seen by other threads as atomic.
In contrast, the corresponding code using volatile
guarantees virtually nothing in a multithreaded context:
volatileint vi(0); // initialize vi to 0
vi = 10; // set vi to 10
std::cout << vi; // read vi's value
++vi; // increment vi to 11
--vi; // decrement vi to 10
During execution of this code, if other threads are reading the value of vi
, they may see anything, e.g, -12, 68, 4090727 — anything! Such code would have undefined behavior, because these statements modify vi
, so if other threads are reading vi
at the same time, there are simultaneous readers and writers of memory that's neither std::atomic
nor protected by a mutex, and that's the definition of a data race.
As a concrete example of how the behavior of std::atomic
s and volatile
s can differ in a multithreaded program, consider a simple counter of each type that's incremented by multiple threads. We'll initialize each to 0:
std::atomic ac(0); // "atomic counter"
volatile int vc(0); // "volatile counter"
We'll then increment each counter one time in two simultaneously running threads:
/*----- Thread 1 ----- */ /*------- Thread 2 ------- */
++ac; ++ac;
++vc; ++vc;
When both threads have finished, ac
's value (i.e., the value of the std::atomic
) must be 2, because each increment occurs as an indivisible operation. vc
's value, on the other hand, need not be 2, because its increments may not occur atomically. Each increment consists of reading vc
's value, incrementing the value that was read, and writing the result back into vc
. But these three operations are not guaranteed to proceed atomically for volatile
objects, so it's possible that the component parts of the two increments of vc
are interleaved as follows:
1. Thread 1 reads vc
's value, which is 0.
2. Thread 2 reads vc
's value, which is still 0.
3. Thread 1 increments the 0 it read to 1, then writes that value into vc
.
4. Thread 2 increments the 0 it read to 1, then writes that value into vc
.
vc
's final value is therefore 1, even though it was incremented twice.
This is not the only possible outcome. vc
's final value is, in general, not predictable, because vc
is involved in a data race, and the Standard's decree that data races cause undefined behavior means that compilers may generate code to do literally anything. Compilers don't use this leeway to be malicious, of course. Rather, they perform optimizations that would be valid in programs without data races, and these optimizations yield unexpected and unpredictable behavior in programs where races are present.
The use of RMW operations isn't the only situation where std::atomic
s comprise a concurrency success story and volatile
s suffer failure. Suppose one task computes an important value needed by a second task. When the first task has computed the value, it must communicate this to the second task. Item 39explains that one way for the first task to communicate the availability of the desired value to the second task is by using a std::atomic
. Code in the task computing the value would look something like this:
std::atomicvalAvailable(false);
auto imptValue = computeImportantValue(); // compute value
valAvailable = true; // tell other task
// it's available
As humans reading this code, we know it's crucial that the assignment to imptValue
take place before the assignment to valAvailable
, but all compilers see is a pair of assignments to independent variables. As a general rule, compilers are permitted to reorder such unrelated assignments. That is, given this sequence of assignments (where a
, b
, x
, and y
correspond to independent variables),
a = b;
x = y;
compilers may generally reorder them as follows:
x = y;
a = b;
Читать дальше