Blog

Concurrency Coding: From CPU Atomics and fork() to Goroutines, Sockets, State Convergence, and Microservices

Date TBD

Introduction

I have been working with JavaScript since 1998, and I've spent my career building systems across frontend, backend, distributed systems, infrastructure, and application architecture. One of the recurring problems in modern software engineering is that concurrency gets taught through syntax — this language's async keyword, that language's goroutines, this other one's threading module — as if each new primitive were a new idea. It isn't. The vocabulary changes constantly. The questions underneath it don't:

Who owns execution? Who owns state? Who communicates? Who waits, retries, and decides when enough information exists to proceed?

An earlier post covered accidental serialization in JavaScript — independent work chained sequentially by accident, purely because await reads top-to-bottom. That same bug, and the same fix, shows up in C threads, Go goroutines, and Python coroutines. Cleaner syntax is not automatically cleaner architecture, and incorrect abstractions increase cognitive load: if you have to mentally undo a language's scheduling model before you can reason about a system, the abstraction made things harder, not easier.

{ "elements": [ { "id": "tasks", "label": "Multiple Independent Tasks" }, { "id": "concurrency", "label": "Concurrency: Interleaved on One Core" }, { "id": "parallelism", "label": "Parallelism: Simultaneous on Multiple Cores" } ], "connections": [ { "from": "tasks", "to": "concurrency" }, { "from": "tasks", "to": "parallelism" } ] }

Concurrency is about structuring independent work so it can make progress without waiting on unrelated work. Parallelism is about whether that work actually runs at the same physical instant, on separate cores. You can have concurrency without parallelism (a single-core event loop interleaving many pending tasks) and parallelism without much interesting concurrency (four completely independent batch jobs on four cores). Threads don't automatically run on different cores, and async syntax by itself is not concurrency architecture — it's a hint to the runtime about where execution can be suspended, nothing more.

Threads vs. Processes

A thread shares its process's memory. A process owns an independent address space unless you go out of your way to create shared memory. That one distinction explains most of the practical tradeoffs:

Property Thread Process
Address space Shared Independent
Communication Direct memory access Explicit IPC (pipes, sockets, shared memory)
Failure isolation One crash can take the whole process down A crashed child doesn't take the parent down
Startup cost Low Higher
Synchronization need Required whenever memory is shared Only where you explicitly opt in

Cheap communication (shared memory) buys you expensive ownership problems (you now need locks). Expensive communication (message passing across a process boundary) buys you cheap ownership reasoning (a value you were handed is yours, full stop). Neither side is free — you're always paying somewhere.

fork() and State Convergence

fork() is where this bites people hardest. A forked child gets a copy-on-write snapshot of the parent's memory at the moment of the call — not a live connection to it. Whatever the child computes afterward lives in its copy. It does not travel back into the parent's memory by itself. If you need the result, you have to build an explicit channel for it — a pipe, a socket, shared memory with synchronization — and read from that channel deliberately. "Forked memory does not magically converge back into the parent" sounds obvious once you say it out loud, and yet it's one of the most common first mistakes with fork().

{ "elements": [ { "id": "parent", "label": "Parent Process" }, { "id": "child", "label": "Child Process (own memory copy)" }, { "id": "pipe", "label": "Pipe (explicit channel)" }, { "id": "result", "label": "Parent Reads Result" } ], "connections": [ { "from": "parent", "to": "child", "label": "fork()" }, { "from": "child", "to": "pipe", "label": "write" }, { "from": "pipe", "to": "result", "label": "read" } ] }

Shared Memory vs. Message Passing

This is the same tradeoff from the thread/process table, made concrete in code. Shared memory is fast and requires synchronization — a semaphore isn't just a lock, it's a resource counter, useful for modeling bounded concurrency and backpressure as much as mutual exclusion. Message passing makes ownership explicit at the cost of a copy: a value handed down a channel doesn't need a lock, because nothing else has a reference to it anymore.

Sockets, Event Loops, and the Same Old Pattern

A blocking socket read ties up a thread until data arrives — simple to reason about, expensive to scale past a few thousand connections. An event loop (select(), poll(), epoll(), or the callback queue underneath every JavaScript runtime) multiplexes many sockets onto one thread by asking the OS "which of these is actually ready?" instead of blocking on each one in turn. It's the exact same concurrency-without-parallelism idea from the introduction, just implemented at the I/O layer instead of the language layer.

The Same Shared Workload, Every Model

Here's the actual proof, and it's the same proof the AJAX post used: four independent workers, 500/700/400/600ms, run two ways. Run it sequentially — one worker fully finishing before the next even starts — and it takes roughly 2200ms. Run the exact same four workers independently and it takes roughly 700ms. Nothing about the workers changed. Only whether something forced them to wait on each other.

Worker results

Performance metrics

The same 2200ms-vs-700ms gap shows up whether "worker" means a JavaScript Promise, a Go goroutine reading a channel, or a pthread being joined. Below is that exact shared workload, written two ways in two different concurrency models — same bug, same fix, completely different syntax.

The Go side above is illustrative, not runnable on this page — for the same shape as a real, working program, concurrent_sensors in my go-katas repo reads several sensors independently and converges on their results the same way the demo above does, in real goroutines and channels instead of a diff panel.

Worker Pools and State Convergence

A worker pool is the same "acquire independently, converge on a decision" shape from the demo above, generalized: a fixed set of workers pull tasks from a shared queue instead of each being spawned for one specific job. State convergence is the step that comes after — once however many of the four workers have actually reported back, something has to decide whether the system has enough information to proceed, wait longer, or degrade gracefully with partial data. That decision point is architecture, not syntax, and it exists identically whether the workers are threads, goroutines, or calls to four different microservices.

A Small Example: Sensor Fan-Out

Picture a robot polling LIDAR, a camera, an IMU, and wheel encoders. If it awaited each sensor one at a time — LIDAR, then camera, then IMU, then encoders — it would be making decisions about a world that no longer exists by the time it finished asking about it. Independent sensors need independent polling, with their readings converging into shared state only once enough of them are in. It's the identical four-independent-things shape as the timing demo above; the only thing that changed is the cost of getting the architecture wrong — a slow dashboard is annoying, a slow robot is dangerous.

From Local Workers to Microservices

Scale the same pattern out past a single process, and "worker" becomes "microservice call," "channel" becomes "message broker," and "state convergence" becomes an aggregation layer that waits on several service calls before responding. None of that makes the underlying problem go away — it just moves the execution boundary further from the CPU. A microservice architecture isn't automatically isolated from the exact same race conditions and convergence questions a multi-threaded program has; it just has network latency and partial failure layered on top of them.

{ "elements": [ { "id": "execution", "label": "Who Owns Execution?" }, { "id": "memory", "label": "Who Owns State?" }, { "id": "communication", "label": "Who Communicates?" }, { "id": "decision", "label": "Who Waits, Retries, Decides?" }, { "id": "architecture", "label": "The Architecture" } ], "connections": [ { "from": "execution", "to": "architecture" }, { "from": "memory", "to": "architecture" }, { "from": "communication", "to": "architecture" }, { "from": "decision", "to": "architecture" } ] }

Architecture Matters More Than Primitives

None of this is an argument against any particular concurrency primitive — threads, processes, goroutines, and async/await all earn their place. The argument is that the primitive is the smallest part of the decision. Choose concurrency tools based on ownership, communication, synchronization, failure isolation, and convergence requirements first. The language syntax you end up writing it in is secondary — which is exactly why the same 500/700/400/600ms demo above tells the same story in every language it's translated into.