Skip to content

Comment on A Design Space Exploration of Async/Awaitparent

Comments

Tactically, this problem is commonly known as “colored functions”[1], and the only option in JS is to have some other runtime coordinate your function execution; in JS, that solution is Effect[2]

[1] https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...

[2] https://effect.website/

There are a lot of libraries which can help you deal with this, but ultimately the parent is right.

I usually arrange my programs to have a call tree of all my async code, and separate call trees of sync processing work. You want to know ahead of time which is which. If you have a sync function which needs data that’s only available via a network request, take that data in as a function parameter or something. And make the caller responsible for making that data available before the function is called.

It’s a simple model. It’s fast and quite easy to understand once you’re used to it. But you do need to plan ahead, and structure your programs with a plan.

A hallmark of good architecture is adaptability to unexpected changes in requirements. Planning ahead helps with 'known unknowns', but it's impractical when building across N years in a dynamic environment - "knowing ahead of time" is just not possible for anything non-trivial. You need strong architectural primitives that don't scale based on developers' omniscience.

For example, say you have a workflow where, when someone signs up, you generate a user label, e.g., `$firstName $lastName`. You decide to move that to a function that might consider their personal title, preferred name, etc. Currently, it's a pure sync operation. Then, you discover people don't fill out that form at all, but some log in with Google, and you can use the name there as a fallback. Under flexible, this change is local: you can put that into your `createUserLabel(userInfo)`, and it can decide to fire off an API call to fill in any missing data, etc. In Promises land, this would taint the entire tree of everything everywhere that called that method, and all of those things must evolve or be refactored. In an Effectful system, this (previously unplanned) change remains isolated to that one function. Multiply that by every decision over multiple years for software looking for PMF, and requiring developers to "know ahead" severely slows down your ability to evolve.

To go with this, don't be afraid of changing your code. If you get an unexpected change that means a whole hierarchy has to become async, bite the bullet and change the hierarchy. Such things happen.

This works for all applications, but not libraries where you don't control your callers. In that case it may make sense to make something async pre-emptively if you think requirements might change in a way that requires it but you can never predict every change successfully and you might need to make a V2 library.

The solution to this is assume an async spine to your program, and branch of to as much sync code as possible. It's the same lesson you learn wrangling the IO monad in Haskell, or dependencies (like databases) in OO-land.

That’s a good example. I would refactor that code to have a different signature and change all callers to fetch the relevant data.

I’d probably insist on doing that even in a blocking language where it’s not necessary. Interspersing database or network requests all through a codebase is horrible. Before you know it, someone is calling that function in a loop and you’re doing N serialised database queries. And you can’t even tell that that’s happening from the function signature. Your program just gets slow as your database grows. To say nothing of the correctness problems from issuing these queries outside of a transaction.

I worked on a project that was written like this in Python. The code was packed full of “convenient” sql queries. Some http requests took seconds to render. Turns out those request handlers were issuing thousands of individual sql queries, loading hundreds of megabytes from our database. A lot of the queries were redundant. The backend was just overfetching the same data over and over in tiny helper functions. Because of how the code was written, fixing performance required huge refactors all over the codebase.

File, network and database queries should not be spread all over “for convenience”. Fetching user data and processing it are different tasks. They generally shouldn’t be combined into a single function.

Languages with effect systems typically let callers inherit the effects of their callees (e.g., calling an async function means the caller also is async), or force them to handle the effect (e.g., spawn the async call as a task and waiting synchronously for it to finish).

Effects are just a generalization, where async/await is one particular effect.

But: The fact that an operation now does some kind of I/O, or waits for user input, or whatever else you might express using async, has an _enormous_ impact on the architecture of your program. The “virality” of async is completely a feature, because it forces you to actually deal with that change, resulting in much more robust software.

It’s “inconvenient” because the architecture of your program changed. That’s what the job is, though. Languages that don’t help you here (by hiding that you made a change with huge ramifications) make it actively harder to deliver working software, in my opinion. You get there faster, but it won’t keep working.

The problem with effects systems is that effects aren't generalisable. Every effect is unique. Sometimes they can be applied automatically and sometimes not.

You can have the compiler automatically recompile map with async to make map<async>, likewise map<pure> and map<nofail> and map<noblock> but they will not be optimal; map<async> could be parallel but isn't. And you probably want to control the amount of parallelism at each call site, which just makes it a completely different function. It's likely that you wrote map in a way that uses a loop counter and it's possible the compiler can't prove it's pure. map<abortable> is likely correct, but the compiler has absolutely no way to prove that, and other functions won't be correct if you naively make them possible to abort from outside.

It's a fundamental architecture change only if we assume async == slow (or potentially slow) which isn't always true. Otherwise you might want to run something inline that the language designer made async - such as writing a file in /tmp.

LLMs are good at refactoring. So function color mismatch is no longer a problem while explicit io helps to read and understand the code.

I'm curious about your mental model. Would it be accurate to say that the async tree is the "IO program" and the sync functions operate on pure data, or is it more complicated than that?

Yeah, more or less. I think Haskell programmers are right on this.

AboutSource Built by g1lg1l

Hackerly is an independent reader for Hacker News, built on the public HN API. Not affiliated with Y Combinator.