Hacker Newsnew | past | comments | ask | show | jobs | submitlogin
Too dangerous for C++ (dureuill.net)
93 points by dureuill on Feb 9, 2024 | hide | past | favorite | 86 comments


The two criticism at the end are... odd.

First, there is criticism that assigning to a shared_ptr is not synchronized so it would be bad to share a single shared_ptr object between threads. True, but that is no different than literally every other non-atomic object in C++. It's not surprising in any way.

Second, there is criticism that assigning to the object pointed at by the shared_ptr is not synchronized between threads. This is odd because that's not actually different than a single thread where there are two shared_ptrs pointing to the same object. That is, even with single threading you have a problem you must be careful about.


But if the Rust versions are as safe as claimed, then you're making the critique of C++ stronger, by pointing out that the pitfalls are easier to fall into than the blog post presents -- for the second, you don't even need threads! (And aliasing is one of the things that Rust's borrow machinery at least tries to address, even in a single-threaded context.)

So, is he wrong about the Rust part?


In this context, "unsynchronized access" refers to read/write operations happening concurrently on multiple threads, *not* to the shared pointers pointing to different objects as a result of the assignment.

Unsynchronized access to the pointed to object will typically cause a specific kind of race condition called a data race, which is undefined behaviour. As it requires threads, it cannot happen in a single-threaded context.


See also:

https://www.boost.org/doc/libs/1_65_0/libs/smart_ptr/doc/htm...

i.e. a single-threaded non-atomic shared_ptr

Rust fans can dislike on the "C++ has no central library system like crates" all they want, but there's not many things you actually need when programming that don't exist for C++, even if you don't like them not coming in a little box that looks like other little boxes.


The point of the article isn't that single-threaded non-atomic shared pointers are unfeasible in C++, it is that their usage is too dangerous.

The fact that it wasn't included in the standard library for this reason is an argument for this. The fact that even `shared_ptr` has thread-safety footguns, one of which made it to the famous C++ talk, “Curiously Recurring C++ Bugs at Facebook”[1], is another. By the way, every single of the bugs from that talk is impossible in safe Rust.

[1]: https://youtu.be/lkgszkPnV8g?si=cCWASihvIGJ25Jf3


While we're at it, std::atomic<std::shared_ptr<T>>

https://en.cppreference.com/w/cpp/memory/shared_ptr/atomic2


Yes, this is discussed in the article, I do not understand your point.


This. As soon as I need something it is quick online search away. Amount of stuff available for C++ is staggering


The criticism is less about what's available, for there is more available in C++ than Rust. The criticism is about ease of packaging in a cross-platform magnet that is easy enough.


Boost is extremely popular. I've used it in almost all my projects.


I have exactly zero problems using the same C++ code on Windows (debug and development) and then building and running it on Linux in production


> Apparently, this is enough of an issue that C++20 added a partial template specialization to std::atomic<std::shared_ptr>. My advice, though, would be "don't do that!". Instead, keep your shared pointer in a single thread, and send copies to other threads as needed.

This is to support an atomic lock-free shared_ptr. You can then use this as a building block for building lock-free data structures.


Interesting, I was lacking this context. Could you provide me with more information about this? I only saw atomic_shared_ptr come up in discussions about bugs up to now.


https://www.youtube.com/watch?v=gTpubZ8N0no

The target of this optimization is low-latency code. Rendezvous will not work for that.


Pretty clickbaitey title.

It's possible to implement in C++... so it's not "too dangerous" for C++. It's dangerous for people who don't have knowledge of what they're doing in C++; same as in any programming language.


On the contrary, I thought it was quite apt. If you follow the article's link to this Stack Overflow answer:

https://stackoverflow.com/a/15140227/1614219

Which summarizes a discussion by the C++ standards committee to reject the C++ version of Rc, and one of the main arguments is the risk of Rc code being accidentally included in threaded code.

I would point out that this code can include: code you wrote years ago that you forgot includes Rc, code in libraries that was modified internally to use Rc and the authors forgot to mention it, code written by colleagues who aren't familiar with the pitfalls, etc. That's why this isn't a trivial problem to solve.


"Too dangerous" doesn't imply impossible.

It's too dangerous to parachute off the Eiffel tower. That doesn't mean it's impossible, periodically somebody does it.


I'm not a C++ expert but I believe it is not possible in current C++ to implement a pointer type that will cause a compiler error when it is sent to another thread.


Interesting thought experiment. While I haven’t tried it, one half-baked idea that comes to mind is disable both copy and move for the pointer and instantiate the type in C++ thread-local storage. Not that I ever would.


It is interesting! I experimented with creating a bad borrow checker for Java using annotations from

https://checkerframework.org/

It supports some level of substructural types using must-call annotations,

https://checkerframework.org/manual/#resource-leak-checker


That's almost certainly true, but first you have to define "send to another thread"


std::jthread?


From my experience, the biggest footgun with shared_ptr and multi threading is actually destruction.

It is very hard to understand which thread will call the destructor (which is by definition a non-thread-safe operation), and whether a lambda is currently holding a reference to the object, or its members. Different runs result different threads calling the destructor, which is very painful to predict and debug.

I think that rust suffers from the same issue, but maybe it is less relevant as it is a lot harder to cause thread safety issues there.


> which is by definition a non-thread-safe operation

yes, but at this point, since the reference count is reaching 0, there is supposed to be only that one thread accessing the object being destroyed, so the destruction not being thread-safe should not be a problem.

If otherwise, it means there was a prior memory error where a reference to the pointed-to object escaped the shared_ptr. From there the code is busted anyway. By the way it cannot happen in Rust.

> Different runs result different threads calling the destructor

What adverse effects can happen there? I can think of performance impact, if a busy thread terminates the object, or if there is a pattern of always offloading termination to the same thread (or both of these situations happening at once). I can think of potential deadlocks, if a thread holding a lock must take the same lock to destroy the object (unlikely to happen in Rust where the Arc object would typically contain the object wrapped in its mutex and the mutex wouldn't be reused for locking other parts of the code). There isn't much else I can think of, what do you have in mind?

> whether a lambda is currently holding a reference to the object, or its members

This cannot happen in Rust. If a lambda is holding a reference to the object, then it either has (a clone of) the Arc, or is a scoped lambda to a borrow of an Arc.


From the stackoverflow link within TFA:

> With GCC when your program doesn't use multiple threads shared_ptr doesn't use atomic ops for the refcount. This is done by updating the reference counts via wrapper functions that detect whether the program is multithreaded (on GNU/Linux this is done by checking a special variable in Glibc that says if the program is single-threaded[1]) and dispatch to atomic or non-atomic operations accordingly.

> I realised many years ago that because GCC's shared_ptr<T> is implemented in terms of a __shared_ptr<T, _LockPolicy> base class, it's possible to use the base class with the single-threaded locking policy even in multithreaded code, by explicitly using __shared_ptr<T, __gnu_cxx::_S_single>. You can use an alias template like this to define a shared pointer type that is not thread-safe, but is slightly faster[2]:


I would rather use the non-atomic shared pointer from Boost[1] linked upthread than a non-standard non-portable implementation detail from GCC, but yes, it exists.

You can definitely implement a non-atomic non-threadsafe shared pointer in C++, my point in the article is that actually using it is very error prone. This is supported by the type being excluded from the standard library with one of the reasons being the risk of bugs.

[1]: https://www.boost.org/doc/libs/1_65_0/libs/smart_ptr/doc/htm...


The extent of the error prone-ness depends entirely on what "send to another thread" means (i.e. precisely how this is done).


Refcounted memory management on a large scale is slow anyway, with or without atomic refcounting. The bigger problem is that Rc, Arc or shared_ptr often only manage one small object, and that object lives in a separate tiny heap allocation. So you end up with many tiny heap allocations spread more or less randomly around in memory and the likelyhood of getting cache misses on access is much highter than tightly packing the underlying data into arrays and walking over the array items in order.

And if you only have a small number of refcounted references in your program, the small performance difference between atomic and non-atomic refcounting doesn't matter either.

Same problem with Box and unique_ptr btw, a handful is ok, but once that number grows into the thousands all over the codebase it's hard to do any meaningful optimization (or even figure out how much performance you're actually losing to cache misses because it's a death-by-a-thousand-cuts scenario).


Shared objects interacting across threads is a bad idea. Java did it “safely” forever ago, it just throws a lock on everything


Data sharing between threads is inherently too much of a complex model for programmers to manage (when systems get complicated enough), that in many cases it is better to think of a solution that avoids it altogether. This is why some concurrency-centric languages (Erlang, Go) choosed to use message passing as the main paradigm, instead of going for locks everywhere at runtime (Java) or an incredibly complex type system that tries to prevents data races at compile time (Rust)...


Rust's type system for thread safety is actually remarkably simple. Types declare whether they add or remove thread safety (e.g. Mutex adds safety, non-atomic Rc removes). Structs automatically become non-thread-safe if they have non-thread-safe fields. Then all the functions that spawn threads or send data over channels require thread-safe types.

The fearless concurrency is real. It reliably prevents data races, use-after-free, and moving of thread-specific data to another thread. It works across arbitrarily large and complex call graphs, including 3rd party dependencies and dynamic callbacks. Plus immutability is strongly enforced, and global mutable state without synchronization is not allowed.

It doesn't prevent deadlocks, but compared to data corruption heisenbugs, these are pretty easy — attach a debugger and you can see exactly what deadlocked where.


> global mutable state without synchronization is not allowed.

That's the java model again. I don't want fearless concurrency, I want intentionally designed threads.


It's not the Java model because Java just makes every operation atomic (from the point of view of the model, I'm sure the JVM and javac must do optimizations to avoid some of them), while Rust enforces that multi threaded access must be through atomic operations, but if there is no multi threaded access or the type is not meant to be used in a multi threaded context, that information is encoded in the type system. This might sound like an academic distinction, but it is different: the developer is in control. You could even go as far as lie to the type system and claim a racy type is actually thread safe. I wouldn't advice doing so, but I can't stop you.


I’m sure the rust version is more ergonomic. It’s great you can do it more safetly. But it’s a bad application design from the start.


That's an incredibly broad criticism aimed at some hypothetical solutions you imagine, not grounded in what Rust does. No language can stop an imaginary infinitely determined fool.

Rust's restrictions, such as strict scopes of references and strongly enforced shared XOR mutable access, prevent many sloppy and careless designs that are possible in Java or C++.

Rust also takes advantage of its type system, generics, and ecosystem to offer solid constructs for multi-threading. There are safe data parallelism libraries, task queues, thread pools, scoped threads, channels, etc. Users are well equipped to implement multi-threading properly, and as much as possible Rust steers users towards locally-scoped, immutable or share-nothing solutions.


Message passing is not any easier or safer though. For every problem with shared memory concurrency you can draw a dual problem in message passing. See: https://songlh.github.io/paper/go-study.pdf

For me, single-threaded intra-task concurrency using async/await turned out to be safer and also easier to work with than either of the above mentioned concurrency models. Just a single loop with a top level select - everything is sequential and easy to reason about, also no need for any synchronization like locks or shared atomic pointers.


I am not going to be surprised to be downvoted, but you don't need shared_ptr in C++, that is itself overkill

The point of C++ is performance. If you don't need performance, why not just use Java or Python, why use Rust?


> The point of C++ is performance. If you don't need performance, why not just use Java or Python, why use Rust?

As a counterpoint, I also don't need to use `Rc` or `Arc` in Rust, and I can get by without reference counting. Why use C++?


You don't need performance until you do. Writing slow rust in my experience is just as easy as writing python. If not easier because the libraries are better designed.


The point of rust is you get the performance of C++ with additional safety.


For example, safe rust forbids code which writes to different elements of the same vector from different CPU cores. C++ compiler has no objections, and doing that is often the best way to parallelize computations.


> safe rust forbids code which writes to different elements of the same vector from different CPU cores.

It doesn't (see e.g. slice::split_at_mut). It however doesn't allow you to do that and mutate the vector at the same time.


Different elements of the output vector may take very different time to compute. If you do parallelization with split_at_mut API, you won’t be able to saturate all cores because the thread who does the splitting can’t possibly know how much time each slice going to take.



Sometimes you don’t want libraries, instead you want to implement similar stuff in your own code. And Rayon uses unsafe to workround the compiler limitation of the safe rust I was talking about.

Also, it seems Rayon is substantially slower than C++ OpenMP https://www.reddit.com/r/rust/comments/brre8o/a_scaling_comp...


> Sometimes you don’t want libraries, instead you want to implement similar stuff in your own code.

That's always the tradeoff, isn't it? You can implement the logic yourself, or modify three lines and immediately get parallel evaluation of you loop (add the dependency in Cargo.toml, add an import statement and modify an .iter() call to .par_iter()).

> And Rayon uses unsafe to workround the compiler limitation of the safe rust I was talking about.

So does the standard library. Using unsafe is not a cheat, it's not a defeat. It is letting the library developer express something that the borrow checker cannot yet comprehend, at the cost of the developer taking responsibility of upholding the language's invariants.

Those "safe rust limitations" are the point, not an accident or misfeature. "If we restrict ourselves to handling the 90% most common cases of problems, we can automate the checks and provide an escape hatch for the other 10%" is the unofficial Rust ethos! The alternatives would be to either sacrifice performance in the general case or sacrifice safety in the general case.

Btw, the author of rayon is Niko Matsakis. It's not part of stdlib because of many reasons, but the quality of implementation is not one of them.


This is probably a better link:

https://www.reddit.com/r/rust/comments/bto10h/update_a_scali...

The poster has figured out a significant performance leak, their Y-axis is no longer nonsense, and they've got a peak indication so that we know the theoretical best possible numbers (no practical software will get there but indeed OpenMP is closer than Rayon)


I wouldn’t mind using unsafe rust for encapsulated cases where it makes sense (though this might not be the one as it could be done in safe rust). It is just nice to have this delineation and it can simplify debugging as well.


I prefer different strategy. I use C++ for encapsulated cases where it makes sense. I compile that C++ into DLLs, and consume these DLLs from C#.

C++ is safer than unsafe Rust. C++ was designed for usability of the unsafe code because the entire ecosystem is unsafe, and have been that way for decades. By now, the tooling is pretty good.

And C# is safer than safe rust due to the VM. Rust compiles to native code. Unlike C# rust requires unsafe to implement any non-trivial data structures.


Do you have an example of C++ being safer than unsafe Rust?

As to C# being safer, wouldn’t your comparison need you to take into account unsafe constructs used to implement the C# VM?


> an example of C++ being safer than unsafe Rust?

Not really, that’s just my impression reading stuff about unsafe rust, and programming C++.

> wouldn’t your comparison need you to take into account unsafe constructs used to implement the C# VM?

Rust compiler depends on LLVM written in C++, kernels for all mainstream OSes are written in C. The probability of bugs in my code I just written is orders of magnitude higher than probability of bugs in these third-party systems.


> Rust compiler depends on LLVM written in C++, kernels for all mainstream OSes are written in C.

But this general criticism is just as valid for C# which you say is safer, isn't it? 18.6% of the C# runtime is written in C or C++[1] and you run the runtime on an OS that's largely written in C or C++.

[1] https://github.com/dotnet/runtime


The point of a program is first and foremost to be correct, performance is never more important than that. /dev/null is not in fact webscale.


You use Rust if you want performance (especially because there is no garbage collection), and strong safety guarantees.

If you don’t care about safety guarantees and abstractions like shared_ptr, you might just as well use C instead of C++.


"performance" is about profiling and avoiding bottlenecks.

That I'm using reference counting on the error path of my parser (so there's something wrong with the input and the task is not going to complete) is very unlikely to become a bottleneck.

You may have a point in that I navigated codebases that were plagued with shared ptrs everywhere in the past, and that general style of programming is not going to yield good performance. But you shouldn't deal in absolutes.


There are valid selling points to rust's safety features, but this just feels like "I use rust because I need my compiler to be my training wheels". More of a self-own than anything.


This strikes me as a weird criticism.

If I hired someone to paint my wall and they were saying "I'm not going to use any protection against splatters on the ground because I am that good and don't need training wheels", I would find that behavior very unprofessional and wouldn't want that person anywhere close to my wall.

I'm writing professional software I'll take any help from tooling that is available without compromising other aspects like performance. It also helps that Rust is much more productive than C++ overall.

About the self-own, my teams over the years lauded my low bug rate, be it in C++ or in Rust. I have a knack for correctness, hence why I prefer languages that make strong guarantees about it by construction to languages where I need to remember and regurgitate thousands of rules at every corner


I don't see what's wrong with having training wheels. In fact, it's not really training wheels, it's just safeguards, and we all need them as much as possible (with a good balance between this and usability of course)


Seeing the astounding number of CVEs in C++ code everywhere, everybody needs training wheels.


My explanation ? There are way more program written in C/C++ than Rust out there, so statistically more bug are found. About the wheels ? Maybe that's true also for Rust developers: https://www.cvedetails.com/vulnerability-list/vendor_id-1902...


The question is whether proportionally more bugs are found, and the indications are yeah, a lot more. There was an academic study of bugs in the Firefox codebase and they found that first time contributors were far more likely to introduce bugs in C++ than Rust proportionally, with the ratio getting tighter as people have more experience with the codebase. If you've got a team of people who've lived with your C++ codebase for a decade, they're perhaps not introducing more bugs than they would in Rust.

You're looking at a list of less than two dozen CVEs over several years across the Rust standard library and tooling. There are no CVEs raised for the analogous C++ behaviour, it's just accepted as normal.


No true Scotsman would use a compiler either, you should be writing programs in binary. Why, only a fool would use a computer to automate repetitive, error-prone tasks!


> More of a self-own than anything.

Only if there's a lot of programmers that don't need them.

There's roughly zero of those programmers. And you're not one of them.


I heard for nearly two decades how type safety, const correctness, etc proved to create better programs in C++ than C. Now Rust could be viewed as the next iteration of that and it seems like many C++ developers like to try and paint this picture that the added type safety and correctness checks are somehow terrible. The mental gymnastics at play are a bit mind boggling really.


Real programmers use butterflies.


> the Rc type does not support being sent between threads

So why even have such a thing in a language designed for concurrent programming from the ground up?

Arc should be called Rc, and that's it.


That's what C++ does because it has no way to ensure that you use the atomic reference counts in multi-threaded code. But, as the author writes in the blog post, Rust can in fact ensure this. So it lets you to use the more efficient non-atomic reference count for single-threaded use, saving the unnecessary cost of various memory access barriers.

Just because a language is designed for concurrent programming, it shouldn't make it impossible to achieve full single-threaded performance, as long as you're not compromising safety.


this is a bit weird.

if you have only 1 thread, you don't atomic, and thus not using atomic reference counts is fine

but if you have more than 1 thread, you can't use a non-atomic refcount, so you can't use Rc but must use Arc.

"but that's such a simple change, just change the decl with a 1 char addition! Pluse, Rust won't let you do bad stuff if you've forgotten to change the type".

I guess I'm just old. Old enough that I've already implemented all the data structures and methods I need in C++, including safely passing around shared_ptr<T>.


In Rust, you may have multiple threads yet still use non-atomic reference counts for objects that are never shared between multiple threads.


And indeed you don't need to care about this, because Rust's type system is looking after this problem, if I use Jim's acrobatics crate, and Jim in turn used Sarah's tightrope crate, which happens to rely on Rc for an internal type which in turn ends up wrapped inside Jim's type which I'm using, my type knows it can't be sent across threads.


which is cool and all that until stephan wants to sends a tightrope to another thread.


In Rust this roadblock is highlighted to Stephan. Aha, we cannot do this. Perhaps Stephan should ask the maintainer of the software they're using for a version which has the properties they desire for threaded use.

In C++ equivalent roadblocks are not sign posted. You may not even realise you're in trouble until some very strange errors begin to happen.


So for the first paragraph, this seems to be severely problematic for any software where someone you know and trust and who continues to have a good relationship with you is no longer available. How important this is will obviously vary, but predicating some important benefit of the language and saying that this benefit won't cause issues in the future because you can "ask the maintainer" is pretty unrealistic for proprietary software.

For the second paragraph, that depends a great deal on (a) what the mechanism used to "send a (shared, ref-counted reference thing) to another thread actually means and (b) what objects are used to accomplish this. Certainly simply writing the address of a shared_ptr<T> in C++ will work out as you indicate. But that's not the only way to do it. Rust's benefit comes from you being "unable" to do it an unsafe way; C++'s benefit comes from the fact that somebody has probably implemented the safe way in C++ already :)


> C++'s benefit comes from the fact that somebody has probably implemented the safe way in C++ already :)

You're an experienced C++ programmer, you already know what the "safe way" will be in C++. "Just don't make any mistakes". There's no possible way to benefit from multi-threading and yet use arbitrary non-thread-safe features magically without problems, the "genius" of C++ is finding a way to blame you for things you can't do anything about.


And then hit annoying roadblocks when you do want to pass those objects between threads?

You should write code to minimize the reference count bumps; they are waste of time whether atomic or not.

If the code spends 0.5% of its time bumping references, and you magically reduce that to zero using alien optimization technology, that only gives you a 0.5% improvement.

If the code spends 10% of its time bumping references up and down, something is wrong.


Yes, Rust also makes it quite easy to minimize reference count bumps. Rc values are moved by default, which introduces no traffic, and increments are explicit calls to `clone`. You can have both optimizations together!

It's even possible to share an Rc-managed value across threads without switching to Arc, as long as the other thread(s) never needs to change the reference count and can be "scoped" (https://doc.rust-lang.org/stable/std/thread/fn.scope.html) to some lifetime that some particular Rc outlives.


> Rc values are moved by default

That could similarly work for Arc values to minimize the atomic bumping.


Yes, Arc values are also moved by default. I was referring to both.


It would be very odd for such a transformation to be so difficult that it would impose a roadblock; after all, I'd think it would usually be the application deciding it only has a single thread, rather than something 10 dependencies up the line.


Not every program that's written will even be multithreaded, and there's a significant cost to using atomic operations when you don't need them. What is the disadvantage of having non-atomic Rc be available?


And Rc can still be useful for multithreaded programs. Not every value needs to be shared between threads.


Why not have such a thing, when it is strictly more performant for any program that has data which isn't accessed concurrently?


Because then you need to complicate the compiler with a diagnostic against misuse, which has to work 100% right in all situations and be maintained forever.


Because Rust has a safety culture, and provides threading, it is crucial that that the compiler will reject types you cannot safely send to another thread. So it does.

So the "diagnostic against misuse" you're concerned about is a necessary part of the compiler anyway.

Indeed, although Rc has this line:

  impl<T: ?Sized, A: Allocator> !Send for Rc<T, A> {}
(which means roughly "You can't send this type to another thread")

It also has these lines:

  // Note that this negative impl isn't strictly necessary for correctness,
  // as `Rc` transitively contains a `Cell`, which is itself `!Sync`.


It's not just Arc<T> vs. Rc<T> that's relevant for thread safety, though. Pretty much any kind of shared mutability requires extra protection (locks or atomicity) to work safely across threads, so there has to be some way to indicate whether or not that extra protection is present. Not to mention objects that interact with FFI, such as mutex locks, which must be unlocked from the same thread. It would be a huge performance drain to demand that "every value everywhere must be usable from every thread".


See the docs for Rc, including an example:

https://doc.rust-lang.org/std/rc/index.html#examples




Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: