5

I have a relatively simple case where:

  1. My program will be receiving updates via Websockets, and will be using these updates to update it's local state. These updates will be very small (usually < 1-1000 bytes JSON so < 1ms to de-serialize) but will be very frequent (up to ~1000/s).
  2. At the same time, the program will be reading/evaluating from this local state and outputs its results.
  3. Both of these tasks should run in parallel and will run for the duration for the program, i.e. never stop.
  4. Local state size is relatively small, so memory usage isn't a big concern.

The tricky part is that updates need to happen "atomically", so that it does not read from a local state that has for example, written only half of an update. The state is not constrained to using primitives and could contain arbitrary classes AFAICT atm, so I cannot solve it by something simple like using Interlocked atomic operations. I plan on running each task on its own thread, so a total of two threads in this case.

To achieve this goal I thought to use a double buffer technique, where:

  1. It keeps two copies of the state so one can be read from while the other is being written to.
  2. The threads could communicate which copy they are using by using a lock. i.e. Writer thread locks copy when writing to it; reader thread requests access to lock after it's done with current copy; writer thread sees that reader thread is using it so it switches to other copy.
  3. Writing thread keeps track of state updates it's done on the current copy so when it switches to the other copy it can "catch up".

That's the general gist of the idea, but the actual implementation will be a bit different of course.

I've tried to lookup whether this is a common solution but couldn't really find much info, so it's got me wondering things like:

  1. Is it viable, or am I missing something?
  2. Is there a better approach?
  3. Is it a common solution? If so what's it commonly referred to as?
  4. (bonus) Is there a good resource I could read up on for topics related to this?

Pretty much I feel I've run into a dead-end where I cannot find (because I don't know what to search for) much more resources and info to see if this approach is "good". I plan on writing this in .NET C#, but I assume the techniques and solutions could translate to any language. All insights appreciated.

Buretto
  • 405
  • 4
  • 12

3 Answers3

4

You actually need four buffers/objects. Two buffers/objects are owned by the reader, one by the writer, and one in the mailbox.

The reader -- each time he finishes a group of atomic operations on his newer object, he uses interlocked exchange to swap his older object handle (pointer or index doesn't matter) with the mailbox one. Then he looks at the newly obtained object and compares the sequence number to the object he just read (and is still holding) to find out which is newer.

The writer -- writes a complete copy of latest data into his object, then uses interlocked exchange to swap his newly written object with the mailbox one.

As you can see, the writer can steal the mailbox object at any time, but never the one that the reader is using, so read operations stay atomic. And the reader can steal the mailbox object at any time, but never the one the writer is using, so write operations stay atomic.

As long as the interlocked-exchange function produces the correct memory fence (release for the swap done in the writer thread, acquire for the reader thread), the objects can themselves be arbitrarily complex.

Ben Voigt
  • 277,958
  • 43
  • 419
  • 720
  • Hmm, I've not yet grasped why two objects/buffers are used for the reader, couldn't it just use one and compare with the mailbox to see if any updates have been made and then swap? I feel like I could technically get away with using only two buffers total (using the idea I outlined in post), but it would probably be a bit more messy and convoluted. I really like the mailbox idea, it seems simple and intuitive and since memory isn't a concern for me using 3-4 buffers isn't a big deal. I'll definitely look into this. Thanks. – Buretto Jun 08 '21 at 21:06
  • 1
    @Bureto: "compare with the mailbox" is a problem. You can't access the object while it is in the mailbox, because the writer can swap the mailbox at any time and start writing to the object while you are reading it. You MUST swap the object out of the mailbox before inspecting it. – Ben Voigt Jun 08 '21 at 21:08
  • Please correct me if I'm wrong, here's my understanding of how this could work: 1. Swaps are done reading the mailbox object into a temporary placeholder object (by reference ofc). 2. For the writer, it can accomplish its job by simply completing the swap via incrementing the sequence number in the object and using `Interlock.Exchange` to update the mailbox ref with newer object. 3. For the reader, it checks the placeholder object's sequence number with its current "primary" object's sequence number, and if the placeholder's sequence is higher then the placeholder becomes the primary,and it.. – Buretto Jun 09 '21 at 02:40
  • ..uses `Interlock.CompareExchange` to try and replace the mailbox ref with the old primary object. It uses `CompareExchange` instead of just `Exchange` because it checks and will subside writing to the mailbox if the writer has already set/exchanged the mailbox object during this short period. So in total there are 3 objects/buffers. Compared to my intial idea, this would perhaps be marginally better since there's no chance of being held up on locks (although the wait time on locks would likely be very tiny when that happens), and has no additional micro checks (locks, other signalling value). – Buretto Jun 09 '21 at 02:41
  • It'll take 50% more memory, but I'm more than happy with that as it's neglible in my case. Am I on the right track or is there something I'm missing? I think I might've missed your point completely but this solution should still work hopefully :). – Buretto Jun 09 '21 at 02:42
  • 1
    @Bureto: Avoiding locks is indeed a feature of my solution, but your attempt to eliminate the fourth object is introducing a race condition (in C++ that's instant undefined behavior, in C# you still get type safety guarantees but no guarantee about what value you actually find). The reader needs to claim the object from the mailbox (via `Interlocked.Exchange`) before it can read the sequence number stored in that object. Merely making another "placeholder" handle (pointer/index/whatever) to the same object that is still owned by the mailbox is not safe. – Ben Voigt Jun 11 '21 at 20:15
  • 1
    @Bureto: However, apart from the fact that you have to stop trying to read from the object while it is in the mailbox, your understanding of incrementing the sequence number in the writer before publication, and checking it in the reader to decide whether it replaces the "primary object" is exactly correct. – Ben Voigt Jun 11 '21 at 20:19
3

If I understand correctly, the writes themselves are synchronous. If so, then maybe it's not necessary to keep two copies or even to use locks.

Maybe something like this could work?

State state = populateInitialState();

...

// Reader thread
public State doRead() {
    return makeCopyOfState(state);
}

...

// Writer thread
public void updateState() {
    State newState = makeCopyOfState(state);

    // make changes in newState

    state = newState;
}
obe
  • 7,378
  • 5
  • 31
  • 40
  • Unfortunately, in my case, updates can possibly affect a large breadth of the state (i.e. not isolated to small part of it), so I'd have to end up copying an arbitrary large piece of the state AFAICT. Hmm.. maybe that's a hint that I've structured my state poorly.. idk. I can see how this idea would work though if using Interlocked/atomic exchange function, I'll definitely remember it. Thanks for the insight. – Buretto Jun 08 '21 at 21:04
2

It looks like you are using the input-process-output pattern in a multithreaded pipeline. Sometimes the input and processing phases (or processing and output phases) are merged when the problem is simple.

You have added a C# tag so using something like a BlockingCollection might be a useful way to communicate between the input and output threads. Since the local state is relatively small (your words) then posting a data-object containing a copy of the local state from the input thread to the output thread could be a simple solution. This follows a share-nothing philosophy which satisfies the atomic requirement because a snapshot of the current state is queued. The "catch up" capability is satisfied because the queue contains the backlog of state changes.

Generally, Messaging Patterns and Conversation Patterns are useful resources when trying to work out what to communicate and how to communicate between 2 or more threads (or processes, services, servers, etc).

Daniel Dearlove
  • 565
  • 2
  • 12