0% found this document useful (0 votes)
23 views

Back To Basics Concurrency Arthur Odwyer

The document discusses various C++ concurrency primitives like mutexes, condition variables, and atomics that can be used to synchronize access to shared memory between threads and avoid data races. It explains what a data race is and how std::atomic can be used to prevent races on integer variables. The document also demonstrates how mutexes allow logical synchronization between threads by blocking one thread until another thread signals it.

Uploaded by

Danielle Zamar
Copyright
© © All Rights Reserved
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
23 views

Back To Basics Concurrency Arthur Odwyer

The document discusses various C++ concurrency primitives like mutexes, condition variables, and atomics that can be used to synchronize access to shared memory between threads and avoid data races. It explains what a data race is and how std::atomic can be used to prevent races on integer variables. The document also demonstrates how mutexes allow logical synchronization between threads by blocking one thread until another thread signals it.

Uploaded by

Danielle Zamar
Copyright
© © All Rights Reserved
Available Formats
Download as PDF, TXT or read online on Scribd
You are on page 1/ 58

Back to Basics:

Concurrency
I also do C++ training!
[email protected]

Arthur O’Dwyer
2020-09-18
Outline
● What is a data race and how do we fix it? [3–12]
● C++11 mutex and RAII lock types [13–23] Questions?
● condition_variable [24–28]
● Static initialization and once_flag [29–34]
● New C++17 and C++20 primitives [35–45] Questions?
● The blue/green pattern [46–52]
● Bonus C++20 slides [53–58] Questions?
2
What is concurrency?
● Concurrency means doing two things concurrently — “running
together.” Maybe you’re switching back and forth between them.
○ Writing slides and answering email
● Parallelism means doing two things in parallel — simultaneously.
○ Writing slides and listening to music
● In extremely broad strokes, parallelism is a hardware problem
(think multiple CPUs) and concurrency is a software problem (think
time-sharing, but also Intel’s “hyperthreading”).
3
Why does C++ care about it?
Standard C++03 didn’t have “threads.” You’d just use some
platform-specific library, such as pthreads. But then what could the
Standard say about multithreaded programs?

Thread A Thread B
int x = 0;
start_thread_b(); while (x != 1) {}
x = 1; Will this write
ever become
“visible” to Will this loop
Thread B? ever terminate?
4
The compiler can rewrite accesses
Original code was... Effectively rewritten to...
int x = 0; int x = 3;
x = 1; sleep(200ms);
sleep(100ms);
x = 2; Is this a legal optimization for a C++ compiler to perform?

sleep(100ms); Prior to C++11, the answer was “no idea.”


The C++11 answer is unambiguously “yes.”
x = 3;
No other thread is allowed to look at the variable x while this thread is
modifying it; and without some kind of synchronization, there’s no way
to ensure that this thread isn’t modifying it right when you happen to be
looking at it.

5
The hardware can reorder accesses
Original code was... Effectively rewritten to...
char a[1000] = {}; cacheLine1 = a[0..63];
a[0] = 1; cacheLine[0] = 1;
a[100] = 2; cacheLine2 = a[64..127];
a[1] = 3; cacheLine2[36] = 2;
cacheLine1[1] = 3;
This is an extreme oversimplification
and/or a flat-out lie — but it shows that a[0..63] = cacheLine1;
what the code says is only loosely
related to what the hardware does.
a[64..127] = cacheLine2;

6
C++11 gave us a “memory model”
● Now a program consists of one or more threads of execution
● Every write to a single memory location must synchronize-with all
other reads or writes of that memory location, or else the program
has undefined behavior
● Synchronizes-with relationships can be established by using
various standard library facilities, such as std::mutex and
std::atomic<T>

7
Starting a new thread
In C++03 pthreads, you’d create a new thread by calling a third-party
library function.
In C++11, the standard library “owns” the notion of creating new
threads. To create a thread, you create a std::thread object. The
constructor argument is a callable that says what you want the thread
to do.
std::thread threadB = std::thread([](){
puts("Hello from threadB!");
});
8
Joining finished threads
The new thread starts executing “immediately.” When its job is done,
the thread has nothing else to do: it becomes joinable.
Call .join() on the std::thread object before destroying it. This call
will block, if necessary, until the other thread’s job is finished.
std::thread threadB = std::thread([](){
puts("Hello from threadB!");
});
puts("Hello from threadA!");
threadB.join();
9
Getting the “result” of a thread
We don’t need any special way to return an “exit status” from a thread,
because joining with a child thread is a synchronizing operation.
int result = 0;
std::thread threadB = std::thread([&](){
puts("Hello from threadB!"); This read synchronizes with
result = 42; this write, because the
synchronizing operation
}); join() returns after the write,
puts("Hello from threadA!"); but before the read.

threadB.join();
printf("The result of threadB was %d\n", result);
10
Example of a data race on an int
using SC = std::chrono::steady_clock;
auto deadline = SC::now() + std::chrono::seconds(10);
int counter = 0;
std::thread threadB = std::thread([&](){
while (SC::now() < deadline)
This is a data race.
printf("B: %d\n", ++counter); No synchronization exists
}); between these two
accesses, and at least
while (SC::now() < deadline) one of them is a write.
printf("A: %d\n", ++counter); (In fact, both are.)

threadB.join(); This program has UB.

11
Fixing the race via std::atomic<T>
using SC = std::chrono::steady_clock;
auto deadline = SC::now() + std::chrono::seconds(10);
std::atomic<int> counter = 0; This minor change
completely fixes the
std::thread threadB = std::thread([&](){ physical data race!
Every access to an atomic
while (SC::now() < deadline) implicitly synchronizes with
printf("B: %d\n", ++counter); every other access to it.
}); (There’s still a “semantic
while (SC::now() < deadline) data race”: different valid
executions will produce
printf("A: %d\n", ++counter); different outputs. That might
threadB.join(); be considered a bug, but it’s
not UB.)
12
“Logical synchronization”
Problem statement:
std::thread threadB = std::thread([&](){
waitUntilUnblocked(); Recall that threads start
printf("Hello from B\n"); “running.” What if we need
to set up a bit more state
}); before letting the new thread
printf("Hello from A\n"); run off on its own?

unblockThreadB(); Can we tell thread B to wait


until thread A unblocks it?
threadB.join();
Yes, using any of several
printf("Hello again from A\n"); synchronization primitives.

13
First, a non-solution: busy-wait
This is not a solution. This is “spinning,” not
“waiting.” Thread B never
std::atomic<bool> ready = false; stops working; it just keeps
std::thread threadB = std::thread([&](){ checking over and over,
never going to sleep. On a
while (!ready) { } single-core system, this is a
printf("Hello from B\n"); huge waste of time.

}); Also, the compiler can see


that if this thread never
printf("Hello from A\n"); No data race, sleeps then ready will never
because
ready = true; ready is an
change, and (in theory) hoist
it out of the loop. We still
threadB.join(); atomic. have UB here. Don’t do this,
printf("Hello again from A\n"); for many reasons.

14
A real solution: std::mutex
One way to solve the problem is std::mutex. mutex is a mutual exclusion
mechanism.
std::mutex mtx;
I like to compare it to a
mtx.lock(); coffeeshop bathroom key.
std::thread threadB = std::thread([&](){ When Alice holds the key,
Bob can’t enter (and vice
mtx.lock(); mtx.unlock(); versa).
printf("Hello from B\n"); mutex’s .lock() method
}); “acquires” the bathroom key
(waiting in line for it, if
printf("Hello from A\n"); necessary). The .unlock()
mtx.unlock(); // Now go! method returns it so the next
person can use it.
threadB.join();
printf("Hello again from A\n");
15
std::mutex frequently protects data
class TokenPool {
std::mutex mtx_; The coffeeshop bathroom
std::vector<Token> tokens_; key protects the facilities of
the bathroom itself.
Token getToken() { TokenPool’s mtx_ protects
mtx_.lock(); its vector tokens_.
if (tokens_.empty()) Every access (read or
tokens_.push_back(Token::create()); write) to tokens_ must be
Token t = std::move(tokens_.back()); done under a lock on mtx_.
This is an invariant that
tokens_.pop_back();
must be preserved for
mtx_.unlock(); correctness.
return t;
}
};
16
Protection must be complete
Token getToken() { The code on this slide almost
mtx_.lock(); certainly has a thread-safety
bug — a data race!
if (tokens_.empty())
tokens_.push_back(Token::create()); Suppose thread A calls
getToken() while thread B
Token t = std::move(tokens_.back()); calls
tokens_.pop_back(); numTokensAvailable().
mtx_.unlock(); Thread A takes the lock and
starts popping from tokens_,
return t;
while thread B (which didn’t
} take any lock) is also reading
tokens_. This is a data race
size_t numTokensAvailable() const { and UB!
return tokens_.size();
Protecting a variable with a
} mutex must be 100% or it’s no
good.
17
Plus, what about exception-safety?
Token getToken() { The code on this slide still has
mtx_.lock(); a potential bug. What
happens if Token::create()
if (tokens_.empty()) or push_back() throws an
tokens_.push_back(Token::create()); exception?
Token t = std::move(tokens_.back()); We’ve locked the mutex, but
tokens_.pop_back(); the exception aborts
mtx_.unlock(); execution of this function, so
we never execute the line that
return t;
would have unlocked it.
}
We should look for a way to
follow RAII principles: Every
“cleanup” action, including
unlocking mutexes, should be
done inside a destructor.

18
RAII to the rescue!
Token getToken() { The class template
std::lock_guard<std::mutex> lk(mtx_); std::lock_guard<T> is
defined in the <mutex>
if (tokens_.empty()) header.
tokens_.push_back(Token::create());
Its constructor locks the given
Token t = std::move(tokens_.back()); mutex, and stores a reference
tokens_.pop_back(); to it.
return t; Its destructor unlocks the
} mutex.

size_t numTokensAvailable() const { In C++17 and later,


lock_guard can be used with
std::lock_guard lk(mtx_);
CTAD, as shown in
return tokens_.size(); numTokensAvailable().
}

19
A “mutex lock” is a resource
● A new’ed T* is a resource, in the sense that you need to do something
special with it when you’re done with it: call delete.
● A locked std::mutex is a resource, in the sense that you need to do
something special with it when you’re done with it: call .unlock().
● We have std::unique_ptr to help us manage unique ownership of heap
allocations.
● Likewise, we have std::unique_lock to help us manage unique
ownership of mutex locks.

20
A “mutex lock” is a resource
Just as functions can pass or return ownership of a pointer, functions can pass
or return ownership of a mutex lock.
std::lock_guard is
unique_ptr<int> foo(unique_ptr<int> p) { just a special case
if (rand()) that can’t be passed
p = nullptr; // prematurely clean up around or prematurely
return p; // the resource cleaned up.
} You might compare
std::lock_guard to
unique_lock<mutex> foo(unique_lock<mutex> lk) {
boost::scoped_ptr
if (rand())
in C++03.
lk.unlock(); // prematurely clean up
return lk; // the resource
}
21
In fact, scoped_lock also exists
C++17 introduced std::scoped_lock<Ts...> as a “new and improved”
std::lock_guard<T>. It can take multiple mutexes “at once,” although naming
the resulting type is quite ugly without CTAD.
size_t numTokensAvailable() const {
std::scoped_lock lk(mtx_); i.e., scoped_lock<mutex>
return tokens_.size();
}

void mergeTokensFrom(TokenPool& rhs) {


std::scoped_lock lk(mtx_, rhs.mtx_); i.e., scoped_lock<mutex, mutex>
tokens_.insert(rhs.tokens_.begin(),
rhs.tokens_.end());
rhs.tokens_.clear();
} 22
Question Break
Metaphor time!

This is Pat. This is Frosty.


Pat is going to deliver a letter. Frosty is waiting for a letter.
24
Mailboxes, flags, and cymbals
● Frosty goes to sleep next
to the mailbox
● Pat puts a letter in the
mailbox
● Pat raises the flag
● Pat clashes her cymbals
● Frosty wakes up, sees the
flag raised, and looks in the
mailbox
25
condition_variable for “wait until”
If we have no Token::create(), then when tokens_ is empty we should
block and wait until some other thread returns a token to the pool.
struct TokenPool {
std::vector<Token> tokens_;
std::mutex mtx_; Remember, every access
std::condition_variable cv_; (read or write) to tokens_
must still be done under a
void returnToken(Token t) { mtx_ lock, so as to avoid
std::unique_lock lk(mtx_); physical data races (UB).
tokens_.push_back(t);
lk.unlock();
cv_.notify_one(); “Notifying” the condition variable will wake up any
one thread that’s blocked on it. This is Pat’s cymbals.
} (Pushing back t is delivering the letter.)
26
condition_variable for “wait until”
Here is the code that blocks and waits, using std::condition_variable cv_.

Token getToken() { Remember, every access


std::unique_lock lk(mtx_); (read or write) to tokens_
must still be done under a
while (tokens_.empty()) {
mtx_ lock, so as to avoid
cv_.wait(lk); physical data races.
}
Internally, cv_.wait(lk) will
Token t = std::move(tokens_.back()); relinquish the lock and go to
tokens_.pop_back(); sleep; then, once it wakes up,
return t; it’ll re-acquire the lock.

} The “mailbox flag is raised” This is Frosty.


}; whenever !tokens_.empty().
At this point we hold the mutex
lock, and know the flag is raised.
27
mutex + condition_variable
● Whenever you have a “producer” and a “consumer”...
○ ...where the consumer must wait for the producer...
○ ...and production and consumption happen over and over...
○ Such as our TokenPool
○ Such as a task queue, work queue, or Go-style channel
● Then you almost certainly want a mutex plus a condition_variable.
If produce/consume happen only once, consider std::promise/std::future, which we
aren’t going to talk about in this presentation. It still uses mutex+cv internally.

Of course try to use higher-level frameworks where you can, especially if your program is
fundamentally concerned with concurrency. This presentation is geared to one-off tasks. 28
Waiting for initialization
C++11 made the core language know about threads in order to explain how
concurrent writes to int cause UB but concurrent writes to atomic<int> don’t.
But C++11 did another cool thing with its core-language-threading idea!
int main() {
std::thread t1(foo), t2(foo); t1 and t2 arrive at this line
concurrently. Which one
t1.join(); t2.join(); performs the initialization?
}
And what is the other one doing
while that’s happening?
void foo() {
static ComplicatedObject obj("some", "data");
std::cout << "Hello from foo! obj.x is " << obj.x << "\n";
} 29
Thread-safe static initialization
In C++03, to make a “singleton” thread-safe, you had to experiment with things
like “double-checked locking,” and of course it was all UB anyway.
In C++11, it’s as easy as:
inline auto& SingletonFoo::getInstance() {
static SingletonFoo instance;
return instance;
}
The first thread to arrive will start initializing the static instance.
Any more that arrive will block and wait until the first thread either succeeds
(unblocking them all) or fails with an exception (unblocking one of them).
30
How to initialize a data member
But suppose you want a singleton per instance of some other object!
class Logger {
std::optional<NetworkConnection> conn_;
NetworkConnection& getConn() {
if (!conn_.has_value()) {
conn_ = NetworkConnection(defaultHost);
}
return *conn_; This code is clearly unsafe if two threads
call getConn() concurrently while
} conn_.has_value() is false. They might
}; both try to modify conn_ without
synchronization.
31
How to initialize a data member
class Logger {
std::mutex mtx_;
std::optional<NetworkConnection> conn_; We could add a
mutex protecting
NetworkConnection& getConn() { every access to
conn_.
std::lock_guard<std::mutex> lk(mtx_);
if (!conn_.has_value()) {
conn_ = NetworkConnection(defaultHost);
}
return *conn_;
} This code is safe, but perhaps
}; slower than it could be.

32
Initialize a member with once_flag
Here, the first access
class Logger {
to conn_ is protected
std::once_flag once_; by a once_flag.
std::optional<NetworkConnection> conn_; This mimics how C++
does static initialization,
NetworkConnection& getConn() { but for a non-static.
std::call_once(once_, []() { Each Logger has its
own conn_, protected
conn_ = NetworkConnection(defaultHost); by its own once_.
});
return *conn_;
}
This access to conn_ doesn’t need to be
}; protected because it is definitely not the
first access. We know that conn_ must be
initialized by now.
33
Comparison of C++11’s primitives
mutex: condition_variable: once_flag:
● Many threads can ● Many threads can ● Many threads can queue up
queue up on lock. queue up on wait. on call_once.
● Calling notify_one ● Failing at the callback
unblocks exactly unblocks exactly one waiter:
● Calling unlock
one waiter. the new “owner.”
unblocks exactly one
waiter: the new ● Calling notify_all ● Succeeding at the callback
“owner.” unblocks all unblocks all waiters and
waiters. sets the “done” flag.
● lock blocks only if
somebody “owns” the ● wait always blocks. ● call_once blocks only if
mutex. the “done” flag isn’t set.
34
C++17 shared_mutex (R/W lock)
class ThreadSafeConfig {
std::map<std::string, int> settings_; unique_lock calls
mutable std::shared_mutex rw_; rw_.lock() in its
void set(const std::string& name, int value) { constructor and
rw_.unlock() in its
std::unique_lock<std::shared_mutex> lk(rw_);
destructor.
settings_.insert_or_assign(name, value);
} shared_lock calls
rw_.lock_shared()
int get(const std::string& name) const { in its constructor and
std::shared_lock<std::shared_mutex> lk(rw_); rw_.unlock_shared()
return settings_.at(name); in its destructor.
}
};
35
C++20 counting_semaphore
A semaphore is a “bag of
max initial poker chips.”
class AnonymousTokenPool {
.acquire() removes a chip
std::counting_semaphore<256> sem_{100}; (perhaps blocking until a chip
void getToken() { is available).
sem_.acquire(); // may block .release() returns a chip.
} We assume that you acquired
a chip earlier. If you didn’t,
void returnToken() { such that the bag overflows,
sem_.release(); that’s UB.
} Chips are indistinguishable,
}; interchangeable, and (unlike
mutex locks) not tied to any
particular thread.
36
C++20 counting_semaphore
using Sem = std::counting_semaphore<256>;
This slight change
struct SemReleaser {
makes our token
bool operator()(Sem *s) const { s->release(); } pool safer to use.
};
Destroying a
class AnonymousTokenPool { Token now
Sem sem_{100}; automatically
using Token = std::unique_ptr<Sem, SemReleaser>; returns it to the
pool.
Token borrowToken() {
sem_.acquire(); // may block See my
return Token(&sem_); CppCon 2019 talk
on smart pointers
}
for more on this
};
pattern.
37
C++20 std::latch
● A latch is kind of like a semaphore, in that it has an integer counter that
starts positive and counts down toward zero.
● latch.wait() blocks until the counter reaches zero.
● latch.count_down() decrements the counter.
○ If the counter reaches zero then this unblocks all the waiters.
● latch.arrive_and_wait() decrements and begins waiting.
Use a std::latch as a one-shot “starting gate” mechanism: “Wait for
everyone to arrive at this point, then unblock everyone simultaneously.”
latch is like once_flag in that there is no way to “reset” its counter.
38
C++20 std::barrier<>
● A barrier is essentially a resettable latch.
● barrier.wait() blocks until the counter reaches zero, as before.
● barrier.arrive() decrements the counter.
○ If the counter reaches zero then this unblocks all the waiters...
○ ...and begins a new phase with the counter reset to its initial value.
● barrier.arrive_and_wait() decrements and waits, as before.
Use std::barrier as a “pace car” mechanism: “Stop everyone as they arrive
at this point. Once everyone’s caught up, unblock everyone, and atomically
refresh the barrier to stop them on their next trip around the loop.”
39
C++20 std::barrier<> arcana
There’s a lot of subtleties to std::barrier which I am glossing over.
● std::barrier, unlike std::latch, is a class template!
○ The template parameter has a default, so CTAD permits you to say
barrier b; in most places. But I recommend barrier<>, just like
less<>.
○ It defines a “completion function” to be called right before everyone is
unblocked. The default is “do nothing,” which is usually fine.
● “Lapping the pace car” produces UB. Falling two laps behind produces UB.
● myBarrier.arrive_and_drop() lets your car drop out of the race forever.
40
Synchronization with std::latch
This gives us another solution to our thread-starting problem.
std::latch myLatch(2);
std::thread threadB = std::thread([&](){
myLatch.arrive_and_wait();
printf("Hello from B\n");
});
printf("Hello from A\n");
myLatch.arrive_and_wait();
threadB.join();
printf("Hello again from A\n");

41
Synchronization with std::latch
The main thread is going to wait in join() anyway, so in fact we can just do:
std::latch myLatch(1);
std::thread threadB = std::thread([&](){
myLatch.wait();
printf("Hello from B\n");
});
printf("Hello from A\n");
myLatch.arrive();
threadB.join();
printf("Hello again from A\n");

42
Synchronization with std::barrier
std::barrier b(2, []{ puts("Green flag, go!"); });
std::thread threadB = std::thread([&](){
printf("B is setting up\n"); CTAD alert!
b.arrive_and_wait();
printf("B is running\n");
}); This code uses std::barrier
printf("A is setting up\n"); with a CompletionFunction of
b.arrive_and_wait(); lambda type. You should see A
and B setting up (in some
printf("A is running\n"); order), followed by "Green flag,
threadB.join(); go!", followed by A and B
running (in some order).

43
Comparison of C++20’s primitives
counting_semaphore: latch: barrier:
● The counter goes ● The counter goes ● The counter goes

● Many threads can ● Many threads can ● Many threads can


queue up on acquire. queue up on wait. queue up on wait.
● acquire blocks only as ● wait blocks only ● wait blocks only
long as the counter is until the counter until the counter
zero. becomes zero. becomes zero.
● Calling release ● (Supports some extra
unblocks exactly one complexity which we
mostly glossed over.)
waiter.
44
Question Break
One-slide intro to C++11 promise/future
This slide shows the future side
std::future<int> f1 = std::async([]() {
(Frosty). Not shown: the promise
puts("Hello from thread A!"); side (Pat).
return 1;
std::async creates a new thread on
}); each call. The STL’s async has
serious perf caveats, but it’s nice API
std::future<int> f2 = std::async([]() { design — this factory function saves
puts("Hello from thread B!"); the programmer from managing raw
return 2; threads by hand.

}); If std::async sounds relevant to


your use-case, don’t base any
int result = f1.get() + f2.get(); architecture decisions on this talk!
// automatically blocks until the results That goes triple for C++20
// are available from threads A and B coroutines, which I won’t even
attempt to demonstrate.
46
Patterns for sharing data
● Remember: Protect shared data with a mutex.
○ You must protect every access, both reads and writes, to avoid UB.
○ Maybe use a reader-writer lock (std::shared_mutex) for perf.
● Remember: Producer/consumer? Use mutex + condition_variable.
● Best of all, though: Avoid sharing mutable data between threads.
○ Make the data immutable.
○ Clone a “working copy” for yourself, mutate that copy, and then quickly
“merge” your changes back into the original when you’re done.

47
The “blue/green” pattern
● The name “blue/green” comes from devops.
Blue/green deployment is an application release model that gradually
transfers user traffic from a previous version of an app or microservice to a
nearly identical new release — both of which are running in production.

The old version can be called the blue environment while the new version is
called the green environment. Once production traffic is fully transferred from
blue to green, blue can ... be pulled from production.

● We might use this to “publish” a new version of mutable global state, such
as a global configuration object.

48
The “blue/green” pattern (write-side)
using ConfigMap = std::map<std::string, std::string>;

std::atomic<std::shared_ptr<const ConfigMap>> g_config;

void setDefaultHostname(const std::string& value) {


std::shared_ptr<const ConfigMap> blue = g_config.load();
do {
Clone the entire map
std::shared_ptr<ConfigMap> green = to get a private copy
std::make_shared<ConfigMap>(*blue); we can write to!
green->insert_or_assign("default.hostname", value);
} while (g_config.compare_exchange_strong(blue, std::move(green)));
}
“Publish” our changes: Expect g_config to still be blue. If so, store green.
Otherwise, update blue and go around again.

49
The “blue/green” pattern (read-side)
using ConfigMap = std::map<std::string, std::string>;

std::atomic<std::shared_ptr<const ConfigMap>> g_config;

// ...

std::shared_ptr<const std::string> getDefaultHostname() {

std::shared_ptr<const ConfigMap> blue = g_config.load();


const std::string& value = blue.at("default.hostname");
return std::shared_ptr<const std::string>(std::move(blue), &value);
}
“Aliasing constructor” alert!
The blue ConfigMap will stay alive for as long
as it is the current g_config or anyone is still
holding one of these shared_ptrs.
50
In conclusion
● Unprotected data races are UB
○ Use std::mutex to protect all accesses (both reads and writes)

● Thread-safe static initialization is your friend


○ Use std::once_flag only when the initializee is non-static

● mutex + condition_variable are best friends


● C++20 gives us “counting” primitives like semaphore and latch
● But if your program is fundamentally multithreaded, look for
higher-level facilities: promise/future, coroutines, ASIO, TBB
51
Questions?
followed by some bonus slides
Bonus: C++20 std::atomic_ref<T>
● Basically, std::atomic<T> protects your data against data races
○ Gives you a T that you cannot access non-atomically

○ Solves the problem at the typesystem level

● std::atomic_ref<T> protects your accesses against data races


○ You supply a plain old T object of your own, anywhere in memory

○ As long as you access it only through atomic_ref, no two protected accesses


will race with each other

○ Similar to protecting the data with a mutex, but can be optimized better for
small/trivial data

Unfortunately, atomic_ref works only for trivial data; no specialization for shared_ptr.
53
Bonus: C++20 std::jthread
● Recall that if you have a std::thread, you must call .join() on it before
it is destroyed; otherwise your program terminates.
○ By the way, you can also discharge this responsibility via t.detach()

● A joinable std::thread is a resource requiring management, just the


same as a heap-allocated pointer or a locked mutex.
● So we should have an RAII type for it, right?
● C++20 gives us std::jthread (“joining thread”)...

54
Bonus: C++20 std::jthread
int main() {
std::barrier<> b(2);
std::jthread threadB = std::jthread([&](){
printf("B is setting up\n");
std::jthread is just like
b.arrive_and_wait(); std::thread, except that it
printf("B is running\n"); joins automatically in its
}); destructor.
may_throw("A is setting up\n"); But do you see a problem here?
b.arrive_and_wait();
printf("A is running\n");
} // threadB is joined automatically in its destructor
55
Bonus: C++20 std::jthread
int main() {
std::barrier<> b(2);
std::jthread threadB = std::jthread([&](){
printf("B is setting up\n");
If this line throws, then
b.arrive_and_wait(); std::jthread’s destructor
printf("B is running\n"); won’t std::terminate; it will
}); simply block forever, waiting for
the barrier’s counter to reach
may_throw("A is setting up\n");
zero.
b.arrive_and_wait();
printf("A is running\n");
} // threadB is joined automatically in its destructor
56
C++20 std::jthread is cancellable
bool ready = false;
std::mutex m; // m protects ready jthread can magically
std::condition_variable_any cv; provide a stop_token to
its job.
std::jthread threadB([&](std::stop_token token) {
printf("B is setting up\n");
Both accesses to ready
std::unique_lock lk(m);
happen under the mutex
cv.wait(lk, token, [&]{ return ready; }); lock.
if (token.stop_requested()) return;
printf("B is running\n"); Meanwhile, if this line
throws, the jthread will
}); notify its stop_token
may_throw("A is setting up\n"); before it joins. The
stop_token wakes up the
{ std::scoped_lock lk(m); ready = true; }
condition_variable,
cv.notify_one(); allowing the task to
printf("A is running\n"); “cancel” itself.
57
Questions?

You might also like