Ruby methods are colorless
jpcamara.com
jpcamara.com
Knowing ahead of time which functions are async is a feature.
It's a big neon sign that says "hey, this function call is expensive". This is a good thing for programmers to easily see and know at the call site.
If you make multiple calls with async/await in a row, the performance issues are plainly obvious at the call site. With "colorless" functions, this information is hidden in a deeper layer. You have to know what the function does on the inside to even know what its performance impacts are.
Also, a nitpick - you can call async functions from sync ones, you just can't access the return value. Sometimes, you don't need to.
IMO the more important part is that it can yield control. Half the advantage of cooperative multithreading is knowing where you may yield control makes sharing memory more or less workable.
I also like Trio’s model: if it’s async, it will yield control, even if the result is already available so it doesn’t technically have to. Makes it more difficult to accidentally starve other tasks. (I suppose I also have to mention Curio’s model: if it’s async, it’s a scheduler upcall, whether yielding or not. But for all that Curio deserves credit for freeing Python from asyncio hell, I just don’t find myself caring about things being scheduler upcalls or not.)
I feel stuck, our whole backend is legacy rails and I can’t escape.
It all depends on how the code is written. Eventually somebody managed to make the Erlang code faster than the baseline C, then someone else made the C version 8k % faster, which proves your point. However, how is that related to using sync/async vs message passing?
If you want to write high-performance code then you need to be able to write synchronous code and have control over what your yield points are. If you take the "all calls are potentially async, runtime does what it wants" approach (i.e. "no function colouring") then you just will not be able to do that.
What makes you think that?
> … Joe Armstrong … mentions message passing …
Where?
"4.5 Programming Notations Based on Message Passing" p33
"Concepts and Notations for Concurrent Programming", Gregory R. Andrews and Fred B. Scheider, Computing Surveys 15(1) March 1983, pp 3 - 43
[pdf] https://www.cs.cornell.edu/fbs/publications/LangSurv.pdf
The language and framework are both centered around developer happiness, which in my experience drops off around 10,000 lines. That's about when projects start getting difficult.
Ruby 12169
ERB 2339
Vue 24895
Js 4526
The Vue frontend is indeed more complex than the Rails backend, and in my experience Vue is much simpler than React. My customer organized the Rails app with models, controllers, api/v1/controllers, jobs, services (naming only the most important stuff). It's not bad to work with."Expensive" is subjective and should be up to the programmer to decide.
And, it absolutely should not require creating two identical function definitions to get around the function coloring problem - that's completely indefensible.
> This is a good thing for programmers to easily see and know at the call site.
Yes, and the IDE can show you that information, exactly like it does the types of arguments. We don't redundantly write the types of arguments at the call site because it's a good thing for programmers to see (which it is), we let the IDE do that.
Yes, this is a great idea, and people would do it if there was widespread support for a binary structured format.
The difference between this incredibly ignorant sarcasm and my point is that IDEs already support the thing that I'm suggesting. You can already hover over a method and it'll give you information about the types of the arguments and return values that are specific to the call site.
> At the end it's a balance and subjective preferences
...and you completely missed the main point of my comment, which was not about what information to show, but avoiding duplication due to function coloring:
> And, it absolutely should not require creating two identical function definitions to get around the function coloring problem
I'm afraid I don't follow. It's impossible for you to have two identical functions (one sync and one async), since then they would both be sync. What did you mean?
Alternately, I want to be able to designate a computationally-intense function call (just that call, leaving the other calls alone) as async so that control yields to the event loop.
The main problem is that someone got it in their head that the function definition was the right place to designate whether a function was async or not, and it's not. The right place is the call site.
This unnecessarily trivializes the technical problems at hand. This wasn't just something someone got in their head, especially considering...
> that the function definition was the right place to designate whether a function was async or not, and it's not. The right place is the call site.
This is exactly what JavaScript did. The program only yields at `await`ed callsites and doesn't yield at all other callsites.
`async` only tells the VM to create the state machine necessary to resume the function after `awaited` calls return.
Making a language that automatically generates two versions of a function, one that is async and returns a Promise-wrapped object, and another that is sync, is not hard.
> This is exactly what JavaScript did.
No, it isn't. JavaScript requires you to declare functions as async at the function definition, and you can't call async functions from sync ones. This is exactly the thing I am arguing against, and is the opposite of "The right place is the call site."
> No, it isn't.
Yes, actually, it is. Async functions are a concept that only affects the internal structure of a function - to generate the state machine that allows yielding at `await` keywords and resuming after they resolve. Externally they are no different from "sync" Promise-returning functions.
So they only thing that async functions do is enable yielding at the callsite, the `await` keyword is what actually yields at the callsite and you can await anything, not just async functions.
Again:
> I want to be able to designate a computationally-intense function call (just that call, leaving the other calls alone) as async so that control yields to the event loop.
This is what `await` is - `await` yields. Non-await calls don't yield.
This comment shows a true departure from reality. Computing is filled to the brim with ideas that are easy but are not implemented for reasons other than difficulty.
> I suppose we should all await your backwards compatible proposal?
Your sarcasm merely helps to further demonstrate that you're not interested in a serious discussion.
> Yes, actually, it is.
Then it should be trivial for you to show me an instance of a sync function calling an async one, synchronously waiting for it, then getting back the return value, without clever hacks.
.NET doesn't really need to provide two separate utility methods like this though, because you can use Task.wait to block until the async task is done.
Their async variants call different OS APIs for asynchronous operation where available (Overlapped IO on Windows, on Linux it is specially scheduled (p)read and write calls).
A similar distinction applies to Socket where asynchronous calls use an internal epoll-based engine while synchronous ones are plain send/recv.
Generally speaking, in synchronous code there is no advantage to calling `.Wait()` or `.GetAwaiter().GetResult()` over synchronous APIs when such are offered, and if you can help it, it is best to update the code using them to be async too if your application is multi-threaded. Luckily it's quite easy in most situations unlike what HN public (that hates better languages like C#) would lead you to believe. But ff you do have to do block on waiting for a task, the impact on throughput is usually negligible if you do so within say threadpool worker - the implementation nowadays can cope with this very well and is more resilient to scenarios that used to be problematic.
Of course, in languages like Rust with multiple colors of async code (single-threaded or multithreading-capable), this would get very messy.
[0] As a major caveat, synchronous code also enforces various state transitions much more efficiently. Want to prove that IO is done (in the sense that the user code is done with it) before closing a file? This is pretty easy in single threaded blocking code.
Oh yes, absolutely. But the solution to that is stop using joke languages that can't do kind polymorphism.
> Yes, and the IDE can show you that information, exactly like it does the types of arguments.
How can it, if the language doesn't give it that information? Either you have a language that can reliably distinguish between sync and async calls in a standard way - which is to say, it has a type system that distinguishes between sync and async, whether it calls it that or not - or your IDE has an ad-hoc informally specified bug-ridden slow implementation of half of one.
This is very rarely necessary in app code. It's usually only a pattern for higher-level utility functions, and even then if you really wanted to you could unify implementations with generators.
Where do you see insanity like this?
If you were designing a language from the ground up, why would you implement a synchronous read operation? I would just assume that all code written in the language will treat async the way that Haskell programmers treat IO and make all IO operations async no matter what.
See https://news.ycombinator.com/item?id=41055328
This bifurcation is mostly a concern for the standard library. It’s not something you do this way in your regular application code.
I was quite shocked to read this, as in it never brushed my mind that it could be. I personally doesn't feel like it is and is one of the reasons why I try to avoid working with that stack at all cost.
I don't think the neon sign is a good excuse for the mess colored functions are. You easily can create a synchronous O(n^4) function, and there are probably tons of quick async functions. Moreover, your comment might be read as a free pass for using functions and methods that you're not really understanding the behavior off, it obviously doesn't sound like a good thing
The information is only hidden with colorless methods if you consider the documentation to be a place to hide information (o^o).
As far as the article goes, I think the pattern in Ruby is great. I prefer it to JS. But the JS approach works fine and the whole controversy about colored functions is a little silly.
I would be interested in understanding why JS can't enable await from inside non-async functions. I (maybe naively) wonder if the compiler couldn't just figure out which functions to treat as async rather than making the programmer do it.
Any function that calls another that returns a Promise will have to return a Promise to represent completion correctly. That's the color.
No, it doesn't, closures let you update values in the wcope of the parent in .then() callbacks and then you can loop in the function and wait for completion and return a non-Promise value.
So yes the compiler can figure it out, but only with Typescript. For javascript, a promise it a promise, but it doesn't know that a function will return a promise until it does. You can try compiling some async/await codebase to an old ES5 version and see the mess.
No, source code is where you hide the information, documentation is where you hide misinformation (or accurate information about how the function worked 3 versions ago, which is much the same thing).
Not having documentation just removes the ability to determine if what the code _does_ is what the code is _intended to do_.
Then I'd say the vast majority of Ruby libraries are buggy.
> Not having documentation just removes the ability to determine if what the code _does_ is what the code is _intended to do_.
But usually there isn't any intent when it comes to async-or-not. Library authors usually just write a method that results in the right value; whether that method can yield or not isn't even something they thought about, much less had a specific design in mind for.
Unlike Go or Ruby, JavaScript is a single-threaded language. Synchronization constructs such as mutexes and semaphores are uncommon and not part of any standard library. When you are calling a synchronous function, you can be completely assured that no race condition can develop while the function is running, but the same guarantee is not true for asynchronous functions.
That's why knowing which function is running asynchronously is even more useful for languages like JavaScript.
There is a parallel universe where JS added almost any other concurrency primitive and got a better trade-off than async/await.
For myself, I happen to agree with OP that single threaded plus async/await has honestly been my favorite concurrency model I've ever worked with for the types of programs I tend to write (IO-bound web apps with lots of network requests and no hot loops). The property that they described of having no race conditions of any kind except where you opt into them by calling out to an explicitly tagged async function is an enormous unnecessary mental burden removed.
In other contexts with higher compute demands, a full threading model or something like Go can make a ton of sense. But it's not worth the overhead for your average web app where what you really want is to just resume when that web request finishes.
Use the right tool for the job, don't get dogmatic about programming language features in the abstract.
Unfortunately, we do not live in that parallel universe, and JavaScript had to evolve the way it did. When callbacks have already been established as the standard concurrency mechanism, promises were a big improvement. When promises became standard, async/await was the natural evolution rather than breaking existing programs by introducing stackful coroutines.
Other languages which chose async/await (Rust, C#, Kotlin) had their own reasons. In all of the cases above, the language designers wanted to give the user more control over the scheduler (Colorless Stackful coroutines require a global scheduler).
In Rust's case, it's async/await state machines are just not wasting memory and unnecessarily copying it like stackful coroutines do (not to mention that Rust doesn't have a garbage collector and pointer rewrites, so it cannot just copy stacks around when they grow). In fact, Rust did have garbage collection and green threads, but they were both removed early on, as the language changed its focus to became a language where you can expect performance that are _at least_ on par with C or C++.
In case of Kotlin, this all mostly had to do with finding an abstraction that can run on pre-Loom JVMs, JavaScript targets and native targets. Kotlin does not own the runtime.
Saying everybody should follow Go here is silly. Not all languages share the same background and design constraints. Go-style Stackful coroutines require garbage collection, full control of the runtime and generally a language that is designed from scratch to have them - or a 6-year project to adapt your language and runtime to have them, like Project project Loom did.
Async/await imposes some costs, but the benefits (compatibility, performance, flexibility) often outweigh these. After working with both async functions and stackful coroutines in multiple languages, I cannot say I ever felt concurrency in Go to be more ergonomic than Kotlin. If anything, it was the reverse for me in that particular case (owing to Go's lack of reification for goroutines and verbose syntax). Deciding in advance what color is my function (i.e. whether it needs to deal with I/O), is pretty simple and it rarely changes during the lifetime of a program (and even when it does, refactoring is not hard unless your design is bad).
The only case where I did feel some pain using async/await, was Rust, and this has more to do with Pin, competing async runtimes (for a while), lack of support for async traits (for a while) and other areas where Async Rust still needs some work.
That's exactly the mindset of quite a number of programmers nowadays, sadly. Two anecdotes: First, there was a blog post (probably even featured on HN) about problems in the design of the API of... Python's requests library, I think? It was quite a bizarre reading, complaints made almost no sense to me until I got to about the middle of the article when it clicked: the author was trying to figure out how to use the API by just reading the names of the classes, methods, and parameter names and making guesses as how it all would connect together instead of, you know, reading the docs. Which do exist, by the way.
The second example happened just yesterday at my job. In the essence, some fellow programmer added a call to function foo() which he thought would do some particular thing and return a particular answer while in reality it did not quite do that, and the return value is essentially meaningless (the function basically always returns "yep, I've scheduled do_foo() to run asynchronously some time soon"). Now, this foo() dunction is not documented, and if you read it's source (3 lines of code), it's quite obvious that its return value is useless... so why did they think it works the way they wanted it to work? When asked about it, the answer was "Well, it's named foo(), so I just thought that's what it would do". Sadly, calling strange third-party functions and hoping them to work they way you'd like them to work is no basis for writing the working programs.
I kind of wish the languages I use had Haskell's IO monad too, to separate functions in terms of the type system, but that's slightly different.
You might like this article (which is my personal favourite about function colouring). https://www.tedinski.com/2018/11/13/function-coloring.html
Or wait for them to finish.
> It's a big neon sign that says "hey, this function call is expensive"
Async and expensive aren't the same thing.
> Also, a nitpick - you can call async functions from sync ones, you just can't access the return value.
That's a quirk of the particular async/await implementation in JS and not generally true of colored-function implementations. (Actually, since async/await is sugar for promises, you can actually, I’m pretty sure, both call and use syntactically async functions from ones which are syntactically not-async if you really want to, the code is just ugly.
It can be, but this fact doesn't always scale well to larger APIs over time.
If you see that a function is async, any of these could be true:
1. The function does some asynchronous work.
2. The function did some asynchronous work at some point in the past, but does no longer, but changing the signature is a breaking API change, so it still looks async.
3. The function does no asynchronous work, but it's a public API and the library maintainer thinks there is a good chance it may need to do async work in the future so made it async today to reserve the right to make that change without breaking the API.
4. The function may or may not do asynchronous work, but it's part of an interface that others are allowed to implement and the designer of the interface wants to give implementers the freedom to make their implementations async if they want.
5. The function is higher-order and wants to be able to accept callbacks that are themselves async.
There are probably others I'm forgetting.
This all sounds hypothetical but it's really not. If you maintain any widely-used long-lived package, you run into design questions like this all the time.
There is always a tension between an API consumer wanting to know what a function "really" does versus the API author wanting some abstraction so that implementation details can be changed over time without breaking users.
Asynchrony is one kind of effect or observable behavior that a user/maintainer of an API may or may not want to encapsulate, but it's not the only one. The C++ standard library documents and commits to the algorithmic complexity of most functions. Haskell function signatures pin down whether or not a function does IO. Statically typed languages pin down the types parameters must be.
Pinning down more of this stuff gives the API consumer some more information, but it's not always a pure win. It calcifies the behavior of the function in ways that can harm its evolution or interact poorly with higher-order code or polymorphism. There's no silver bullet.
Personally, I've never found "does this function suspend" to be a particularly interesting effect for a function to have to commit to and the fact that it poisons the entire callstack makes it very difficult to work with in practice.
I dunno. Maybe I'm old now?
Anyways; good for Ruby! Async/await just seems very faddish to me: it didn't solve any of the hard multithreading/multiprocessing problems, and introduced a bunch of other issues. My guess is that it was interesting type theory that bled over into Real Life.
I generally agree that manually migrating everything to "be async" in order to achieve this exposes at the very least a lack of belief that computers can be used to automate drudge work.
Thank goodness we have wiser language developers out there who have resisted this impulse.
This is one of the most blatent "failure to learn from our history" debates I know of, when people act as if the question started with Javascript just a few years ago, when in fact the programming community has experience with this style that predates when most of us were born. I'm getting to old fogey myself and it still predates my 1978 birthdate. I was never even tempted to get into Node because I'd already made an async/await-style mess in Perl before Node was even released, and the mess had everything to do with the programming style and little to do with Perl qua Perl, and the libraries I used were already old and well-established then, if not outright long in the tooth. There is nothing new about this, except keywords.
a := &blah{}
rA, err := engine.doSomethingWithA()
b := &bloop{}
rB, err := engine.doSomethingWithB()
This might have started out with both the doSomethings being very quick painless procedures. But over time they’ve grown into behemoth network requests and very thing is slow and crappy. No, it’s not exactly hard to spin up a go routine to handle the work concurrently, but it’s not trivial either - and importantly, it’s not immediately obvious that this would be a good idea.Contrast to TS:
let a = {blah}
let [rA, err] = engine.doSomethingWithA()
let b = {bloop}
let [rB, err] engine.doSomethingWithB()
Now, time passes, you perform that behemoth slowing down of the doSomethings. You are forced by the type system to change this: let a = {blah}
let [rA, err] = await engine.doSomethingWithA()
let b = {bloop}
let [rB, err] await engine.doSomethingWithB()
It’s now immediately obvious that you might want to run these two procedures concurrently. Obviously you will need to check the engine code, but any programmer worth their salt should at least seek to investigate concurrency when making that change.I wouldn’t be bringing this up if I hadn’t made 10x+ performance improvements to critical services within a month of starting at a new company in a new language on a service where the handful of experienced go programmers on the team had no idea why their code was taking so long.
const [[rA, errA], [rB, errB]] = await Promise.all([
engine.doSomethingWithA(),
engine.doSomethingWithB()
])
At least these days you can ask an LLM to write the WaitGroup boilerplate for you in Go. type result[T any] struct {
el T
err error
}
chanA := make(chan result[aResultType])
chanB := make(chan result[bResultType])
go func() {
defer close(chanA)
a := &blah{}
rA, err := engine.doSomethingWithA()
chanA <- result[aResultType]{
el: rA,
err: err,
}
}()
go func() {
defer close(chanB)
b := &bloop{}
rB, err := engine.doSomethingWithB()
chanB <- result[bResultType]{
el: rB,
err: err,
}
}
resultA := <- chanA
resultB := <- chanB func runTask[T any](task func() (T, error)) chan result[T] {
ch := make(chan result[T])
go func() {
defer close(ch)
res, err := task()
ch <- result[T]{ el:res, err:err } }()
return ch }
Does that sort of thing happen much in practice?One of the common pasttimes in the threaded versus async debate is to present code in which one side uses all sorts of helpers and patterns and libraries and the other side is presented through writing it "raw". The great-grandparent of my post here is guilty of this. While there are interesting reasons to debate threaded versus async code, this is not one of them. Both of them are absolutely capable of writing the moral equivalent of "output = parallel_map(myMapFunc, input)" and all similar operations to within practical epsilon of each other, and anyone citing this sort of thing as an argument on either side should probably be ignored. And both languages will feature code written by people who don't know that, and it shouldn't count against either.
And I'm admitting this "against interest", as the lawyers say. I'm not striking a disinterested "middle of the road" pose here. I'm hugely on the side of threads. But it is still not a relevant criticism of async. You can easily "parallel map" with either abstraction and you are not stuck unable to abstract the control flow in either case.
^Without any non-local changes, obviously.
And this is a bit of a weird case, at least where I am. I tend to have a bunch of things to process and have one goroutine sending keys/indices/etc to a channel that multiple workers are processing off of.
We did have an abstraction for that at one point, but there were enough edge cases in our domain that we either had to develop a config system or rip it out and go back to writing each one (we went with the latter after an attempt at the former went really bad).
If A failed, the whole function is failed, and we don't need B any more. To save resources we should cancel B. And vice versa, cancel A if B failed.
import github.com/carlmjohnson/flowmatic
func whatever(ctx context.Context) {
err := flowmatic.Race(ctx, taskA, taskB)
}
Provide your definition of taskA and taskB, of course.As I said in another message, this is not a particularly fruitful line of attack in either direction. All the languages in question are perfectly capable of abstractions.
I guess I'm just still salty when someone commented (in another post a long time ago) that golang only has `go` (compared to `launch`, `async`, and `coroutineScope` in Kotlin) and is simpler.
> All the languages in question are perfectly capable of abstractions.
I don't think async functions in JS can be cancelled though.
Me neither, however, it is generally the most flexible approach and I can see why a library takes it. If you want to communicate it via the return, you also have to impose a restriction that the tasks all return the same type. I think it makes sense for a library to work this way because you can easily add this around the library call yourself, but it's more difficult to go the other way. (Not impossible, just more difficult.)
"I don't think async functions in JS can be cancelled though."
Poking around, it looks pretty hard.
That said, cancelling in generally is very difficult in imperative languages, so even as someone who finds async in JS quite distasteful I can't dock too many points. Go basically just reifies the JS solution into a standard community practice, which is definitely an improvement since you can largely rely on it being supported everywhere, but one could reasonably debate how good it is. It is occasionally a problem that if you want to cancel an ongoing computation you may have to have your code manually check a context every so often, because there's no "real" connection between a context and a goroutine.
https://developer.mozilla.org/en-US/docs/Web/API/AbortContro...
Not to mention waitgroups are way overkill for this. You’d just use a channel or two. Or an errgroup if you want to be fancy.
let gizmos = nocheckperms lookupRequestedGizmos(request);It's possible to write a language where awaiting a task is done through a method on the task type. I don't think this is ideal because the whole reason you're using explicit yield points is so you can tell when something yields. Using method syntax makes that harder to see at a glance.
I think what happened what that JavaScript absolutely necessitated the addition of async/await to avoid callback hell (due to its single-threaded mandate)... just to create a new kind of hell all of its own.
But before the long-term consequences within large code bases could be observed, the cool kids like C# & friends jumped on the bandwagon believing that "more syntax equals more better code".
I don’t see why you would need threads to create a stackful coroutine implementation. However, what you would indeed need is a much more heavy runtime.
Async and await were nice additions in C# to make working with tasks more convenient, but there were other ways to manage async tasks without ending up with a tower of callbacks. The situation was messier in JS.
It’s not really “a new kind of hell”, there’s a logical progression from callbacks to reified callback (promises) to coroutines, and each step makes concurrency more manageable so you do more of it until you hit a new wall.
And client-side JS probably can’t ever be preemptive, so not only would a thread model require adding a ton of tooling at once it would still behave quite strangely.
My UI background is web testing and C++ game UIs in C++. There are like 3 patterns in multithreaded games. Thread per responsibility (old/bad), Barriers blocking all threads and give each game system all the threads, or something smart. Few games do something smart, and the thread per responsibility is not ideal and we will ignore it for now.
In games often pausing everything and letting the physics system have all the threads for a few milliseconds is "fast enough". Then the graphics systems will try to use all the threads, and so on so that eventually everything will get all the threads even thought everything else rarely needs it. Sometimes two things are both close to single threaded and have no data contention so they might both be given threads, but this is almost always a manually decided things by experts.
This means that once UI, the buttons, the text, cursors, status bars, etc, gets its turn there won't be any race conditions (good), but if it needs to request something from disk that pause will happen on a thread in the UI system (bad and analogous to web sites making web API calls) so UI latency can be a real problem. If any IO small or some resource system has preloaded it then there isn't a detectable slowdown, but there are still plenty of silly periods of waiting. There is also a lot of time when some single threaded part of the game isn't using N-1 hardware threads and all that IO could have been asynchronous. But often game UIs are a frame behind the rest of the game simulation and there is often detectable latency in the UI like the mouse feeling like it drags or similar.
Allowing IO to run in the back while active events are processed can reduce latency and this is the default in web browsers. IO latency in web pages is worse than in games and other computation seems smaller than games, so the the event loop is close to ideal. A function is waiting? throw it on the stack and grab something else to do! This means that all the work that can be done while waiting on IO is done and when does well makes a UI snappy.
If that were available sensibly in games it could allow a game designed appropriately to span IO across multiple frames and be snappy without stutters. With games using the strategy I described above latency in the game simulation or IO can cause the UI to feel sluggish and vice versa. In games caching UI details and trying to pump the frame rate is "good enough". If the UI is a frame behind but we have 200 frames per second, that isn't really a problem. But when it chugs and the mouse stops responding because the player built the whole game world out of dynamite and set it off the game will not process the mouse until that 30 minutes of physics work is done.
There are better scheduling schemes for games. I am a big fan of doing "something smart" but that usually means scheduling heterogenous work with nuanced dependencies and I have written libraries just for that because it isn't actually that hard. But if you don't have the raw compute demands of a game scheduling IO along side your UI computation is often "fast enough" and is any easy enough mental model for JS devs to grok and allow them freedom to speed things up with their own solutions like caching schemes and reworking their UIs.
Although my real guess is ECS but that's more like the "everyone gets every thread for a time."
Sorry for the novel.
I meant those nuanced dependencies as a way of managing shared mutable state and complex computations that really do take serious CPU time. Let's make a simple example from a complex game design. This example is ridiculous but conveys the real nature of the problems with CPU and IO. Consider these pieces of work that might exist in a hypothetical game where NPCs and a player are moving around a simulated environment with some physics calculations and that the physics simulation is the source of truth for locations of items in the game. Here are parts of a game:
Physics broad phase: Trivially parallelizable, depends on previous frame. Produces "islands" of physics objects. Imagine two piles of stuff miles apart, they can't interact except with items in the same pile, each island is just a pile of math to do. Perhaps in this game this might take 20 ms of CPU time. Across the minimum target machine with 4 cores that is 5ms apiece.
Physics narrow phase: Each physics island is single threaded but threadsafe from each other, depends on broad phase to produce islands. Each island takes unknown and unequal time, likely between 0 and 2 ms of just math.
Graphics scene/render: Might have a scene graph culling that is parallelizable, and converts game state into a series of commands independent of any specific GPU API. Depends on all physics completing because that is what it is drawing. Likely 1 or 2 ms per island.
Graphics draw calls: Single threaded, sends render results to GPU using directx/opengl/vulkan/metal. This converts the independent render commands to API specific commands. Likely less than 1 ms of actual CPU work, but larger wait on GPU because it is IO.
NPC AI: NPCs are independent but light weight so threading makes no sense if there are fewer than hundreds. Depends on physics to know what NPCs are responding to. Wants to add forces to the physics sim next frame. For this game lets say there are many, I don't know maybe this is dynasty warriors or something, so lets say a 1~3 ms.
User input: Single threaded, will to add forces to the physics sim next frame based on user commands. Can't run at the same time as NPCs because both want to mutate the physics state. Less than 1 ms.
We are ignoring: Sound, Network, Environment, Disk IO, OS events (window resize, etc), UI (not buttons or text positioning), and a few other things.
A first attempt at a real game would likely be coded to give all the threads to each piece of work one at a time in some hand picked order, or at least until this was demonstrate to be slow:
Physics Broad -> Physics Narrow -> Graphics render -> Graphic GPU -> NPC AI -> User input -> Wait/Next frame
But that is likely slow, and I picked our hypothetical math to be slow and marginal. Sending stuff to the GPU is a high latency activity it might take 5 ms to respond, and if this is a 60 FPS game then that is like 1/3 of our time. If we simply add our hypothetical times that is frequently more than 16ms making the game slower than 60fps. Even an ideal frame with just a little physics is right at 15 to 16 ms So a practical game studio might do other work while waiting on the GPU to respond:
Physics Broad -> Physics Narrow -> Graphics render ->
At the same time: { Graphics GPU calls (Uses one thread) NPC AI (Uses all but one thread) -> User input } ->
Wait/Next frame
Most of the time something like this is "fast enough". In this example that 5 ms of CPU time wait on the GPU is now running alongside all that NPC AI so we only need to add the larger of the two. If this takes only a few days of engineer time and keeps the game under the 16ms on most machines then maybe the team makes a business decision to raise the minimum specs just a bit (from 4 to 6 cores would reduce physics time by another ms) and now can they ship this game. There are still needless waits and from a purely GigaFLOPS perspective perhaps much weaker computers could do the work but there is so much waiting that it isn't practical. But this compromise gets all target machines to just about 60 FPS.
Alternatively, if the game is smart enough to make new threads of work for each physics islands (actually super complex and not a great idea in real game, but this all hypothetical but there are similar wins to be had in real games) and manage dependencies carefully based on the the simulation state then something more detailed might be possible:
1. Physics broadphase, create known amount of physics islands.
2. Start a paused GPU thread waiting on known amount of physics islands to be done rendering. This will start step 5 as soon as the last step 4c completes.
3. Add the player's input work to the appropriate group of NPCs
4. Each Physics island gets a thread that does the following: a. Physics narrow phase for this island, b. Partial render for just this island, c. Sets a threadsafe flag this island is done, d. NPC AI is processed for NPCs near this physics island, e. If this is the island with the player process their input.
5. The GPU thread waits for all physics islands threads to get to step 3c then starts sending commands to the GPU! and 3d gets to keep running.
6. When all threads from step 4 and 5 complete pause all game threads to hit the desired frame rate (save battery life for mobile gamers!) or advance to next thread if past frame budget or framerate is uncapped.
This moves all the waits to the end of each thread's frame runtime. This means a bunch of nice things. That last thread can likely do some turbo boosting, a feature of most CPUs where they clock up one CPU if it is the only one working. If the NPCs ever took longer than the GPU they still might complete earlier because they get started earlier. If there are more islands than hardware threads this likely results in better utilization because there are no early pauses.
This would likely a take a ton of engineering time. This might move the frame time down a few more ms and maybe let them lower the minimum requirements perhaps even letting the game run on an older console if market conditions support that. Conceptually, it might be a thing that could be done with async/await, but I don't think that is how most game engines are designed. I also think this makes dependencies implicit and scattered through the code, but likely that could be avoided with careful design.
I am a big fan of libraries that let you provide work units, or functors, and say which depend on each other. They all get to read/write to the global state, but with the dependencies there won't be race conditions. Such libraries locate the threading logic in one place. Then if there is some particularly contentious state that many things need to touch it can be wrapped in a mutex.
I suppose this might just be the iterative vs recursive discussion applied to threading strategies. It just happens that most event loops are single threaded, no reason they need to be in the long run. In the long run I could see making that fastest scenario happen in either paradigm even though the code would look completely different.
The difference I believe is between updating each UI widget and doing something in case of still missing texture or yielding on the texture in some place of UI code and never touching rest of the UI in the frame.
The good job systems I've dealt with have their dependencies in the functors. So you "wait" on a job to finish, which is really a while loop that plucks and executes other jobs while the dependency job hasn't finished. This kind of job system is nice to deal with as they are generally low overhead which means all threads (processes really) are generally saturated with work at all times.
I don't really remember any global state with contention because that's generally very very slow, but maybe there were bits of our gameplay code I'm not aware of.
I have worked with and without ECS systems both with and without good threading models. ECS writes do create possible issues if write-locks need to be acquired but that isn't usually so big of a deal.
I'm battling to understand what you want then, sorry. The systems that you say you would like to use (discrete jobs with dependencies) are the kind of systems the industry has been using since the advent of data-oriented architecture, which includes ECS. That is, a job worker process per core plucking off work and doing it.
In the engines I've dealt with, we don't usually have write locks, instead preferring copies of "last frame data" and "next frame data". And all our "read locks" are waits for jobs. Our game code is generally single threaded, but the main loop pretty much just kicks off and waits for jobs.
I guess what is a good threading model to you?
(As a side note I've worked on projects that use ECS on a single core and they still confer benefits there even though that's not what they were invented for)
Fibers are cool and performant but usually they should be limited to lightweight work with large concurrency requirements.
My beef, if you will, with async (and I realize now I was a little loose in making the distinction between async and fibers) is that engineers reach for async because they are intimidated by threaded code. Instead they are used to the ergonomics of async/await in the browser and think it's basically the same with the async library in ruby and is somehow immune to the considerations you have to make when spawning a thread. This just isn't true. The dragons are mostly the same, or at least they are in our code base.
Async/await handles that well and so far the other paradigms just have not targeted these use cases well.
Message passing and promise libraries end up looking a lot like async/await with less sugar. (Compared to C#'s async/await implementation. The level of sugar depends on the language, of course.)
This can be done manually by polling status of descriptors, and stepping into and out of callables / passed continuations by hand, or it can be done with a task scheduling API and typesafe chained async functions.
Pick your poison, I guess, but it probably scales better than using synchronous IO and thread-per-request.
There's also a very specifically Not JavaScript problem (that happens to also show up in Node.js): exotic concurrency scenarios involving lots of high-latency I/O (read: network sockets). OS kernel/CRT[0] threads are a lot more performant than they used to be but they still require bookkeeping that an application might not need and usually allocate a default stack size that's way too high. There's no language I know of where you can ask for the maximum stack size of a function[1] and then use that to allocate a stack. But if you structure your program as callbacks then you can share one stack allocation across multiple threads and heap-allocate everything that needs to live between yield points.
You can do exotic concurrency without exposing async to the language. For example, Go uses (or at least one point it did) its own thread management and variable-size stacks. This is more invasive to a program than adopting precise garbage collection and requires deep language support[3]. Yes, even moreso than async/await, which just requires the compiler transform imperative code into a state machine on an opt-in basis. You'd never, ever see, say, Rust implement transparent green threading[2].
To be clear, all UI code has to live in an event loop. Any code that lives in that event loop has to be async or the event loop stops servicing user input. But async code doesn't infect UI code in any other language because you can use threads and blocking executors[4] as boundaries between the sync and async worlds. The threading story in JavaScript doesn't exist, it has workers instead. And while workers use threads internally, the structured clone that is done to pass data between workers makes workers a lot more like processes than threads in terms of developer experience.
[0] Critical Runtime Theory: every system call is political
[1] ActionScript 3 had facilities for declaring a stack size limit but I don't think you could get that from the runtime.
async/await syntax in Rust also just so happens to generate an anonymous type whose size is the maximum memory usage of the function across yield points, since Rust promises also store the state of the async function when it's yielded. Yes, this also means recursive yields are forbidden, at least not without strategic use of `Box`. And it also does not cover sync stack usage while a function is being polled.
[2] I say this knowing full well that alpha Rust had transparent green threading.
[3] No, setjmp/longjmp does not count.
[4] i.e. when calling async promise code from sync blocking functions, just sleep the current thread or poll/spinwait until the promise resolves
Go developer here: Yup, the language still works like that, and probably always will.
As for me, I like Go's CSP better.
I thought Go style could be better, but the Go type system is completely inadequate to support that style: it is impossible to guaranteed that a goroutine is panic free, it is not possible to put a recover in the goroutine unless that code is under author's control (could be a lib) and a goroutine panic takes down the whole app.
Suddenly I want a wrapper around each goroutine that bubbles up the panic to the main thread without crashing the app, that sounds a lot like an erlang supervision tree
I have yet to see a good reason for async/await, other than syntax sugar on a flawed language architecture. The very thing people like about Nodejs (async reactor), creates a lot of problems in production web and data pipeline code.
As an aside, Elixir’s Task.async and Task.await are function helpers that work with message passing primitives. Code execution can be truly suspended, and messages are queued. Javascript’s async/await queues code execution rather than messages, and I think that leads to error prone code by design.
What color is your function? (2015) - https://news.ycombinator.com/item?id=28657358 - Sept 2021 (58 comments)
What Color Is Your Function? (2015) - https://news.ycombinator.com/item?id=23218782 - May 2020 (85 comments)
What Color is Your Function? (2015) - https://news.ycombinator.com/item?id=16732948 - April 2018 (45 comments)
What Color Is Your Function? - https://news.ycombinator.com/item?id=8984648 - Feb 2015 (146 comments)
Dart, which I work on, is still a colored language. And it's like, fine. But I do wish it was colorless. It would make library design a lot easier. There is real friction all the time when doing API design to decide which things should and shouldn't be async.
Multithreading is strictly more powerful than single threaded event loops. For some kinds of software there is just no alternative - a modern browser engine for example needs to be multithreaded.
The trade off is that you need to make sure your code is thread safe, which is not trivial as the collection of articles explains. That's your function color right there, green functions are verified thread safe, gray functions are not or not sure.
Personally in nearly 30 years of programming I've never needed to write multithreaded code. I still haven't found a business need that could not be met with suitable choices between multiprocessing (i.e fork) and event loops.
I'll definitely take wait/async programming over having to worry about concurrent thread safety any day of the week.
I object to doing what a computer can do for me (in programming), and manually creating separate versions of functions that are identical up to async absolutely falls into that category.
transformFile = do {
x <- readFile(...)
y = process(x)
z <- writeFile(...)
}
is the equivalent of something like: async transformFile = {
x = await readFile(...)
y = process(x)
z = await writeFile(...)
}
In Haskell you write exactly the same code when readFile and writeFile are async, or when they're polymorphically async-or-not, your function will just implicitly be as polymorphic as it possibly can be based on the functions it's calling.In Scala you need an explicit type parameter if you want to be polymorphic:
def transformFile = for {
x <- readFile(...)
y = process(x)
z <- writeFile(...)
} yield ()
def transformFile[F[_]: Monad] = for {
x <- readFile(...)
y = process(x)
z <- writeFile(...)
} yield ()
The first example is always async, the second example is polymorphic (it's calling readFile[F] and writeFile[F], but those get inferred).I noticed a couple details that seem wrong:
- You are passing `context` to `log_then_get` and `get`, but you never use it. Perhaps that is left over from a previous version of the post?
- In the fiber example you do this inside each fiber:
responses << log_then_get(URI(url), Fiber.current)
and this outside each fiber: responses << get_http_fiber(...)
Something is not right there. It raised a few questions for me:- Doesn't this leave `responses` with 8 elements instead of 4?
- What does `Fiber.schedule` return anyway? At best it can only be something like a promise, right? It can't be the result of the block. I don't see the answer in the docs: https://ruby-doc.org/3.3.4/Fiber.html#method-c-schedule
- When each fiber internally appends to `responses`, it is asynchronous, so are there concurrency problems? Array is not thread-safe I believe. So with fibers is this safe? If so, how/why? (I assume the answer is "because we are using a single-threaded scheduler", but that would be interesting to put in the post.)
I don't understand how Ruby added this after the fact, globally to ALL potential cpu/io blocking libraries/functions without somehow expressing `value = await coro`
Python is currently going through an "coloring" as the stdlib and 3rd-party libraries adapt to the explicit async/await syntax and it's honestly kind of PITA. Curious if there's any more info on how Ruby achived this.
For historical context, python had several "colorless" attempts but none achieved widespread adoption.
Python in contrast has a runtime ultimately held back by its deep integration with the C-ecosystem which creates more black-boxes inside the runtime than you can find inside a swiss cheese.
Similar with C# and Rust. No strong runtime, no colorless functions for you.
Zig is the exception here but not sure how well it worked out in practice.
In Python gevent can monkeypatch the standard library to do that transparently (mostly). I assume it works the same way except better: have the runtime keep track of the current execution (mostly the stack and ip), when reaching a blocking point register a completion event agains the OS then tell the fiber scheduler to switch off, the fiber scheduler puts away all the running state (stack and instruction pointer) then restores an other one (hopefully one that's ready for execution) and resumes that.
The base of most arguments against async. And it's false. You can call red from blue. And you should, sometimes.
Python (and rust) are coroutine based so calling an async function does essentially nothing, you need to acquire a runtime handle in order to run and resolve the coroutine.
Fibers are not fungible with async/await. This is why structured concurrency is a thing.
Best of both worlds: you no longer have two functions with ReturnType and Promise<ReturnType>. You just mark potentially blocking function with suspend and you’re done.
Aren't all these calls blocking? Doesn't `File.read` still block? Sure it's multithreaded, but it still blocks. Threading vs an event loop are two different concurrency models.
Only locally, which is pretty much the same as when you `await` a call.
> Threading vs an event loop are two different concurrency models.
The point is that you can build a "threading" (blocking) model on top of an event loop, such that you get a more natural coding style with most of the gain in concurrency.
It loses some, because you only have concurrency between tasks (~threads) leaving aside builtins which might get special cased, but my observation has been that the vast majority of developers don't grok or want to use generalised sub-task concurrency anyway, and are quite confused when that's being done.
For instance in my Javascript experience the average developer just `awaits` things as they get them, the use of concurrent composition (e.g. Promise.all) is an exception which you might get into as an optimisation, e.g. `await`-ing in the middle of a loop is embarrassingly common even when the iterations have no dependency.
If you are blocking the caller you have not "solved" the colored function "problem".
That is an end not a mean.
Again in my experience the vast majority of devs could not give less of a shit about “not blocking the caller” by default. What most devs want is a reasonably cheap way to get a high amount of concurrency.
If anything not blocking the caller by default is generally an error, because somebody forgot an await.
> If you are blocking the caller you have not "solved" the colored function "problem".
Of course you have: you have solved the actual problem that needs solving without using function colouring. That’s how e.g. Go works. Go has problems up the ass but it doesn’t have that one at least.
Java is also moving back to Userland threads rather than towards async/await.
Go has NOT solved the problem in a fungible way, as evidence by all the dual APIs; methods that return a channel, and those that don't.
CSP is great at modeling data flow, but IMHO it's lesser than async/await imperative programing for modeling more standard business logic flows.
Ruby 1.9 added Fibers. These are coroutines with their own call stack that yield and resume explicitly. They're like Goroutines but without Go's scheduler. Fibers are commonly used to build Enumerators, internal iterators support external iteration. File.read in a Fiber still blocks by default.
Ruby 3.0 added support for truly asynchronous File.read. The batteries are not included. A fiber scheduler is required to enable this optional feature:
https://docs.ruby-lang.org/en/3.3/Fiber.html#class-Fiber-lab...
Colorless brings no meaning when i look at the signature of a method, which is a warning !
Async at the boundary, sync at the core is my favorite paradigm.
Interestingly, while this has proven true of async/await for many languages it has not at all been true for perl.
The pluggable keywords feature lets us register 'async' and 'await' with the parser as (block scoped) imported keywords and with a little suspend/resume trickery you get https://p3rl.org/Future::AsyncAwait which I've been using happily pretty much since it was released (generally operating on https://p3rl.org/IO::Async::Future and https://p3rl.org/Mojo::Promise objects, often both in the smae process).
I even wrote https://p3rl.org/PerlX::AsyncAwait as a pure perl proof of concept later on, which injects computed gotos as resume points ala the switch/case trick you can use for resumable functions in C (nobody should really be using that one, mind, I wrote it to prove that I could and as potential fodder for https://p3rl.org/App::FatPacker usage later).
I do very much appreciate there are a lot of reasons one might dislike perl (I've been writing it long enough my list is probably longer than most naysayers') but its sheer malleability as a language remains unusually good.
def log_then_get(url, context)
puts "Requesting #{url}..."
get(url, context)
end
def get(uri, context)
response = Net::HTTP.get(uri)
puts caller(0).join("\n")
response
end
def get_http_thread(url)
Thread.new do
log_then_get(URI(url), Thread.current)
end
end
Good example of the downsides of dynamic typing:1) get_http_thread takes a url (String) and converts it to a URI object
2) log_then_get defines its parameter as `url`, but really its expecting a URI object
3) get defines its parameter as `uri`, but we're passing it an argument called `url` from within log_then_get.
Lots of traps readily awaiting an unsuspecting programmer or newcomer to a project that contains code like this.
To the point where I consider "colorless functions" to be a leaky abstraction; i.e. I do a lot of ORM stuff, and "I'll just call author.getBooks().get(0) b/c that is a cheap, in-memory, synchronous collection access ... oh wait its actually a colorless SQL call that blocks (sometimes)" imo led to ~majority of ORM backlash/N+1s/etc.
Maybe my preference for "expressing IO in the type system" means in another ~4-5 years, I'll be a Haskell convert, or using Effect.ts to "fix Promise not being a true monad" but so far I feel like the JS Promise/async/await really is just fine.
Ugh. I know Ruby (which I used to code in a lot more) has made some real progress toward enabling practical use of parallelism but this sounds still pretty awful.
Is there any effort to make sharing data across threads something that doesn't have to be so "carefully coordinated" (ala Clojure's atom/swap!, ref/dosync)?
Is the inability to parallelize CPU-bound code to do with some sort of GIL?
And, yes, it's to do with a GIL/GVL. The lock is released during blocking IO, and some C extensions etc., so in practice for a lot of uses it's fine.
EDIT - I remember when patch notes years ago said the GIL was gone and this says there is a GVL, I guess there is some subtle difference.
then I think for practical purposes, "yes" is your answer, but not in precisely that name.
If you want parallel threads then you can use JRuby and your threads will run in parallel on the JVM. I've used the Concurrent-Ruby gem to coordinate that[1].
It has copies of some of the Clojure data structures.
Otherwise, Ractors the up coming solution for MRI Ruby.
I find many descriptions of async code to be confusing, and this kind of description is exactly why.
This description is backwards. You don't choose to use await and then decorate functions with async. Or maybe you do and that's why so many async codebases are a mess.
You don't want to block while a long running operation completes, so you decorate the function that performs that operation with async and return a Promise.
But Promises have exactly the same value as promises in the real world: none until they are fulfilled. You can't do further operations on a promise, you can only wait for it to be done, you have to wait for the promise to be fulfilled to get the result that you actually want to operate on.
The folly of relying on a promise is embodied in the character Whimpy from Popeye: "I'll gladly pay you Tuesday for a hamburger today".
Once you have a promise, you have to await on it, turning the async operation into a synchronous operation.
This example seems crazy to me:
async function readFile(): Promise<string> {
return await read();
}
This wraps what should be an async operation that returns a promise (read) in an expression that blocks (await read()) inside a function that returns a promise so you didn't need to block on it!. This is a useless wrapper. This kind of construct is probably the significant contribution to the mess: just peppering code with async and await and wrapper functions.await is the point where an async operation is blocked on to get back into a synchronous flow. Creating promises means you ultimately need to block in a synchronous function to give the single threaded runtime a chance to make progress on all the promises. Done properly, this happens by the event loop. But setting that up requires the actual operation of all your code to be async and thus callback hell and the verbose syntactic salt to even express that in code.
That all being said, this piece is spot on. Threads (in general, but in ruby as the topic of this piece) and go's goroutines encapsulate all this by abstracting over the state management of different threads of execution via stacks. Remove the stacks and async programming requires you to manage that state yourself. Async programming removes a very useful abstraction.
Independent threads of execution, if they are operating system managed threads, operating system managed processes (a special case of OS managed threads), green threads, or go routines, are a scheduler abstraction. Async programming forces you to manage that scheduling. Which may be required if you don't also have an abstraction available for preemption, but async leaks the single threaded implementation into your code, and the syntactic salt necessary to express it.
Async code bubbles all the way to the top. If you want to use await, then you have to mark your function as async. [...] If at any point you don’t then you have to use the async result (in JavaScript’s case a Promise<T>).
I think it's just an artificially lengthy example to show how the responsibility of working with promises grows up the callstack. Interpreting it that way since the final function they define in that sample is `iGiveUp` which is not using the async keyword, but returns a promise. Definitely could be made a bit more clear that's it's illustrative and not that the async keyword is somehow unlocking some super special runtime mode separate from it's Promise implementation.To be fair, this is sort of pointless in JS/TS because an async function returns a promise type by default, so the return value has to be ‘await’ed to access the value anyways. There are linter rules you can use to ban ‘return await’.
The only benefit to ‘return await…’ is when wrapped with a try/catch - you can catch if whatever you ‘await’ed throws an error.
Javascript is as "colorless" any other programming language. You can write "async"-style code without using async/await at all while it being functionally equivalent.
Async/await is just syntactic sugar that saves you from writing "thenable" functions and callbacks again and again and again ...
Instead of:
function(...).then(function() {
// continue building your pyramid of doom [1]
})
... you can just do: await function()
That's literally it.1: https://en.wikipedia.org/wiki/Pyramid_of_doom_(programming)
- anonymoushnBulletML is not even Turing complete and still has a wait function that does the exact thing mentioned.
Just FYI, please become familiar with this site's guidelines before posting [1]; try to make @dang's work a bit easier.
>An example of a language or of a program?
A small code snippet would suffice, any language of your choice that gets the point across more meaningfully. Something like:
function() {
// code that shows feature
}
"... and this is the functionality that you're missing on Javascript".For a real world example, https://github.com/alexnask/iguanaTLS is a TLS library written in terms of the stdlib's reader and writer interfaces. If the read and write methods of the provided reader and writer are normal, non-async functions, then the library's functions are too, and they can be used in a program that does not have an async runtime. If they are async functions, then the library's functions inherit this property, and they can be used in a program that uses async I/O. They could also be used in both modes in the same program, though I can't think of a good reason to do this. Normally in Javascript the consumers of your library would all be imprisoned within an event loop provided by node or the browser, so there would be no point exposing a synchronous variant, but for example see https://nullderef.com/blog/rust-async-sync/ for someone's experience trying to write a library that exposes the same functionality both ways in some other language.
What I would do on JS is ...
... on the lib. side, code a single function that behaves the same when meant to run sync or async.
... on the client side, just await the function every time is called; if the sync version is running you don't return a Promise and await-ing on primitives is free, the program will lock automatically if needed.
Obviously, the trivial solution would be two different methods that do the same thing (as is the case now with things like readFile and readFileSync) but I agree that's not elegant.
There are a lot of things I dislike in JS, but I think the I/O async model is just right from an ergonomics point of view. The event loop is implicit, any async function returns a promise, you can deal with promises from inside sync code without much trouble.
It's just the right balance.
The IDE can tell you.
Besides, many people dev Ruby with a lightweight text editor, like text mate, that can't introspect code.
I haven't seen more than a handful of PRs with monkey-patching in the last decade and even then they are accompanied by long-winded explanations/apologies and plans for removal ASAP (eg monkey-patching an up streamed fix that hasn't been released yet).
Also, ruby classes/methods can tell you all about themselves, so if you haven't got ruby-lsp up and running (and even if you do) you can always open a repl (on its own, in the server process, or in a test process) and ask the interpreter about your method. It's pretty great!
It's definitely the case that the editor's ability to understand code via static analysis is limited compared to other languages, but it's not like we ruby devs are navigating in the dark.
If not, then we should be able to use the type annotations that are being added to also indicate async-ness.
If people decide to code Ruby "blind" (without a smart IDE), then that's their choice. There's no reason why someone using an IDE should have to pay for their decisions. We don't force people to manually and redundantly add names and types of parameters to call sites - it makes equally little sense to do the same for async. If someone decides to use a dumb IDE, then they can read the docs, exactly the same as they do for function parameters.
The only way the IDE can tell you is if the language tells it, or it guesses - and if it guesses then it will get it wrong sometimes. Which things are async or not is exactly the kind of thing that needs to be part of the language definition, so that all tools will agree about it and you won't have functions that are async in one IDE and not another, or async in a profiler but not an IDE, or...