A Quick Disclaimer
Before diving in, I want to clarify that the examples below focus specifically on C# and Go. These are the languages I have spent the most time with and the ones that shaped my opinions on this topic. While other languages certainly fall into the "async/await" or "green thread" buckets, the specific pros, cons, and tooling quirks I mention here might be specific to the .NET and Go ecosystems.
The Concept: What Color is Your Function?
In 2015, Bob Nystrom coined the metaphor of "Colored Functions." The rule is simple:
Blue Functions (Synchronous) can call other Blue functions.
Red Functions (Asynchronous) can call other Red functions.
Red Functions (Asynchronous) can also call other Blue (Synchronous) functions.
The Catch: A Blue function cannot call a Red function directly.
If you need to call an async function, you must become async yourself. This creates a "contagion" that spreads up your call stack.
C#: Embracing the Contagion
C# is a "colored" language. It treats asynchrony as a distinct type (Task).
The Refactoring Ripple If you change a database call at the bottom of your stack from sync to async, you have to update every single method calling it, all the way up to Main.
// 1. Bottom level becomes Red/Async
public async Task<string> GetDataAsync() {
await Task.Delay(100);
return "Data";
}
// 2. The Caller is forced to become Red too
public async Task<string> Process() {
// You must await, or you get a Task<string>, not string
return await GetDataAsync();
}The Good: You have explicit control. You know exactly where the thread yields, and
CancellationTokenoffers a unified way to handle timeouts.The Bad: The "Ripple Effect" makes refactoring painful. Plus, if you try to cheat the system (using
.Result), you risk deadlocking your app.
Go: The Colorless World
Go rejects the premise. In Go, blocking is the default behavior, and the runtime scheduler manages the threads for you. There is no async, no await, and no function coloring.
Channels & WaitGroups Instead of Task<T>, Go uses primitives to manage concurrency:
Channels: Typed pipes to send data between routines.
WaitGroups: Counters to wait for multiple routines to finish.
func fetchData() string {
time.Sleep(100 * time.Millisecond) // Blocks the Goroutine, not the OS thread
return "Data"
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() { // Explicit concurrency starts here
defer wg.Done()
fmt.Println(fetchData()) // Call blocking code freely!
}()
wg.Wait()
}The Good: Composition is effortless. You can call a blocking function from anywhere without breaking the API.
The Bad: Concurrency is implicit. You don't "see" the yield points, and you have to manually wire up cancellation using
context.
My Personal Experience
I've spent years in massive C# codebases and, more recently, small-to-mid-sized Go projects and in my opinion, here is the reality of working with both:
The "Implicit Var" Trap in C#
The most frequent annoyance I have encountered is something rather minor and is an error caused by var hiding the function's color.
When you write var user = GetUser();, it is easy to forget the await. Because var infers the type, the compiler happily assigns the Task to your variable instead of the result. If you then pass that variable into a logger or a null check, the code compiles perfectly because Task is a valid object. You only realize the mistake at runtime when your logs are filled with System.Threading.Tasks.Task instead of the actual user data. Well written code assumes that the function would be called GetUserAsync() however well written code isn't always the case. And even then, it's not impossible to mess it up.
The Legacy Wall
I once worked on a project involving complex code translation where the architecture backed me into a corner. I found myself in a spot where I had to call an asynchronous method from a strictly synchronous context due to how the code was structured.
Because of the "colored function" rules, you aren't supposed to do this. I tried to force it by waiting on the Task synchronously which led to the dreaded "Sync-over-Async" anti-pattern. And while there were workarounds (for that specific codebase, which I didn't know at the time), the sheer annoyance of fighting the language just to make a function call stuck with me.
Even then, pinning this entirely on function colors wouldn't be entirely fair, as it was partially due to using an outdated and unmaintained library that was doing basically black magic by translating C# into other languages.
5. The Verdict
Ultimately, I prefer Go for its simplicity and concurrency patterns. The ability to write blocking code that scales like non-blocking code removes a significant amount of cognitive overhead.
While the C# ecosystem is undeniably powerful, especially its Dependency Injection model, that flexibility can introduce its own layer of complexity. But that is a deep dive for another article. For now, I am enjoying the "colorless" world of Go, where I write my code without the mental overhead of making everything async compatible.
