30 ms·
I had the same thing initially. The upside of async over a simple event loop is, in my experience, when things become less simple, and you end up with hard-to-
by tijsvd 6y ago
I had the same thing initially.
The upside of async over a simple event loop is, in my experience, when things become less simple, and you end up with hard-to-read little state machines all over the place.
With async, you can have your event loop, but the state machines are handled by the compiler. Code is like threaded code. That can be very convenient.
Threads, obviously, accomplish the same thing, and arguably more easily. But threads have a performance problem when they must interact heavily. Cross-thread communication is expensive. Single-threaded async task interaction is very cheap, comparatively (I use tokio only in single-threaded mode, as an event loop replacement; its multi-threaded scheduler performs terribly on serious I/O). I think the interaction problem is often more important than just the number of tasks.
As for the function coloring argument, I've started to see the async keyword as documentation. A non-async function is "regular logic", it must complete without blocking. An async function is a state machine; as such it must be part of a larger state machine (the event loop), and it can go down into smaller sub-state machines (i.e. call other async functions). If you make that distinction explicit, it all makes a lot of sense.
- sanxiyn 6y ago> A non-async function is "regular logic", it must complete without blocking. Maybe one can enforce this convention in the particular project, but there's no ecosystem-wide consensus on this, and in fact I don't want this to be consensus. I write blocking non-async functions every day. Why am I wrong to do so?
- josephg 6y agoThere is consensus in some ecosystems. Javascript absolutely maintains that invariant. There are (almost) no blocking functions in the javascript / node standard libraries and we work hard to keep it that way. Go maintains similar discipline at the OS syscall level. I feel like the "what color is your function" thing is incomplete. There are arguably 3 types of functions: - Functions which do all their work synchronously and return without blocking - Async functions which contain an internal state machine - Functions which block on expensive IO or long computations Mixing blocking functions and async functions in the same kernel thread leads to various performance disasters. Javascript is so meticulous about not having blocking IO in part because its basically impossible to tell from a function's signature whether it will block the thread. Lua has this problem - callback oriented lua feels like a natural fit for the language, but lots of 3rd party libraries are packed with blocking calls. Writing asyncronous lua feels like fighting a river. You have to constantly guard against calling blocking code, and most API docs won't tell you where they block.
- sanxiyn 6y agoWe are talking about Rust. There is no consensus in Rust.
- afavour 6y agoI think the OP was using examples from a different ecosystems to demonstrate how such a consensus could be reached, if people wished for it.
- chrismorgan 6y agoI have seen fs.*Sync functions being called deep in async-land more than once or twice in Node.
- josephg 6y agoThose methods were added super early (node 0.2 or something) and can’t be removed because of backwards compatibility. Many of the core node team think they should never have been added - for that exact reason.
- mleo 6y agoI believe the idea is that within a project that uses async functionality, you should only use non-async functions when the logic does not call blocking functionality. If you are mixing async functionality and synchronous functions with/blocking I would consider the latter a defect unless it is handled properly within an asynchronous context.
- lumost 6y agoI really don't understand the logic of this on a multi-threaded system. The vast majority of functions I write are best executed synchronously, the remainder is usually composed of logic wrapping heavy computations which can be executed in parallel or logic surrounding I/O which can be executed concurrently. An async system which poisons the rest of my code to force async usage doesn't seem like it will scale to code leveraging multiple libraries and will likely fail at the first lib where the author decided not to bother. The beauty of coroutines in go and Java(soon) is that the async functionality remains local to the code that can make use of it - everyone else just sees a thread-like API.
- lmm 6y agoI think you're right on one level: if your codebase is pervasively, implicitly multithreaded, then there's little value in explicitly marking yield points. But if your codebase is pervasively, implicitly multithreaded, then it's impossible to maintain without locking everywhere (and difficult even then), and combining async with (blocking) locking does not work well. In a codebase where concurrency is carefully controlled and constrained, an async system that gives you visibility into where the yield points are is very valuable: https://glyph.twistedmatrix.com/2014/02/unyielding.html https://glyph.twistedmatrix.com/2014/02/unyielding.html .
- dboreham 6y ago> An async system which poisons the rest of my code to force async usage doesn't seem like it will scale to code leveraging multiple libraries and will likely fail at the first lib where the author decided not to bother. You are quite correct. This happens.
- 6y ago
- bigiain 6y ago> I write blocking non-async functions every day. Why am I wrong to do so? This (in my opinion) not "wrong". At least not in general. There are instances where it might be more or less probematic though. It's probably problematic if you already have a bunch of async code in the codebase, because other readers of the code are likley to expect blocking functions to be async. It's maybe problematic for high performance or high scale code. Synchronous blocking functions are more likely to hit OS limits (file handles, network sockets, etc) than async code. If the code is obviously written from the ground up for high scale/performance, this is less likely to be a problem, but if it's proof of concept code that's likely to get pushed into production by over eager PMs as soon as it passes tests, it'll be worse. It's possibly problematic if done in a language/frameworks where async is idiomatic - it'd be wrong to write using blocking functions in a nodejs codebase, because you'd be breaking other people expectations when reading/understanding the code. Maybe a useful rule of thumb might be "if more than some number (perhaps 30 or 50%) of other people working on the code might think 'hang on, I'm gonna refactor this to use async', then maybe using a non-asyn blocking function was the wrong choice. That means it's _never_ the wrong choice for one person codebases. It means it's probably almost always a wrong choice in a javascript codebase. For everything else? "It depends". I'd always choose to go with "the principle of least astonishment" - do whatever other people who might be affected would expect you to wherever possible.
- simias 6y ago>Threads, obviously, accomplish the same thing, and arguably more easily. But threads have a performance problem when they must interact heavily. Cross-thread communication is expensive. I didn't know that cross "async" communication was cheaper, that does seem like a good selling point, but what exactly makes it cheaper? After all threads share the same address space, so you can just pass pointers around the same way you would within the same thread. I expected the overhead to be roughly similar. Things can get cache-expensive if the code is running on different cores, but then again using all the hardware resources available is generally something you want to do if you care about performance.
- sanxiyn 6y agoI think the point is that you can omit atomics in async case, if you are always using async in single thread mode.
- thegeomaster 6y agoNot just atomics, you'd probably need mutexes or rwlocks in a lot of scenarios, and these can become a bottleneck quickly if you don't think it through. Async has the benefit of context switches (handing off execution) being explicit, so you're fine as long as you don't leave any half-updated state before you do an async function call.
- afavour 6y agoRust also lets you avoid atomics by using structs that implement Send. Having an async function return such a struct is a lot easier to map mentally (for me, at least)
- tijsvd 6y agoIt's faster than multiple threads even on a single core. There are syscalls involved to wait, and to wake up. That doesn't matter for I/O, since syscalls are involved anyway, but it does for mutexes and condition variables. With async, handing off control to one or more other tasks is cheap (tokio around 100 ns), for threads it's more expensive (2-3 us). And of course with threads it's harder to actually run single-core, you need to dedicated a specific core which brings operational complexity.
- slavik81 6y ago> A non-async function is "regular logic", it must complete without blocking. What does 'blocking' mean? I would expect the definition of synchronous to be the exact opposite; i.e., a synchronous function must block the caller until the function has finished executing. For that matter, what is "regular logic"? The name implies there is some sort of "irregular logic" to contrast it with. I get the feeling that the writing may be unclear because the concepts are themselves not well-defined.
- bigiain 6y agoMy reading of that was 'you must write your non-async "regular logic" functions so that they cannot block.'
- deleted 6y ago[deleted]
- tijsvd 6y agoSorry for the wording, the term blocking is common in network programming. With blocking I mean waiting. For I/O to complete, for time to pass, or for another task to complete something. In event-based programming, functions must not block. Async functions may seem to block, but they don't rely because a state machine is involved.
- slavik81 6y agoThat does answer my question, but I don't really understand why the distinction is made. To the caller, a function that spends time waiting and a function that spends the same time calculating both look the same, don't they?
- ubercow13 6y agoThe difference is that the runtime can schedule something else if the blocking is async. It looks the same as sync blocking to the caller, but not the scheduler. The point of async is you can write code that looks synchronous but is actually participating in cooperative multitasking.
- wahern 6y ago> Cross-thread communication is expensive. Single-threaded async task interaction is very cheap, comparatively This all depends on how threads are implemented. If they're scheduled preemptively then communication can be expensive, relatively speaking, because of the need for locking and atomic operations. But you can also schedule cooperatively in user space, just as Tokio does when serially resuming async tasks; or as Java's Project Loom does for its new "lightweight" threads. Note that unlike JavaScript, Tokio and Project Loom can also run different tasks on different, preemptively scheduled threads. And while I don't know that much Rust, I imagine you're going to need to use either unsafe or Rc or maybe even Arc if you intend to share data between different Tokio tasks--i.e. data that doesn't fit the normal caller/callee borrow semantics. The other part of the problem is space requirements. Usually where you have preemptively scheduled threads the stack space for a thread is allocated lazily as a function is called and faults in pages via the OS' virtual memory system, much like single-thread, single-stack processes in a preemptive process OS. This means the minimum space allocation for a thread is at least 2x the page size (e.g. 4096 * 2). But many times a thread of execution only goes a couple of function calls deep, with minimal amounts of function-local (i.e. stack-allocated) data. If you have 1 thread per network connection, with hundreds of thousands or millions of connections that overhead could be significant. But this, too, is a function of the implementation. Goroutines in Go use normal heap memory for stacks, and the compiler emits code to grow and move threads automatically. Rust proponents will tell you that async functions don't require any runtime cost because the stack requirements can be calculated statically. But to calculate this statically you can't support recursive functions. And if you can statically calculate your space requirements for the hidden async state object, you could also statically calculate the stack size for a thread just the same. So really what it all comes down to isn't whether "async" is better or worse than "threads" along any of these dimensions. Abstractly, all threading implementations are async, and all async implementations effectively implement threads (i.e. a data structure that encapsulates a program counter, local automatic storage, etc). The real reason you choose one over the other is external factors. For Rust that dominate factor is interoperability with native C ABIs, particularly native stack disciplines. Because Rust can't implement much magic in the lower layers of the runtime environment while maintaining the degree of interoperability with C, C++, and other language libraries (via the C ABI) that they're committed to, they have no choice but to put most of the instrumentation into the language itself. And this necessitates the async contortions, independent of any other preferences. Contrast that with Go, where calling into C is slightly more costly because they preferred to push more of the async/thread abstraction beneath the language syntax. But perhaps what this tells us is that we should think about revisiting native stack disciplines and thread scheduling semantics. IIRC, Linux will soon get scheduler activations (i.e. ability for userland to efficiently switch execution to another specified kernel-visible thread). That's a small step in the right direction, and if it catches on more operating systems will adopt this--after having ditched them 20 years ago, ironically, before async network I/O became popular and when 1:1 thread scheduling became the preferred kernel model).
- Animats 6y agoI've been amused to watch how Rust now does a simple blocking HTTP request. A few years ago, you used the "hyper" crate, which was a convenient wrapper around the "http" crate. Now, you're supposed to use "reqwest", which is a convenient wrapper around the "hyper" crate. "Reqwest" uses the Tokio machinery, even for a blocking request. If you turn on "Trace" level logging, you can watch it start up a thread pool and go through a 35-step process, using all the async and futures machinery, to do one synchronous request. Log messages include "handshake complete, spawning background dispatcher task" and "signaled close for runtime thread (ThreadId(2))" This seems excessive.
- tijsvd 6y agoBut then that is nothing inherent to Rust, it's a choice made by a particular external library. That is probably the worst downside of async, it splits the ecosystem. Reqwest probably tries to offer both choices by hiding one behind the other (although what you describe sounds excessive - running a single task with tokio's single-threaded executor is actually quite lightweight). That split between "sync" and "async" was always there, for networking libraries. E.g. on C/C++ you find libs that run on libevent, or Boost.Asio, or other varieties, and they don't mix, so you end up spawning a seperate thread - exactly the same thing. And this is, IMHO, how we should see Tokio + async: as a more ergonomic libevent.
- skohan 6y ago> it's a choice made by a particular external library Yeah I think it points to a culture problem. In some ways because dependency management is so easy with Cargo, I think it creates the temptation to just throw in some dependencies to make something work without truly understanding the overall complexity of what you're creating. Something very similar happens in the NodeJS world. > it splits the ecosystem This is something I've really noticed with Rust: it almost seems like there are really two things: Rust, and Rust+Tokio. I'm a bit ambivalent about Tokio being baked into so many libraries: I think it's great to have as an option, but once I decide to use one library built around Tokio, it imposes a lot of constraints about how the flow of control is going to work in my program.
- skohan 6y ago> the state machines are handled by the compiler I think this is the aspect of modern approaches async which I am most ambivalent about. One of the things I have learned about programming over the past 10 years is that I, as the programmer, really want to own the flow of control of my program. Once I hand that over to some other system, usually in the name of convenience, I will start to have issues which are difficult to understand and solve. For instance, a while ago, I was working on a project which was using making heavy use of RXJava. One of my colleagues pushed a commit, and suddenly CI was failing on a unit test which passed when run locally. It turns out the problem was because the CI server was running tests with a different scheduler, so GC was happening at a different time, creating an NPE which didn't happen locally. Imo when you start to see unit tests behaving inconsistently based on a factor which is completely outside the actual code you yourself have written, this is a sign you are going down the wrong path. I also wonder how much a lot of the buzz around async actually has to do with the fact that it's a bit brain-bending to wrap your head around at first, as compared to its actual utility. I think for a lot of us as programmers, we enjoy that feeling of understanding something difficult - like when you really get recursion for the first time - and we're attracted to the idea of really fundamentally new concepts being introduced to programming. But it seems to me that async is one of those concepts which brings us farther away from actually programming the hardware, and puts a kind of middle-man between us and the CPU, and I'm not sure that has ever been a good thing.
- gr__or 6y agoI think the pain you went through with Java is not quite compareable, as the described havoc (and I do really feel your pain here) would not happen like that in Rust, for the reason that you would have to model these things more explicitly. In detail, it sounds like a cascade of implicit null-ness (aka the billion dollar mistake) and weak references. In Rust null (called None) is explicit through the Option type and the Weak type returns exactly that. More to your point, as I think you were using this as an analogy for the perils of giving up control: Rust's explicitness should entail all the semantics of your program, and hence async Rust makes you model out all the potentially-racy async interactions with Arc, Mutex, etc. The same middle-man (the borrow checker) who watches over your regular ol' sync code's memory-correctness, now expects extra constraints to be upheld for values passing through async-boundaries. And for me the whole point of Rust is that this correctness proof will do a better job than any person could, for any moderately sized program. So this is middle man you'd want between you and the CPU. That said, your async runtime could definitely do shenanigans that screw up your nicely modeled program, but that would be a bug in that specific runtime. I haven't deeply read Tokio's source and even if I did, making a qualified judgment about it is beyond me.