Blocking code is a leaky abstraction

(notgull.net)

29 points | by zdw 7 hours ago ago

34 comments

  • accelbred an hour ago

    I have the opposite experience, working in embedded (C, not Rust...). Building a synchronous API on top of an async one is hell, and making a blocking API asynchronous is easy.

    If you want blocking code to run asynchronously, just run it on another task. I can write an api that queues up the action for the other thread to take, and some functions to check current state. Its easy.

    To build a blocking API on top of an async one, I now need a lot of cross thread synchronization. For example, nimBLE provides an async bluetooth interface, but I needed a sync one. I ended up having my API calls block waiting for a series of FreeRTOS task notifications from the code executing asynchronously in nimBLE's bluetooth task. This was a mess of thousands of lines of BLE handling code that involved messaging between the threads. Each error condition needed to be manually verified that it sends an error notification. If a later step does not execute, either through library bug or us missing an error condition, then we are deadlocked. If the main thread continues because we expect no more async work but one of the async functions are called, we will be accessing invalid memory, causing who knows what to happen, and maybe corrupting the other task's stack. If any notification sending point is missed in the code, we deadlock.

    • hackit2 44 minutes ago

      The trick with synchronization/parallelism systems is to only communicate over a known yield point this is normally done via queues. It is the only way you get deterministic behavior from your sub-systems or multi-threaded environments.

  • klodolph 4 hours ago

    What is the difference between code that blocks waiting for I/O and code that performs a lengthy computation? To the runtime or scheduler, these are very different. But to the caller, maybe it does not matter why the code takes a long time to return, only that it does.

    Async only solves one of these two cases.

    I’d like to draw an analogy here to ⊥ “bottom” in Haskell. It’s used to represent a computation that does not return a value. Why doesn’t it return a value? Maybe because it throws an exception (and bubbles up the stack), or maybe because it’s in an infinite loop, or maybe it’s just in a very long computation that doesn’t terminate by the time the user gets frustrated and interrupts the program. From a certain perspective, sometimes you don’t care why ⊥ doesn’t return, you just care that it doesn’t return.

    Same is often true for blocking calls. You often don’t care whether a call is slow because of I/O or whether it is slow because of a long-running computation. Often, you just care whether it is slow or how slow it is.

    (And obviously, sometimes you do care about the difference. I just think that the “blocking code is a leaky abstraction” is irreparably faulty, as an argument.)

    • akoboldfrying 3 hours ago

      >What is the difference between code that blocks waiting for I/O and code that performs a lengthy computation?

      To the caller of that specific function, nothing. To the entire program, the difference is that other, useful CPU work can be done in the meantime.

      This might not matter at all, or it might be the difference between usable and impractically slow software.

      • klodolph 2 hours ago

        Yes, you understand exactly the point I’m making.

    • rileymat2 4 hours ago

      Async is worse than leaky it often goes viral because many other parts need to be async to call it.

      • gwbas1c 2 hours ago

        It's better to think of "async" as indicating that a code will do something that blocks, and we're allowing our process to manage its blocking (via Futures) instead of the operating system (via a context switch mid-thread.)

        I would argue a few things:

        First: You need to be aware, in your program, of when you need to get data outside of your process. This is, fundamentally, a blocking operation. If your minor refactor / bugfix means that you need to add "async" a long way up the stack, does this mean that you goofed on assuming that some kind of routine could work only with data in RAM?

        Instead: A non-async function should be something that you are confident will only work with the data that you have in RAM, or only perform CPU-bound operations. Any time you're writing a function that could get data from out of process, make it async.

        • hackit2 28 minutes ago

          You could make the proposition that sequential code is inherently asynchronous in modern operating systems, because the kernel inherently abstracts the handling of blocking/unblocking your process.

  • rtpg 3 hours ago

    "asynchronous code does not require the rest of your code to be synchronous" fails the smell test.

    Many APIs are shipping async-only. You can't stick calls to those APIs in the middle of your sync code in many cases (unless you have an event loop implementation that is re-entrant). So... you gotta convert your sync code to async code.

    I am curious as to what a principled definition of a "blocking" function would be. I suppose it's a call to something that ends up calling `select` or the like. Simply saying "this code needs to wait for something" is not specific enough IMO (sometimes we're just waiting for stuff to move from RAM to registers, is that blocking?), but I'm sure someone has a clear difference.

    If you care about this problem enough, then you probably want to start advocating for effect system-like annotations for Rust. Given how Rust has great tooling already in the language for ownership, it feels like a bit of tweaking could get you effects (if you could survive the eternal RFC process)

    • vlovich123 25 minutes ago

      On the other hand, it might flag a code smell to you in that you’re injecting I/O into a code path that had no I/O before which forces you to either make annotations making that explicit for the future or to realize that that I/O call could be problematic. There’s something nice about knowing whether or not functions you’re invoking have I/O as a side effect.

    • PaulDavisThe1st 3 hours ago

      > I am curious as to what a principled definition of a "blocking" function would be.

      It's one where the OS puts your (kernel) thread/task to sleep and then (probably) context switches to another thread/task (possibly of another process), before eventually resuming execution of yours after some condition has been satisfied (could be I/O of some time, could be a deliberate wait, could be several other things).

      • leni536 2 hours ago

        OS threads can be put to sleep for many reasons, or they can be preempted for no explicit reason at all.

        On such reason could be accessing memory that is currently not paged in. This could be memory in the rext section, memory mapped from the executable you are running.

        I doubt that you would want to include such context switches in your "blocking" definition, as it makes every function blocking, rendering the taxonomy useless.

      • rtpg 2 hours ago

        This feels mostly right to me. I think that you get into interesting things in the margins (is a memory read blocking? No, except when it is because it's reading from a memory-mapped file!) that make this definition not 100% production ready.

        But ultimately if everyone in the stack agrees enough on a definition of blocking, then you can apply annotations and have those propagate.

        • kevincox 23 minutes ago

          > except when it is because it's reading from a memory-mapped file

          Where "memory mapped file" includes your program executable. Or any memory if you have swap space available.

          And any operations can be "blocking" if your thread is preempted which can happen at basically any point.

          So yes, everything is blocking. It is just shades of grey.

      • o11c 2 hours ago

        For reference, there are 2 lists of such functions in signal(7).

        The first list is for syscalls that obey `SA_RESTART` - generally, these are operations on single objects - read, lock, etc.

        The second list is for syscalls that don't restart - generally, these look for an event on any of a set of objects - pause, select, etc.

      • akoboldfrying 3 hours ago

        That seems a necessary but not sufficient condition, since a pre-emptively multitasking OS may do this after almost any instruction.

        Not only that, but any OS with virtual memory will almost certainly context-switch on a hard page fault (and perhaps even on a soft page fault, I don't know). So it would seem that teading memory is sufficient to be "blocking", by your criterion.

        • PaulDavisThe1st 2 hours ago

          1) I deliberately left it open to including page faults by design. If you do not understand that reading memory might lead to your process blocking, you have much to learn as a programmer. However, I would concede that that this is not really part of the normal meaning of "blocking", despite the result being very similar.

          2) I did not intend to include preemption. I should reword it to make it clear that the "condition" must be something of the process' own making (e.g. reading from a file or a socket) rather than simply an arbitrary decision by the kernel.

  • PaulDavisThe1st 5 hours ago

    Sometimes, things need to leak.

    For example, the fact that code actually takes time to execute ... this is an abstraction that should almost certainly leak.

    The fact that some data you want is not currently available ... whether you want to obscure that a wait is required or not is up for debate and may be context dependent.

    "I want to always behave as though I never have to block" is a perfectly fine to thing to say.

    "Nobody should ever have to care about needing to block" is not.

  • akira2501 2 hours ago

    I can't buy this premise. The leaky abstraction is that your selected libraries are attempting to be as efficient and as fast as possible while also trying to be fully general. As a result you get the full abstraction layer necessary to do this, which, as you've noted, for any one project is too much.

    The complication you seem to be experiencing is other projects have come to rely on these libraries and their particular abstractions and so you've actually got a dependency management problem due to the design of the "core" libraries here or due to a lack of language features to /constrain/ all of them. A critical point of abstraction totally lacking in this ecosystem.

    In any case if you look at any language that has contexts that can be externally canceled you should realize this actual solution must be implemented at a higher level than these "async primitives."

  • nemetroid 5 hours ago

    The author seems to reinforce the original point, that the async paradigm ends up working best in an all-or-nothing deal. Whether the difficulties in interfacing the paradigms should be attributed to the blocking part or the async part does not really matter for the practical result: if calling blocking code from async code is awkward and your project's main design is async, you're going to end up wanting to rewrite blocking code as async code.

  • plorkyeran 3 hours ago

    The author has a very strange understanding of the idea of a "leaky abstraction". AppKit requiring you to call methods on the main thread is just not an abstraction at all, leaky or otherwise.

  • gwbas1c 2 hours ago

    > Dependency Dog: If you want to see a good example of a leaky abstraction, consider AppKit. Not only is AppKit thread-unsafe to the point where many functions can only safely be called on the main() thread, it forces you into Apple’s terrifying Objective-C model. Basically any program that wants to have working GUI on macOS now needs to interface in Apple’s way, with basically no alternatives.

    Uhm, every OS that I've developed a GUI for has a similar limitation. The "only call this thing from this thread" is a well-known thread safety pattern. It might not be the pattern that this author prefers, but it is an industry standard pattern for UI.

    • koito17 an hour ago

      I agree with your comment. Every UI framework I have used (AppKit, Swing, JavaFX, etc.) is not thread-safe. It requires all UI code in the main thread or, in the case of JavaFX, a dedicated UI thread (not necessarily the main thread, but it must be a single thread where all UI operations occur).

  • skybrian 2 hours ago

    This is the opposite in JavaScript (and similar single-threaded languages) where sync code is atomic and normally can't do I/O.

  • paulyy_y 5 hours ago

    Or just use fibers and avoid icky futures.

    • aphantastic 5 hours ago

      Doesn’t change the core issue, which is that if you want to do multiple operations simultaneously that take time to execute, you’re going to need to write stuff to handle that which you wouldn’t otherwise. Whether it’s channel piping or async awaiting is semantics.

      • PaulDavisThe1st 2 hours ago

        It's not just semantics. With synchronous code, the blocking aspects are invisible in when reading code:

             size_t rcnt = read (fd, buf, sizeof (buf)); /* nobody knows if we will block */
        
        With some async code, the fact that there will be a wait and then a continuation is visible from just reading the code.
  • wavemode 3 hours ago

    > Frankly, I don’t think async code is leaky at all, and the ways that it does leak are largely due to library problems.

    Huh? One could argue the exact same in opposite direction - blocking code isn't leaky at all, just wrap it in a Future. And if doing that is difficult because of !Send data, then those are just "library problems". You could always just use a threadsafe library instead, right?

  • samatman 2 hours ago

    There's a conflation going on here which is a blocker, shall we say, for understanding the two sides of this coin. The antonym of async is not blocking, it is synchronous. The antonym of blocking I/O is evented I/O.

    Stackless coroutines à la Rust's async are a perfectly reasonable control flow primitive to use in 'blocking' contexts, which properly aren't blocking at all since they aren't doing I/O, I'm thinking of generator patterns here.

    It's also entirely possible, and not even difficult, to use evented I/O in synchronous function-calls-function style code. You poll. That's the whole deal, somewhere in the tick, you check for new data, if it's there, you eat it. This does not require async.

    I found the "what if the real abstraction leak was the friends we made along the way" portion of the argument. Unconvincing. When people complain about async leaking, what they mean is that they would like to be able to use libraries without have to start a dummy tokio in the background. That's what's getting peas in the mashed potatoes, that's what annoys people.

    "Abstraction leak" is a sort of idiomatic way to complain about this. Focusing on the slogan does nothing to address the substance, and "have you considered... that, a function, might take a while, and perhaps that, that is an 'abstraction leak'? eh, eh?" is. Well it probably seemed clever when it was being drafted.

  • ianhooman 2 hours ago

    Godël covers how it’s leaky abstraction all the way down. If you consider a specific chunk of useful async code it still doesn’t compose enough of system to do much of use; it can’t answer many questions alone or any about itself. We never just have Map() but other functions. Its constant composition of symbolic logic we vaguely agree means something relative to an electrical system of specific properties.

    A useful system is “blocked” on continuous development of that system.

    These posts are too specific to code, miss the forest for a tree. A more generalized pedantry (like electrical state of systems rather than dozens of syntax sugars to simulate the same old Turing machine) and more frequent spot checks for stability and correctness relative to the ground truth would be great.

    Way too much circumlocution around code. Map is not the terrain. I for one am having blast training a state generator that acts like a REPL and (for example) infers how to generate Firefox from a model of its code (among other things).

    Excited about the potential to peel away layers of abstraction by generalizing the code with automated assistants.

  • Spivak 4 hours ago

    It seems like the magic for the author is in the annotation. There's still two kinds of functions "fearlessly run in the UI thread" and "must be run in a worker" and async is great in that it greatly expands the number of functions in that first pool but it's effectively the same thing as the [blocking] annotation but the other way and not totally complete. Because there are async-safe but not async functions.

    So to me it seems like it all leaks because in all cases what's desired is some external signal about how the function behaves. A "badly written" async function can stall the loop and that isn't conveyed only by the async tag. It's rare to do this but you can and there might even be times where this makes sense[1]. But it happens that by convention async and ui-thread-safe line up but there's a secret other axis that async is getting mixed up in.

    [1] Say running a separate event loop in a background thread used for CPU intensive tasks that occasionally perform IO and you want to make the most use of your thread's time slice.

  • worik 4 hours ago

    Asynchronous code is the bees knees

    Async/await is a horrible fit for Rust.

    > Some of this criticism is valid. async code is a little hard to wrap your head around, but that’s true with many other concepts in Rust, like the borrow checker and the weekly blood sacrifices. Many popular async libraries are explicitly tied to heavyweight crates like tokio and futures, which aren’t good picks for many types of programs. There are also a lot of language features that need to be released for async to be used without an annoying amount of Boxing dynamic objects.

    Yes. So don't do it

    It is entirely possible to write asynchronous code without using asyc/await.

    async/await is a fun paradigm for garbage collected systems. It is a horrible mess in Rust