Safely writing code that isn't thread-safe
cliffle.com
cliffle.com
But the flip side is that naive Rust code just isn't useful for multithreaded work and tends to require heavier abstractions (e.g. copy "commands" or whatever in and out of queues vs. just taking a lock around some shared data). I occasionally try to look at the natural way one expresses the kind of stuff we do in Zephyr in a Rust kernel, and... there's really not much there besides copious and extensive use of unsafe.
Can’t you do this in Rust with std::sync::Mutex and similar?
What’s more interesting is figuring out why that is. Also why Arc<T> is Send only if T is Send.
- RwLock hands out multiple references (that's the point), Sync means a type can be used from multiple threads (concurrently), if RwLock<T: !Sync> was Sync it would allow multiple outstanding references for the same !Sync object, which is not legal.
- Mutex, however, only hands out a single reference at a time (which is also why it can always hand out a mutable reference), meaning semantically it acts like it moves the T to the target thread then borrows it = nothing to sync, that's why Mutex<T> is Sync if T is Send.
- For Arc, if it were Send without the wrapped object being Send it would allow Send-ing !Send objects: create an Arc<T>, clone it, move the clone to a second thread, drop the source, now you can try_unwrap() or drop() the clone and it'll work off of the second thread.
This is a problem with threadlocal state, but also with resource affinity (e.g. on windows a lock's owner is recorded during locking, and only the owner can release the lock, there are also lots of APIs which can only work off of the main thread.), or thread-safety (Rc for instance would be completely broken if you could Send it, as the entire point is to use an unsynchronised refcount).
In fact even without actually putting anything of substance in the mutex, you can get value from type system judo using this mechanism, which C++ doesn't appear to do either.
[[no_unique_address]] since C++20. Before that there was the empty base class optimization.
Can you say whether you genuinely thought C++ had ZSTs? And if so, how you came to that conclusion ?
Thanks for pointing me to Boost synchronized_example<T> showing that this does exist, at least as an experimental library feature.
This is the synchronized value pattern [1]. I'm pretty sure that my 3rd edition of "The C++ programming Language" by Bjarne had a description of it and it predates rust by at least a decade.
[1] https://www.boost.org/doc/libs/1_80_0/doc/html/thread/sds.ht...
That Boost link says it is "experimental and subject to change in future versions" but I don't know whether Boost just says that about everything or whether this would particularly mark out this feature.
† In Rust Mutex<T> is 8 bytes bigger than T, typically. In C++ std::mutex is often 40 bytes.
The boost warning doesn't have much to it. Boost libraries don't even guarantee API stability across versions and boost.synchronized has been available for a few years with no changes.
> In Rust Mutex<T> is 8 bytes bigger than T, typically. In C++ std::mutex is often 40 bytes.
That's because on libstdc++ std::mutex embeds a pthread_mutex_t which is 40 bytes for ABI reasons. It is a bad early ABI decision that can't be changed unfortunately. std::mutex on MSVC is worse. std::shared_mutex is much smaller on MSVC, but even worse than std::mutex on libstdc++.
A portable Mutex<T> of minimal size can be built on top of std::atomic::wait though.
We have to be realistic, the standard library will never be complete and you'll always have to get basic components from 3rd party or write them yourself.
That way, such ‘bleeding edge’ features evolve and improve a lot before being set in stone.
I'd guess that while a modern class are probably taught some sort of open addressed hash map, they aren't being taught anything as exotic as Swiss Tables or F14 (Google Abseil and Facebook Folly's maps) but that's OK because standardising all the fine details of those maps would be a bad idea too.
On the other hand, the document does not tell you to use a halfway decent hash function, and many standard implementations don't provide one, so in practice many programs don't use one. The "bad hash map" performs OK with a terrible hash function, whereas the modern ones require decent hashes or their performance is miserable.
for (auto foo : bar.wlock()) {
baz(foo);
}
You need to do bar.withWLock([](auto &lockedBar) {
for (auto foo : lockedBar) {
baz(foo);
}
});
In rust, the lifetime checking prevents that.The other big gotcha is accidentally blocking while holding a lock. E g. Instead of
auto g = bar.wlock();
baz(*g);
co_await quux();
You should do {
auto g = bar.wlock();
baz(*g);
}
co_await quux();
Or use withWLock. If you co_await with the lock held you can deadlock if the executor switches to a different coroutine that tries to acquire the same lock. If you actually need to hold the lock across the blocking call, you need coroutine-aware locks, which turns it into auto g = co_await bar.wlock();
baz(*g);
co_await quux();
No idea if rust prevents this problem - I suspect not, but I haven't used async rust.Tokio provides a Mutex like your final example that is intended for use in such async code that will hold locks while waiting because Tokio will know you are holding the lock. It is accordingly more expensive, and so should only be used if "Don't hold locks while asleep" was not a practical solution to your problem.
You can take a lock around some shared data; the standard library has such a thing. If the standard library lacked such a thing, then you could implement that abstraction in unsafe, but that at least narrows the amount of code you need to reason about the thread-safety for. Of course that only prevents data-races, not deadlocks and livelocks.
Most code that you can't "just do" in Rust is code that really should have a lot of eyeballs on it were it implemented in some other language. Kernel code gets a lot of eyeballs, usually a lot of testing, and still ends up with fielded bugs[1].
For kernel code, I imagine Rust shines for device drivers; TFA applies well there; a multithreaded kernel could have thread-oblivious device drivers and no future "optimization" would accidentally share data between threads.
There are data-structures and algorithms that put you in conflict with Rust's type system (a non-concurrency one that comes to mind is trees with parent pointers). Sometimes the solution is to pick a different data-structure, other times it is judicious use of unsafe.
1: Note that the same is true with parts of the Rust stdlib that are implemented with unsafe.
struct Shared { /* ... */ }
#[derive(Clone)
struct Context {
shared:Arc<Mutex<Shared>>
}
impl Context {
fn do_stuff (&self) {
let shared = self.shared.lock();
/* ... */
}
fn do_more_stuff (&self) {
let shared = self.shared.lock();
}
}
What's wrong with that? You have a bunch of safety guarantees here :- mutex poisonings are detected (you can use try_lock to recover from them)
- the shared data isn't leaked and guaranteed to live long enough in each calling context
- the Mutex implementation doesn't suck and iirc supports reentrant code without deadlocks, making it safe to use on the same thread
- it's really easy to call `context.clone()` to send it wherever you want
I don’t think that’s true, at least in the “rust mutexes are reentrant” sense: a mutex hands out &mut refs, a reentrant mutex would mean concurrent &mut, which is illegal.
However rust does allow handing out the one &mut to callees, without fear that it will be leaked and outlive the lock or anything, so this sort of “reentrancy” is fine if you split functions into lock acquisition and actual processing.
IME deadlocks (and self-deadlocks) remain the biggest issue in concurrent rust. Lock ordering concerns are especially frustrating when fine-locking.
#[derive(Clone)]
struct S {
a: Arc<Mutex<A>>
}
impl S {
fn reentrant(&self) {
let cloned = self.cloned();
let locked = self.a.lock();
if condition {
cloned.reentrant();
} else {
/* do stuff */
}
}
I believe it will not deadlock. Reentrant mutexes are pretty common implementations - is that not true in Rust right now?https://play.rust-lang.org/?version=stable&mode=debug&editio...
The parent comment correctly explained that it would be incorrect for a Mutex<A> to hand out unique references &mut A to multiple call frames simultaneously. If it did, one of them could be passed to another thread and accessed concurrently with the other one to create race conditions.
https://doc.rust-lang.org/std/sync/struct.Mutex.html#method....
“The exact behavior on locking a mutex in the thread which already holds the lock is left unspecified. However, this function will not return on the second call (it might panic or deadlock, for example).”
The parking_lot crate has a ReentrantMutex<A> that would not deadlock here. It hands out shared references &A instead of unique references &mut A. If you want to be able to mutate the value, you can wrap it in a type with interior mutability (Cell or RefCell), and then the type system will prevent you from passing those references to other threads.
https://docs.rs/parking_lot/latest/parking_lot/type.Reentran...
FWIW they wouldn't even need to be moved between threads. IIRC creating two independent &mut to the same object is one of the instant UBs, it already is an invalid program state.
Slightly inaccurate. You can recover from them with `lock` too. In fact, to _not_ attempt to recover from them, your code should have looked like:
let shared = self.shared.lock().expect("mutex poisoned!")
as `lock` returns a `Result`.The difference between `try_lock` and `lock` is unrelated: `try_lock` fails fast if it's not possible to take the mutex immediately.
There's UnsafeCell type that is a deliberate loophole in immutability of types behind shared references. It's used as the basis for atomic access and mutexes.
Just another comment about this:
> Basically all languages except C(++) have an async mode that transforms code into state machines that can be interleaved on a single thread of execution.
C++ has coroutine support since C++20. It's not pretty, but it's there.
Unlike C# you can't just sprinkle in async and await keywords and let the background thread pool take care of it...and doing so would be undesirable, given the lack of thread safe garbage collection.
Boost.Fiber on the other hand pretty damn cool.
But I'm dubious about the use of needing to explicitly mark functions as non-threadsafe, its a bit like the const vs mut, but while const might be slightly less common save for things that should be autogenerated, in the case of reentrant, if it isnt explicitly marked, it should never be used reentrant, even if it happens to be a pure function at the moment, or for this one implementation etc.
https://tutorial.ponylang.io/reference-capabilities/capabili...
I am deeply interested in parallelism, asynchrony and multithreading. I blog about it everyday in ideas4 (see my profile)
I try think of automatic parallelization approaches. And how to structure programs that take advantage of queuing theory and processing ratios. Imagine if the compiler could work out that one program has a ratio of processing of 2:1 then it could scale out automatically.
I am very interested in compilers that can automatically parallelise. I find Pony to be very interesting with its reference capabilities.
My problem with Rust is that is precludes many safe programs. References can be alive and be passed around.
Regarding bank account transactions, I worked on the same problem. It lead to me implementing multiversion concurrency control. Multiversion concurrency control allows thread safety avoiding locks except for internal data structures of the MVCC.
Multiple threads can read/write the same data but they'll never modify the exact same data due to the multiple versions.
https://github.com/samsquire/multiversion-concurrency-contro...
I also implemented the bank account example but I serialised the account transactions to the same account numbers in ConcurrentWithdrawer.java - this solution doesn't use multiversion concurrency control.
My other solution BankAccounts2.java and BankAccounts3.java take different approaches.
The BankAccounts2.java has 1 thread out of 11 threads that synchronizes the bank accounts and performs the serialization of account balances. The other threads generate transactions.
BankAccounts3.java simply uses a lock.
The problem with the bank accounts problem is that I am yet to work out how to scale it. You could shard threads to serve a certain range of account numbers.
Or you can separate the current balance of each account across threads and if the receiving transaction is less than that balance, you don't need to read other threads to check.
I recommend this article by Vale dev's
https://verdagon.dev/blog/seamless-fearless-structured-concu...
I also experimented with other concurrency primitives.
One is a scheduler that receives requests to write to shared memory. Then it schedules the request then the thread marks a callback array and the next request can be served. There's scalability challenges.
Damn that's a lot of code. Mutability everywhere, nulls and special integers (-1), triply-nested for-loops, direct indexing into lists. And it's all in memory? If you're in a situation where you want multiple safe writers, just use software transactional memory.
Checkout MVCC.java and TransactionC.java for multiversion concurrency control. It's far easier to understand.
It takes less code to implement multiversion concurrency control.
Software transactional memory is great and I like it but it can have scalability problems.
I really enjoyed Joe Duffy's blog series on Midori, the Microsoft .NET software transactional memory implementation.
http://joeduffyblog.com/2015/11/03/blogging-about-midori/
STM essentially can get transpiled into a for loop modifying a log area and then a CAS instruction to the actual memory.
STM can be thought of as multiversion concurrency control because the operations occur on a log.
But the problem is that most things are not transactional. How do you reverse an API call?
I do agree with this and it rubs me the wrong way. Sure I can put something into `std::mem::ManuallyDrop` and hand out transmuted unsafe `&'static mut` references to it but then people come and point out (rightfully) how this library is unsound.
Safe Rust precludes some safe programs, but Rust's soundness rules preclude far fewer. I've only seen a couple of real-world programs that you couldn't express in Rust (without unnecessary runtime checks), and that's solely due to the hierarchical lifetimes.
Don't think that's possible, as there rarely are programs where compute to IO ratio is constant. Even the simplest of say web API will have calls that take more CPU and calls that just wait for network somewhere.
It would be nice to have tooling to figure out whether app is "just" idling or waiting for network for scaling purposes, we already have that in form of IO wait on disk IO.
Then again Go's solution of "have threads so light you can just run 100000 of them to fill CPU even if each of them have a lot of network wait" works well enough. There is also of course async/event driven but that generally leads to code with worse readability (at best similar) and more annoying debugging
It has 1 schedule thread that preempts kernel and lightweight threads and M kernel threads and N lightweight threads
Hot for and while loops can be interrupted by setting the looping variable to the limit.
https://GitHub.com/samsquire/preemptible-thread
I am currently investigating coroutine transpilation with async await similar to Protothreads implementation that uses switch statements. I am trying to do it all in one loop rather than reentrant functions as in Protothreads.
Essentially I break the code up around the async/await and put different parts into a switch. I "think* it's possible to handle nested while loops inside these coroutines.
The problem I don't know how to handle is if a coroutine A calls coroutine B and B calls A. I get the growing data problem which I'm trying to avoid. I want fixed size coroutine list
I think some Microservices take more CPU than others. You can scale one Microservice more than another based on CPU usage.
Another of my benchmarks is multithreading message generation and sending it between threads in thread safe. I can get 20-100 million message sent per second, sending in batches
Cilk and Cilk++ are parallel variants of C that scale up automatically in the way you describe.
It is not hard to implement it in a scale out way in make (by following the example of distcc), and some of the big data frameworks get it right (especially ones with finer grained tasks than Hadoop MapReduce).
The whitepaper Serializable Snapshot Isolation talks more of dangerous read-write structures.
My MVCC may be vulnerable to write skew even though it generates the right result. I am yet to generate a test case that exhibits that behaviour. The write skew occurs when there's two dangerous read-write structures
Which still isn’t quite good enough because files (especially mmaped files) can be abused as shared memory even between multiple processes. But the general point is good, “you’re better off with mutable xor shared memory while concurrently processing”.
I work more in web dev now and I suspect the majority of my team does not know the difference between a thread and a process. HN readers may scoff but these are productive engineers who have build some honestly pretty impressive apps and systems.
(though this also makes it impossible to have shared static data...)
BTW, "multiprocessing" is not a drop-in replacement for multithreading. In fact, there are many domains where "multiprocessing" just isn't possible or practical. So the real solution is to use another language.
Multiprocessing has other benefits too, like making your code simpler, fault tolerant (you can restart failed processess, etc...) and you can make sure OS is handling all resource cleanup. I've seen many multithreaded monsters that would've been better as multi-process architecture instead, and maybe even more faster / safer too, this is usually how actor based languages like erlang is implemented, behind the scenes everything is actually a separate process.
If you need to work with shared structure in threads, keep that part very small. Or consider if GPU or SIMD would be a better fit.
If it compiles, it's thread safe. If it isn't thread safe, Rust will show you exactly where. It is as amazing and magical as it sounds.
OTOH it is an example how thread-safety extends to everything in Rust, and you can't be surprised by some function somewhere unsafely mutating global state.
It's super common for programmers coming from C to have a blind spot around strict static ownership, equate references with pointers and try to use them for "not copying", while in Rust they mean "not owning". This desire to avoid copies instead results in needless fights with ownership and the borrow checker. Rust moves never do deep copies, but Rust approaches this differently than C, and it takes practice to internalize that.
Everyone in that thread told you OnceCell would work, because it really would. People do write huge programs in Rust every day. There's a steep learning curve, but once you know the Rust-specific programming patterns, rather than try to write C in Rust, it goes smoothly.
The next most common footgun C-style expectations get you in Rust is self-referential structs, when you create a new object and then take (temporary scope-bound) references to inside of it, and try to store them long-term in unrestricted scope. That is a thing safe Rust doesn't guarantee without Rc/Arc, regardless whether it's thread-safe or not (moves can trivially invalidate such structs in single-threaded code too).
I do find it a better lever for "make the easy things simple and the hard things possible" though.
Edit: Do you actually mean multiprocess programs? Those are the ones that communicate via lock free data structures stored in shared memory, with the invariant that processes can be kill -9’ed or segfault without leaving the shared memory in an inconsistent state.
That’s like switching your compiler into nightmare mode. It’s technically possible, but first you need to master piles of obscure data structure research.