You are right: the synchronisation of the parameters between the UI thread and the audio thread is not clean. In fact, according to the C++ standard, it is undefined behaviour to access the same memory address from multiple threads without using std::atomic
(or JUCE’s Atomic) or some kind of locks (which is a bad idea in your audio thread as your audio thread should be lock-free - so use atomics instead).
However, in practice, it currently works to share a float like this in plug-ins. This is because - on all platforms that support desktop DAWs (basically x86) - reading/writing a float (or anything that is 64-bits or less) is a single machine instruction and is atomic [1][2], i.e. it will never be in a teared state - an update to a float was either executed successfully or not.
In fact, on these platforms, I’ve found that std::atomic
will generate the exact same load and store machine instructions compared to not using std::atomic
at all. On arm, however, the situation is different: there you are likely to need std::atomic
(or JUCE’s Atomic
class) as floating point access is not guaranteed to be atomic.
I say currently, because future (and in rare cases, even current) compiler optimizations may break your code if you do not use std::atomic
. Consider the following example:
void processBlock (...)
{
float localCopy = myParam;
for (int i = 0; i < numSamples; ++i)
{
sample[i] *= localCopy;
}
}
....
float myParam;
In this code, you want to avoid tearing and therefore copy the parameter value to localCopy
before you do any processing. For arguments sake, let’s say your processor depends on localCopy
being constant during the for
loop - otherwise it will crash.
The above code will currently work on x86 - even if you update myParam
from the UI thread. However, technically, the compiler optimizer is allowed to replace any reference to localCopy
with a reference to myParam
. This could be because the compiler optimizer wants to save stack-space or save valuable registers for other operations and therefore tries to optimize away the storage for localCopy
. The result is that localCopy
will not remain constant during the loop if myParam
is updated from the UI thread.
The standard says that the C++ compiler is allowed to do this as changing myParam
outside of processBlock
would be undefined behaviour. Therefore it can assume that myParam will remain constant in processBlock
as the compiler sees that processBlock
itself (or any of the sub-routines it calls) does not change myParam
.
Wrapping myParam
in std::atomic
explicitly tells the compiler that myParam
may change from outside of the thread and therefore the above optimization is not allowed.
In essence, if you want to play it safe and be future-proof, use JUCE’s Atomic
class or std::atomic
- currently, however, it is very unlikely to make any difference for plug-ins running on desktop OSes.
[1] There are some other requirements for it to be atomic like proper padding and alignment. The compiler will do that for you anyway though unless you are using some crazy pragmas
[2] If an operation is expressed as a single machine instruction, it does not mean that it is atomic. However, load/stores of anything that is a 64-bit word or smaller is atomic on x86 when properly padded and aligned.