None of the 5 points in that article about callbacks in 2015 node.js apply to async in Rust. The Rust people spent years agonizing over their version of async and applied a lot of lessons learned from implementations in other languages.
It's trivial to turn async into sync in Rust. You can use ".poll", "executor::block_on", et cetera.
Turning sync into async is harder in any language. Even Go with it's easy threading. That's a good argument to make async the default in libraries in Rust, but since async isn't stable yet, that would have been hard to do 5 years ago.
Literally none of your rebuttals actual rebut the claims. The very fact that async-std exists is absolute proof that the issue remains--if there wasn't a "color" problem there wouldn't be any need for a "port" of the standard library.
Rust had legitimate reasons for taking the approach that they did. One can agree that they made the correct decision without excusing and obscuring the consequential costs.
The async-std is taking the “always red” approach that the article mentions and that wasn’t possible until now due to async being hot of the presses. The rest of the arguments in the article are based on the point that “red functions are more clumsy to call” which doesn’t hold for Rust, but holds for JavaScript.
The article explicitly admits that async/await is ergonomically much nicer than explicit futures/promises. But the color problem still remains, one consequence of which is duplication of code and interfaces.
Arguing that the problem doesn't exist if you only stick to functions of a single color isn't a rebuttal, it's an admission! But the fact of the matter is async functions have real limitations and costs, which is why they're not the default in Rust, which in turn is why any Rust program will always have some mix of differently colored functions. But, yeah, the fewer of one color and the more of the other color, the better. That's the point.
Once again, the article is about JavaScript and everything it says still holds today, introducing async/await didn't change anything. Sync functions can only call other sync functions and use the result immediately. To use an async function, you have to convert the caller to an async function too which can be anything from annoying to impossible.
So yes, Rust still has colors, but it doesn’t matter because a red function can call a blue one without a problem and vice versa. You’re right in saying that async functions have a cost and shouldn’t be used indiscriminately - so just use them when it makes sense. As opposed to JavaScript, Rust doesn’t make you commit to one or the other early and either face major refactors in the future or pay the price of async when it’s not required.
P.S. I think there are some caveats for library authors and also to blocking the thread on a single future, but maybe more qualified people can comment on those.
I think the point is that "colored" functions only existed because Rust did not previously have async support. Now that it has async support, new code can be one color: async, while maintaining ergonomics.
Maybe new code will be exclusively async and existing code will switch over.
Not all new code shouldn't be async. I write graphics code. There is no benefit to me, or any of my users, if all of my code is async. No system has 10,000 simultaneous GPUs to drive independently.
I agree with your general point, but I do want to point out (as I'm sure you're aware) there's plenty of asyncronous logic in graphics code.
Some (but not all) of which might even benifit from async... although graphics code has it's own solutions to many of these problems, and it certainly wouldn't be the bread and butter of your core render loop.
1) For performance reasons, your GPU consumes command buffers after a decent delay from when your CPU requests it. This means async logic crops up for screenshot/recording readbacks, visibility queries, etc. assuming you don't want to simply stall everything and tank your framerate.
2) New lower level graphics APIs expose the asyncronous logic of command submission more than ever before, limiting safe CPU access to memory based on what the GPU is still accessing. This sometimes spills into higher level APIs - e.g. bgfx buffer uploads can either take a reference to memory (fast) - which you must keep valid and unmodified for a frame or two (asyncronous, and currently difficult to expose a sound+safe API for to Rust) - or it can make an extra deep copy (perf hit) to pretend it's behaving in a more syncronous fashion.
3) Resource loading is heavily asyncronous. You don't want to stall a game out on blocking disk I/O for a missing minimap icon if you can just fade it in a few seconds later. I might not have 10,000 GPUs to drive, but I've certainly had 10,000 assets to load, semi-independently, often with minimal warning.
We can never eliminate sync stuff, because async requires a runtime. Async is great when you need it, but you don't always need it, and you shouldn't have to pay the cost if you don't plan on using it.
Nope, it still holds. It’s in fact impossible to call an async function from a sync function and return the result. To use await you have to make the function async which means the caller needs to be async-aware and so on, all the way to the top of the stack.
There are hacks like “deasync”, but I personally wouldn’t use it.
But you don't need `await` to call an async function, you can use a regular function call in a symc and then the function returns (synchronously) a Promise.
What cannot be done is to perform a blocking call on a Promise from a sync function. And that is by design because JavaScript has a single threaded runtime.
Given history of JS not being able to call an async function from sync function is a none issue. JS went from callbacks to promises to async/await (sugar on top of promises).
Five years ago Rust still had green threads. Literally every standard library I/O function was async, and the awaits were always written for you with no effort.
Its literally taken five years to get back to an alpha thats not as good, and we'll still have to wait for a new ecosystem to built on top of it. I know not everyone writes socket servers and so forcing the old model on everyone probably doesn't make sense long-term, but I still have to shake my head at comments like this.
Green threads have no place in a low level systems language like Rust whose design goals are zero cost abstraction and trivial C interop.
D made a similar mistake by requiring GC/runtime from start and now even though they added ways to avoid it the ecosystem and the language design are "poisoned" by it an itmakeas it a very hard sell in some places where it could be sold as a C++ successor.
Because rust made the right choice in time it's now a contender in that space, if it chose to go down the runtime required/custom threading model route it would have much less practical appeal. If you can swallow runtime/threading abstraction overhead why not just bolt on a GC and use Go
>C++ on Windows makes heavy use of reference counting (which is a GC algorithm from CS point of view), via COM/UWP.
Not sure if Ref counting is a good example here, as there is no runtime monitoring the object graph hierarchy and of course Rust it’s self uses ref counting in many situations.
Chapter 5 of "The Garbage Collection Handbook", one of the GC gospel books.
Reference Counting is a garbage collection implementation algorithm from CS point of view.
RC has plenty of runtime costs as well, cache invalidation, lock contention on reference counters, stop the world in complex data structures, possible stack overflows if destructors are incorrectly written, memory fragmentation.
> Its literally taken five years to get back to an alpha thats not as good
The new I/O system is better in several ways. First, as you acknowledged, not everyone writes servers that need high scalability. M:N has no benefit for those users, and it severely complicates FFI. Second, async is faster than M:N because it compiles to a state machine: you don't have a bunch of big stacks around.
Yes, its better in several ways, but its also worse in several ways. It will take another five years to build a robust ecosystem for servers, and you'll still have to be careful not to import the wrong library or std module and accidentally block your scheduler. Plus the extra noise of .await? everywhere.
I'm not saying it was the wrong decision five years ago, but it definitely was a choice and there could have been a different one. I was responding to someone who said async wasn't an option five years ago.
M:N was slower than 1:1 in Rust. That's why it was removed. The problems you cite are problems of async/await, but they can be addressed by just using 1:1 threads.
I don't think M:N forces a stack. The stack no stack is called stackless coroutine vs stackful coroutine.
M:N is the parallelization level. I'm actually not sure if Rust is M:1 or M:N or both based on configuration.
M is the number of concurrent process in the language, basically the number of user thread. These user threads can be implemented to be stackful or stackless, up to the language. The N is the number of OS threads.
I do Rust since 2013. It did actually had two half-baked runtimes as a compile time mode.
It also was constantly crashing and had weird semantic issues. I very much prefer the current state, even if I'm a bit sad that async/await has taken us so long.
I used "5 years ago" as a code for "the first time I played with Rust". Obviously not 5 years ago, then. It's pretty amazing how far it's gone so quickly.
> It's trivial to turn async into sync in Rust. You can use ".poll", "executor::block_on", et cetera.
Is it 0-cost abstraction? I mean, is `sync_read` will compile to the same code like `async_read.poll`? Because turning sync into async is kind of trivial as well: just spawn new thread for that sync block.
0-cost abstraction was summarised by Stroustrup as:
> What you don’t use, you don’t pay for. And further: What you do use, you couldn’t hand code any better.
In that mind-set, it is completely okay that `sync_read` and `async_read.await` can totally compile to something different, as they abstract different things.
That's a tricky question. My understanding is that while it's (in theory, modulo compiler bugs and features) a 0-cost abstraction over different underlying system APIs, those different underlying system APIs aren't necessarily the same cost. For example, if I'm trying to read from a socket in the synchronous world, I just issue the `read` system call. But in the async world I'm going to do quite a bit more:
- Create an epoll descriptor.
- Add my socket to that descriptor.
- Poll the descriptor for a readiness notification.
- Read the descriptor.
Those first three system calls weren't required in the synchronous version, and unless the read is large enough to overshadow them, they represent some additional cost. But that cost is required by the OS itself, not by Rust's abstractions.
Someone with more experience writing Mio code might want to jump in and correct me here though.
Spawning a new thread for an operation is not async in the sense people typically mean. For an async IO library, you would expect it to be using async IO primitives like epoll, not just wrapping blocking operations in a thread.
> You write sync code, but because Go routines aren’t OS threads they operate with the efficency of async code.
No, they don't. Goroutines have stacks, while Rust async code does not. Go has to start stacks small and copy and grow them dynamically because it doesn't statically know how deep your call stack is going to get, while async/await compiles to a state machine, which allows for up-front allocation. Furthermore, Go's M:N scheduling imposes significant costs in other places, such as FFI.
Besides, for the vast majority of apps, OS threads are not significantly different from goroutines in terms of efficiency. Rust doesn't have a GIL and per-thread startup time and memory usage are very low. It only starts to matter once you have a lot of threads—as in, tens of thousands of clients per second—and in that case it's mostly stack size that is the limiting factor.
> OS threads are not significantly different from goroutines in terms of efficiency
This is not true for a use case with a lot of connections, additionally context switch cost a lot more now with all side channel attack mitigations on.
I don't doubt that but apparently we have different definitions of a lot. Additionally latency and hardware also matters, I can say that C is doing n things a second while PHP is doing the same but C is running on EC2 micro instance and PHP is running on 2x Intel Xeon Platinum 9282 dedicated machine. C10K problem was not solved by 1:1 model and this is an old problem. C100K+ is what I see in some of production systems I work on.
> Turning sync into async is harder in any language.
well in most languages you can wrap sync into async. so it's not "hard". it's just harder to have NON blocking code.
i.e. in c# there is a difference between:
`await Task.Run(() => Thread.Sleep(5000));`
and
`await Task.Delay(5000);`
both will wait for 5 seconds but one will waste cpu cycles while the other won't.
> well in most languages you can wrap sync into async. so it's not "hard"
It is not easy to do in a cirrect and performant way. "async" doesn't mean "code that runs in another thread". You can have a single threaded runtime running async code (that's usually the case for javascript).
The "async-ness" is in those cases provided by the use of non-blocking primitives for IO, network etc. If a function is making a blocking call to the file system even if you make it async it will not help since the main thread will still be blocked on that system call.
The performance will also be quite different: waiting for data on 10000 sockets in a non-blocking way is quite different from having 10000 threads doing the same.
Mixing it with the Enum library makes concurrency dead-simple (got I a junior dev dispatching concurrent tasks in scripts with confidence), at the expense of an ugly nested double lambda.
Does Elixer overload IO operations to be async in async contexts? Because that is largely why you cannot just wrap sync code in an async block and call it a day - once it hits a system call the thread is paused but the scheduler cannot tell that it should be dequeued.
This is largely why Python async took so long to mature, because so much inbuilt functionality was making IO operations transparently using core sync impls that locked up any async executor.
I'm still relatively new to the erlang vm so some of the details here might be wrong, if someone wants to correct me, please it's welcomed.
Console IO operations are actually message call to a "global group leader" which performs the operation, so they are async (and atomic). This can sometimes be confusing if an operation (such as logging) has a bunch of middlemen with an IO operation as a side effect. It's worth the atomicity, though, so none of your IO calls are interrupted by another IO call. Also, if you run a command on a remote node which dispatches IO as part of its own process, the IO will be forwarded back to its group leader (which is on your local node), which is useful for introspecting into another VM.
Disk IO is also different; each open file descriptor effectively gets its own "thread" that you send IO messages to. There are ways to bind a file descriptor directly to your current "thread", but you "have to be more careful when you do that" - you do that if performance is more important (and I have done this, it's not terrible if you are careful).
Network IO is also different; the erlang VM kind of has its own network stack, if you will, but you can set up a socket to be its own "thread" or you can bind a socket into a thread so that network packets get turned into erlang messages.
Handling blocking is all done for you by the VM, which is preemptive and tries to give threads fair share of the VM time.
When people say that programming the erlang VM is like doing everything in its own os, they aren't kidding. Except unlike linux, where your communications are basically limited, you get to interact with your processes via structured data types with coherent language (and also IPC calls are way cheaper than OS processes).
> Does Elixer overload IO operations to be async in async contexts?
Maybe the right way to answer this is: When in Elixir, presume everything is async.
like what, a few microseconds? What are you doing where you're awaiting for things in parallel where that matters? HPC? We're dispatching things that take on the order of minutes. Typically a local network request has 10-20 milliseconds of latency on our office LAN, so whatever. Clean and comprehensible code with very little boilerplate is more important when I'm reviewing my junior's code.
I think if you're striving for that, then a bit of complexity is warranted. Not everything has to be simple, and async is hard to do correctly without the correct abstractions. Honestly, though I was hoping Rust would go with the Actix way of doing things, but that's fine. You don't have to use Rust's async.
Elixir Tasks act closer to a very lightweight threadpool dispatch, rather than the coroutine style of async/await in other languages. An Elixir task doesn't, iirc, share memory with other tasks and won't block if you make it spin.
This makes it a hell of a lot easier to reason about.
https://news.ycombinator.com/item?id=20676641
It's trivial to turn async into sync in Rust. You can use ".poll", "executor::block_on", et cetera.
Turning sync into async is harder in any language. Even Go with it's easy threading. That's a good argument to make async the default in libraries in Rust, but since async isn't stable yet, that would have been hard to do 5 years ago.