Summary
A high-concurrency Go port scanner suffered from terminal output corruption (garbled text) when attempting to display real-time progress. While the scanning logic was mathematically sound, the attempt to print status updates from multiple concurrent goroutines simultaneously violated the principle of thread-safe I/O, leading to interleaved byte streams and broken escape sequences.
Root Cause
The failure stems from a fundamental misunderstanding of how fmt.Printf interacts with the standard output stream in a multi-threaded environment:
- Non-Atomic I/O Operations: Each call to
fmt.Printfis not a single, atomic operation at the kernel level. When multiple goroutines callfmt.Printfat the same time, their byte streams interleave. - Race Conditions on Stdout: The terminal buffer receives fragments of different strings (e.g., half of a “GREEN” color code and half of a port number) from different goroutines.
- Interference with ANSI Escape Codes: The user attempted to use ANSI color codes. When these sequences are interrupted by other writes, the terminal receives malformed escape sequences, causing visual glitches, incorrect colors, or broken text formatting.
Why This Happens in Real Systems
In production-grade systems, this pattern manifests as Log Interleaving. When high-throughput microservices write directly to a shared file descriptor or a centralized logging socket without a synchronization primitive, you see:
- Corrupted JSON logs: Making them impossible to parse by automated systems like ELK or Datadog.
- Race conditions in CLI tools: Where progress bars (like
tqdmin Python or custom Go implementations) flicker or jump because multiple threads are fighting for the same cursor position. - Resource Contention: Even if the output isn’t garbled, excessive syscalls to
write()from thousands of goroutines can introduce significant latency spikes in the application logic.
Real-World Impact
- Observability Failure: If logs are garbled, debugging an incident becomes impossible because the “truth” of the system state is physically unreadable.
- Automated Parsing Breakdown: DevOps pipelines that rely on regex or JSON parsing of logs will fail, potentially triggering false positive alerts or failing to catch critical errors.
- Degraded UX: For CLI tools, garbled output signals a lack of professional quality and makes the tool difficult to use in automated cron jobs or CI/CD environments.
Example or Code
To fix this, we must decouple the scanning logic from the reporting logic using a Channel and a single “Manager” goroutine responsible for all I/O.
package main
import (
"fmt"
"net"
"strconv"
"sync"
"sync/atomic"
"time"
)
// Result represents a scan finding
type Result struct {
Port int
Open bool
}
func scanhost(host string, start int, step int, end int, wg *sync.WaitGroup, results chan<- Result) {
defer wg.Done()
for i := start; i <= end; i += step {
address := host + ":" + strconv.Itoa(i)
conn, err := net.DialTimeout("tcp", address, 500*time.Millisecond)
if err != nil {
results <- Result{Port: i, Open: false}
continue
}
conn.Close()
results <- Result{Port: i, Open: true}
}
}
func main() {
host := "127.0.0.1"
startPort := 1
endPort := 1024
workers := 20
var wg sync.WaitGroup
results := make(chan Result, workers)
var openPorts int32
// Start workers
for i := 0; i < workers; i++ {
wg.Add(1)
go scanhost(host, startPort+i, workers, endPort, &wg, results)
}
// Closer goroutine
go func() {
wg.Wait()
close(results)
}()
// Single consumer goroutine handles all I/O (The "Manager")
// This prevents garbled output because only one goroutine writes to stdout
fmt.Println("Starting scan...")
for res := range results {
if res.Open {
atomic.AddInt32(&openPorts, 1)
fmt.Printf("\x1b[0;32m[OPEN]\x1b[0m Port %d\n", res.Port)
} else {
// Optionally show progress without cluttering
fmt.Printf("\rScanning... current port: %d", res.Port)
}
}
fmt.Printf("\nScan complete. Total open ports: %d\n", openPorts)
}
How Senior Engineers Fix It
Senior engineers solve this by applying the principle of “Don’t communicate by sharing memory; share memory by communicating.”
- Centralized I/O (The Actor Model approach): Instead of allowing every worker to write to
stdout, workers send “events” (results) through a thread-safe channel. A single dedicated goroutine listens to that channel and performs the actual printing. - Decoupling: They separate the computation (network I/O) from the presentation (UI/Progress Bar).
- Buffering: They use buffered channels to ensure that the worker goroutines are not blocked by the speed of the terminal’s rendering.
- Atomic Operations: For simple counters (like
openPorts), they use thesync/atomicpackage rather than async.Mutexto minimize lock contention overhead.
Why Juniors Miss It
- The “It works on my machine” Fallacy: On a local machine with low latency and few goroutines, the race condition might not trigger often enough to cause visible garbling, leading to a false sense of security.
- Focus on Logic over Orchestration: Juniors often focus on the “happy path” (making the scan work) and forget that concurrency introduces non-determinism in every shared resource, including the terminal.
- Missing the Abstraction: They view
fmt.Printfas a simple command rather than a shared resource that requires synchronization.