I am currently studying Google's Filament job system. You can find the source code here. The part that confuses me is this requestExit() method:
void JobSystem::requestExit() noexcept {
mExitRequested.store(true);
{ std::lock_guard<Mutex> lock(mLooperLock); }
mLooperCondition.notify_all();
{ std::lock_guard<Mutex> lock(mWaiterLock); }
mWaiterCondition.notify_all();
}
I am confused why we need to lock and unlock even though there is no action in between the lock and unlock. Are there any cases where this empty lock and unlock is necessary?
Viewed in isolation, a condition variable allows threads to block and to be woken by other threads. However, condition variables are designed to be used in a specific way; a condition variable interacts with a mutex to make it easy to wait for an arbitrary condition on state protected by the mutex.
Condition variables allow us to synchronize threads via notifications. So, you can implement workflows like sender/receiver or producer/consumer. In such a workflow, the receiver is waiting for the sender's notification. If the receiver gets the notification, it continues its work.
A lock_guard always holds a lock from its construction to its destruction. A unique_lock can be created without immediately locking, can unlock at any point in its existence, and can transfer ownership of the lock from one instance to another.
Condition variables are synchronization primitives that enable threads to wait until a particular condition occurs. Condition variables are user-mode objects that cannot be shared across processes. Condition variables enable threads to atomically release a lock and enter the sleeping state.
This is a bit of a hack. First, let's look at the code without that:
mExitRequested.store(true);
mLooperCondition.notify_all();
There's a possible race condition here. Some other code might have noticed that mExitRequested
was false and started waiting for mLooperCondition
right after we called notify_all
.
The race would be:
mExitRequested
, it's false
.mExitRequested
to true
.mLooperCondition.notify_all
.mLooperCondition
.But in order to wait for a condition variable, you must hold the associated mutex. So that can only happen if some other thread held the mLooperLock
mutex. In fact, step 4 would really be: "Other thread releases mLooperLock
and waits for mLooperCondition
.
So, for this race to happen, it must happen precisely like this:
mLooperLock
.mExitRequested
, it's false
.mExitRequested
to true
.mLooperCondition.notify_all
.mLooperCondition
, releasing mLooperLock
.So, if we change the code to:
mExitRequested.store(true);
{ std::lock_guard<Mutex> lock(mLooperLock); }
mLooperCondition.notify_all();
That ensures that no other thread could check mExitRequested
and see false
and then wait for mLooperCondition
. Because the other thread would have to hold the mLooperLock
lock through the whole process, which can't happen since we acquired it in the middle of that process.
Trying it again:
mLooperLock
.mExitRequested
, it's false
.mExitRequested
to true
.nLooperLock
, we do not make any forward progress until the other thread releases mLooperLock
.mLooperCondition.notify_all
.Now, either the other thread blocks on the condition or it doesn't. If it doesn't, there's no problem. If it does, there's still no problem because the unlocking of mLooperLock
is the condition variable's atomic "unlock and wait" operation, guaranteeing that it sees our notify.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With