0

I have a consumer thread that must never lock nor allocate memory, and a producer thread that can. I want to implement a two place circular buffer to be able to provide data to the consumer thread from the producer, with the bound that whenever no new data is available to consume, the consumer just re-uses the already available data.

This is what I've come up with for now:

bool newDataAvailable = false;
bool bufferEmpty = true;

foo* currentData = new foo();
foo* newData = new foo();

void consumer() {
  while(true) {
    currentData->doSomething();
    if(newDataAvailable) {
      foo* tmp = currentData;

      // Objects are swapped so the old one can be reused without additional allocations
      currentData = newData;
      newData = tmp;
      newDataAvailable = false;
      bufferEmpty = true;
    }
  }
}

void producer() {
  while(true) {
    while(!bufferEmpty) { wait(); }
    newData->init();
    bufferEmpty = false;
    newDataAvailable = true;
  }
}

Is this naive implementation ok? I know reading and writing to variables can be non-atomic, so I should use an atomic storage, but those can cause locks. Is the use of atomic storage needed here? Also, I'd like to eliminate the active wait in the producer, and I thought I could use a std::condition_variable, but they require the use of mutexes and I cannot afford them.

1 Answers1

1

Writing multi-threaded code that shares variables without using a mutex is very difficult to get right. see An Introduction to Lock-Free Programming, Lock Free Buffer.

If you absolutely must avoid using mutexes, then i would highly recommend using a pre-made lock-free queue, like e.g. Boost.lockfree or MPMCQueue as a light non-boost alternative.

I know reading and writing to variables can be non-atomic, so I should use an atomic storage, but those can cause locks.

std::atomic is generally lock-free (doesn't use a mutex) for all primitive types (up to the native size of your cpu). You can check if std::atomic will use a mutex for a given type by calling std::atomic<T>::is_lock_free

Is the use of atomic storage needed here?

Yes, absolutely. You either need to use mutexes or atomics.

Also, I'd like to eliminate the active wait in the producer, and I thought I could use a std::condition_variable

When you can't use mutexes your only option is to use a spin lock. If it is allowed in your context, you could use std::this_thread::yield() in the spin lock to reduce CPU load. (however a mutex might be faster then)

Edit: A potential solution with only 2 atomics would be:

std::atomic<foo*> currentData = new foo();
std::atomic<foo*> newData = new foo();

void consumer() {
    foo* activeData = currentData;
    while (true) {
        activeData->doSomething();
        foo* newItem = currentData;
        if (newItem != activeData) {
            newData = activeData;
            activeData = newItem;
        }
    }
}

void producer() {
    while (true) {
        foo* reusedData = newData;
        if (!reusedData)
            continue;
        newData = nullptr;
        reusedData->init();
        currentData = reusedData;
    }
}
Turtlefight
  • 9,420
  • 2
  • 23
  • 40
  • I can't use mutexes because the consumer thread is higher priority and waiting on the producer might cause priority inversion. Do you think just using and atomic bool (assuming it's actually lock-free) would make my example code work? – Francesco Bertolaccini Jul 25 '19 at 17:03
  • @FrancescoBertolaccini If you make all 4 variables atomic and correctly arrange the loads / store, it will be safe. Although please note that getting atomics right is very difficult and can lead to a lot of hard to debug problems. I would strongly recommend using an already existing library that has been thoroughly tested. – Turtlefight Jul 25 '19 at 18:20
  • @FrancescoBertolaccini Why don't you use the same priority for the consumer & producer? Due to their tight coupling (consumer waits for producer to produce the next item, producer waits for consumer to process the item) they need to take turns anyway. – Turtlefight Jul 25 '19 at 18:26
  • Because the consumer doesn't wait, it just continues to operate on the old data. It's an audio application, so if the consumer is blocked I get stuttering, but the producer can provide data as slowly as it wishes, the consumer adjusts accordingly – Francesco Bertolaccini Jul 25 '19 at 18:29
  • Unfortunately I cannot choose to run the app thread with the same priority as the audio thread – Francesco Bertolaccini Jul 25 '19 at 18:30
  • @FrancescoBertolaccini I think i have a better understanding of your use-case now :) I added a small example that uses 2 atomics to my answer. Is there any reason why you need to reuse the foo objects? You could greatly improve the throughput by allowing multiple foo objects. – Turtlefight Jul 25 '19 at 19:11
  • From a first glance I don't quite understand your example, so I will try and give it a thought before commenting on it. Regarding the multiple objects: I want to minimize the amount of (de)allocations, also the nature of the task is such that it makes no sense for the producer to put more data than the consumer can accept. Think of the objects as parameters for the currently playing note: if the current batch of samples has not done processing, it makes not sense to put more notes in, much less buffer them. – Francesco Bertolaccini Jul 25 '19 at 19:34
  • 1
    @FrancescoBertolaccini ok, thanks for your detailed explanation :) Let me know if you have any questions :) The way the atomics work is like a channel: the consumer pushes its new item into `currentData`, after that the producer will pick it up. Then the producer sends back his old data item to the consumer via the `newData` variable. After that the consumer picks up the item from `newData`, "refurbishes" it and pushes it back to the producer via `currentData` – Turtlefight Jul 25 '19 at 19:43
  • Your solution looks pretty much ideal to me, thanks :) – Francesco Bertolaccini Jul 25 '19 at 20:02
  • @FrancescoBertolaccini np, glad i could help :) – Turtlefight Jul 25 '19 at 20:03