3

I have a huge tbb::concurrent_unordered_map that gets "read" heavily by multiple (~60) threads concurrently.

Once per day I need to clear it (either fully, or selectively). Erasing is obviously not thread safe in tbb implementation, so some synchronisation needs to be in place to prevent UB.

However I know for a fact that the "write" will only happen once per day (the exact time is unknown).

I've been looking at std::shared_mutexto allow concurrent reads but I am afraid that even in an uncontended scenario might slow things significantly.

Is there a better solution for this?

Perhaps checking a std::atomic<bool> before locking on the mutex?

Thanks in advance.

Santiago
  • 379
  • 3
  • 14
  • 2
    find/insert is a write – Neil Butterworth Nov 18 '22 at 11:08
  • That's thread safe on `tbb` implementation and it is not relevant for the question I've asked. – Santiago Nov 18 '22 at 11:18
  • How huge? Can you afford copying it? – n. m. could be an AI Nov 18 '22 at 11:41
  • Around 10k - 20k entries. Do you mean copying it on each read? – Santiago Nov 18 '22 at 11:51
  • Single producer, multiple consumers certainly sounds like it should be possible without locks. I've seen a good yt talk about it a while ago, trying to dig that up... – nick Nov 18 '22 at 12:12
  • 2
    "checking a std::atomic before locking on the mutex" is probably what the mutex does. Have you tested it? – user253751 Nov 18 '22 at 12:12
  • Santiago: I take it @n.m. meant creating a new, empty or filtered copy with your writer thread, then switching the readers to that when done. – nick Nov 18 '22 at 12:24
  • This is a perfect job for a [read/write lock](https://en.wikipedia.org/wiki/Readers%E2%80%93writer_lock). Create a shared_mutex, then lock it for writing with a unique_lock and for readers with a shared_lock. The consequence is readers will never block eachother, but if the writer has the lock everything will wait. This is what that construct was designed for. – Jason C Nov 18 '22 at 12:32
  • Does this answer your question? [Reader/Writer Locks in C++](https://stackoverflow.com/questions/244316/reader-writer-locks-in-c) – Jason C Nov 18 '22 at 12:32
  • 1
    No, on each cleanup of course. You basically have two copies, the current copy and the new copy. Everybody reads the current copy. The writer prepares the new copy and then at some point does an atomic switch between the copies. The simplest way is probably to have `std::array, 2>` and an `std::atomic_int current_index`. Hopefully everybody finishes reading the old copy by the time you need to switch again (if not, increase the array size). – n. m. could be an AI Nov 18 '22 at 12:33
  • @n.m. The simplest way is just to use a read/write lock. If the write is time consuming you can do an update on the writer thread into a new array like you say (but with no locking operations), then take the write side of the read/write lock during the swap operation. – Jason C Nov 18 '22 at 12:35
  • @Santiago What platform are you on? If you're on Windows there are some [constructs in the API](https://learn.microsoft.com/en-us/windows/win32/api/synchapi/) that could help, and if you're on Linux or you're using MinGW or some other compiler with POSIX stuff there's some similar constructs in pthreads as well. There's also some utilities in boost. (Beyond what std provides) – Jason C Nov 18 '22 at 12:52
  • @JasonC I am linux CenOS 7 with gcc-compat-4. I'll give the shared_mutex with shared_lock a try then. Thanks! – Santiago Nov 18 '22 at 13:06
  • @Santiago Fwiw, pthreads has an [actual read/write lock construct](https://docs.oracle.com/cd/E19455-01/806-5257/6je9h032u/index.html), but std on a POSIX system could very well be implemented with the same strategies anyways so I'm not sure if it's a benefit or not. But you could try it to see if you like it more, if needed. – Jason C Nov 18 '22 at 14:38

2 Answers2

4

This is a perfect job for a read/write lock.

In C++ this can be implemented by having a shared_mutex, then using a unique_lock to lock it for writing, and a shared_lock to lock it for reading. See this post for a example code.

The end effect is that readers will never block on eachother, reads can all happen at the same time, but if the writer has the lock, everything will block to let the writing operation proceed.

If the writing takes a long time, so long that the once-per-day delay is unacceptable, then you can have the writer create and populate a new copy of the data without taking a lock, then take the write end of the lock and swap the data:

Readers:

  1. Lock mutex with a shared_lock
  2. Do stuff
  3. Unlock
  4. Repeat

Writer:

  1. Create new copy of data
  2. Lock mutex with a unique_lock
  3. Swap data quickly
  4. Unlock
  5. Repeat tomorrow

A shared_lock on a shared_mutex will be fast. You could use a double check locking strategy but I would not do that until you do performance testing and also probably take a look at the source for shared_lock, because I suspect it does something similar already, and a double-check on the read end might just add overhead unnecessarily. Also I don't have enough coffee in me yet to work out double check locking in a read/write lock scenario.

There is a threading construct called a spin lock as well, but it's really just an encapsulation of a double-checked lock that repeats the "check" until it clears. It's a good construct but again you'll want performance analyses and a look at the shared_lock + shared_mutex source, because they might spin already. A good implementation of a spin lock can be found here, it covers some common gotchas. You might have to tweak it to get a read/write spinlock.

Generally speaking though, it's best to use existing constructs during the initial implementation at the very least as a clearly coded proof-of-concept. Then if you know that you're seeing too much read contention, you can optimize from there. But you need the general strategy down first, and 91 times out of a hundred, it's good enough. In this case, no matter what, some manifestation of a read/write lock is what you're going to end up with.

Jason C
  • 38,729
  • 14
  • 126
  • 182
  • Yes, this is what I had in mind with `std::shared_mutex` + `std::shared_lock`, however I was a bit afraid to whether there would be a performance impact on the reader case even if the mutex is uncontended (ie: no writer). But I guess I'll have to try and see. Thanks! – Santiago Nov 18 '22 at 13:00
  • 1
    @Santiago `locking`/`unlocking` mutex, even a shared mutex, triggers memory fences, then again, uses of `tbb::concurrent_unordered_map` probably triggers them. So it shouldn't matter too much... the only problem is if one of threads keeps it under shared lock for a long amount of time while a "write" is waiting to happen - this way other threads will be blocked for too long while waiting for their shared lock. – ALX23z Nov 18 '22 at 13:29
  • @ALX23z Do you know if any priority is placed on a waiting `unique_lock` over a waiting `shared_lock`? I have no idea, tbh, but if there is some priority then that would help let the writer butt in to highly contended read locks. I mean an alternative, I guess, is to just pause all reading explicitly at the same time every day for a moment to let the writer do its thing; if that's acceptable in this context. – Jason C Nov 18 '22 at 14:11
  • In any case the write lock eventually *would* get the lock; and since it happens daily, +/- a couple seconds worst case seems like it would be well within requirements. So it might be best to just do a proof-of-concept and see how it behaves first, and if any contention for writing is causing a daily update delay that is beyond spec, then it can be addressed. – Jason C Nov 18 '22 at 14:32
  • @Santiago Just make sure you use `unique_lock` on the write end (just to be clear). `shared_lock` is for the read ends. And the same `shared_mutex` for everybody, of course. – Jason C Nov 18 '22 at 14:34
  • 1
    @JasonC that not how it works. Locks can only wait while calling `lock()`, so when a `shared_lock` is already acquired there is no option for `unique_lock` to capture the mutex but to wait till it gets released on its own. On the other hand while `unique_lock` waits for its time other new `shared_locks` cannot capture the mutex and are put on hold. – ALX23z Nov 18 '22 at 14:40
  • @ALX23z Thanks; I wasn't sure if there were some kernel-level primitives coming into play where some sort of OS-level scheduling started to have an effect. – Jason C Nov 18 '22 at 14:42
3

It might require a bit of extra work on maintaining it, but you can use copy-on-write scheme.

Keep the map in a singleton within a shared pointer.

For "read" operations, have users thread-safely copy the shared pointer and use it for as long as they want.

For "write" operations, create a new instance map in a new shared pointer, fill it with whatever you want and replace the old version it in the singleton.

This way "read" users will still see the old version and can use it safely. Just make sure they occasionally get the newest version from the singleton. Perhaps, even give them a handle that automatically updates the shared pointer once a second or something.

This works in case you don't need the threads to synchronously update all at once.

Another scheme, you create atomic boolean to indicate when an update is incoming, and just make all threads pause their operations on the map when it is true. Once they all stopped you perform the update and let them resume their operation.

ALX23z
  • 4,456
  • 1
  • 11
  • 18
  • You still need to do synchronization with COW; because you don't want a reader to suddenly see the new version in the middle of the update swap / copy, or to get the new version while the copy is happening. "Get the new version" still needs to be atomic even if there's only one writer. And - COW or not - even though "copy" doesn't need to be atomic, "copy then update the data in the copy" does, or else a reader could get a new version that is still being updated. – Jason C Nov 18 '22 at 12:42
  • 1
    @JasonC fairly certain that I've written that user keeps the shared pointer and updates it once in a while. Updating it once a second or so won't cause any problems with performance. – ALX23z Nov 18 '22 at 13:01
  • It's *highly* possible that I misinterpreted! – Jason C Nov 18 '22 at 14:39