How to Use conc Library for Safer Concurrency in Go

The `conc` library provides a lightweight, zero-allocation wrapper around Go's standard concurrency primitives to prevent common race conditions and goroutine leaks without sacrificing performance.

When background work outlives its purpose

You are building a data fetcher. You spawn twenty goroutines to hit different APIs. The user clicks cancel. The main function returns. But those twenty goroutines are still sleeping, still holding open TCP connections, still waiting for a response that will never be used. Your memory usage climbs. Your process gets killed by the OOM killer. Go gives you the tools to stop them. It also gives you the freedom to forget.

The standard library expects you to wire cancellation manually. You create a context. You pass it down. You check ctx.Done() in every loop. You track every spawned worker with a sync.WaitGroup. You handle panics. You close channels in the right order. It works, but it adds boilerplate to every concurrent operation. The conc library removes that boilerplate by wrapping the pattern into a single call. It bakes context awareness and synchronization into the API so you cannot accidentally leave a worker running.

How the wrapper changes the contract

Go treats concurrency as a building block, not a managed service. You get goroutines, channels, and the sync package. You assemble them. The conc library sits on top of those primitives and enforces a stricter contract. Every operation requires a context.Context. Every spawned worker is tracked. Every cancellation signal is routed automatically.

Think of it like a project manager who automatically stops the team when the budget runs out. You do not have to walk around and tell each person to pack up. You flip the switch, and the system handles the teardown. The library does not change how Go schedules goroutines. It changes how you wire them together.

Context is plumbing. Run it through every long-lived call site.

The minimal pattern

Here is the baseline pattern. One context, one worker, automatic shutdown.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/stevenle/conc"
)

func main() {
	// Timeout after two seconds to simulate a user cancellation
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	// conc.Wait tracks the goroutine and stops it when ctx expires
	conc.Wait(ctx, func() {
		for {
			// Check cancellation before doing work
			select {
			case <-ctx.Done():
				return
			default:
				fmt.Println("Working...")
				// Sleep to simulate I/O or computation
				time.Sleep(500 * time.Millisecond)
			}
		}
	})

	// Main blocks here until the worker finishes or context expires
	fmt.Println("All workers finished or cancelled safely.")
}

The function conc.Wait accepts a context and a callback. It spawns a goroutine, runs the callback, and waits for it to return. If the context expires or gets cancelled, the library signals the callback to stop. The main function blocks until the worker exits. You do not need a sync.WaitGroup. You do not need a separate select statement in main. The synchronization is implicit.

The callback still needs to respect the context. The library cannot force a blocking network call to return early. It only provides the signal. If your callback ignores ctx.Done(), the goroutine stays alive until the underlying operation finishes. The wrapper reduces boilerplate, but it does not rewrite physics.

Goroutines are cheap. Channels are not magic.

What happens under the hood

When you call conc.Wait, the library creates a goroutine and increments an internal counter. It passes your context into the callback. Your callback runs in that new goroutine. When the context deadline passes, ctx.Done() closes. Your select statement catches it and returns. The callback exits. The library decrements the counter. When the counter hits zero, conc.Wait returns to main.

Compare this to the manual approach. You would create a sync.WaitGroup, call wg.Add(1), spawn a goroutine, defer wg.Done(), and call wg.Wait() in main. You would also need to handle context cancellation inside the goroutine. The manual approach gives you more control. You can track multiple independent groups. You can add workers dynamically. You can inspect the counter. The wrapper trades that control for safety and brevity.

Go convention dictates that context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. The conc library enforces this by making the context a required argument. You cannot accidentally spawn a fire-and-forget goroutine without a cancellation path.

Trust gofmt. Argue logic, not formatting.

Processing slices in parallel

Real code rarely runs one task at a time. You usually process a slice of items in parallel and collect the results. conc.Map handles the fan-out and fan-in. It preserves order, catches panics, and stops early on error or cancellation.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/stevenle/conc"
)

func main() {
	ctx := context.Background()
	numbers := []int{1, 2, 3, 4, 5}

	// conc.Map fans out work, collects results, and preserves slice order
	results, err := conc.Map(ctx, numbers, func(ctx context.Context, n int) (int, error) {
		// Simulate I/O latency
		time.Sleep(100 * time.Millisecond)
		if n == 3 {
			// Return an error to trigger early termination
			return 0, fmt.Errorf("simulated error for 3")
		}
		// Double the value and return
		return n * 2, nil
	})

	// Handle the aggregated error or print the ordered results
	if err != nil {
		fmt.Printf("Operation failed: %v\n", err)
	} else {
		fmt.Printf("Results: %v\n", results)
	}
}

The conc.Map function iterates over the input slice. It spawns a goroutine for each element. Each goroutine runs your callback with the context and the current item. Results are sent back through an internal channel. The library reassembles them in the original order. If any callback returns an error, conc.Map cancels the context, stops waiting for remaining workers, and returns the error immediately.

This pattern replaces a common manual setup. You would create a result slice, a channel for errors, a sync.WaitGroup, and a loop that spawns goroutines. Each goroutine would write to a specific index in the result slice. You would need to handle race conditions if multiple goroutines write to the same index. You would need to close the error channel at the right time. The library handles all of that.

The callback signature requires context.Context as the first argument. This is standard Go convention. It ensures that every worker can check for cancellation. If you forget to check ctx.Done() inside a long-running callback, the worker will continue until it finishes. The library cannot interrupt a running function. It can only signal it to stop.

Don't reinvent the fan-in pattern unless you need custom ordering.

Where wrappers hit their limits

Third-party wrappers hide complexity, but they do not eliminate it. If your callback blocks on a network call that ignores context, conc cannot force it to stop. The goroutine stays alive until the underlying operation finishes or times out. You will still see context canceled errors if you forget to respect the context inside your own logic. The compiler will reject missing imports with undefined: conc. Runtime panics like all goroutines are asleep - deadlock! still happen if you block on an unbuffered channel inside a conc callback without a cancellation path.

Wrappers also add a dependency. Your go.mod grows. Your build pipeline fetches an external package. Your team needs to learn a new API. The standard library requires more lines of code, but it requires zero external trust. You know exactly what sync.WaitGroup does. You know exactly how context propagates. You do not need to read source code to understand the contract.

Go convention accepts verbose error handling by design. The pattern if err != nil { return err } makes the unhappy path visible. Wrappers that swallow errors or aggregate them silently can hide bugs. conc.Map returns a single error, which is useful for batch operations. It is not useful when you need to know exactly which item failed and why. You would need to inspect the results slice or use a custom error type.

The worst goroutine bug is the one that never logs.

Choosing the right concurrency primitive

Use conc.Wait when you need fire-and-forget background tasks that must respect parent cancellation. Use sync.WaitGroup when you need fine-grained control over goroutine lifecycle without adding a dependency. Use conc.Map when you are transforming a slice in parallel and want ordered results with automatic error aggregation. Use manual channels and errgroup when you need custom fan-in logic or early termination on the first error. Use sequential loops when the dataset is small enough that concurrency adds more overhead than it saves.

Pick the tool that matches your complexity, not the one that matches your fear of goroutines.

Where to go next