Show HN: Async tasks in 350 lines of C (github.com)
Tasks are a primitive for asynchronous programming and an alternative to working with threads directly. Instead of creating a thread, performing work, then waiting on it using synchronization primitives, you only define a unit of work and let a scheduler decide when and where to execute it. The threads that the scheduler uses are created once and reused for many tasks throughout the program's lifetime. In most cases, creating and executing a task requires not even a single syscall and is a significantly faster operation than creating a thread, resulting in lower latency. Furthermore, tasks from different subsystems can be automatically interleaved by the scheduler, which is difficult to achieve manually with threads without communication between the subsystems.
The library is written in standard C11 and only depends on the C POSIX library. It is designed to be easy to integrate into a project by just copying the header and simple enough to be understood in an afternoon.
55 comments
[ 3.6 ms ] story [ 152 ms ] threadI think it is the wording, semantics. Replace “return” with “emit” and it all holds up.
Lua coroutines represent a yieldable computation, but given a coroutine object, you can't await it or retrieve a return value from it. The only things you can do with a coroutine are check its status and resume it. So how do asynchronous computations return values in Lua?
It's simple: don't create an extra coroutine around it. If you're already in a yieldable coroutine, then you can call the yielding function directly. Nested yields will bubble up, and from the caller's perspective you just call a function and it returns a value.
Doing this from Lua is a bit of compiler/interpreter magic, but some modified Lua interpreters support yieldable C functions that can also perform yieldable calls [0]. This requires some due diligence on the part of the caller, making sure to bubble up yields from the inner function and also pass down resumes until it completes (you need to add a state for "this function call yielded"). But it uses no promises, futures or channels.
Of course, you can create a coroutine that wraps a yielding function and notifies a channel or callback once it's done. Those just aren't part of the language and aren't actually necessary for multitasking.
[0]: https://github.com/MCJack123/craftos2-lua/blob/89dcba94c28be... (lua_getctx returns whether you're being resumed from a yield, and expects you to have pushed your own state to the Lua stack if you need any.)
Promises in, say, JavaScript, represent the eventual outcome of an asynchronous computation. When you call an asynchronous function, it starts immediately and you get a Promise for it. Asynchronous callers can then await this Promise to give the impression that they're performing a sub-computation, but technically they are just waiting on a new separate task. Futures in, say, Kotlin, are the same thing, and channels are how both of these are typically implemented.
Those kinds of Promises require some bookkeeping that's not free. While it's helpful to be able to just pass around "the eventual outcome of a specific task", that's not actually needed for asynchronous functions to be able to return values. You just need a way for the function to execute nested within another without necessarily requiring a new task.
I've taken a look at OP's library, and it seems to be lower-level than yielding. That means either one of these paradigms can probably be built on top of it. I take back my statement that it's a missing feature, I thought this library was meant to do more than it does.
That sounds like a lazy future rather than a concurrent one, but both are traditional futures.
> I'm pretty sure GGP was referring to the OOP concept.
Its not really an “OOP concept”. AFAICT, concurrent promises/futures or equivalent constructs mostly appeared in (often relatively obscure) functional-ish and logic languages before the mid-00s, when they started getting implemented in industrially popular, mostly OOP, languages.
It sort of is, because what you're doing is encapsulating the concept of "a value that may arrive in the future" with an object. Both Promises (in JS) and Futures (in Kotlin) work this way.
What I'm saying is that you don't necessarily need to encapsulate that. Rust doesn't: Futures may be called "futures", but they're actually more like "suspendable computations" (not to be confused with generators, which have their own proposal, or coroutines, which also have their own proposal). You can await one as part of another suspendable computation, but you're not holding an object that represents a concept (as in a Promise), you're holding the computation itself. By awaiting it, it becomes a part of you.
The only place a Promise/Future is necessary is when you're not using the computation directly: when you chuck a function off to a task scheduler in order for it to be executed for you. Then, in order to get anything back, you need a handle to that eventual return value. There's your Promise/Future. But if you simply don't chuck it off in the first place, and integrate it into your own suspendable computation instead, you don't need any sort of Promise/Future.
I should note that having taken a look at this library myself, it seems like both paradigms should be possible to implement on top.
I'm not going to press someone else to do it though, I'm just gonna implement it myself since I have to rewrite it all anyways.
That said, the API here is undocumented and the chosen names are very confusing (implementations do not do what I would expect functions of those names to do).
https://fzn.fr/readings/ppopp13.pdf
GCD is a lot more heavyweight though (but it brings a lot of niceties), whereas this is going to be much more portable.
[0]: https://softwareengineering.stackexchange.com/a/99546/54480
[0] https://en.wikipedia.org/wiki/Indentation_style#K&R_style
Combined with the absence of any comments, the source looks very "optically dense" in this style.
The were, however, the only people in the world with multiple monitors (outside of SGI).
Started from learning about Protothreads (cooperative concurrency) as described by Adam Dunkels [1], and ended up devising a Task manager/runner library with a main loop, so multiple protothreads could be scheduled easily. At any given point in time, any of the scheduled tasks could be running or yielding on some continuation point.
There's some beauty in grasping these concepts from the ground up. Some time later I had to learn JavaScript, and the concept of async/await clicked so fast and nicely in my mind. Saving distances, the core idea is fundamentally the same, and that was enlightening (and fun!)
[1]: https://dunkels.com/adam/pt/
Sometimes, functions may call other functions in the same code file.
This required that functions be declared before they are referenced so C knew it existed.
You can also see this on lines 84-92.
C11 has a <threads> header that should be usable in place of the pthreads api.
I may play with the C11-standard `threads.h`, but note that it was not implemented by MSVC at all until quite recently: https://devblogs.microsoft.com/cppblog/c11-threads-in-visual...
Edit: Made a C11 threads.h implementation as well. https://github.com/rkaehn/cr_task.h/pull/3
I have used this sort of thing a lot though, and something I often find both essential, and overlooked is you really need something like `cr_task_sync_and_do_work` that isn't just entirely blocking on the current thread. Since as you build systems on this sort of API, you very quickly get bound by threads just stuck waiting on their children jobs.
Having said that, I realize that complicates the implementation quite a bit. The most elegant approach I have seen is one where you put tasks on fibers, and when you sync, instead of entering a sync loop, or waiting on a mutex, you instead suspend the fiber in the awaiting job, and put it back on the task queue. This was detailed by Naughty Dog in their Job System talk at GDC: https://www.gdcvault.com/play/1022186/Parallelizing-the-Naug...
When I see "impressive thing in only a few lines of C", I think someone has invented a new way to crash my computer if I don't color inside invisible, underspecified lines.
What I'd want to see, if I were looking for a library like this, is tests. I see that you do have a test suite: that's good. I also see that it covers the basic functionality: that's a good start. There are a number of excellent tools for testing for the various woes which C code is prone to, consider integrating them.
C code doesn't have to be full of goblins which will venture forth at midnight and eat the candy out of your nose. But it should be suspected of such goblins until demonstrated otherwise. Few lines is actually good! And the test suite suggests you've implemented a good surface area here.
And 350 lines means you probably only need a couple thousand more to really, very thoroughly test the code. It will take some time but it's well worth it.