Building ch.Send: Controlled Communication Between Goroutines
A channel send operation in Go often looks deceptively simple:
jobs := make(chan int)
go func() {
val := <-jobs
fmt.Println(val)
}()
jobs <- 42
At first glance, this feels like:
send a value into a queue, receive it somewhere else.
But channel sends are not just data movement.
They are synchronization points between goroutines.
That distinction becomes important very quickly.
A Send Operation Can Block Forever
Consider this program:
package main
func main() {
jobs := make(chan int)
jobs <- 42
}
This crashes with:
fatal error: all goroutines are asleep - deadlock!
The reason is simple:
An unbuffered channel send requires a receiver to be ready.
This line:
jobs <- 42
does not mean:
put 42 into a queue.
It actually means:
wait until another goroutine is ready to receive 42.
That is a synchronization contract.
Channels Coordinate Goroutine Progress
Once a receiver exists, the send can continue:
package main
import "fmt"
func main() {
jobs := make(chan int)
go func() {
val := <-jobs
fmt.Printf("received: %v\n", val)
}()
jobs <- 42
}
The sender and receiver now coordinate through the channel.
One of the most important mental models in Go concurrency is this:
channels are not just about moving data. They are about coordinating goroutine progress.
Passing Channels to Functions
A helper can initially accept a regular channel:
func SendInt(out chan int, val int) {
out <- val
}
This works.
But the function receives more authority than it actually needs.
A chan int supports both operations:
out <- val
val := <-out
Our helper only needs to send values.
Go lets us express that directly.
Send-Only Channels
func SendInt(out chan<- int, val int) {
out <- val
}
Now the helper can only send values.
Trying to receive from the channel inside the function no longer compiles:
val := <-out // compile error
Go supports three channel forms:
chan int: bidirectionalchan<- int: send-only<-chan int: receive-only
This is not runtime protection.
It is a communication contract enforced at compile time.
The Real Problem: Blocking Sends
The moment concurrency becomes part of a real system, another issue appears.
What happens if the receiver is slow?
Or disappears entirely?
This becomes dangerous:
jobs <- 42
because the sender may block forever.
In real systems this can happen because of:
- request cancellations
- timeouts
- shutdown signals
- dead workers
- slow downstream systems
This is where select becomes essential.
Using select as an Escape Hatch
select {
case jobs <- 42:
fmt.Println("sent")
case <-time.After(2 * time.Second):
fmt.Println("timeout")
}
Now the send operation no longer waits forever.
The timeout channel competes with the send operation.
If the receiver never becomes ready, the timeout wins.
This is an important detail about select:
select does not work with context specifically.
select works with channels.
time.After simply returns another channel that becomes ready later.
context Uses the Same Idea
context.Context builds on the same coordination model.
select {
case jobs <- 42:
fmt.Println("sent")
case <-ctx.Done():
fmt.Println("cancelled")
}
ctx.Done() returns a channel.
Once the context is cancelled or times out, that channel becomes ready.
The send operation now has an escape hatch.
Repeating the Same Pattern
After writing the same select block repeatedly:
select {
case <-ctx.Done():
return ctx.Err()
case jobs <- val:
return nil
}
The abstraction starts becoming obvious.
Not because a helper looked interesting.
But because the concurrency problem keeps repeating.
ch.Send
func Send[T any](ctx context.Context, out chan<- T, val T) error {
if err := ctx.Err(); err != nil {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
case out <- val:
return nil
}
}
The helper itself is small.
The interesting part is understanding which problem forced the helper to exist in the first place.
Cancellation Does Not Automatically Win
There is one subtle detail worth stating explicitly.
Go’s select chooses pseudo-randomly among cases that are ready at the same time.
That means cancellation does not automatically take priority over a send that is also immediately possible.
For example:
ctx, cancel := context.WithCancel(context.Background())
out := make(chan int, 1)
cancel()
err := Send(ctx, out, 42)
If out still has space and ctx.Done() is already closed, both cases may be ready.
So without any pre-check, the helper can sometimes return nil and still send 42.
Adding:
if err := ctx.Err(); err != nil {
return err
}
helps with the common case where cancellation was already visible before the select.
But even that does not create a perfect priority rule.
The context can still be cancelled after the pre-check and race with a ready send.
So the practical contract is this:
Send prevents indefinite blocking and usually respects already-cancelled contexts, but Go select semantics do not guarantee that cancellation always beats a simultaneously ready channel operation.
Final Thought
This line:
jobs <- 42
is not just data transfer.
It is a synchronization contract between goroutines.
Once that mental model becomes clear, ch.Send stops looking like a utility helper and starts looking like a controlled communication helper.