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: bidirectional
  • chan<- 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.