Some of us believe GC[0] isn't an impediment for systems programming languages.
They haven't taken off as Xerox PARC, ETHZ, Dec Olivetti, Compaq, Microsoft desired more due to politics, external or internal (in MS's case), than technical impediments.
Hence why I like the way Swift and Java/Kotlin[1] are pushed on mobile OSes, to the point "my way or get out".
I might discuss about many of Go's decisions regarding minimalism language design, however I will gladly advocate for its suitability as systems language.
The kind of systems we used to program for a few decades ago, compilers, linkers, runtimes, drivers, OS services, bare metal deployments (see TamaGo),...
[0] - Any form of GC, as per computer science definition, not street knowledge.
[1] - The NDK is relatively constrained, and nowadays there is Kotlin Native as well.
> Channels were a nice idea but I've become convinced that cooperative async-await is a superior programming model.
Curious as to your reasoning around this? I've never heard this opinion before from someone not biased by their programming language preferences.
Sure. First you need to separate buffered and unbuffered channels.
Unbuffered channels basically operate like cooperate async/await but without the explictness. In cooperative multitasking, putting something on an unbuffered channel is essentially a yield().
An awful lot of day-to-day programming is servicing requests. That could be HTTP, an RPC (eg gRPC, Thrift) or otherwise. For this kind of model IMHO you almost never want to be dealing with thread primitives in application code. It's a recipe for disaster. It's so easy to make mistakes. Plus, you often need to make expensive calls of your own (eg reading from or writing to a data store of some kind) so there's no really a performance benefit.
That's what makes cooperative async/await so good for application code. The system should provide compatible APIs for doing network requests (etc). You never have to worry about out-of-order processing, mutexes, thread pool starvation or a million other issues.
Which brings me to the more complicated case of buffered channels. IME buffered channels are almost always a premature optimization that is often hiding concurrency issues. As in if that buffered channels fills up you may deadlock where you otherwise wouldn't if the buffer wasn't full. That can be hard to test for or find until it happens in production.
But let's revisit why you're optimizing this with a buffered channel. It's rare that you're CPU-bound. If the channel consumer talks to the network any perceived benefit of concurrency is automatically gone.
So async/await doesn't allow you to buffer and create bugs for little benefit and otherwise acts like unbuffered channels. That's why I think it's a superior programming model for most applications.
Buffers are there to deal with flow variances. What you are describing as the "ideal system" is a clockwork. Your async-awaits are meshed gears. For this approach to be "ideal" it needs to be able to uniformly handle the dynamic range of the load on the system. This means every part of the clockwork requires the same performance envelope. (a little wheel is spinning so fast that it causes metal fatigue; a flow hits the performance ceiling of an intermediary component). So it either fails or limits the system's cyclical rate. These 'speed bumps' are (because of the clockwork approach) felt throughout the flow. That is why we put buffers in between two active components. Now we have a greater dynamic range window of operation without speed bumps.
It shouldn't be too difficult to address testing of buffered systems at implementation time. Possibly pragma/compile-time capabilities allowing for injecting 'delay' in the sink side to trivially create "full buffer" conditions and test for it.
There are no golden hammers because the problem domain is not as simple as a nail. Tradeoffs and considerations. I don't think I will ever ditch either (shallow, preferred) buffers or channels. They have their use.
I agree with many of your points, including coroutines being a good abstraction.
The reality is though that you are directly fighting or reimplementing the OS scheduler.
I haven’t found an abstraction that does exactly what I want but unfortunately any sort of structured concurrency tends to end up with coloured functions.
Something like C++ stdexec seems interesting but there are still elements of function colouring in there if you need to deal with async. The advantage is that you can compose coroutines and synchronous code.
For me I want a solution where I don’t need to care whether a function is running on the async event loop, a separate thread, a coprocessor or even a different computer and the actor/CSP model tends to model that the best way. Coroutines are an implementation detail and shouldn’t be exposed in an API but that is a strong opinion.
As you probably know, Rust ended up with async/await. This video goes deep into that and the alternatives, and it changed my opinions a bit: https://www.youtube.com/watch?v=lJ3NC-R3gSI
Golang differs from Rust by having a runtime underneath. If you're already paying for that, it's probably better to do greenthreading than async/await, which is what Go did. I still find the Go syntax for this more bothersome and error-prone, as you said, but there are other solutions to that.
I can see the appeal for simplicity of concept and not requiring any runtime, but it has some hard tradeoffs. In particular the ones around colored functions and how that makes it feel like concurrency was sort of tacked onto the languages that use it. Being cooperative adds a performance cost as well which I'm not sure I'd be on board with.
“Systems programming language” is an ambiguous term and for some definitions (like, a server process that handles lots of network requests) garbage collection can be ok, if latency is acceptable.
Google has lots of processes handling protobuf requests written in both Java and C++. (Or at least, it did at the time I was there. I don’t think Go ever got out of third place?)
My working definition of "systems programming" is "programming software that controls the workings of other software". So kernels, hypervisors, emulators, interpreters, and compilers. "Meta" stuff. Any other software that "lives inside" a systems program will take on the performance characteristics of its host, so you need to provide predictable and low overhead.
GC[0] works for servers because network latency will dominate allocation latency; so you might as well use a heap scanner. But I wouldn't ever want to use GC in, say, audio workloads; where allocation latency is such a threat that even malloc/free has to be isolated into a separate thread so that it can't block sample generation. And that also means anything that audio code lives in has to not use GC. So your audio code needs to be written in a systems language, too; and nobody is going to want an OS kernel that locks up during near-OOM to go scrub many GBs of RAM.
[0] Specifically, heap-scanning deallocators, automatic refcount is a different animal.
I wouldn’t include compilers in that list. A traditional compiler is a batch process that needs to be fast enough, but isn’t particularly latency sensitive; garbage collection is fine. Compilers can and are written in high-level languages like Haskell.
Interpreters are a whole different thing. Go is pretty terrible for writing a fast interpreter since you can’t do low-level unsafe stuff like NaN boxing. It’s okay if performance isn’t critical.
You don't (usually) inherit the performance characteristics of your compiler, but you do inherit the performance characteristics of the language your compiler implements.
Yes, you can via unsafe.
And if you consider K&R C a systems language, you would do it like back in the day, with a bit of hand written helper functions in Assembly.
So that fits, given that Go compiler, linker, assembler and related runtime are all written in Go itself.
You mean an OS kernel, like Java Real Time running bare metal, designed in a way that it can even tackle battleship weapons targeting systems?
https://www.ptc.com/en/products/developer-tools/perc
It's non-application software meant to support something else at run time. Like a cache, DBMS, webserver, runtime, OS, etc.
From what I remember, Go started out because a C++ application took 30 minutes compiling even though they were using google infrastructure, you could say that they set out to create a systems programming language (they certainly thought so), but mostly I think the real goal was recreating C++ features without the compile time, and in that, they were successful.
is there a language that implements cooperative async-await patterns nicely?
I mean, they claimed that one didn't need generics in the language for some 12 years or so ...