Maybe Everything Is a Coroutine
adam.nels.onl
adam.nels.onl
http://logic.cs.tsukuba.ac.jp/~sat/pdf/tfp2020.pdf
In short: algebraic effects.
Here’s a whole thesis on the cool things that can do done with this one simple trick: https://publikationen.uni-tuebingen.de/xmlui/bitstream/handl...
In general every Monad can be expressed "interpreting" the free monad. This relaxes the "one-shot" restriction, and can be implemented using delimited control. One-shot means faster performance though and is still useful for many things - and that can be implemented using coroutines.
If you acquire some understanding of what this means then you'll have a very good idea about the expressive power of what you can use coroutines (with nothing more) for, so it's very interesting.
A quick scan of https://okmij.org/ftp/Haskell/extensible/more.pdf doesn't yield much one way or another.
Haskell ends up being a bad place to talk about this (or a great place, depending on your goals) because due to laziness you get to write a lot of structures which look inductive but end up being able to express coinductive structures.
From memory and intuition, if you're working in a strict language (or better yet, something like Agda where the distinction becomes very sharp) then you end up finding that continuations make for good coinductive "free" structures and the free monad (and its ilk) make for good inductive free structures.
The distinction between inductive and coinductive types is fairly subtle and hard to see in most languages where those distinctions are blurred, but broadly you can think of inductive structures as ones that are, in principle, finite and coinductive structures as being those which may be, in principle, infinite.
For example, a linked list is inductive. If you're looking at one cons cell of it you can't prove that, you may have to chase pointers for longer than your patience allows, but at least in principle there is an end. A stream is coinductive, because it instead suggests a generative process.
In languages such as C++ and Java, raising an exception defaults to an unwinding only if not handled within the same function that raised it.
Then there are languages with resumable exceptions, and languages wherein unwinding is considered normal control flow as "shortcut returns".
A handler for a resumable exception is passed down the call stack, rather than an object being passed up the call stack to the handler. In some languages the handler can conditionally decide whether to resume back into or restart the block that raised the exception, or to cause an unwinding of the stack. In other languages, the action is fixed per exception type. (`try ... rescue`)
Exceptions are merely the record of the programmer's mistake. Essentially an error, except an error that could have been caught at compile time given a sufficiently advanced compiler.
Exception handlers provide a control flow for dealing with exceptions, which may include unwinding.
Exception handlers, while primarily intended for use by exceptions, are not necessarily restricted to exceptions. Often programmers use them to move other things around, most notably errors.
From an interface-contract point of view, exceptions model the case that an operation cannot complete normally. Any mechanism that enables an operation to complete normally after all upon encountering an error condition, should better be modeled with a separate language feature, for example with callbacks specific to the concrete error condition.
"Professors HATE HIM"
I gave a talk about it recently at Michigan Typescript: https://www.youtube.com/watch?si=Mok0J8Wp0Z-ahFrN&v=uRbqLGj_...
https://github.com/thefrontside/effection
https://github.com/neurosnap/starfx
With delimited continuations, we are able to express any async flow control, it is an incredibly powerful paradigm.
Instead, define effects that are actually relevant to your particular app. DB reads/writes, I/O to third party systems.
Model those as data-driven effects. Keep the rest pure.
It's just the way we force the results to appear that differs.
IO Monad, if you squint, is extremely similar.
Continuations are a different way to "store a procedure and state" (like a partially evaluated function in a lazy sense).
It's not totally obvious, but it's a lot of fun to think about how these things are all related.
It's literally as simple as it sounds:
https://gobyexample.com/goroutines
Want to wait for a bunch of parallel coroutines to finish (sync) before proceeding? Sure, that's easy too:
https://gobyexample.com/waitgroups
This simple primitive is one of two major go features that made me not really pursue Rust more, simply because Rust's concurrency libraries (it doesn't seem to be an intrinsic part of the language like Go) seem to be more modeled after Python's or Node's. Go's `go` is even more readable than Erlang and Scala, so in my opinion it's one of the great three features of the language
(OT, but to me the other two major features are 1: channels being an integral part of the language, and 2: forced immediate error handling -- perhaps you disagree that this second one is a feature and prefer tracebacks, but in my experience large Go projects always seem to have cleaner code than, say, Python, probably because errors are handled very close to their origination. Lastly, there are a minimum of footguns in Go.)
https://softchris.github.io/golang-book/05-misc/04-goroutine...
Channels let you block (control suspension points) and wait for progress from any other thread.
Pretty much nothing in the blog post is possible is Go, without 'writing it yourself', which is what you are doing with channels. The same is possible in most other languages.
Coroutines are one of those terms that used to have a really price academic definition and has gotten a lot looser over the years. Python generators don't conform to the academic definition, but casually calling them coroutines is pretty popular now.
However, no matter how you slice it, goroutines aren't coroutines and that's not the derivation of the name. They're threads. Within those threads is bog-standard structured programming. All functions have one entry point. Being able to push out values through channels is no different than a normal function that writes several things to a file, it doesn't have the distinctive characteristics of a coroutine.
Could you elaborate on how Python's implementation doesn't meet the precise definition?
I agree with you, as anyone should, that goroutines aren't coroutines at all. Preëmptive scheduling is disqualifying. I'm confident that the cute name was intended to convey "these aren't coroutines at all" but that was somewhat lost in the translation.
Out of curiosity, what is the precise academic definition?
goroutines + channels were specifically designed to solve the concurrency/communication/locking/flow/performance problems that traditional coroutines introduced. Coroutines are much heavier and introduce a lot of overhead.
Goroutines are not necessarily threads; they're more akin to greenthreads. In Go, they're much cheaper and lower latency and still automatically move between processors (subject to your control).
But, aside from lower performance and greater difficulty in locking and multi-threaded communication via shared objects, coroutines are also much harder to code with and reason about.
The primitives in Go are very simple and that is one of the best features of the language. Running a for loop across a set of channels is very easy and built-in to the language, making it childs-play to write a heavily-concurrent network server. Channels are specifically designed to be a high-speed data bus between goroutines, rather than ever use more expensive and less safe shared memory. (Channels are preferred over shared memory in Go)
This is a fantastic video explaining how and why Rob Pike and team developed the idea of goroutines and channels, building on their work from Smalltalk: https://go.dev/blog/waza-talk
What do you mean? How do you think channels are implemented? Go is open source, you can see for yourself, channels still use mutexes internally.
https://github.com/golang/go/blob/b6ca586181f3f1531c01d51d63...
If you needed one mutex lock to receive a reference to a data structure, a channel does not make that cheaper. The absolute best case is that the same OS thread served both sides of the channel transaction, but you can't plan your overall performance around that, because it becomes less likely the more loaded your program's goroutines are. In general you still have to think of both the send and receive side of the channel as short-lived exclusive locks of the mutex, with all of the contention and cache effects that implies.
Shared memory is not more expensive. Memory is memory, it's either cached on your core or not. In fact, Go still has to issue fence instructions to ensure that the memory it observes after a channel read is sequenced after any writes to that memory, so it's at best the same cost you'd have with other forms of inter-thread communication in any language.
Anyway, even that is missing the point. Go still shares memory if you used a reference type, and most types in Go end up being reference types, because it's the only way to have a variable-sized data structure (and while we're at it, string is the only variable-sized data structure that's also immutable).
The bigger problem is that Go doesn't enforce thread safety. Channels only make communication safe if you send types that don't contain any mutable references... but Go doesn't give you any way to define your own immutable types. That basically limits you to just string. Instead people send slices, maps, pointers to structs, interfaces, etc. and those are all mutable and Go does nothing to enforce that you didn't mutate them.
Even if all of that somehow wasn't true, many parallelism patterns simply don't map well to channels, so you still end up with mutexes in many parts of real world projects. Even if you don't see the mutexes, they're in your libraries. For example, Go's http.Transport contains a connection pool, but it uses mutexes instead of channels because even the Go team knows that mutexes still make sense for many real-world patterns.
https://github.com/golang/go/blob/b6ca586181f3f1531c01d51d63...
This whole "channels make Go safe" myth has to stop. It's confused a generation of Go programmers about the actual safety (and apparently performance) tradeoffs of channels. They do not make Go safer (mutable references are still mutable after being sent on a channel), they do not make it faster (the memory still has to be fenced), and heck while we're at it, they do not even make it simpler ("idiomatic" use of channels introduces many ways that goroutines can deadlock, and deadlock-free use of channels is much more complicated and less idiomatic).
The most useful thing about channels is that you can select{} on multiple of them so they partly compensate for Go's limitations around selecting on futures in general. They're a poor substitute when you actually needed to select on something like IO, where io.Reader/Writer still don't interact with select, channels, or even cancellation directly.
But now that Java has the same feature in the form of virtual threads, I'm once again back to complaining!
Yes, good grief this is annoying.
Context would suggest you are referring to Go, but Go has exceptions. It even has exception handlers. As do all of the other languages mentioned.
Everything is a tables and SQL!
(hint: tables and queries implement content-addresable memory [1] and, consequently, dataflow architecture [2], which is also Turing complete)
[1] https://en.wikipedia.org/wiki/Content-addressable_memory
state1a state1b state1c | state2a state2b state2c | state3a state3b state3c
This means wait for state1a, state1b state1c in any order, then move to the next sequence of things to wait for.In a multithreaded server or multimachine distributed system, there are global states you want to wait for and then trigger behaviour. The communication can be inferred and optimised and scheduled.
It's BNF syntax - inspired by parsing technology for parsing sequences of tokens but tokens represent events.
If you use printf debugging a lot, you know the progression of what you see is what happened and that helps you understand what went wrong. So why not write or generate the log of sequence of actions you want directly not worry about details?
But wait! There's more. You can define movements between things.
So take an async/await thread pool, this syntax defines an async/await thread pool:
next_free_thread(thread:2);
task(task:A) thread(thread:1) assignment(task:A, thread:1) = running_on(task:A, thread:1) | paused(task:A, thread:1);
running_on(task:A, thread:1)
thread(thread:1)
assignment(task:A, thread:1)
thread_free(thread:next_free_thread) = fork(task:A, task:B)
| send_task_to_thread(task:B, thread:next_free_thread)
| running_on(task:B, thread:2)
paused(task:A, thread:1)
running_on(task:A, thread:1)
assignment(task:B, thread:2)
| { yield(task:B, returnvalue) | paused(task:B, thread:2) }
{ await(task:A, task:B, returnvalue) | paused(task:A, thread:1) }
| send_returnvalue(task:B, task:A, returnvalue);
Why not just write what you want to happen and then the computer works out how to schedule it and parallelize it?I think iteration/looping and state persistence and closures are all related.
I have a parser for this syntax and a multithreaded barrier runtime which I'm working on, I use liburing. I want to get to 500 million requests per second of the and ~50ish nanosecond latency of LMAX Disruptor.
The notation could be used for business programming and low level server programming I think.
Conway worked out some results for non-serialised (commutative) events in Regular Algebra and Finite Machines about half a century ago.
IMHO petri nets are the most widely applicable method for modelling concurrent processes that I've seen yet.
Would you like to talk more about this subject?
My state machines in those contexts are such beasts sometimes, when you have to account for different combinations of DMA progress/completion, etc. Sometimes the hardware limitations you need to handle in software makes you really bang your head to the wall...
If you have some taskgraph that is static or predictible (think closed-loop control) and you need low latency this might be your best option.
https://ubf.github.io/ubf/ubf-user-guide.en.html
but part (c) was only ever a sketch of a service/event grammar.
I implemented REGEV (regular expression for events) in my previous job. It was a REGEX for event types, but in that case, applied to log/trace emissions from test runs. So it allowed you to specify a regex of what events to expect for success, and had various listening and pattern-matching streams for runtime verification. It did not generate full-blown state machines for every protocol grammar. The wildcard matching helped ensure the tests were not fragile to minor changes to the code, or extra trace/log emissions.
So I think the opposite (complement) of what you were describing - not a specification of what should happen, but a grammar pattern to match downstream of what actually happened.
P.S. It's closed source, but the company is bankrupt, so maybe the IP will surface some day.
Did you regex engine support commutative events? (Happen in either order?)
https://en.wikipedia.org/wiki/Session_type
I think one difference is that session types capture which state a process/co-routine is in after receiving a sequence of messages whereas this system does not, it captures how one can respond to each state in isolation (e.g. I don't think you can statically capture that once a door is closed and locked you can only receive the locked state from it and respond by unlocking it).
It's a lot easier to read and write, BUT it comes with the downside that its not easy to save or load, which makes it only usable in certain game types and/or actions.
Can you expand a bit on that?
The whole mess also has to be deterministic too, or as you say, it won't have the same outcome across save/load.
With a coroutine the data can be made global to be saved, but there's usually no flag to indicate what step we're on. On a load the data can be used but its really hard to get back to the exact step in the coroutine stack without building in a lot of conditional logic, which then voids using it in the first place.
Go lets users do this only over channels, so the rest of the ecosystem has to bend everything into the shape of a channel, including timers, cancellation tokens, etc.
Rust generalizes this to any kind of future, though the ergonomics get rough in many cases, sometimes requiring pinning and sometimes risking dirty cancellation with no way to account for the resulting state.
Under this proposal:
How would you allow multiple coroutines to progress in parallel while selecting on the next update from each of them? When you're selecting, you don't yet have anything to send any of them, and when you get something back from one, you might have to send something to one or more of them, but they're still working and are not yet in a state where they can receive.
How would you cancel coroutines while still allowing their implementation (not the caller's) to decide how to get back to a known state? I can understand if the intention is that you always have to send them a signal to indicate this, but as above they have to be in a state where they can receive it, and it will still be extremely likely that at least one caller forgets to cancel at least one coroutine. Especially since the purity of this approach means it must work recursively as well.
It's syntax didn't help it's spread unfortunately.
The world truly belongs to those that can be both.
I agree with your take. In this case, we have one group that tries to explain the world using algebra while loudly endorsing that approach. Meanwhile, the other group just wants to write error-free state machines.
door() ->
| :open(:close)
| :closed(:open | :lock)
| :locked(:unlock)
What feels wrong to me in this example is that the type system doesn’t “know” that :close-ing an :open door will yield :closed. Expressed as a state diagram, it would only have a single node, whose type would be a sum type with three options, and the diagram doesn’t tell you which option you end up with for any given state transition, while at the same time you have to know the current option to know which transition you may take from the current state.So there is an asymmetry regarding the associations to the three sub-states between the two ends of a state transition. Or in other words, the state transition arrows begin in three different states but end in a single state that is the opaque combination of the three source states.
- Cuda: https://github.com/mratsim/weave/issues/133 - OpenCL: https://github.com/mratsim/weave/issues/134
a() -> Int() | Void
b() -> Int() | Void
for(a()) {
| na -> for(b()) {
| nb ->
print(na + nb)
// now what? From here, I want to continue to a(), not b()
I don't believe there is much use for coroutines without support for their interleaved execution.You have to liberate yourself from tick-tock synchrony.
There are 'a' and 'b' processes, they generate messages. You cannot predict how those will arrive. If you want them strictly alternating, you can tag each of the messages with the source, and block until you get the next other one you expect. Or you can just receive them all and make sense of them on their own terms.
Thank you for telling me which kinds of program I am not allowed to write any more in the bright new future of the programming.
As for the rest of your comment, I am aware of CSP and its derivatives, thanks. The question was about the language proposed in TFA that seems to be lacking the choice/select primitive.
Virding's First Rule of Programming:
Any sufficiently complicated concurrent program in another language
contains an ad hoc informally-specified
bug-ridden slow implementation of half of Erlang.They are deliberately simple and for dataprocessing. They not meant/suitable for things like e.g. IO whereas the continuation monad is.
First time that I learn of the .onl TLD. I thought it was a typo but it actually exists for 10 years already: https://icannwiki.org/.onl
A function is some executable code. A closure is that plus some mutable state. A coroutine is usually a closure because something needs to track where it was suspended. Maybe this post is from the context of one of the languages that does the function colouring trick, where the answer is usually that said colouring is motivated by performance concerns.
There is a missing abstraction in the pthreads model - one can yield the current thread, but cannot yield to some specific other thread. I vaguely remember a patch set from google to add that to linux but am having trouble finding it now.
IMHO the invisible switch-case code transform only makes sense when stack switching isn't available (such as in Javascript or WASM).