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.)
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.
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.
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.
> 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.
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.
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.
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.
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.
> 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.
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).
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.
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.
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.
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.
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.
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.
> 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.
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.
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++.
"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.
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)
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!
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.
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.
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>.
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.
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.
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?
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".
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.