Data race in Spectrum Analyser Tutorial?

I’m invesigating the following JUCE tutorial: Tutorial: Visualise the frequencies of a signal in real time

What is peculiar to me is the lack of any synchronization mechanism between the audio thread (calling getNextAudioBlock()) and the GUI thread (calling timerCallback()). The former writes to and the latter reads from nextFFTBlockReady and fftData. To my understanding, since there’s no indication to the compiler that these reads/writes can occur from different threads, it is a data race.

I’ve run the tutorial app with the Thread Sanitizer and, as expected, it reports a data race on nextFFTBlockReady = false; with the previous read at if (! nextFFTBlockReady).

Can someone smarter than me explain why there isn’t a race condition here? Otherwise, has this been discussed already somewhere?

I’ve attached the said file below.

SpectrumAnalyserTutorial_02.h (6.5 KB)

4 Likes

That looks like a data-race to me. TBH I have never looked closely into those examples :innocent:

1 Like

The problem with the code has been noticed many years ago, but I guess since it’s just some example code and seems to work anyway, it hasn’t been fixed. Of course the code should at least get some kind of comment that it’s not exactly following best practices with the variable used by multiple threads.

2 Likes

I’ve always wanted some sort of thread-safe container in JUCE that can transport samples from the audio thread to the message thread.

The new webview plugin example does at least use a spinlock for this (JUCE/examples/Plugins/WebViewPluginDemo.h at 51a8a6d7aeae7326956d747737ccf1575e61e209 · juce-framework/JUCE · GitHub), but it’s not an ideal solution as you could fail to acquire that lock many times in a row resulting in a lot of data being lost. I also don’t typically like doing GUI-specific things on the audio thread like processing FFTs that will only be used for a spectrum analyser.

I did write such a container myself years ago but I’ve no idea if it’s 100% guaranteed to be realtime safe. It uses a double-buffering technique without any locks: JUMP/JUMP/audio/jump_AudioTransferManager.h at main · ImJimmi/JUMP · GitHub. It’s based on the techniques described in this talk: https://www.youtube.com/watch?v=Q0vrQFyAdWI

That’s something I could not find anywhere :wink:

Thanks for the replies and example code!

Yes, an in-tutorial comment about it would be nice… Especially for new users.

2 Likes

IIRC I have read a post (by you?) that recommends FIFO for FFT processing. I have been using FIFO and it works pretty well.

We recently made an object which simply wraps around a juce::AbstractFifo and juce::AudioBuffer to do exactly this. I’ve found it to be one of the most useful pieces of code we have!

3 Likes

I think a FIFO is fine so long as you can be sure that the realtime thread will never overwrite a portion of the buffer that the non-realtime thread is reading from. With a sufficiently large buffer you’ll probably always be safe - especially if you make a copy of the data before doing any processing on it.
The double-buffering technique guarantees (?) that the data you’re reading won’t be overwritten until you’re done with it as the realtime thread will always be writing to a separate buffer.

1 Like

FIFOs can report their free-space (read-pointer - write-pointer, taking account of wrap-around).
This allows you to avoid overwriting data from which the UI is reading.

That’s not to say one is better than the other because double-buffering is conceptually also a class of FIFO (you fill and consume buffers in a circular pattern { 1,2, 1, 2, 1, 2…}.

juce::AbstractFifo is safe under one-producer-one consumer situation. Without double-buffering, it is possible that FFT get discontinuous sample input if the non-realtime thread is too slow (or is scheduled strangely). I think double-buffering should solve that problem, but the realtime thread will need more processing and the non-realtime thread will get a lock.

I think the code from the tutorial is fine (though perhaps not optimal) if you make nextFFTBlockReady atomic.

The audio thread will only write into the buffer when nextFFTBlockReady is false. The audio thread is the only one that will set it to true.

The UI thread will only read from the buffer when nextFFTBlockReady is true. The UI thread is the only one that will set it to false.

1 Like

Thanks for the reply! So you believe that loads/stores to the atomic variable will cause the corresponding buffer changes to be visible from the UI thread? I believe this should be the case but I am not sure…

The issue is that we want to pass data from the audio thread to the UI thread and we’re using a shared array for this, fftData. We don’t want the audio thread to write into this array while the UI thread is reading from it. So we need to protect it somehow.

The audio thread does the following:

void pushNextSampleIntoFifo (float sample) noexcept
{
    if (fifoIndex == fftSize)
    {
        if (!nextFFTBlockReady)
        {
            /* copy the data into the fftData shared array */
            nextFFTBlockReady = true;
        }
        fifoIndex = 0;
    }
    fifo[fifoIndex++] = sample;
}

Suppose fftSize is 1024. So once every 1024 samples this tries to copy the data from fifo into the fftData array.

The only time the audio thread will change the fftData array is when nextFFTBlockReady is false. Initially this will be the case. Once the data is copied into fftData, we set nextFFTBlockReady to true.

Let’s say the UI thread does not read from fftData for a while. In the mean time, we process another 1024 samples and now if (!nextFFTBlockReady) will be false, since nextFFTBlockReady is still true from last time. So the audio thread throws away these most recent 1024 samples and will start collecting the next set of 1024 samples from scratch.

This is why this method isn’t optimal: if the UI thread doesn’t read fast enough, the audio thread cannot update fftData with the latest values. In other words, we may end up displaying stale data once the UI gets around to redrawing itself.

You can think of it as follows:

  • when nextFFTBlockReady is false, the audio thread is in charge of fftData.
  • when it is true, the UI thread is in charge of fftData.

The UI thread runs a timer that fires every X times per second and does:

    if (nextFFTBlockReady)
    {
        drawNextFrameOfSpectrum();
        nextFFTBlockReady = false;
        repaint();
    }

Initially this doesn’t do anything because nextFFTBlockReady is false, meaning that only the audio thread can access fftData.

However, after the audio thread has written into fftData, nextFFTBlockReady will be true. That means the UI thread is allowed to use fftData (and only the UI thread).

In drawNextFrameOfSpectrum() it copies whatever it needs from fftData into its own variables. Then it sets nextFFTBlockReady to false again, to “pass the baton” back to the audio thread. From now on, the UI thread is no longer allowed to access fftData.

And so on it goes, back-and-forth.

To summarize, this is thread-safe because the value of nextFFTBlockReady determines which thread is allowed to access fftData.

Now, there is an obvious problem. The compiler is allowed to re-order statements if they do not change the meaning of the code, so the UI thread might end up doing the following:

    if (nextFFTBlockReady)
    {
        nextFFTBlockReady = false;
        drawNextFrameOfSpectrum();
        repaint();
    }

This is wrong! It sets nextFFTBlockReady to false, passing ownership back to the audio thread, before reading fftData.

In the code from the original tutorial, this could theoretically happen (depending on the compiler settings etc), and now the whole thing is no longer thread-safe.

By making nextFFTBlockReady a std::atomic, the compiler will insert memory barriers to prevent this kind of reordering from going on. So the logic as discussed above works fine, and the compiler won’t do any funny stuff that breaks this logic when you’re not looking.

4 Likes

Thanks for the extended comment; it’s an incredibly useful analysis on a topic that’s not discussed enough!

The quoted paragraph is a reply that I was not shure of; as I understand your reply, making nextFFTBlockReady a std::atomic forces the correct order of operations and makes the latest fftData content visible to the other thread (otherwise, the GUI thread could see an outdated version of fftData although nextFFTBlockReady is true because of cashing or memory ordering issues).

Thanks!

Edit: Running the app with std::atomic<bool> does not trigger the thread sanitizer anymore :+1:

Correct. Note that std::atomic’s load() and store() functions take a std::memory_order argument. By default this is memory_order_seq_cst, which makes the strongest guarantees but is also potentially the slowest.

You can specify a different memory ordering mode, depending on what you’re doing. An important part of lock-free programming is choosing the correct/most optimal memory ordering flags.

Timur’s ADC24 talk gives a pretty good introduction to this but it’s not online yet. https://www.youtube.com/watch?v=uiBMczcm69A

1 Like