Why does unsafe multithreaded std:unordered_map crash more than std:map?
devblogs.microsoft.com
devblogs.microsoft.com
In C++ or Rust, we get multiple readers OR a single writer. Shared ref or exclusive mut, pick one. But in .NET we have AND not OR: one writer AND multiple readers, all concurrent.
This works because .NET's GC can tolerate the race. A writer may trigger a table rehash, and a reader will read either the old or new table. If the reader loads the old table, it won't be deallocated until it's done: the GC will keep it alive.
Any cool uses of this one writer AND multiple readers?
https://learn.microsoft.com/en-us/dotnet/api/system.collecti...
It's also a map that supports multiple readers AND a writer.
It does this by maintaining two maps, one used by the writer and one used by the readers and atomically swap the two when useful, the cool part is that reads are wait-free but the cost is at least double memory usage and slow writes (twice the work).
edit: note this is not just a map datastructure. As far as I understand, left_right can wrap any data structure.
For my text editor, I went with an immutable balanced tree. I was surprised by how easily I was about to implement all operations I needed with reasonable runtime complexity (log N mostly, operations like "give me a copy with element at position i removed"): https://github.com/alefore/edge/blob/master/src/language/con...
I use this throughout my editor. For example, I load a file into a sequence of lines represented here: https://github.com/alefore/edge/blob/8fdf7f76ffa167497a7e9e9...
I found that the good performance, relatively straightforward implementation and very strong thread safety (making the data immutable means a whole class of problems just disappears) works very well.
Writing sometimes needs to "re-bucket" everything and during that process it's mayhem if you were stupid enough to be simultaneously looking something up (but in .NET this will be prevented†) but before and afterwards even write operations "just" add or remove elements to linked lists, which you're correct in a GC language it's not crucial to care that some reader may look at an element which you have meanwhile removed [in C++ that would be a use-after-free]
> Enumerating through a collection is intrinsically not a thread safe procedure. Even when a collection is synchronized, other threads can still modify the collection, which causes the enumerator to throw an exception. To guarantee thread safety during enumeration, you can either lock the collection during the entire enumeration or catch the exceptions resulting from changes made by other threads.
So, you know, congratulations you played silly games and you have won yourself a stupid prize.
This has basically the same "cool uses" as with an RwLock, AFAIU the readers aren't actually allowed to look at the hash table while it is being rehashed, they're silently held by the implementation.
You could do the "Reclaim data only once nobody is looking" in a non-GC language with any of the other concurrent reclamation techniques such as RCU or Hazard Pointers and in many cases you'd be fine with a reference counting approach (not always which is why these other techniques exist).
Edited To Add: † Huh. Maybe under GC they get away with doing all the work and then just pivoting to the new hashtable after rebucketing with a single writer? So then readers just get obsolete data for a while and it's the poor GC which has to cope with the mess.
Maybe that guarantee was too strong and prevented a more efficient implementation.
They're faster than mutexes, which themselves also internal borrow check, but also have the logic mechanic.
This allows you to make any type work similarly but eschew the overhead in cases where it's not needed.
A similar case to what the parent describes is RCU or crossbeam's epoch-based reclaim.
But ultimately I don’t recall anything special about the runtime semantics the first time I came across this technique which if I recall correctly came from the brilliant folk at Azul Systems for their JVM. You just need a way to atomically adjust certain nodes. And if I recall correctly Azul’s supported N concurrent writers that were all wait free (ie every thread participated in forward progress)
So not quite as performant but in terms of having a writer and lots of concurrent readers, it achieves that requirement. And it’s data structure agnostic and you only need to pay this cost if you really have concurrency.
Typically you need hazard pointers or similar deferred reclamation tricks.
pub struct DashMap<K, V, S = RandomState> {
shift: usize,
shards: Box<[RwLock<HashMap<K, V, S>>]>,
hasher: S,
}
so an array of RwLocks.That's quite different than what .NET offers, which is multiple readers and a single writer with NO locks outside of the allocator/GC.
I'm not shilling for .NET (I've never written a .NET app) but it does have a really cool hash table.
There's std::shared_mutex, which allows multiple readers and a writer.
The analogous Rust type is std::sync::RwLock<T> (but as with Mutex, RwLock is a wrapper, so it is actually protecting something inside it, not just providing the raw mutex type).
If you've been using std::shared_mutex assuming you can have both then, I guess it's good luck that you didn't cause a deadlock of some sort, but at least this API unlike most C++ APIs isn't just "LOL, if you use this wrong it's Undefined Behaviour, all bets are off, goodbye" in the respect that matters to you.
Maybe it's beacause std::map tree nodes don't get reallocated while unordered_map hash buckets do ?
Suppose you have two threads doing writes at a rate of r per second. Suppose that it takes time t to do an operation. Suppose that concurrent operations have a probability p of crashing. What rate do crashes happen at?
Well, the one thread spends time t * r per second in the operation. The other spends the same. So you spend time (r * t)^2 per second with concurrent accesses, and crashes happen at a rate p * (r * t)^2.
Now let's look at the normal case for a hash insert. t is small and constant in the size of the collection. p is proportional to 1/n. And therefore collisions happen at a rate of O((r * t)^2 / n).
Now let's think about the red-black tree. Exact numbers are calculated. While the writing part is at most O(log(n)). But on average you spend time O(log(n)) finding where to write, and only write O(1) nodes. Think half the time you only write once, 1/4 of the time you write once, rebalance once. 1/8 of the time you write once and rebalance twice. The result is complicated to analyze, but it works out as worse than the regular hash operation by a constant factor. So crashes happen at a rate O((r * t)^2 / n) with worse constants.
Now let's look at the resize operation. This works very differently. The first thread encounters resize every n operations, for a rate r/n. A resize takes time O(t * n). If any write comes in during that resize, the other thread will also do a resize, and they will crash. So the odds that we crash is the time of the operation, times the rate at which writes come, which is O(r * t * n). Multiplying this by the rate we get a probability of O(r/n * r * t * n) = O(r^2 * t).
The previous equation was smaller by a factor of t/n. But remember, t is a very small time (operations are fast) and n tends to be reasonably large (the size of the collection). The result is that race conditions happen dramatically more often for resizes of hashes than any other operation.
No, and Chen emphasizes this point repeatedly in his blog post. The customer knew the crashing was bad, but it wondered why switching to the unordered map caused the frequency to increase.
For threading-related problems, an increase in the crash frequency can be a good thing, since it makes the problem somewhat easier to reproduce and debug.
That said, it’s can be really helpful when you suspect a race condition.
Tsan is also not static analysis.
If anyone knows how to make relacy run a somewhat fair scheduler please let me know - it told me a server thread that waited for work never made any progress, but it also never gave a client thread any cycles to submit work so that wasn't very informative.
(edit: just occurred to me I can probably fake it adequately by having a yield that makes the current thread start playing the role of the other service, coroutine style)
Please protect your STL data structures with a mutex. Please keep your thread communications sane. There’s no reason to think through why one is more prone to explosion than another; any conclusions you draw are at best only relevant to your current compiler and STL implementation, and maybe only during some phases of the moon.
Seems reasonable to me. It's an interesting question even if any given answer is only valid under narrow conditions.
Have you ever wondered what’s up with the CF_SYLK and CF_DIF clipboard formats? [0]
Or why did the message on the Start menu change from Start to Ship It! for one build? [1]
Or how about filling in some gaps in the story of Space Cadet Pinball on 64-bit Windows [2]
[0] https://devblogs.microsoft.com/oldnewthing/20200226-00/?p=10...
[1] https://devblogs.microsoft.com/oldnewthing/20200505-00/?p=10...
[2] https://devblogs.microsoft.com/oldnewthing/20220106-00/?p=10...
Look at this from the point of code analysis - if "a bit more thread-safe" map is used - you have less chances to detect the problem in local testing & and may be surprised in prod. And, you may be even more surprised when switching to unordered map.
Usually, any big app has a huge tail of very rare crashes that nobody has time to fix, ever. Those rare crashes are indistinguishable from some system/hardware related faults (system OOM, system trying to kill an app, battery dying, bit flipping due to radiation/faulty silicon). And that's the point - if you are planning to change map to unordered map - look at the crashes list around map invocations and try to understand, if any of them are due to multithreading and not system/hw...
Asking and answering "why" is an important educational process. I've met other developers like you, and quite frankly they've always been mediocre to abysmal in their work, especially when debugging, because they've almost completely lost the ability to reason and hypothesise.
It isn't an official Microsoft publication.