Thread-per-core
without.boats
without.boats
The debate isn't about thread-per-core work stealing executors, it's whether async/await is a good abstraction for it in Rust. And the more async code I write the more I feel that it's leaky and hard to program against.
The alternative concurrency model people want is structured concurrency via stackful coroutines and channels on top of a work stealing executor.
Until someone does the work to demo that and compare it to async/await with futures I don't think there's any productive discussion to be had. People who don't like async are going to avoid it and people who don't care about making sure everything and its mother is Send + Sync + 'static are going to keep on doing it.
It also doesn't have much to do with structured concurrency either, and we could have gotten structured concurrency for free in Rust had tokio decided to cancel tasks handles on drop like futures are. And they even weren't that far of doing so[2]
This is exactly the same reasoning as the difference between Rust's `Result` and C++ exceptions. They serve the same purpose, they spread into the code base the same way (if on of the function you transitively call can raise an exception, you can raise an exception) but in one case we ask the programmer to to the bookkeeping manually so that it's explicit when reading the code.
The only meaningful difference between stackful and stackless coroutines is the ability to write recursive function call. If your coroutines are stackless you need to box to avoid the sizedness issue, but in practice it's only marginally different from segmented stacks. If you have a moving GC and can just relocate the stack then you performance in case of recursive function call will be better, but it's also true about boxing if you have a generational collector with bump allocation. So this is mostly a difference of GC vs no-GC, in a situation where GC is actually improving performance characteristics.
> You have static enforcement of blocking code (aka function coloring) in your language without even having any kind of coroutines.
And now it's annoying to write sync and async Rust.
True, but there's no free lunch: you either have the ability to express these things in the type system, or you don't. I'm personally very happy rust has `Result` even though it's legitimately much more tedious than having invisible exceptions.
> A big goal of Rust is to be accessible, and all these sorts of "function colors" are a hindrance to that goal.
It's always a trade-off between usability and expressive power. And not every Rust user have the same priorities. As an application developer, I care less about having allocations expressed in a tractable way than a kernel developer for instance.
> An effects system could abstract all of this away
Maybe, but it's not clear yet if that's something that could be added at this point. Also it would add yet another layer of complexity which is also a factor that needs to be taken into accounts.
> And now it's annoying to write sync and async Rust.
Honestly, this is just a matter of tooling: the annotation could/should be transitively added to every caller function (with a boundary at threads spawn) by something like `cargo fix`.
I mean, is there anybody who seriously thinks that colored functions is the holy grail?
...when there's literally no work in those threads whatsoever, that is. Unless you have something more substantial than the many "benchmarks" out there that are "look, spawning 10 bajillion virtual threads that don't do anything at all but sleep is now super efficient"?
Rust had green threads until late 2014, and they were removed because of their impact on performance.
Everyone has done the basic research: green threading is a convenient abstraction that comes with certain performance trade offs. It doesn't work for the kind of profile that Rust is trying to target.
And yes I agree Rust has its own application domain and they are perfectly right to do things as they see fit.
How does this work?
Copying portions of the stack means that you can continue executing code directly in OS threads (rather than change the stack pointer), so the overhead for FFI is zero. However, you cannot copy portions of stack if there are pointers into the stack. In .NET and in low-level languages there are pointers into the stack which makes copying inefficient. Furthermore, managing these stack portions efficiently in the heap requires extremely efficient dynamic memory management, which is something that languages with good GCs do better than low-level languages with more direct memory management.
I mean why not just use a thread per connection and not bother with anything fancier at all unless you really truly need to hit those C10M scales? Which I suspect is a very rare need for most things?
So many of these articles just go "kernel threads are expensive" and blow on past it as if that's just inherently true & nothing else needs to be said on it. But they really aren't, and unless your work is doing nothing but spawning no-op tasks then the overhead of a "real thread" is likely minimal and in exchange the simplicity you get is tremendous.
At every larger company I've worked for, that breaks down right away. It's really not hard to see how processing some larger proto/json/soap/xml msg can slow the entire system down.
What's the problem with thread-per-connection in this case? It usually works well when application code is moderately heavy since the overhead from threads is very small in comparison.
By contrast I've often seen issues in event driven systems without work stealing where heavy requests slow everything else down.
I think work stealing is a sensible default for an event driven system in a language like Rust. But I do sometimes find myself wishing for the option to write non-async threaded http servers with zero-copy send.
Right because threads vs async doesn't make any sense. You use both, async is your default, free yourself up during io, and is basically the developer friendly abstraction on top of epoll, and then use threads for stuff you actually need threads for.
In these types of systems, you may issue a thousand concurrent I/O operations before the first I/O operation returns a result. Threads don't wait for the first operation to finish, they keep a deep pipeline of I/O operations in flight concurrently so that they always have something to do.
This is true for some applications, like an OLAP database or similar. It’s not true for the typical user-facing app where you want to finish requests as soon as possible because a user is waiting and every millisecond costs you money.
And frankly, if well architected so much of the logic and business is well separated from the logistics, it's not the hardest thing (and by then there plenty of money to fund it) to rearchitect a thread per client model into something more effecient.
It's hard to go wrong when you create a system initially and stick with the easy lower scale ways, like a single big MySQL (or oracle or postgres or even SQLite or whatever) database, thread per connection, etc. YAGNI, and if you do need it, get it when you do need it
I think async/await vs stackful coroutines is a more interesting debate [1], but it's definitely not the debate. The quote withoutboats discussed is from a linked article complaining specifically about multi-threaded by default and work-stealing.
[1] https://www.reddit.com/r/rust/comments/16p47f1/the_state_of_...
This article focuses on a vexing problem of thread-per-core architectures: balancing work across cores. There are four elementary models for this, push/pull of data/load. Work-stealing is essentially the "load pull" model. This only has low overhead if you almost never need to use it e.g. if the work is naturally balanced in a way that few real-world problems actually are. For workloads where dynamic load skew across cores is common, which is the more interesting problem, work-stealing becomes a performance bottleneck due to coordination overhead. Nonetheless, it is easy to understand so people still use work-stealing when the workload is amenable to it, it just doesn’t generalize well. There are a few rare types of workloads (not mentioned in the article) where it is probably the best choice. The model with the most gravity these days seems to be "data push", which is less intuitive but requires much less thread coordination. The "data push" model has its own caveats — there are workloads for which it is poor — but it generalizes well to most common workloads.
Thread-per-core architectures are here to stay -- they cannot be beat for scalability and efficiency. However, I have observed that most software engineers have limited intuition for what a modern and idiomatic thread-per-core design looks like, made worse by the fact that there are relatively few articles or papers that go deep on this subject.
> For workloads where dynamic load skew across cores is common, which is the more interesting problem, work-stealing becomes a performance bottleneck due to coordination overhead. Nonetheless, it is easy to understand so people still use work-stealing when the workload is amenable to it, it just doesn’t generalize well
This sounds right to me. The reason Rust async frameworks use work-stealing is mainly that it's easy to enable at the framework level and will improve performance in a lot of applications, especially those that are not ideally architected. Based on your comment and your self-description on your profile, these are not the kinds of applications you work on.
I would be interested in receiving links to more literature.
Many of the applications I write are like this, a daemon sitting in the background reacting to events. Making them single threaded means I can get rid of all the Arc and Mutex overhead (which is mostly syntactic at that point, but makes debugging and maintenance easier). Being able to do this is one of the things I love about Rust: only pay for what you need.
The article that this one is responding to calls out tokio and other async libraries for making it harder to get back to a simple single threaded architecture. Sure there is some hyperbole but I generally agree with the criticism.
Making everything more complex by default because its better for high throughput applications seems to be opposite of Rust's ideals.
The problem is that one may still want concurrency even when a single thread on a single CPU is enough.
What cloud providers give you for VMs is not the norm in the datacenters anymore.
If you want 10g line-rate bandwidth between hundreds or thousands of servers? Yeah, I used to help build those fabrics at Google. It's not cheap or easy.
10g to the internet is more about aggregate bandwidth for a bunch of clients than throughput to any single client. Except for very specialized use cases you're going to have a hard time pushing anywhere close to 10g over the internet with a single client.
[1] https://forums.servethehome.com/index.php?threads/cheap-40gb...
There's some interesting reading in the Maglev paper from Google about the work they did to push 10gb line rate on commodity Linux hardware.
The 2016 Maglev paper was an interesting read, but note that the 10G line rate was with tiny packets and without stuff like TCP send offload (because it's a software router that handles each packet on CPU). Generally if you browe around there isn't issue with saturating a 100G nic when using multiple concurrent TCP connections.
IO bound means you spend most of your time waiting on an IO operation to complete. Usually writes are bound by the hardware (how fast your NIC is, how fast your storage is, ...), but reads are bounds by the hardware, but mostly by the "thing" that sends the data. So it's great you have a 10Gbps NIC, but if your database takes 10ms to run your query, you'll still be sitting for 10ms on your arse to read 1KB of data.
Throughput being IO-bound is indeed about the hardware, and the truth is that at the high end it's increasingly uncommon for things to be IO-bound, because our NICs and disks continue to improve while our CPU cycles have stagnated.
a. you cannot plan to read data from disk on demand, because it will take too long (still!), and it will almost certainly block
b. you cannot plan to write data to disk on demand, because it will take too long (still!) and it will almost certainly block
c. the bandwidth is still a limit on the amount of data that can be handled. It is much higher than it was with spinners, but there is still a limit.
for my data crunching project, one core processes about 500MB/s = 4Gb/s, and I have 64 cores..
Web servers should be overwhelmingly synchronous.
They are the one easiest kind of application to just launch a lot more. Even on different machines. There are some limits on how many you can achieve but they aren't anything near low. (And when you finally reach them, you are much better rearchitecting your system than squeezing a marginal improvement due with asynchronous code.)
There's a lot to gain from non-blocking IO, so you can serve lots and lots of idle clients. But not much from asynchronous code. Honestly, I feel like the world has gone crazy.
I understand that it is less efficient but in the case you describe wouldn't paying for a few extra atomics be negligible anyway?
https://docs.rs/tokio/latest/tokio/task/struct.LocalSet.html...
If you try to use axum in this way you'd still need to use send and sync all over the place.
> The Original Sin of Rust async programming is making it multi-threaded by default. If premature optimization is the root of all evil, this is the mother of all premature optimizations, and it curses all your code with the unholy Send + 'static, or worse yet Send + Sync + 'static, which just kills all the joy of actually writing Rust.
Agree about the melodramatic tone. I also don't think removing the Send + Sync really makes that big a difference. It's the 'static that bothers me the most, and that's not there because of work stealing. I want scoped concurrency. Something like <https://github.com/tokio-rs/tokio/issues/2596>.
Another thing I really hate about Rust async right now is the poor instrumentation. I'm having a production problem at work right now in which some tasks just get stuck. I wish I could do the equivalent of `gdb; thread apply all bt`. Looking forward to <https://github.com/tokio-rs/tokio/issues/5638> landing at least. It exists right now but is experimental and in my experience sometimes panics. I'm actually writing a PR today to at least use the experimental version on SIGTERM to see what's going on, on the theory that if it crashes oh well, we're shutting down anyway.
Neither of these complaints would be addressed by taking away work stealing. In fact, I could keep doing down my list, and taking away work stealing wouldn't really help with much of anything.
For all the hate that Java gets, this is something that has Just Worked(tm) for like 25 years, and it's enormously helpful for troubleshooting. You don't even need a debugger; you can just send the JVM a SIGQUIT, and it'll dump a stack trace of every thread to stderr (including which locks each thread is holding and/or waiting on) and keep running.
I miss this feature in every other language I've worked with. You can even use it for ad-hoc profiling in production: just take a bunch of snapshots, and use grep/sed/sort/uniq to look for hotspots.
There are libraries for doing the same for any language that just uses kernel threads. It's when you throw in async that you really need to reinvent this kind of observability, and Rust isn't there yet unfortunately.
To mitigate this kind of problem, at my company we use a library [1] that allows regularly logging which tasks are running and what file/line numbers each task is currently at. It requires manually sprinkling our code with `r.set_location(file!(), line!());` before every await point, but it has helped us so many times to explain why our systems seem to be stuck.
[1] https://github.com/antialize/tokio-tasks/blob/main/src/run_t... has set_location(), and task.rs has list_tasks()
That said, I think the newest version of the third-party library might have some middleware hooks and/or tracing spans. With the right middleware impl / tracing subscriber, maybe I could accomplish something similar.
This code also should be following the general distributed systems practice of setting deadlines/timeouts at the top level of each incoming request, propagating through to all dependent requests, and also setting timeouts on background ops. It's not. Fixing that is also on my list and might be enlightening...
It comes down to I/O-bound vs CPU bound workloads, and to how negatively things like cache evictions and lock contention might affect you. If your thing is an HTTP server talking to an external database with some mild business logic inbetween, and hosted on a shared virtual server, then, yeah, work-stealing and re-using threads at least intuitively makes sense (tho you should always benchmark.)
If you're building a database or similar type of system where high concurrency under load with lots of context switches is going to lead to cache evictions and contention all over the place -- you're going to have a bad time. Thread per core makes immense sense. An async framework itself may not make any sense at all.
But there is no right, dogmatic answer on what "is better." Profile your application.
I've said it before, but I feel like the focus of Rust as a whole is being distorted by a massive influx of web-service type development. I remain unconvinced that Rust is the right language for that kind of work, but it seems to do ok for them, so whatever. But the kind of emphasis it puts on popular discussion of the language, and the kind of crates that get pushed to the forefront right now on the whole reflect this bias. (Which is also the bias of most people employed as SWEs on this forum, etc.)
There's a long tradition of not wanting to "waste" threads by blocking on I/O.
True, I think it happened because Rust community quite aggressively sought out those developers to gain market and mindshare. I am not saying it is bad or good but now Rust has to live with unending stream of web related libraries and frameworks of varied quality.
And async will remain constant topic of discussion because most of critical base libraries/crates etc have taken async first approach. Now it is to a level that normal devs can not write plain sync code for business problems unless they make not using async one of main point of their projects.
Guilty as charged, honestly. But we were really target high performance web services where Rust really makes sense. And Rust has had a lot of success gaining market share in that area, but most people who work on things like that are writing closed source software and don't comment so much on the internet, so what you see online is mostly people blogging about their side project that doesn't need to serve a million concurrent connections.
So now if someone is told their web app is cute little thing more suited for Rails/PHP/Go etc. they will feel patronized and try to use Rust despite being unsuitable because their app is going to be serious one.
I maintain large cryptography libraries, and have been completely unaffected by the async business.
I think it's more a question of emphasis. If you go looking for crates for network I/O related things (esp HTTP) on the whole you'll find mostly async driven ones. And among them, you'll often find they're hardcoded for tokio, too.
This. That is quite right and well said
Selectively pinning tasks to a thread/core has advantages and can be really useful in some circumstances, but it's a finer discussion, which has little to do with async users dissatisfaction related to Send.
My favorite line: > I have a hard time believing that someone who’s biggest complaint is adding “Send” bounds to some generics is engaged in that kind of engineering.
Edit: I fully agree with the comment of "duped". I was not aware of the greater context of this discussion. As such, I might have quoted this sentence prematurely.
Is it just me or does this come across as rather arrogant? The problem of 'static lifetimes and send/sync constraints resonates among developers, and my impression is not that those were morons.
If you would need to do it anyway it isn't harder.
Less "people are worked up over doing a little work" more "async makes you solve problems earlier you were going to solve anyway".
Similar vibe to the borrow checker. Sometimes it is overly restrictive othertimes you didn't actually consider the corner cases when you assumed everything would be fine.
I don't think people who struggle to get parallel & concurrent Rust to compile are morons, though I don't like when they act like the APIs we built for them are ruining their lives.
There is no one always-right way to get best performance for every program. You can argue about it all you want. Thread-per-core benefits/downside is a typical "it depends" discussion.
The problem is that using `async` in the first place is a premature optimization. 99% of Rust programs are not redis, linkerd and alikes. They are some CLI tools or Web apps that could have been written in Python or Ruby, and they would still be fast enough.
So why as a community we abandoned blocking IO Rust and everything is async now, and developers are just doing `#[tokio::main]` by default on everything?
I could see this approach making sense in a platform where transferrable state was the default and is rarely broken, but it doesn't sound like that's the case in Rust?
edit: I'm curious, what's the ergonomics like for this? Is it just "oh, your code won't compile if you don't add this magic incantation to say it has `Send`?" Or is it "your code will fail in intermittent, hard-to-debug ways as state gets mangled during work-steals if you don't do this right"?
And that's the crux of the matter: people are griping on the whole because maybe Tokio async is hard when it puts Send&Sync demands all over the placer, but the reality is that writing safe concurrent code of any kind is hard. It's not intuitive, and the problem is that async makes them feel like it's happening automagically and just "taking care of it" -- but it's really not. You need to know what you're doing, and the compiler is just helping you here.
Yes, you can hide that by going thread-per-core and that could eliminate the need for Send in some circumstances (not all). But it might come back to bite you, architecturally, in the long run.
Your code won't compile unless everything is Send/Sync as appropriate, and (I might be wrong, but) the lazy path to achieving this is usually wrapping things that might be shared in Arcs and/or Mutexes.
- if you are CPU bound, work stealing will be better for most cases
- if you are IO bound, thread-per-core might work better, but again, you have enough CPU headroom that the performance doesn't really matter
IMO, work stealing is a better default to encode into language API
But it would affect power consumption? (Just trying to understand.)
There are cases where you can be CPU bound and using the share nothing model would work out to your advantage. There's also the case where you only have one cpu core anyway (for example if you want to get all the juice out of a cheap single core VPS)
IMHO it’s a misconception that this is somehow tied to CPU or IO bound work. Take for example databases. You’d think that that’s the prime “I/O bound” use case. Except it’s not. There’s a talk about a DB researcher that analyzed that Postgres spends 70% of its time book keeping things within the database. And that makes sense. I/O is done in bulk with the cost amortized over a lot of transactions. That book keeping work? Extremely expensive because you have to acquire locks all over the place, do atomics, memory allocations etc.
Atomics and memory allocations are extremely expensive in certain contexts and atomics also have a negative in that your scaling with number of CPUs is sub linear due to hard to remove false sharing of cache lines and CPU stalls to handle the synchronization.
On the other hand, a shared nothing approach where you’re not allocating memory in your hot path is very hard to achieve and not suitable for all problems. Nor does everyone need that performance. So the work stealing approach is better in those use cases as it provides reasonable performance and the programming model is simpler in some ways since you don’t have to think about the data path as careful since everything has an Arc / Mutex in there.
You write some code that looks like this.
struct Server {
// ...
}
impl Server {
async fn serve(&self) {
loop {
let server = self.clone();
let message = read_message().await;
// Each message handler is a new task, seems reasonable?
spawn(async move {
let result = server.handler(message).await;
write_response(result);
})
}
}
async fn handler(&self, arg:Message) -> Result<U, Error> {
let this = do_this();
let that = do_that(arg).await;
do_the_rest(this, that);
}
}
And everything works fine.Then one day you change the implementation of `do_this` such that the type of `this` is no longer `Send`. You will get a nasty compile error that spawn(...) doesn't work because the type created by the anonymous scope in `async move { }` is not Send. The reason is not necessarily obvious (and the error message is unhelpful). If `this` is not `Send` then you can't hold it across the `.await` of `do_that(arg).await`, because each .await represents a point in execution where the future may yield and be scheduled onto another thread by the executor.
If you can make the type Send then everything is fine. If you can't (which is entirely possible!) then you need to change the scheduling of the future to `spawn_local` (or whatever your async executor calls it). This may require adding a bunch of boilerplate to even call `spawn_local` in the first place.
This is the issue with Send. It's not just adding type annotations. There are subtle ways it infects your code that may cause it to break later in non-obvious ways, because if a type implements Send is not always obvious.
Until the handler needs to access a resource that's shared by tasks.
You were always sending crap across concurrent execution contexts. But the language syntactically kinda-hid it from you and you got away with not worrying about it. And now it bit you in the ass.
In general, this is the problem with implicit/masqueraded behaviour. Why back in the day we moaned about REST vs RPC (stop trying to hide that there's a network there!). Or why it's a bad idea to have languages where "+" is a string append operator. Etc. In systems design: surprises suck.
Tokio is sending your stuff around, passing it around. So of course it has to be Send. Yeah, you can avoid that by doing thread per-core and just never having things send. But I swear, it's going to bite you in the ass in the end anyways. It's your control flow that's confusing (because it's hidden), not the Send.
In the end, C++ or etc would just let you hang yourself here, and you'd be puzzling over a segfault or deadlock later, at 3am.
(There are some things that piss me off tho. AtomicPtr<T> being Send+Sync while *T/*const T is not makes no sense. Both are equally unsafe, and there's no difference in how they behave across thread boundaries, really.)
> You were always sending crap across concurrent execution contexts. But the language syntactically kinda-hid it from you and you got away with not worrying about it. And now it bit you in the ass.
I disagree. Here's a more realistic example
async fn func (resource: Resource) {
let a = foo(resource).await;
let b = bar(resource).await;
baz(a, b).await
}
Assume that this isn't "crap" and the only correct way to implement this code. There are two fundamentally distinct async operations over the same resource and you can't call them in parallel.Now say you have an async callstack that looks like this
let task = async move {
outer(inner(func(resource).await).await).await;
};
spawn(task);
That call to func() could be buried deep in the callstack instead of inner or outer or anywhere else, and very small changes to the implementation of func can cause it to create a compiler error a mile away from where the problem actually is.I don't feel like this is inherent complexity and it has nothing to do with tokio, it has a bunch of related problems with the limitations of Rust, from the stripped-down generator design to lack of specialization for traits.
imo these limitations make async code quite fragile to write in practice, and it's kind of frustrating to be gaslit repeatedly with "no it's actually good that your code is hard to write."
Maybe I didn't write that clearly?
I like the thread per core design because kernel context switches are expensive. But userspace context switching scheduling (such as an unbuffered golang channel) for a 64 bits of data at a time makes me uncomfortable too unless it represents a large amount of work backed by a pointer to work data.
I think I like to multiplex sockets over threads and multiplex IO over threads so that you can do CPU work while IO is going on and you can process multiple clients per thread.
You cannot scale memory mutation by adding threads. You want ideally one thread to own the data and be safe to mutate it uncontendedly.
When you send data to another thread, don't refer to that data again. Transfer ownership to that thread.
If you can divide your request into phases of expansion (map) and contraction (synchronization) you can do intrarequest parallisation.
I've been looking into runqueues of go and tokio where you have a local runqueue without a mutex and a global runqueue with a mutex.
I've been trying to think how IO threads that run liburing or epoll can wake up a Coroutine or an async task on a worker thread without the mutex.
The worker thread is looking for tasks to resume that are unblocked and needs to be notified when there is IO finished. I think you can have a Coroutine that is always runnable to read from a lock free ringbuffer. You can have that Coroutine that checks for finished IO's ringbuffer yield if its contended by writes by the IO thread that is trying to make work available to the worker threads.
You can do a millions to tens of millions kernel entries/exits per second per core. They're on the same order of magnitude as a single digit number of full cache misses.
So, expensive, but a lot less expensive than many people assume.
Edit: for some (outdated, 2018) measurements, https://eli.thegreenplace.net/2018/measuring-context-switchi...
Hardware engineers are building systems for the applications of today. And the kernel developers are optimizing as well. It's all a moving target.
Dogma has no place here.
The other thing is 'io_uring', which is trying to come at the problem by removing context switching altogether by providing a cheap primitive for communicating with the kernel.
But this is silly — shared nothing will have no substantial difference in the rate of context switches (or of syscalls) compared to a thread-per-core-shared-everything architecture. The architecture that actually loses is many-threads-per-core if it ends up in a small-batch mode in which it context switches once per request or so.
The article explains how work-stealing is a way to solve tail latency. I understand tail latency to be caused by tasks that yield (by themselves) after an unexpectedly long amount of time. This cannot be know in advance. The article explains how work-stealing results in cache misses and extra developer constraints like Send, Sync and ‘static.
What if the executor only moves a task to another thread after a timeout period has expired? This should result in no latency penalty if things are running smoothly and a constant, low, extra latency when tasks need to be stolen now and again. This would mitigate cache miss issues but alas not the developer overhead of all those multithreaded constraints.
In other words, core utilization is less relevant today - what you care about is energy utilization (which is shared across cores).
Of course, there's a point at which this stops being relevant - if you have multiple sockets for example, this won't apply. But work stealing across multiple sockets is so expensive anyway that you would never want to do it. You might as well work-steal across machines at that point - something which is indeed useful sometimes, but usually niche.
Higher frequencies have diminishing returns and exponential heat loss.
You might as well work-steal across machines at that point
Shared memory is extremely fast, it crushes using local loopback networking, let alone using actual networking.
It's not the industry. It's people.
> What these people advocate instead is an alternative architecture that they call “thread-per-core.” They promise that this architecture will be simultaneously more performant and easier to implement. In my view, the truth is that it may be one or the other, but not both.
Thread-per-core is hands-down better for performance. That's because you can't rely on storing application state smeared on a thread/co-routine stack, so you have to instead make all your state explicit, and in the process you'll effectively compress it by no longer smearing it on a very large data structure called the call stack. Smaller state == less cache thrashing.
The problem with thread-per-core is that you really need async/await or coding in continuation passing style in order to make it work. CPS is mostly not an option in most cases, so async/await it has to be.
Developers should understand the concepts of thread-per-core and thread-per-client and pick the best one for their case. IMO thread-per-core is always better because if your application explodes in popularity then you'll really care to make it more efficient, but rewrites are always hard or infeasible, so if you get it right from the beginning, then you win. What I mean by "pick the best one" then has to do with the developer's skills and productivity and time constraints. Sometimes "best" is about what you can manage, not what's "best" in an objective vacuum that ignores real-world constraints.
Yes, and this is just naive to think that other industries are somehow bastions of serious debate.
Do any existing "thread-per-core" systems actually provide yield points as a thing you can do? Most of my experience is with OpenMP (using both BSP and task parallelism) and a little bit of TBB (also task parallelism). If you want to yield in these systems, you break it up into two tasks.
> if state is moved from one thread to another, this introduces synchronization costs
This isn't obvious to me. If you're moving state, then there is no synchronization because only one thread is touching the state at any given point. Unless we're talking about synchronization due to starting a new task?
My understanding is that 'static is program lifetime, meaning either static compile-time data like constant strings, or it is memory leaks. How does this apply to async in practice? Do you need to leak things that you want to make async?
`&'static T` is a reference to something that lives as long as the program (static data or leaked memory).
Lifetime requirements simply don't apply at all to types that own their data. Or another way to see it is that self-contained types like Vec and String automatically meet every lifetime requirement, regardless of how long or short they actually live.
Rust kinda screwed up with terminology here, because the general computing term of "object lifetime" applies to more things than the specific 'lifetime concept that Rust applies to references/loans.
1. Straw man 2. Talk about an unrelated paper 3. Conclusion: I am smart and those people are dumb
On a somewhat related note: I'd say 95% of the software I've written was IO bound(and if it wasn't at the beginning then the goal was to make it IO-bound), and that remaining 5% does not benefit at all from async/coroutines(used them in Haskell, Kotlin(Quasar) and Rust). I'm very curious about what real-world CPU-bound use cases people have that can benefit from async performance-wise. If you're optimizing on that microsecond-nanosecond scale then you shouldn't even have yields/blocks on your hot paths, and synchronization will at most be done using memfences, so what are we talking about?
I'd also conjecture that if you place your problem domain on the IO vs CPU bound axis, the problems where async would provide performance benefits can invariably be solved by other designs that perform even better (GPU/FPGA).
Yes I'm one of those people who thinks coroutines have extremely marginal actual value, and most of that value is the "feeling of how cool this is" that people experience when they first learn about the concept. If there was a way to bin the concept altogether I would do it in a heartbeat. As-is, the entire Rust ecosystem is suffering heavily because of it.
GPU/FPGA programming is kind of irrelevant because as expensive as developing high performance code is for CPUs, costs go up by an order of magnitude for those platforms unless there is some existing library you can utilize (mainly applicable for ML/AI). These platforms are also very expensive. It's like saying just rent a helicopter if you need to get somewhere quickly instead of asking what "quickly" actually means and optimizing accordingly.
My point with GPU/FPGA is that if you're at the point where this level of optimization matters then you're actually dealing with situations where it's worth to invest in the big boy tools. Examples are HPC in fintech (low latency trading) and scientific computations, game development, video codecs etc. You know, "actual" computations.
Webservices are not in this category. Generally speaking with web services your goal is to "hide in the shadow of IO". If you max out your network and/or database and/or filesystem capacity, further CPU optimizations will have literally no effect. I have yet to encounter a web service where this wasn't the case.
What I do see sometimes with webservices is simply unnecessary compute, bad internal structuring, dynamic dispatch, parallelism overcommit, lack of batching, fragmented apis, fragmented data accesses etc etc all of which appear as CPU capacity saturation and also sometimes as kernelspace overhead in profiles. Async does not help solving any of these issues. And once you do solve them, you reached IO boundness and it doesn't matter anymore.
Again this is just my experience, and I'm happy to learn about what kind of web service can utilize coroutines with measurable performance benefits over a managed threadpool.
I'm not familiar with FPGA scheduling, are there any resources that explore the issue? I was under the impression it's akin to GPU compute where the main bottleneck is the bus.
Second, the send + sync thing seems onerous, but from my POV you are encouraged to only pass little messages on channels anyway. I find it easier to just not share anything. If two things need the same structure, they can both subscribe to the ring that gets the messages, and each construct the object for themselves. I find if you have a bunch of Arc<RwLock<>> something is wrong. YMMV.
Does that mean that I have to unnecessarily use locking (e.g. ARC and Mutex) even with flavor = "current_thread" or am I misunderstanding this?
However, `&mut T` is Send if `T` is Send. So if you can avoid aliasing your references, you're also ok.
But you can use tokio's LocalSet to run non-Send futures.
https://docs.rs/tokio/latest/tokio/task/struct.LocalSet.html
The actual number of schedulers (real threads) is configurable as a command line option, but it's rare, approaching unheard-of, to override the default.
Virding's First Rule of Programming ...
https://rvirding.blogspot.com/2008/01/virdings-first-rule-of...
Most programmers should indeed do that. There's no need to bring these problems on yourself if you don't actually need them. Personally I harbor a deep suspicion a non-trivial amount of the stress in the Rust ecosystem over async and its details is coming from people who don't actually need the performance they are sacrificing for. (Obviously, there absolutely people who do need that performance and I am 100% not talking about them.) But it's hard to tell, because they don't exactly admit that's what they're doing if you ask, or, at least, not until many years and grey hairs later.
But in the meantime, some language needs to actually solve these problems (better than C++), and since Rust has volunteered for that role, that means that at the limit, the fact that other languages that chose to just take a performance hit don't seem to have these problems doesn't have very many applicable lessons for Rust, at least when it is being used at these maximum performance levels.
[Elixir is a little better, supporting binary-first for strings, rather than charlists - Erlang is very good at pattern-matching binaries.]
Share-nothing and thread-per-core are good for many reasons, including performance, but they also feed into the main philosophies for Erlang development: resilience, horizontal scalability and comprehensibility.
As Joe Armstrong said:
“Make it work, then make it beautiful, then if you really, really have to, make it fast.
90% of the time, if you make it beautiful, it will already be fast.
So really, just make it beautiful.”
It's not the slowest language either; it has a decent performance advantage over most of the dynamic scripting languages. But it is quite distinctly slower than Go, let alone Rust.
1. Erlang makes sharing data easy, it does so through mailboxes. Data moves across actors constantly.
2. Erlang is not "Thread per Core". I think you're misunderstanding TPC as "there is one OS thread per CPU Core" and that is not the case.
Erlang is basically the exact opposite of the TPC design.
For most of Erlang's history, there was a single scheduler per node, so one thread on a physical machine to run all the processes. There is a fixed number of reductions for each (green thread) process, then a context switch to a different process. Repeat.
A few years ago (2008), the schedulers were parallelized, so that multiple schedulers could cooperate on multi-core machines. The number of schedulers and (hw/thread) cores are independent - you can choose any number of real threads to run the schedulers on any physical machine. But, by default, and in practice, the number of schedulers is configured to be one thread-per-core, where core means hardware supported thread (e.g. often Intel chips have 2 hardware threads for each physical core).
So yes, almost always and almost everywhere, there really is one OS thread per hardware supported thread (usually 1x or 2x physical CPU cores) to run the schedulers.
https://www.erlang.org/doc/man/erl.html
https://erlang.org/pipermail/erlang-questions/2008-September...
This is done in order to minimize the need to transfer L1 caches between threads, and to keep each thread's cache pool tied to one request, and not much else (at least, to the extent possible).
In the context of Rust async runtimes, this is very similar to Tokio if work-stealing did not exist, and all futures spawned tasks only on their local thread, in order to make coding easier (lack of Sync + Send + 'static constraints), while also making code more performant (which the article argues it does not).
For examples of thread-per-core runtimes, see glommio and monoio.
Again, the fact that data moves across threads in Erlang means it is not TPC - period. Erlang is basically the exact opposite of a TPC system, it is practically its opposite because it is all about sharing data across actors, which can be on any thread.
Seriously. I've said this before, the way people talk about these problems is so dramatic. I've written 10s of thousands of LOC in Rust and you'd think, from these blog posts, that I must be miserable. I am not. I am quite pleased with it.
It's so funny to me that Thread Per Core is seen as the holy land. Like it's just objectively better. It is not. TPC is quite tricky, indeed. Scylla had a great post recently talking about how they were suffering performance penalties due to accidentally sharing some memory - this is the sort of thing you have to be super careful about when leaning into TPC. "Hot partitions" is another one.
I'm going to refrain from commenting more (this article looks excellent and I want to read the citations) but I'm very very happy to see this post. I think far to little credit is given to the effort put into getting a novel language like Rust to support this so well.
> It’s always off-putting to me that claims written this way can be taken seriously as a technical criticism, but our industry is rather unserious.
From the article, this resonates so strongly with me. Instead of people reading papers, talking seriously, it's so hand wavy and weak.
Not to say these people don't exist. But I think they exist in lesser numbers. Even I'm guilty of this. I know it... so I do it... but it's very unfun and tedious and... well I'm paid to do it so I can't complain.
I'll use myself as a research subject: I really like the scientific method, and don't mind a long grind. But the main barriers I run into with software fall into at least two major categories: 1. Compatible datasets or software: it takes a LOT of manual effort to collate good data sets, and because I am a software engineer, I want to automate these things, then I end up in tarpits and rabbit holes, building bridges rather than testing hypotheses.
2. Abstraction fatigue, There is so much vocabulary that we use, and we switch layers so quickly and in different contexts that I find it very time consuming and opaque to harmonize all the concepts. Real life pressures don't always afford the ability to really understand an entire stack, which I posit is necessary for a certain level of Rigor.
For example, right now I'm trying to Grok Clojure Transducers, and some folks are saying they are the same things as Monads. Are transducers a better pattern? Are they the same? Different? How much do they overlap?
I have feelings relating to dynamic types vs static types. I have feelings about wrapping implementations. I have feelings about the JVM vs the Haskell compiler vs the V8 JavaScript engine. Also, I get feelings related to polymorphism, metaprogramming, dependent types, homoiconicty.
But nothing fully baked. To call my feelings even 10% baked would be charitable. I can't currently describe all the relationships, or why my brain is currently zeroing in here in full fidelity. Only parts of it, and I remain in a state perplexity for now.
The formality of Category Theory and Abstract algebra sounds like it would be really nice here, but it's going to take me years to get to to speed in those domains, and I'm not even sure they have the theorums to compare these two patterns!
It's amazing to me how much more informed you can seem than everyone else if you just read the thing everyone is quoting.
I've also been banging the "you are using 'IO Bound wrong'" drum for years and still this persists.
HN is a daily example of this. Reading the blog post immediately highlights all of the comments where users seemingly did not.
And it isn't like rust is particularly hit by it, either. You'd think people writing PHP or Java hate life, if you only went by what you are likely to see in our social sites.
Well, that does make some sense: if something bothers you so much that you'd "fly into a rage over it", you would avoid using it as much as possible (and PHP is not like JavaScript which has no real alternative).
Even that does make some sense: if, on a first look, you see something you deeply dislike, you probably will also avoid using it in the first place.
As a personal example, I never learned Go because, when I first looked at it (IIRC, it was when I had to use it to run a Heartbleed detector), I deeply disliked the way it required developers to organize their source code (a single per-language directory mixing all projects together, instead of the per-project directories I have always used); I understand this might have changed later, but in the meantime I invested my time in learning another programming language, and so far haven't found a need to look at Go again.
Running in an IDE hooked to rust-analyzer helps a lot too since you get pretty instant feedback if you get something wrong most of the time. I have this suspicion that at least a few of the people who hate a lot of languages with more complex type systems or large stdlibs (e.g. Go, Java, C#) are trying to edit them in a plain vanilla text editor without these features. This would require you to memorize way too much shit. Why? Modern machines have gigabytes of RAM. Run a language server.
However, I've bantered a bit with Scylla's CTO and others about how I would regularly run into issues with Scylla because they didn't use TPC, and I'd see hot keys that would suffer performance because they only let one core manager a subset of keys...
So, while I can't make an authoritative statement, I think TPC is better overall...