The techniques and code in this tutorial are for educational purposes only. Always obtain explicit written authorization before scanning systems you do not own. Unauthorized port scanning may violate laws including the Computer Fraud and Abuse Act (CFAA) in the United States and similar legislation worldwide. All examples use scanme.nmap.org — a host explicitly provided by Nmap for legal testing.
net.DialTimeout, the sequential scanner, and a first look at the semaphore pattern. If you’re new to port scanners in Go, start there.
A concurrent port scanner in Go can be built two ways: the semaphore pattern (buffered channel limits concurrent goroutines — one goroutine per port, channel acts as a gate) or the worker pool pattern (fixed number of goroutines pull from a job channel). Both are effective. The semaphore is simpler; the worker pool is more extensible — easier to add context cancellation, error propagation, and clean shutdown. This tutorial builds both, compares them, and shows how to add context cancellation and detect data races.
Photo by Josh Sorenson on Pexels
If you built the sequential and semaphore-based port scanner in the previous article, you have a working concurrent tool. What this tutorial adds is architectural perspective: there are two common ways to implement concurrency for this kind of task in Go, each with different trade-offs around extensibility, performance, and debuggability. Understanding both patterns — and when to reach for each — is more useful than just having a working scanner.
We’re also going to cover two topics that most port scanner tutorials skip: context cancellation (how to stop all goroutines cleanly when a user presses Ctrl+C or a timeout fires) and the Go race detector (a built-in tool that finds data races in concurrent code). These are worth understanding before you start writing larger concurrent programs.
Two Concurrency Architectures
At a high level, both architectures solve the same problem — run many TCP connection attempts concurrently without overwhelming the network stack. The difference is in how they manage which goroutines are running:
- Semaphore pattern: Spawn a goroutine per task. Use a buffered channel as a gate — goroutines must acquire a slot before starting, release it when done. Maximum concurrency = channel capacity.
- Worker pool pattern: Spawn a fixed number of goroutines upfront (the “pool”). Push all tasks into a job channel. Workers pull jobs until the channel is closed and empty, then exit.
Both patterns are idiomatic Go. They appear throughout production code and open source tools. The choice between them is more about what comes next in your codebase than about raw performance — though there are differences there too.
Architecture A: Semaphore Pattern
The semaphore implementation is covered in depth in the prerequisite article, but a quick anatomy before the comparison is useful.
scanner_semaphore.go
Goroutines spawned per-port; buffered channel controls maximum concurrency.
package main
import (
"fmt"
"net"
"sort"
"sync"
"time"
)
func scanPort(host string, port int, timeout time.Duration) bool {
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), timeout)
if err != nil {
return false
}
conn.Close()
return true
}
func main() {
host := "scanme.nmap.org"
timeout := 3 * time.Second
concurrency := 100
var mu sync.Mutex
var openPorts []int
sem := make(chan struct{}, concurrency)
var wg sync.WaitGroup
for port := 1; port <= 1024; port++ {
wg.Add(1)
sem <- struct{}{}
go func(p int) {
defer wg.Done()
defer func() { <-sem }()
if scanPort(host, p, timeout) {
mu.Lock()
openPorts = append(openPorts, p)
mu.Unlock()
}
}(port)
}
wg.Wait()
sort.Ints(openPorts)
for _, p := range openPorts {
fmt.Printf(" %d/tcp OPEN\n", p)
}
}
Anatomy of the Semaphore Pattern
Main goroutine drives the loop. The main function both dispatches work and waits for completion. It spawns one goroutine per port, but blocks on sem <- struct{}{} when the concurrency limit is reached.
Goroutines manage their own lifecycle. Each goroutine acquires a semaphore slot on entry, releases it on exit. They’re independent — they don’t know about each other.
Shared state via mutex. The openPorts slice is shared across goroutines. The sync.Mutex prevents concurrent writes from causing a data race.
This works correctly and is easy to read. The limitation is extensibility: adding context cancellation, error handling, or more complex coordination requires patching a lot of independent goroutine bodies. The worker pool trades some simplicity for better structure.
Architecture B: Worker Pool Pattern
The worker pool pattern inverts the relationship between goroutines and tasks. Instead of one goroutine per task, you create a fixed pool of goroutines and feed them tasks through a channel.
Worker Pool — A pattern where a fixed number of goroutines (“workers”) are spawned once and process tasks from a shared job channel. Workers run continuously until the job channel is closed. Tasks are sent to the channel by a producer; workers consume them as capacity allows. The number of workers defines maximum concurrency.
scanner_workerpool.go
Fixed pool of worker goroutines consuming from a job channel. Cleaner separation of concerns.
package main
import (
"fmt"
"net"
"sort"
"sync"
"time"
)
type Job struct {
Host string
Port int
}
type Result struct {
Port int
Open bool
}
// worker processes jobs from the jobs channel until it's closed.
func worker(jobs <-chan Job, results chan<- Result, timeout time.Duration, wg *sync.WaitGroup) {
defer wg.Done()
for job := range jobs {
address := fmt.Sprintf("%s:%d", job.Host, job.Port)
conn, err := net.DialTimeout("tcp", address, timeout)
open := err == nil
if open {
conn.Close()
}
results <- Result{Port: job.Port, Open: open}
}
}
func main() {
host := "scanme.nmap.org"
timeout := 3 * time.Second
numWorkers := 100
startPort, endPort := 1, 1024
total := endPort - startPort + 1
jobs := make(chan Job, total)
results := make(chan Result, total)
// Start the worker pool
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go worker(jobs, results, timeout, &wg)
}
// Send all jobs, then close to signal no more work
for port := startPort; port <= endPort; port++ {
jobs <- Job{Host: host, Port: port}
}
close(jobs)
// Close results after all workers finish
go func() {
wg.Wait()
close(results)
}()
// Collect results
var openPorts []int
for r := range results {
if r.Open {
openPorts = append(openPorts, r.Port)
}
}
sort.Ints(openPorts)
fmt.Printf("Open ports on %s:\n", host)
for _, p := range openPorts {
fmt.Printf(" %d/tcp OPEN\n", p)
}
}
How the Worker Pool Works
Typed structs for jobs and results. Job and Result are explicit types. This is more readable than passing loose primitives through a channel, and makes it straightforward to add fields later — a priority, a timeout override, a protocol selection — without changing function signatures throughout the codebase.
for job := range jobs in each worker. This is the key idiom. A range over a channel blocks waiting for the next value, processes it, then blocks again. When the channel is closed and empty, the range loop exits and the worker goroutine returns. Closing jobs is the signal that no more work is coming — it’s a clean, push-based shutdown.
No mutex needed. Results go into a buffered channel, one per port. Channel sends are goroutine-safe — no mutex required. The semaphore version needed a mutex because multiple goroutines were writing to a shared slice. Here, the channel takes that role.
The coordinator goroutine closes results. wg.Wait() ensures all workers have finished (which means all results have been sent), then close(results) signals the consumer that it can stop reading. The consumer’s for r := range results exits when the channel is closed.
Side-by-Side Comparison
| Property | Semaphore Pattern | Worker Pool Pattern |
|---|---|---|
| Goroutine count | One per task (up to concurrency limit) | Fixed pool size |
| Goroutine creation overhead | Higher (new goroutine per task) | Lower (goroutines reused) |
| Code complexity | Lower (fewer moving parts) | Slightly higher (separate types, channel coordination) |
| Context cancellation | Requires patching each goroutine body | Naturally fits in worker function |
| Shared state | Mutex required for shared slice | No mutex needed (results via channel) |
| Extensibility | Harder to add per-task logic (retry, error, priority) | Job struct is easy to extend |
| Best for | Simple fan-out tasks, quick scripts | Production tools, extensible pipelines |
For a port scanner you’re writing once and running locally, the semaphore version is fine. For a scanner that’s part of a larger toolchain, feeds into a logging system, or needs clean interruption support, the worker pool is the better starting point.
Adding Context Cancellation
Context cancellation lets you stop all in-progress scans cleanly — for example, when a user presses Ctrl+C, or when a timeout fires, or when you’ve found enough results. The worker pool pattern integrates naturally with Go’s context package.
context.Context — Go’s standard mechanism for propagating cancellation, deadlines, and request-scoped values across goroutines. A context is created by the caller and passed down to callee functions. When cancel() is called on a cancellable context (or a deadline passes), the context’s Done() channel is closed, signaling all goroutines watching it to stop work.
The key change is replacing net.DialTimeout with net.Dialer.DialContext, which accepts a context and respects cancellation:
scanner_context.go
Worker pool + context propagation for clean cancellation and timeout support.
package main
import (
"context"
"fmt"
"net"
"os"
"os/signal"
"sort"
"sync"
"syscall"
"time"
)
type Job struct {
Host string
Port int
}
type Result struct {
Port int
Open bool
}
func worker(
ctx context.Context,
jobs <-chan Job,
results chan<- Result,
timeout time.Duration,
wg *sync.WaitGroup,
) {
defer wg.Done()
dialer := &net.Dialer{}
for job := range jobs {
// Check for cancellation before each dial
select {
case <-ctx.Done():
return
default:
}
dialCtx, cancel := context.WithTimeout(ctx, timeout)
conn, err := dialer.DialContext(dialCtx, "tcp", fmt.Sprintf("%s:%d", job.Host, job.Port))
cancel() // Always release the timeout context
open := err == nil
if open {
conn.Close()
}
results <- Result{Port: job.Port, Open: open}
}
}
func main() {
host := "scanme.nmap.org"
timeout := 3 * time.Second
numWorkers := 100
startPort, endPort := 1, 1024
total := endPort - startPort + 1
// Root context — cancelled on Ctrl+C or SIGTERM
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Signal handling: cancel context on interrupt
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigs
fmt.Println("\nInterrupt received — cancelling scan...")
cancel()
}()
jobs := make(chan Job, total)
results := make(chan Result, total)
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go worker(ctx, jobs, results, timeout, &wg)
}
for port := startPort; port <= endPort; port++ {
select {
case jobs <- Job{Host: host, Port: port}:
case <-ctx.Done():
break
}
}
close(jobs)
go func() {
wg.Wait()
close(results)
}()
var openPorts []int
for r := range results {
if r.Open {
openPorts = append(openPorts, r.Port)
}
}
sort.Ints(openPorts)
fmt.Printf("Open ports on %s:\n", host)
for _, p := range openPorts {
fmt.Printf(" %d/tcp OPEN\n", p)
}
}
What Changed
context.WithCancel at the top. Creates a root context and its cancellation function. Calling cancel() closes the context’s Done() channel, which all downstream code watches.
Signal handling goroutine. signal.Notify routes Ctrl+C (SIGINT) and SIGTERM into a channel. When the signal arrives, the goroutine calls cancel(). Every worker goroutine will exit when it next checks ctx.Done().
select check before each dial. Before starting a new connection, workers check whether the context has been cancelled. If it has, they return immediately rather than starting unnecessary work. This is what “cooperative cancellation” means in Go — code checks for cancellation at natural points rather than being forcibly killed.
context.WithTimeout per-dial. context.WithTimeout(ctx, timeout) creates a child context that is cancelled either when timeout expires or when the parent ctx is cancelled — whichever comes first. The cancel returned by WithTimeout must always be called to release resources, hence the immediate defer cancel() after the dial.
📋 Worth Knowing
The pattern ctx, cancel := context.WithTimeout(parent, d); defer cancel() inside a function that gets called in a loop is a subtle but common issue: defer runs when the enclosing function returns, not at end of scope. Here that function is worker, not the loop body — so the cancel does fire at the right time (when the worker exits). If this were inlined in a loop body instead of a function, you’d use explicit cancel() immediately after the dial rather than defer.
~Cipherceval
The Race Detector (go run -race)
Go ships with a built-in data race detector. It instruments your binary at build time to track all memory accesses and detect cases where two goroutines access the same memory location concurrently, with at least one writing, without synchronization.
Run the semaphore scanner without the mutex to see a race in action:
// Remove mu.Lock() / mu.Unlock() from the semaphore version
// Then run:
go run -race scanner_semaphore.go
You’ll see output like this:
The race detector tells you exactly which line the race is on, and which goroutines are involved. This is invaluable for debugging concurrent code — especially when races only manifest under specific timing conditions that make them hard to reproduce with testing alone.
Run it on both the correct semaphore version (with the mutex) and the worker pool version to verify no races exist:
go run -race scanner_workerpool.go
No race report means the synchronization is correct. The -race flag is worth running periodically during development — it adds a performance overhead (typical 5–20x slower), so it’s not for production binaries, but it’s worth using during any development phase involving concurrency.
Goroutine Leaks — What They Are and How to Avoid Them
A goroutine leak is a goroutine that’s started but never terminates. In a long-running program, leaking goroutines gradually exhaust memory. In a port scanner, leaks are usually caused by one of two things:
A full results channel. If the results channel is buffered but workers generate more results than the buffer can hold before the consumer starts reading, worker goroutines block on results <-. If the consumer never starts (maybe it’s waiting for all workers to finish in the wrong order), you have a deadlock.
The fix: ensure the results channel is large enough to hold all results without blocking, or start the consumer goroutine before feeding jobs. In the worker pool implementation, the results buffer is sized to total — the maximum number of results possible — which prevents this scenario.
A context that’s never cancelled. If workers check ctx.Done() but the context is never cancelled and no more jobs arrive, workers waiting on range jobs will exit cleanly when the channel is closed. This is fine — but if close(jobs) is never called (maybe because a panic interrupted the job-feeding loop), workers block forever.
The fix: use defer close(jobs) immediately after creating the jobs channel, and ensure the job-feeding loop always terminates. With context cancellation, the job-feeding loop exits via case <-ctx.Done(): break, and then close(jobs) runs.
🔍 Things to Consider
The goleak package from Uber is worth knowing about — it’s used in test suites to detect goroutine leaks by checking that no unexpected goroutines are still running after a test completes. This is useful when your concurrent code gets complex enough that manual reasoning about goroutine lifecycle isn’t sufficient. The port scanner patterns here are straightforward enough to reason about manually, but it’s a tool worth adding to your Go testing toolkit as programs grow.
~Cipherceval
Frequently Asked Questions
What is the difference between the semaphore pattern and the worker pool pattern in Go?
The semaphore pattern uses a buffered channel to limit the maximum number of goroutines running concurrently — each task spawns its own goroutine, but sends to the channel first to acquire a slot. The worker pool creates a fixed number of goroutines upfront that pull from a shared job channel. The semaphore is simpler to implement. The worker pool reduces goroutine creation overhead and is easier to extend with context cancellation, typed job/result structs, and error propagation.
What is context cancellation in Go?
Context cancellation in Go allows you to signal goroutines to stop work early — for example, on a user interrupt, a timeout, or when enough results have been found. A cancellable context is created with context.WithCancel() or context.WithTimeout(). Passing the context to net.Dialer.DialContext() means the connection attempt will terminate if the context is cancelled before the connection completes. When the caller calls cancel(), the context’s Done() channel is closed, and goroutines checking it can exit cleanly.
What does the Go race detector do?
The Go race detector (enabled with go run -race or go build -race) instruments your code to detect concurrent memory accesses where at least one access is a write and the accesses aren’t protected by a lock. It reports data races at runtime with the goroutine stacks involved. Enable it during development whenever you’re writing concurrent code. It adds significant performance overhead and is not for production binaries — but it finds races that would otherwise only manifest under specific timing conditions.
How do I close a results channel safely from multiple producer goroutines in Go?
The standard pattern: never close a channel from a producer when there are multiple producers (any of them might close it while another is still sending, causing a panic). Instead, use a sync.WaitGroup to track producers, then have a single coordinator goroutine call wg.Wait() followed by close(results). The consumer uses for r := range results, which processes all values and unblocks when the channel is closed.
What is a goroutine leak and how does it happen in a port scanner?
A goroutine leak occurs when goroutines are spawned but never terminate — typically because they’re blocked on a channel send or receive that never completes, or waiting on network I/O with no timeout. In a port scanner, leaks happen when the results channel fills up before the consumer reads from it (goroutines block on send), or when the jobs channel is never closed (workers block on range). The fixes: size the results buffer to hold all results, use defer close(jobs), and propagate context cancellation so workers can exit when the scan is interrupted.
What to Build Next
The concurrent port scanner covers the core patterns. Natural extensions from here:
- CIDR range input: Parse
192.168.1.0/24as a target, scan all hosts in range — adds a host enumeration layer above the port scanning loop - Rate limiting: Use
time.Tickorgolang.org/x/time/rateto cap connections per second, useful for staying below IDS thresholds during authorized tests - JSON output: Extend the
Resultstruct and useencoding/jsonto produce structured output for integration with logging or SIEM systems - UDP scanning: A fundamentally different approach — UDP doesn’t complete handshakes, so absence of a response doesn’t mean the port is closed
Stay vigilant, stay curious, and update your stuff.
— Cipherceval / Forgebound Research
Exploit Brokers by Forgebound Research
Disclaimer: The content presented by Exploit Brokers by Forgebound Research is for educational and informational purposes only. Cipherceval is a cybersecurity educator and commentator — not your personal security consultant, legal counsel, or professional advisor. The information shared here reflects publicly available research, industry reporting, and the host’s personal perspective. It does not constitute professional security consulting or individualized guidance for your specific environment. Always consult with qualified professionals for decisions affecting your systems and security posture.
Technical Disclaimer: The techniques discussed here are for educational purposes only. Always obtain explicit written authorization before testing systems you do not own. Unauthorized access to computer systems is illegal under laws including the Computer Fraud and Abuse Act (CFAA) in the US and similar legislation worldwide.
