20 ms·
Benign data races considered harmful
- Hokusai 6y ago> Weak atomics give you the same kind of performance as benign data races but they have well defined semantics. That is a great argument. Self-explanatory code make it easier to understand what are the design goals and usage of that code. > Traditional “benign” races are likely to be broken by optimizing compilers or on tricky architectures. This is even better. One of the reasons is so hard to move code from one architecture to another one is because not only word sizes but small non-defined behaviors. I had the pleasure to make C++ code compile for WebAssembly. It is challenging and interesting the unexpected ways that it breaks. And how many times the mental model that we have as developer and the reality of the C++ specification differ in small ways. My most absurd example was a test application that had to crashi on the push of a button, to verify that all logs and recovery code was working. To crash the application the code was accessing a NULL pointer. Alas, WebAssembly allowed read access to address Zero without problem. So, the button did nothing when compiled in WebAssembly.
- rezonant 6y agoHuh, I didn't know this was the case for WebAssembly. I found a discussion about it amongst the WebAssembly designers. https://github.com/WebAssembly/design/issues/204 https://github.com/WebAssembly/design/issues/204 -- Looks like efficient implementation of mprotect() was a sticking point, and its been relegated to a Future Feature https://github.com/WebAssembly/design/blob/master/FutureFeatures.md#finer-grained-control-over-memory https://github.com/WebAssembly/design/blob/master/FutureFeat...
- msla 6y ago> To crash the application the code was accessing a NULL pointer. Alas, WebAssembly allowed read access to address Zero without problem. Good thing nothing in the C or C++ standards mandates NULL be address all-bits-zero regardless of how it's sometimes spelled. Any compiler which assumes that memory access to address all-bits-zero is access to NULL is not compliant.
- PaulDavisThe1st 6y agoSince C++11, the definition of NULL is: an integer literal with value zero, or a prvalue of type std::nullptr_t See https://wg21.cmeerw.net/cwg/issue903 https://wg21.cmeerw.net/cwg/issue903 for a case where NULL was did not evaluate to zero.
- AlotOfReading 6y agoThe best part of C++ is how even the caveats get caveats. The fun never ends for rules lawyers.
- wolfgang42 6y agoI thought “integer literal zero (in pointer contexts)” was the definition of NULL in all versions of C. As I understand it the resulting pointer doesn’t necessarily have to have a value of 0 (even though that’s what it says in the source code) so long as it behaves appropriately nullish—i.e. the compiler can translate “int *foo = 0;” as “MOV foo 0xDEADCODE” so long as that value won’t be used for any legitimate pointer.
- msla 6y agoThe above-linked site about POSIX has this paragraph: https://www.austingroupbugs.net/view.php?id=940 https://www.austingroupbugs.net/view.php?id=940 > The C standard goes to great length to ensure that NULL (and for that matter, any representation of the null pointer, which is written in source code by converting a constant value 0 to a pointer) need not be represented in hardware by all 0 bits (that is, 'int p = 0;' or 'static int p;' is allowed to compile to assembly instructions that assign a non-zero bit pattern to the location named p). And in fact, there are historical machines where the most efficient null pointer in hardware was indeed a non-zero bit pattern: http://c-faq.com/null/machexamp.html http://c-faq.com/null/machexamp.html ... and the site it links to has this: > Q: Seriously, have any actual machines really used nonzero null pointers, or different representations for pointers to different types? > A: The Prime 50 series used segment 07777, offset 0 for the null pointer, at least for PL/I. Later models used segment 0, offset 0 for null pointers in C, necessitating new instructions such as TCNP (Test C Null Pointer), evidently as a sop to [footnote] all the extant poorly-written C code which made incorrect assumptions. Older, word-addressed Prime machines were also notorious for requiring larger byte pointers (char 's) than word pointers (int 's). [snip] > The Symbolics Lisp Machine, a tagged architecture, does not even have conventional numeric pointers; it uses the pair <NIL, 0> (basically a nonexistent <object, offset> handle) as a C null pointer.
- mehrdadn 6y agoDespite the quote, it's just not true that relaxed atomics will give the same kind of performance. They may, but they may not, depending on the situation. The issue with them is that they're still atomic throughout the entire program, even in the situations where you can guarantee there are no other threads running and hence don't need atomicity. (Perhaps in particular scenarios, like with small inputs, you never even spawn additional threads to begin with.) For native-sized integers, ensuring atomicity might or might have overhead depending on the implementation. But it will at least have overhead for larger objects.
- PaulDavisThe1st 6y ago> But it will at least have overhead for larger objects. Given that there's essentially no way to do atomic operations on larger objects (than machine-level types), all std::atomic<> for such types must involve a mutex, and are therefore guaranteed to have performance implications. std::atomic<T> provides syntax that treats std::atomic<int> and std::atomic<MyHugeObject> in precisely the same way. This is useful, but entirely misleading if you care about performance.
- mehrdadn 6y agoYou're arguing for my point, not against it. (Though your basis for it isn't even true; HTM could avoid mutexes.)
- PaulDavisThe1st 6y agoHTM?
- mehrdadn 6y agoHardware transactional memory. TSX-NI in Intel's case.
- pkolaczk 6y agoThey don't have to involve a mutex but some kind of synchronisation. A sequential lock is another way of efficiently implementing that, without blocking the happy read path. Instead of locking the large object, it detects if a read was atomic and retries if it was not.
- ganafagol 6y ago> I had the pleasure to make C++ code compile for WebAssembly. It is challenging and interesting the unexpected ways that it breaks. And how many times the mental model that we have as developer and the reality of the C++ specification differ in small ways. And that's because the way most programmers write C/C++ programs is with the mental model of the actual machine they are writing the code for. That's not so strange, it's being taught that way. It's even praised as a skill to look at the generated assembly if you want to understand how something is compiled. But that approach is fundamentally broken. One needs to program against the abstract machine which defines the semantics of your programming language. Everything else leads to unportable and subtly broken code with hidden assumptions of how undefined behavior is resolved. Sometimes you need to do this for performance reasons? Well, then you pay the price. It's expensive.
- secondcoming 6y agoThe comment: > if you have mutexes in your code you’re probably programming at too low a level is quite disappointing to read
- mjburgess 6y ago> something based on CSP Gives the game away. Just another proselytising FP nut trying to justify all the time they've spent learning "universal" FP-abstractions (ie., highly specific hacks around lazyness).
- pornel 6y agoThe difficulty with "benign" data races and atomic operations with weak ordering, is that they're not guaranteed to behave like the target architecture behaves! It's insufficient to say "this is fine on x86" even in an x86-only program! That's because the optimizer symbolically executes the program according to the C memory model, not x86 memory model (as if it was some kind of Low-Level Virtual Machine…). The optimizer may reorder or remove memory accesses in ways that wouldn't happen on the target hardware.
- jackewiehose 6y ago> The optimizer may reorder or remove memory accesses in ways that wouldn't happen on the target hardware. yes, but so far (C/C++2003) I always thought that something like mutex.lock() in the example, before accessing x, would be recognized as a memory barrier by the optimizer so it should be safe to access x as long as your thread acquired the lock. Now for C++11 I'm not so sure anymore because of this last paragraph of the article: > What’s more, since C++11 has well defined memory semantics, compiler writers are no longer forced to be conservative with their optimizations. If the programmer doesn’t specifically mark shared variables as atomic, the compiler is free to optimize code as if it were single-threaded. Does this really mean that the above x must always be declared volatile even though it's surrounded by a mutex-lock? If so I hope there's a compiler flag do get the old behavior...
- secondcoming 6y agoIn this instance the shared variable isn't x, but the internal workings of the mutex.
- manwe150 6y agoA mutex lock will still be modeled as an aliasing read/write of all global memory. So you’re fine.
- kccqzy 6y agoNo, you shouldn't declare x as volatile. Also note that the article never even talked about volatile in C++; volatile in C++ is entirely different from volatile in Java. Instead when you use a mutex, your mutex internally uses atomics. In fact the simplest kind of mutex can be built with atomics alone (spinlock), though a production-quality mutex will have to use other techniques such as a Linux futex.
- microcow 6y ago> It is now widely recognized that this attempt to define the semantics of data races has failed, and the Java memory model is broken (I’m citing Hans Boehm here). This is a bit misleading. C++ also ended up defining the semantics of data races with memory_order_relaxed. In the standard they are not called "data races", but they correspond closely to what Boehm calls Java "giving some semantics to data races". C++ relaxed atomics also have the same issues with causality. See Russ Cox's talk http://nil.csail.mit.edu/6.824/2016/notes/gomem.pdf http://nil.csail.mit.edu/6.824/2016/notes/gomem.pdf And Viktor Vafeiadis' slides: https://people.mpi-sws.org/~viktor/slides/2016-01-debug.pdf https://people.mpi-sws.org/~viktor/slides/2016-01-debug.pdf > Weak atomics give you the same kind of performance as benign data races but they have well defined semantics. This is mostly true, but not at the limits. GCC, clang, MSCVC, and icc tend to be pretty conservative in how they treat relaxed atomics. For example, on x86 they tend not to generate the reg+mem form and instead generate an explicit `mov` instruction (https://gcc.godbolt.org/z/K135Mo https://gcc.godbolt.org/z/K135Mo). This requires extra resources for instruction fetch & decoding and uses an extra named register (potentially causing spills to the stack). > Traditional “benign” races are likely to be broken by optimizing compilers or on tricky architectures. This is true, but the sad thing is that the implementation of the C++ memory model is sometimes broken too. GCC had lots of real bugs up through about GCC 4.9 (I think...). And then there's http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/p0668r1.html http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/p066... -- "Existing implementation schemes on Power and ARM are not correct" (2017)
- GeertB 6y agoHaving been there, and having bitten often enough, I can confirm that most compilers just try to generate the most conservative code possible for volatile accesses. Their semantics are crazy/inconsistent, so you can't reason about them, and high performance code won't use them. Nobody will ever be interested in making them work/perform well. C++ atomics in their various memory models are different in that they have well-defined semantics, used in high performance code and people do care. You're right that there have been bugs (and probably still are), but at least you can _argue_ that something is a bug if there is a coherent definition. Optimization of atomics, such as merging barriers, is still in its infancy.
- carabiner 6y agomemes considered memeful
- Animats 6y ago"Benign data races" have been considered harmful from a theoretical perspective for decades. But most deployed CPUs had memory semantics that made them work. Mostly. On x86 multiprocessors, if, without taking any special measures, you set x=1 from processor 0, and processor 1 reads x shortly, but not too shortly, thereafter, it will see 1. If you try that on, say, an 80-core ARM CPU [1], CPU #53 might not see a 1 for a long time, if ever.[2] You're no longer guaranteed that cache changes propagate unasked. As we get more and more CPUs, the cost of creating the illusion that there's a single memory goes up. It's now important that compilers know what's shared so they can generate the proper fence and barrier instructions. Especially since the latest iteration of ARM has new features such as "non-temporal load and store." What used to be a theoretical problem, or at most a problem for OS architects, has thus acquired teeth and claws that can bite ordinary applications programmers. This is where traditional C++ mutexes start to break down. From a theoretical perspective, it's always been annoying that mutexes at the POSIX level have no tie to what they're supposed to be protecting. Hitting a mutex has to mean "flush everything". That's inefficient. Rust has the advantage that its locks own data, so the compiler knows what has to be flushed. That becomes more important as the number of CPU cores becomes very large. I've said for years that the three issues in C/C++ are "how big is it", "who owns it", and "who locks it". C++ now at least has abstractions for all of these, although they all leak. You can still get raw pointers out to misuse, or, more likely, pass to some API for misuse. [1] https://venturebeat.com/2020/03/03/ampere-altra-is-the-first-80-core-arm-based-server-processor/ https://venturebeat.com/2020/03/03/ampere-altra-is-the-first... [2] https://developer.arm.com/documentation/den0024/a/the-a64-instruction-set/memory-access-instructions/memory-barrier-and-fence-instructions https://developer.arm.com/documentation/den0024/a/the-a64-in...
- rrss 6y ago> If you try that on, say, an 80-core ARM CPU [1], CPU #53 might not see a 1 for a long time, if ever.[2] You're no longer guaranteed that cache changes propagate unasked. What do you mean by this? ARM requires cache coherency for data caches, which requires that every store is eventually made visible to all cores. Fences are necessary to impose additional requirements on the order which writes to different addresses are made visible, but AFAIK not necessary to ensure that a single write is eventually visible.
- valuearb 6y agoIt seems I deal with race conditions all the time in Swift (Fixed one bug just today), but can understand little of this. Every time I turn around, it turns out things are harder and more complex than I thought.
- brundolf 6y agoFwiw "data race" != "race condition", confusing as that may be. A race condition is a blanket term for any nondeterministic bug resulting from differences in timing between independent, concurrent processes. A data race is a specific kind of race condition where reads/writes to the same value in machine memory step on each other's toes and lead to inconsistent behavior. The classic example is two threads that try to increment the same number but only one takes effect, because they both read the current value before either writes their new value (instead of one read/writing and then the other read/writing). I'm not really a C/++ programmer, though, so I only have a rough understanding of this topic. I don't know what a "benign data race" is, for example.
- vermilingua 6y ago"Considered harmful" considered harmful: https://meyerweb.com/eric/comment/chech.html https://meyerweb.com/eric/comment/chech.html