8 ms·
Let futures be futures
- quietbritishjim 3y agoThat is a depressingly large amount of text to write about futures without referencing or even seemingly being aware of Notes on structured concurrency, or: Go statement considered harmful [1] or the Python library Trio that resulted from that. Trio doesn't have any future or task objects at all. To be honest, it is occasionally annoying (mainly for fetching the return value of a background task) but far far less often than you'd expect. For composing operations, you are generally much better off using using Trio nurseries (or task groups as asyncio calls them – having copied the idea from Trio!) and channels for communication between them if needed. [1] https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/#conclusion https://vorpus.org/blog/notes-on-structured-concurrency-or-g...
- TwentyPosts 3y agoThank you, that article looks quite interesting and I look forward to giving it a proper read.
- withoutboats3 3y agoSorry my blog post depressed you, but I wonder if it did so nearly as much as your arrogant & rude comment did me. I am deeply aware of njs’s notes on structured concurrency & have been working on a post engaging with it. This post was not about structured concurrency & so it wasn’t relevant here.
- airstrike 3y agoMany of us loved the article but didn't have anything worthwhile to comment, so maybe one consolation is that the one rude comment is just the one that gets posted but not representative of people's overall reception to the article. You can't please everyone, as they say
- withoutboats3 3y agoThanks I shouldn’t get so angry but I’ve spent most of the past year reading essays & interviews from the 70s to try to get my head around how to respond that specific blog post (which I think makes both good and bad points). Was very offended by the suggestion I wasn’t aware of it.
- mrkeen 3y ago> even seemingly being aware of Notes on structured concurrency, or: Go statement considered harmful [1] > The popular concurrency primitives – go statements, thread spawning functions, callbacks, futures, promises, ... they're all variants on goto, in theory and in practice. [...] Therefore, like goto, they have no place in a modern high-level language. That's a really long and abrasive article ("Everyone else's stuff is GOTO, but mine isn't") to read just to get to "Wait for your return values before moving on". No mention of any kind of synchronisation primitives, liveness guarantees, determinism, channels, transactions...
- nyanpasu64 3y agoI've also seen https://trio.discourse.group/t/structured-concurrency-kickoff/55 https://trio.discourse.group/t/structured-concurrency-kickof... which comments on an edge case of this article: > His claim is that structured concurrency is the only way to make Ctrl+C handling work in a way how users naively expect it to work. > And it’s easy to see why: If the exceptions are propagated up the call tree, freely crossing the thread boundaries, the KeyboardInterrupt exception is eventually going to reach the top of the call three (main function) and exit cleanly. > However, there’s a hidden race condition involved: Imagine two threads in a scope, one gets KeyboardInterrupt and the other one raises an unrelated exception. If the latter exception bubbles up to the parent thread faster it will cancel the thread that’s processing KeyboardInterrupt and Ctrl+C would get lost. I wonder if progress has been made since 2019.
- quietbritishjim 3y ago> However, there’s a hidden race condition involved: Imagine two threads in a scope, one gets KeyboardInterrupt and the other one raises an unrelated exception. If the latter exception bubbles up to the parent thread faster it will cancel the thread that’s processing KeyboardInterrupt and Ctrl+C would get lost. I don't think there's a race condition in what you've described. Trio and that article is about single threaded async applications, but it's true that it could apply to threads in principle. But, in that case, I would expect cancellation to only be propagated at specific points, much like Trio's checkpoints [1]. You can see exactly that concept applied to threads in C#'s CancellationToken object [2]. You can only cancel a C# Task (which can be running in another thread or asynchronously in this thread) by using one of those, and the cancellation only takes effect when the Task checks it (e.g. by passing the token to a suitable async routine, or by explicitly calling CancellationToken.ThrowIfCancellationRequested()). So, in the example you gave, there's two possibilities: (1) A race happens because an exception is thrown right after a checkpoint (i.e., cancellation check) and the cancel exception gets thrown at that checkpoint - but that's not really a race (except in your application logic) because that's what you asked for. Or, (2), you get to the point of throwing an exception in one thread after cancellation has been requested from another thread (but before you got a chance to check it) - but in that case the exception will propagate to the top of the thread's call stack (if not otherwise caught) and out of the thread into your nursery-like concept. So you'll end up with a multi-exception containing the KeyboardInterrupt and the other thread's exception. In the second case, there's a "race" in that you requested cancellation of a task but it finished before it had a chance to be cancelled. But that's always a risk, even with single-threaded async applications. I think it's just fundamental to the concept of cooperative cancellation. [1] https://trio.readthedocs.io/en/stable/reference-core.html#checkpoints https://trio.readthedocs.io/en/stable/reference-core.html#ch... [2] https://learn.microsoft.com/en-us/dotnet/api/system.threading.cancellationtoken?view=net-8.0 https://learn.microsoft.com/en-us/dotnet/api/system.threadin... (Aside: Personally, when using Trio in any application, I run it in a secondary thread, and I save the main thread for just waiting for KeyboardInterrupt (or for the secondary thread to finish naturally). That way, I can propagate the cancellation to the right bit of my application, which allows shutdown in an orderly fashion. I view Trio's default KeyboardInterrupt handling as just a decent attempt for those that aren't writing a serious enough application to need really reliable handling.)
- harpiaharpyja 3y agoThat's an amazing article, thank you for sharing that link.
- mjevans 3y agoPseudo-golang with generics func yield[G AnyGenericEvenInterface](promise G) G {...} I'd have preferred the language expand yield() to take an argument which yields until the ((promise)d) variable is resolved to a value. This makes it more clear that the work is being passed off to runtime and will wait for that value. I'm still somewhat confused that promise constructions don't have any well defined start time https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe... They could start instantly or defer entirely until waited on by any of the resolution methods. It seems more complex than light threads / goroutines synchronized by communications queues and other first class language objects.
- TwentyPosts 3y agoWhen I read this, I have to think of a take I heard a few times, namely that "Rust would be better off if the language had opted for a proper effect system from the get go, instead of grafting specialized forms of it onto the language now." Does anyone have some thoughts on this? I just lack the knowledge to really evaluate that sort of take. There's a lot of discussions on whether async Rust is "good" or "bad", or the right abstraction or not, but it's pretty clear that (at this point) we're more less stuck with it (for better or worse), but that's not what I am asking about. What I am asking is whether a proper effect system would have genuinely, and legitimately improved or significantly simplified the whole state of Rust async or not, and how. A lot of the async Rust problems sound like "hard" problems, and I'm a bit skeptical that a feature like that would really reduce the "heavy lifting" you have to do.
- withoutboats3 3y agoThis is the subject of the final section of this post, effect systems specifically are referenced in the margin note.
- zozbot234 3y ago> Does anyone have some thoughts on this? Discussed most recently @ https://www.abubalay.com/blog/2024/01/14/rust-effect-lowering https://www.abubalay.com/blog/2024/01/14/rust-effect-lowerin... / https://news.ycombinator.com/item?id=39005780 https://news.ycombinator.com/item?id=39005780 It's a viable approach if you can take the time to get it right. Async rust started out with more of a limited, MVP approach, where they first did the simplest thing that would surely work, and then went on from there.
- mrkeen 3y ago> Rust would be better off if the language had opted for a proper effect system from the get go It does sound very appealing, but I'm not sure there's enough prior art to steal from, even now, but especially not when Rust was hatching. It would probably be too big a gamble. I'm checking out Koka in that space, but it's early days.
- Animats 3y agoI've had to build something like an explicit futures system for a common case that doesn't look like an async pseudo-thread. There are a large number of requests for various assets. When an asset arrives from the network, all the objects waiting for it need to be informed so they can update. Individual objects may have multiple updates pending. Updates need not arrive in order. It's a many to many relationship, not a 1-1 relationship. This is not uncommon in big-world games. I hit it in my metaverse client. It's a standard Windows feature. Windows file systems have "FindFirstChangeNotificationA", which lets a process monitor a collection of files for modifications. That has scaling issues for large numbers of files, but it's the same problem. Similar ideas exist in the database world. You'd like to be able to ask "tell me when the results of this SELECT change". That's been played with as a concept, but didn't go mainstream. In this area you can turn a manageable special case into a really hard generic problem. This is useful if you need a thesis topic, less useful if you need a working system.
- golergka 3y agoI've worked with similar systems in gamedev. To be exact, we downloaded 2d images and then managed image atlases with them — so we needed to update renderers not only when image they required was downloaded and included in an atlas, but also when an atlas with their image was rebuilt. To be honest, the system that we designed wasn't that dissimilar from promises. It was a dynamic collection of callbacks (using built-in C# events), not unlike then/catch delegates. I've worked on it about 10-12 years ago, exactly when JS world had discussions on what flavour of promises was the best, and it was one of the main sources of inspiration.
- Animats 3y agoThere's a general pattern here: * Object wants some asset. * Asset might be in memory in a cache. If so, satisfy request from cache. * If asset is not in cache, queue up request, possibly on a priority queue that gets re-prioritized. Merge requests for same asset. * When request reaches front of queue, see if the things that wanted it still want it. Remove dead requests. Discard entire request if all requestors no longer need it. * Lock against two asset fetches for same asset being performed simultaneously. * Fetch and preprocess asset * Check again that asset is still wanted. * Deliver asset * Unlock against two asset fetches for same asset. This is almost generic, except that once level of detail becomes involved, it gets less generic.
- jcranmer 3y agoWhat I like about this post is that it provides a good attack against the function coloring philosophy, which always rubbed me the wrong way. Function coloring essentially implies that there's no good reason for async and sync to be different, and it's being used as an argument that we should try to sweep the distinction between the two under the rug as much as possible, and that's just not a good idea. I also like the point that maybe(async) isn't terribly compelling. Recently, I've been looking at building an async parser, and the sync version of the interface is pretty simple: fn parse(r: &mut dyn Read) -> Result<ParsedObj> [1]. In theory, I could slap an async on it, change Read to AsyncRead, and now I have an async version. Except it's not usefully async: the consumer of the data can't do anything until everything has been fully read in. So what you want in the asynchronous version of the API is something that looks like a stream of parse events. But that API is way too much faff for anyone trying to use the API synchronously. Or is it? If you have a high-level method that distilled the stream of output events into a simple, easy-to-use answer, then you end up with something that looks maybe(async)-able [2]. Where I think the author gets it wrong, though, is in asserting that it's statefulness that causes maybe(async) to break down. State per se isn't the problem: our underlying parser above is statefully turning a stream of input data into a stream of parse events, and this could sometimes work well with maybe(async). The problem arises when you're multiplexing many streams at once. In synchronous code, there's just no way to do that kind of multiplexing (as pointed out earlier in the blog post), while it's a pretty key design feature of async code. The trouble really comes in when you have a system whose outer interface is a 1:1 stream interface, but which internally needs to go through a 1:N piece and an N:1 piece. It looks like it could be maybe(async)-able from the outside, but its implementation is hopelessly not maybe(async)-able. [1] Honestly, a lot of parsers go a step further and just read in the input buffer as a &[u8] and rely on zero-copy, but that interface turns out to be even less useful in an async context. [2] But the faff remains if you can't resort to one of the pre-canned methods. How much of an issue that ends up being is left to the reader to decide.
- withoutboats3 3y agoYou’re right, state isn’t really what I meant. I meant exactly the sort of concurrent collating you allude to.
- deleted 3y ago[deleted]
- airstrike 3y ago> If you were a language designer of some renown, you might convince a large and wealthy technology company to fund your work on a new language which isn’t so beholden to C runtime, especially if you had a sterling reputation as a systems engineer with a deep knowledge of C and UNIX and could leverage that (and the reputation of the company) to get rapid adoption of your language. Having achieved such an influential position, you might introduce a new paradigm, like stackful coroutines or effect handlers, liberating programmers from the false choice between threads and futures. If Liebniz is right that we live in the best of all possible worlds, surely this is what you would do with that once in a generation opportunity. This seems... a bit specific? Was it a reference to a specific new language or was that more of a poignant wish?
- wging 3y agoIt's Go. The description fits well, and it's pretty much confirmed by the Rob Pike quote in the sidebar. https://www.youtube.com/watch?v=uwajp0g-bY4 https://www.youtube.com/watch?v=uwajp0g-bY4
- opnitro 3y agoI'm guessing it's a reference to Go
- zozbot234 3y ago> This seems... a bit specific? Was it a reference to a specific new language or was that more of a poignant wish? Not very new, seeing as Erlang was open sourced by Ericsson in 1998. (The language itself is from 1986 but was proprietary up to that point.)
- Georgelemental 3y agoFrom the following paragraph: > You might go do that, in a less than optimal world.
- kwkelly 3y agoThis is tangential, but was Voltaire mocking Leibniz in Candide?
- amluto 3y ago> Rust chose this approach to get zero-cost FFI to the enormous amounts of existing C and C++ code written using that model, and because the C runtime is the shared minimum of all mainstream platforms. But this runtime model is incompatible with stackful coroutines, so Rust needed to introduce a stackless coroutine mechanism instead. Can anyone (withoutboats?) elaborate on this? If “stackful” coroutines means functions with an ordinary-ish stack that can yield, then those stacks are just memory, and you can take references to them. Heck, you can do this, slowly and painfully, in C, with setcontext. And you can mostly do FFI with a degree of care about stack sizes. You can even yield across FFI boundaries. In some sense, the coroutine objects implicitly created by Rust and C++ are really just stacks minus the hardware stack pointer part. Also, I found this amusing as a kernel programmer: > the implementations of each of these things are completely different. The code that runs when you spawn an async task is nothing like spawning a thread, and the definition and implementation (for example) of an async lock is very different from a blocking lock: usually they will use an atomics-based lock under the hood with the addition of a queue of tasks that are waiting for the lock. Instead of blocking the thread, they put this task into that queue and yield; when the lock is freed, they wake the first task in the queue to allow it to take the lock again. When blocking a thread, Linux (and most conventional operating systems) put the thread into a queue, mark the thread as non-runnable, and yield, and when the thing they’re waiting for is ready, they mark one or more queued threads runnable so the executor, I mean scheduler, will run them so they can try again :) Most OSes do this a lot more slowly than can be done in userspace.
- zozbot234 3y ago> Can anyone (withoutboats?) elaborate on this? If “stackful” coroutines means functions with an ordinary-ish stack that can yield, then those stacks are just memory, and you can take references to them. See http://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p1364r0.pdf http://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p136... for an in-depth analysis. The short version is that stackful fibers turn out to be a terrible choice. The author (Gor Nishanov, for the C++ ISO committee) explicitly says that they should not be used - this is even in spite of the fact that Windows API explicitly supports fibers as part of the programming interface, at a far deeper level than just POSIX setcontext.
- deleted 3y ago[deleted]
- n1c00o 3y agoReally good critique of the current state of handling, but my opinion is still that the language should not itself makes any design decision on async. I believe everything should just keep being a set of instructions that are executed accordingly to the machine, and that's it. It helps ensuring the language semantics are still relevant on newer execution machines. Indeed, async v. sync should be a matter of the application and the application only.
- Rusky 3y agoThis is like arguing for goto over structured concurrency. Async/await is a structured control flow primitive that lets you compose functions in more flexible ways. It will stay relevant as long as we still use things like functions, branching, and looping.
- yazaddaruvala 3y agoToo often people think the problem between sync and async is a red/blue coloring within the programming language. The problem really is that every OS already has red/blue syscalls. Everything else about “why can’t I mix these functions” is a direct result of that broken consistency exposed by the OS. As such I’m not sure there is a zero-cost abstraction (stackful coroutines is close) that any programming language can build to improve the situation. Meanwhile, the trend is clear - every OS has adopted (and is adopting more) non-blocking syscalls because they are direly needed. The only benefit blocking syscalls offer is sugar to improve syscall ergonomics. I think if more people talked about it this way it would become clear that adding a “blocking” tag to the syscalls and bubble that tag up the call stack is the right next step to depreciating those legacy OS APIs. I don’t mean to say we should accept poor ergonomics but adding the blocking tag is a great first step to 1. Reminding people of the problem. 2. Identifying areas where research is needed to improve ergonomics and replace the existing “blocking” tag with minimal downsides.
- karmakaze 3y agoThe post makes a good point, albeit in a long-winded way. My short summary would be in languages that result in function-coloring due to async/await, using Future/Promise directly avoids the coloring and the ecosystem is better off. Places where async/await are ok are JS (because both the browser and node/deno) are for the most part in an async context, and Go since everything's a goroutine hence all one color. Languages such as Java have worked well with Future/CompletionStage and doesn't really need the convenience of writing async/await which translates the rest of the program after await into a hidden 'rest of program' callback. That async/await syntactic convenience is not worth the complexities added to libraries. Rust including async/await was a poor 'cargo cult' choice. I would welcome a Rust fork/ecosystem without it.
- withoutboats3 3y agoThat’s absolutely not a summary of my blog post, since I completely disagree with it. I think async/await is great and avoiding it for some reason would do nothing to mitigate the function coloring problem.
- karmakaze 3y agoSorry I meant to say my summary of the situation with asymc/await not a TL;DR of the post.
- camgunz 3y agoTFA is pretty good. I would shrink it to: If you're a language/platform like Swift or Rust (see also Erlang or OCaml) you're gonna end up with some kind of userspace task scheduler because OSes don't give you enough control over threads. If you're a systems language you're gonna use stackless coroutines because speed/memory and C interop. In Rust, you use their task scheduler via async/await, but sadly the ergonomics aren't wonderful and there's now weirdo problems like what reactor/engine/whatever they standardize on etc. The interesting and good stuff will be built on top of async/await, probably shouldn't have touted it as the interface. My hypothesis here is that something like concurrency/parallelism exists on two levels. You've gotta get the underlying implementation right, and you have to expose a mental model and affordances for using it. The problem is that languages that are good at the first thing are usually real bad at the second thing. More broadly I think this is a trilemma--pick any 2 for your language: - small - can implement performant concurrency/parallelism - can express a high-level mental model of concurrency/parallelism No one should be surprised that Rust will throw "small" overboard here. I think the only question is how many models will they support? Will they do CSP AND actors (they're different!)? Will there be some kind of OpenMP type thing? Will there be new keywords or--shudder--new SYNTAX? --- I feel like I have to say Go's solution to this is bold and elegant. Giving up straightforward C interop was pretty gutsy, and the affordances for working with its task scheduling system are familiar and intuitive. I want to stress this (because TFA goes way out of its way to be a little shitty w/ Go): this worked absolutely great. Tons of good software is written in Go. It is super effective. It spent its innovation tokens on experience and tooling and that was really successful (i.e. type systems aren't the only way to fight data races). There were exactly zero debates about stuff like await syntax or what engine to use/standardize. In the time it took Rust to ship async/await, probably 7 billion actually useful Go programs were shipped. Just because someone else's values aren't yours doesn't mean they're the wrong values. I think it's cool that people like Boats are being really thoughtful about how to do this and sharing their thoughts and processes out in the open. I just think there's room in this crazy world for at least two points of view, and that we could do with a little less sneering.
- zozbot234 3y ago> Will they do CSP AND actors (they're different!)? Will there be some kind of OpenMP type thing? Rust builds support for all of these models based on it's basic underlying "shared ^ mutable" and "Send/Sync" safety guarantees. It should be quite clear based on this whether you're sharing access to some read-only data, staying within a single-threaded context with mutability, or relying on some sort of synchronization primitive (be it atomic data, locks, r/w mutexes etc). The fact that we understand some uses of these facilities as involving patterns such as "CSP" or "Actors" is quite beside the point.
- throwawaymaths 3y agoIt seems to me that there are two huge (cosmetic? branding?) problems with async/await 1. The words are completely awful. There's nothing "not synchronous" about an async function. Maybe at best "not synchronized". 2. The idea of pretending async functions are still "functions" is kinda terrible. This is really a huge problem in say python where the architecture (iirc) is via iterator/yield, so your function "actually" has multiple outputs. Maybe a radical idea in PL design would be to actually make "async functions" be some sort of other thing in the same sense that, for example, pascal had "procs" and "functions" You could easily solve the coloring problem by creating some sort of builtin that does an immediate, blocking execution of the function by compiling out all the suspend sites, or keeps them "in place" in the case where you want to keep things as if they are futures.
- hn_throwaway_99 3y ago> The idea of pretending async functions are still "functions" is kinda terrible This makes no sense to me. An async function is just syntactic sugar for a (normal in every way) function that returns a promise.
- zbentley 3y agoNot necessarily. That's (mostly) true in e.g. JavaScript, but is not true in Rust. My novice understanding of the Rust model is that an async function is compiled down to a state machine and a set of functions which execute parts of the async function's code and then manipulate the state machine. Those manipulations have two broad categories of side effects: they can wake up/be observed by pollers, and can change the state of later parts of the task corresponding to an invocation of the original async fn (e.g. by setting variables for subsequent functions in the set--code on the other side of an internal await point that uses the result of the await). In other words, async functions in some languages are more than just sugar for "delegate to event loop and return promise resolved by delegate completion".
- withoutboats3 3y agoI don’t really understand what you mean about side effects but async functions do indeed just return futures just like JavaScript async functions return promises - rust futures just are compiled into state machines in a way JavaScript promises are not.
- ismailmaj 3y agoWhat about higher order library functions, since calling a green function from a red context is bad and we have no way to guarantee that a function is blue and not green, does this mean the function needs to be unsafe? It would be nice if the conversion from red to green would be done implicitly by the language where there is no ambient executor available, but I'm not sure what would be the implications for intra-task concurrency.
- zozbot234 3y agoPart of the point of effect systems is precisely to allow HOF's to deal with these "color" differences in a consistent way. Similar to how in Rust, an error return is modeled as just an instance of some Option<> or Result<> type instead of some bespoke 'exception', so all HOF's can seamlessly manage the 'exceptional' case.
- bluepnume 3y agoThis might be a silly question, but what would be the downside of a single threaded language similar to javascript, but where every function is just 'async capable' and can always be non-blocking, with no special syntax like async/await? So every time you call foo(); you anticipate that it might resolve immediately, or it might take some time, but it won't block any other function (unless it actually does some cpu bound operation) And any time foo(); does something asynchronous, all of its callers, and its callers callers, become implicitly asynchronous too. Of course you would have to have some primitive for when you actually want to do several things concurrently within the scope of a single function rather than blocking that function, but that doesn't sound too bad and in JavaScript you effectively need to use Promise.all most of the time anyway. I'm sure there's some major downside I'm missing -- but what is it?
- withoutboats3 3y agoThis is what I describe as "stackful coroutines" in the final section of the post.
- catern 3y agoIs stackfulness required for what the grandparent describes? It seems possible to do this stacklessly: all functions implicitly compile down to a state machine, but futures are never visible.
- withoutboats3 3y agoYou need stackful coroutines or continuations (which become really just a kind of growable stack) if you want recursive functions. This is a limitation of Rust’s design.
- TylerE 3y agoGenerally speaking async code in a language that isn't ground up designed around that is far slower, because the language ends up using some relatively expensive mechanism like mutexs. In most languages making 90% of your code 90% slower but "everything async" isn't really a worthwhile general trade off. Even is your language run time is fully async, it's really hard to do anything useful without touching C/C++/the kernel/system libraries, and as soon as you do that all those guarantees go out the window, because you cannot rewrite the world in $newlang.
- kragen 3y agoi think that if someone is really interested in this topic, they'd do well to read mark s. miller's doctoral dissertation on it, 'robust composition': http://www.erights.org/talks/thesis/ http://www.erights.org/talks/thesis/ this is relevant because miller explains why he designed promises the way he did, although since he wrote the dissertation in 02006, he couldn't yet explain how he adapted them for incorporation into the javascript standard. following his dissertation, he was on the ecmascript committee for a number of years, which is how we got e-style promises in javascript, although obviously that also involved the committee as a whole being convinced that they were a good idea. (much of the dissertation is actually about object-capability security, but chapters 13–19 are about promises.) the great benefit of promises is that you eliminate data races, where one procedure is correct only if a particular piece of mutable data remains unchanged, while another concurrently runnable procedure changes it, so the interleaving of their accesses can result in a failure. consequently, you don't need locks or queues to communicate between different concurrent procedures; you can just use a shared data structure with no locking. this is because promises are an alternative to threads. if you're familiar with async rust, you will note three glaring problems with this: 1. rust eliminates data races through the type system, so the great benefit of promises doesn't exist in this context 2. tokio runs multiple tasks concurrently, so if you do have data races, promises won't save you 3. so you do in fact use channels and locks in async rust so i am puzzled about why you would ever want to use promises in rust, and i guess this post is an attempt to answer that question. it explains why people have this bizarre misconception that promises are just an efficiency hack, because in rust that's all they are, and those people have never used promises in javascript or e or the other children of e such as spritely goblins. this bears repeating. before reading this post i didn't realize this, but rust async/await is just an efficiency hack. even the section of this post entitled 'i don't want fast threads, i want futures' never brings up, as far as i can tell, any correctness or flexibility advantages for async rust, only efficiency advantages ('to multiplex arbitrarily many perfectly-sized tasks on a single thread', 'much better performance characteristics' etc.) there's brief lip service to concision ('concurrency-related boilerplate about spawning threads', which i don't think actually exists) before returning to the main theme of performance. then it proposes some truly astoundingly terrible ideas about automatically introducing race conditions if you await with a lock held, in the name of being 'even more optimal', at which point we're well into 'mongodb is web scale. if /dev/null is web scale i will use it' territory a terminological quibble: i wish people wouldn't refer to promises as 'futures'; a future is, or anyway originally was, a different approach to the concurrency problem, one where reading from a perfectly normal variable would block your thread until that variable's value had been computed (allowing other threads to run). this approach does not have the advantages of promises. promises are what async rust implemented, just under the name of 'futures'. really, though, confusing terminology seems to be the least of the problems with rust's implementation of the paradigm
- comex 3y ago> "I don’t want fast threads, I want futures" I don't really understand this point. In a hypothetical world where (a) threads are cheap, and (b) threads support some form of cancellation (with an ecosystem that expects this and is built around it), then you don't need "intra-task concurrency" as a fundamental primitive. You can achieve the exact same semantics by just having a helper function that, given N closures representing work to perform, spawns a new thread to run each one, and waits for any/all of them to finish, while making sure to cancel all those threads if it itself is cancelled. (Or if you want to manually manage future objects for more control, you can do that too. But a future just represents work being done on a thread.) Compare to Go. Go has cheap threads. Boats criticizes Go's "threads, locks and channels", but the fact that you have to interact with those primitives directly, rather than using a helper function as described, is really a limitation of Go's type system, not an inherent result of basing concurrency on cheap threads. With one exception: Go threads do not support cancellation. Cancellation is extremely important! Without it, you have to manually pass down timeouts and other forms of cancellation into each function that performs I/O, and it becomes impossible to write that kind of generic helper function. And of course, Rust has to fit into an existing thread ecosystem that does not meaningfully support cancellation, and does not make threads cheap. So the tradeoff for Rust remains. Still, I don't see this anywhere near the way boats sees it. In my ideal world, if I didn't care about performance or compatibility, I wouldn't want "a language in which all functions are coroutines, meaning that all functions can yield." I don't think yielding needs to be visible to the user in any meaningful way; why does it need to be? It can be a hidden implementation detail, just like it is with green threads or OS threads. But I'd add on a concept of cancellation. …In short, I think my ideal model is something like Trio, but I haven't had the opportunity to actually use Trio. I hope that changes. (edit: and I'm looking forward to when boats does respond to the Trio blog post.)
- nyanpasu64 3y agoDoes synchronous Rust (with no runtime and no tokio) have a cross-platform way for a parent thread (eg. a GUI) to wait for messages either from user input or a child thread (eg. audio device changed), and/or a child thread to wait for messages either from IO (eg. audio buffers ready to fill) or other sources (eg. audio timeout or cancellation as you mentioned)? In C++ on Windows you'd use WaitForMultipleObjects, on Unix you'd use poll on hardware/event file descriptors, Crossbeam channels have a select macro but I'm not sure if it's as real-time as WaitForMultipleObjects (officially recommended for WASAPI) or poll (officially recommended for ALSA), and it probably doesn't work outside of channels?
- zbentley 3y agoTerrific post. I think several of the more disagreeable commenters here are engaging with it piecemeal and projecting stronger opinions onto it than it actually has. I interpreted it as more of a review/analysis/set of musings by a qualified expert in an area than a bunch of decisive hot takes. It definitely makes me wonder if the original sin of Rust and many of the other colored-function-based concurrency systems was limiting the enforcement of color-can-only-call-color to only async functions. Would a language that disallows calls back from async functions into blocking functions make sense? Probably not. But what if we split "blocking" into two categories: functions that provably blocked for constant time (or even constant-less-than-X time; simple systems like BPF can verify that already, so I'd assume it's possible to get more nuanced here with effort) versus functions that did not (including I/O, while-true-with-non-statically-verifiable-condition)? If we disallowed calls from async functions into functions of the latter category but not the former, would that be ergonomic/accessible for programmers? (A minor quibble/criticism of an otherwise great post: I think the discussion of "blocking functions" should get a crisp definition up front, qualifying that "blocking" in this case means undesirably long or potentially indeterminate blocking of executor threads, so readers don't get confused and wonder whether the implication is that any synchronous function call is inherently--rather than just potentially--toxic to good async behavior). Continuing to think about the idea of "async code can't call 'bad blocking' code": I suspect that one of the reasons this isn't widespread is that it really does require compiler/runtime validation that code down a call stack is good/bad. Erlang almost got there, but (just like Boats points out happened with Rust) the C FFI chimera stopped them in the home stretch and they settled for asking users to tag dirty vs. clean NIFs. But maybe compiler awareness of call path blocking risk is advanced enough today that such a system could be implemented? It definitely wouldn't be simple (...he said, as if he were anything but a rank amateur when it came to understanding compilers). It definitely would solve the mutex-held-across-await-point class of problems. Two other points that stood out to me that I thought were worth mentioning: 1. The rejoinder to the blocking-reqwest-vs-ureq situation is well put. Honestly, Boats could have gone further here: I think that a large proportion of the complaints/reluctance around using synchronous APIs that wrap block_on or whatever are fundamentally aesthetic/emotional objections arising from people's bad vibes around including or separately instantiating an async runtime inside a library just to provide a blocking API. In practice, the compile overhead is rarely severe and nearly always paid extremely infrequently, and the runtime overhead in time/memory is negligible in release builds. Supporting the emotional resistance here is, I think, a lot of user ignorance around the practical differences between, say, how a current_thread Tokio entry point runs Rust futures and how a libuv loop or a boost::asio context or whatnot run futures in JS/C++/etc. While Rust and Tokio aren't magic (and while I would love more crates to be async-to-syncable with pollster alone), I think that folks often project assumptions about heaviness/performance from other systems onto parts of the Rust ecosystem where they don't hold as true. 2. The Golang callout is fair (and was hopefully cathartic to write), but in addition to being punchy I think there's a lesson to be learned there, albeit a bitter one: Go is widely considered to have wildly succeeded at/"solved" the ergonomics of concurrent colored-function programming for a bunch of technical reasons that Boats calls out ("this is for network servers, C interop's not priority 1", growable stacks, GC, nonzero cost scheduler, etc.), but I think it's also important to note that the problem was addressed to completion in the initial version of the language. Rust's async functionality was not; it was added later. And while it was added carefully, I think that the fact that it was not part of the first coat of paint inevitably increases the severity of some of its pain points, technical constraints aside. I think the lesson that features in the first draft are often the most beloved/powerful holds true in other ways here: consider the trait system and borrow checker, two of the most important features of Rust that were present in v0 (or at least very early on? I don't honestly know) compared to Go's slow and acrimonious adoption of generics in 1.18. I guess another way of putting the lesson is "get it right the first time", alternatively phrased as "hindsight is 20/20". Or maybe I'm extrapolating patterns from unrelated technical phenomena, who knows?
- mwcampbell 3y agoBoats, if you're still reading this: I don't have anything of technical substance to add to the discussion, but thanks for repeatedly explaining the rationale behind async Rust. And thanks for doing what you believed you had to do to make Rust a successful language, and especially, as you explained in an earlier post, for implementing the big feature that the companies who were most willing to fund Rust development wanted. I hope this can at least somewhat counterbalance all of the rants, complaints, and uninformed but overly confident comments. Edit to add: I haven't personally done much work with async Rust yet, but the thing that appeals to me about async Rust is intra-task concurrency. So I'm not just trying to counterbalance the complainers and make you feel better; I really believe that Rust's futures and async/await syntax were not a mistake.
- leighleighleigh 3y agoThis is written so thoughtfully - thank you to the author! :)