DEV Community

Rami
Rami

Posted on

From 16 Hours to Minutes: The Hard Part of Multithreading Isn't the Threads

Multithreading is easy when every piece of work is independent.

Throw the work into a queue, start a bunch of workers, and let them fight it out.

Unfortunately, many real-world systems aren’t that simple.

We recently looked at modernizing a legacy batch-processing system with a fairly straightforward job:

  1. Read a CSV.
  2. Load some state from a database.
  3. Apply a calculation.
  4. Update the state.
  5. Store the result.

The catch?

A file containing a few hundred thousand rows could take up to 16 hours to process.

Our goal was to scale the architecture toward processing around a million operations in minutes rather than hours.

What looked initially like a performance problem quickly became a much more interesting concurrency problem:

How do you massively parallelize a workload while preserving ordering for related operations?


The giant for loop

Conceptually, the legacy system looked something like this:

for row in csv:
    state = database.read(row.item)
    result = calculate(state, row.action)
    database.write(result)
Enter fullscreen mode Exit fullscreen mode

Simple. Predictable. Correct.

And painfully sequential.

Every row involved database access, computation, and another database operation before moving to the next row.

Another complication: the system supported an all-or-nothing processing mode, so a database transaction could remain open for essentially the entire job.

For a large import, that could mean hours.

Before redesigning anything, though, we needed to understand where the time was actually going.


Measure first

Suppose 300,000 operations take 16 hours.

Some rough math gives us:

16 × 60 × 60 × 1000
--------------------  ≈ 192 ms / operation
      300,000
Enter fullscreen mode Exit fullscreen mode

Roughly 200 ms per operation.

We then broke an operation into its major components:

DB Read → Calculation → DB Write
Enter fullscreen mode Exit fullscreen mode

The calculation itself was relatively fast. Database reads were more expensive, and writes were particularly costly.

That immediately exposed two separate opportunities:

Do more calculations concurrently, and stop treating the database as part of every individual operation.

The first one naturally leads to multi-threading.


Just add threads™

Imagine that the total workload represents roughly 16 hours of serial work.

Ignoring overhead for a moment, spreading that work across 32 cores gives us:

16 hours / 32 ≈ 30 minutes
Enter fullscreen mode Exit fullscreen mode

Add more cores and, at least theoretically, we start getting into the range of minutes rather than hours.

Great.

Let’s use 90 workers.

Problem solved.

Except now we’ve broken the system.


The ordering problem

Our CSV isn’t actually a collection of completely independent operations.

Consider this simplified input:

Item-A, Action-1
Item-B, Action-1
Item-C, Action-1
Item-A, Action-2
Item-D, Action-1
Item-A, Action-3
Enter fullscreen mode Exit fullscreen mode

Actions belonging to different items can run concurrently.

But actions belonging to the same item cannot.

Action-2 for Item-A must operate on the state produced by Action-1.

And Action-3 must operate on the state produced by Action-2.

So this is perfectly valid:

Item-A: A1 → A2 → A3
Item-B: B1 → B2
Item-C: C1
Enter fullscreen mode Exit fullscreen mode

while all three item chains execute concurrently.

But this isn’t:

Thread 1: Item-A / Action-1
Thread 2: Item-B / Action-1
Thread 3: Item-A / Action-2
Enter fullscreen mode Exit fullscreen mode

Thread 3 could finish before Thread 1.

Now Action-2 has been calculated against the wrong state.

We’ve made the application dramatically faster at producing incorrect results.

Parallel across keys, sequential within a key

That observation became the fundamental rule of the design:

Operations for different keys can execute in parallel. Operations for the same key must execute sequentially.

In our case, the key was an item ID.

Instead of assigning individual rows directly to arbitrary workers, we introduced a router.

Conceptually:

The CSV feeds a router, which distributes operations among workers 0 through N.

The router reads the input sequentially.

When it encounters an item, it assigns that item to a worker.

All future operations for that item go to the same worker.

Each worker consumes its operations through a FIFO queue.

So if the input contains:

Item-A / Action-1
Item-B / Action-1
Item-A / Action-2
Item-C / Action-1
Item-A / Action-3
Enter fullscreen mode Exit fullscreen mode

the queues might become:

Worker 0:
Item-A / Action-1
Item-A / Action-2
Item-A / Action-3
Worker 1:
Item-B / Action-1
Worker 2:
Item-C / Action-1
Enter fullscreen mode Exit fullscreen mode

We’ve preserved ordering for Item-A without sacrificing concurrency between A, B, and C.

This is essentially keyed partitioning.


Hashing gets us most of the way there

An obvious implementation is:

worker = hash(itemId) % workerCount
Enter fullscreen mode Exit fullscreen mode

It has a very useful property:

The same item always produces the same worker.

Therefore:

hash(Item-A) % N → Worker 3
hash(Item-A) % N → Worker 3
hash(Item-A) % N → Worker 3
Enter fullscreen mode Exit fullscreen mode

Ordering becomes much easier because one worker owns the processing sequence for that item.

But now another problem appears.

Distribution matters.

Suppose four workers receive:

Worker Relative workload (illustrative)
Worker 0 20
Worker 1 7
Worker 2 5
Worker 3 3

The other three workers can finish and go home.

The entire job is still waiting for Worker 0.

At that point, our execution time isn’t determined by average throughput.

The slowest partition determines it.

Load balancing is part of the algorithm

For workloads where the keys are known during routing, another approach is to explicitly assign newly encountered keys across workers.

For example:

Item-A → Worker 0
Item-B → Worker 1
Item-C → Worker 2
Item-D → Worker 3
Item-E → Worker 0
...
Enter fullscreen mode Exit fullscreen mode

Store that assignment:

Item-A → 0
Item-B → 1
Item-C → 2
Item-D → 3
Enter fullscreen mode Exit fullscreen mode

When Item-A appears again, the router looks up its existing assignment and sends it back to Worker 0.

This preserves our important invariant:

same key → same worker
Enter fullscreen mode Exit fullscreen mode

while allowing us to control the distribution more deliberately.

The resulting architecture is roughly:

Stage Responsibility
Reader Read the CSV in order.
Router Send each key to its assigned worker.
FIFO queues Preserve arrival order for each worker.
Workers Process their queues concurrently, one operation at a time per worker.

At this point we have ordered parallelism.

But we have also created another problem.


Congratulations, we just DDoS’d our own database

Imagine 90 workers doing this:

READ
CALCULATE
WRITE
READ
CALCULATE
WRITE
READ
CALCULATE
WRITE
...
Enter fullscreen mode Exit fullscreen mode

against the same database.

A million operations multiplied by database reads and writes, now happening concurrently from dozens of threads.

The application might scale.

The database probably won’t appreciate our enthusiasm.

So the next optimization was more important than adding additional threads:

Remove the database from the hot path.

Move the working state into memory

Instead of loading an item repeatedly, we load its initial state once.

DB → State Cache
Enter fullscreen mode Exit fullscreen mode

The first operation works against that state.

State0 + Action1 → State1
Enter fullscreen mode Exit fullscreen mode

The next operation works against the newly mutated in-memory state.

State1 + Action2 → State2
Enter fullscreen mode Exit fullscreen mode

Then:

State2 + Action3 → State3
Enter fullscreen mode Exit fullscreen mode

The database is no longer the source of truth for every intermediate operation.

During the job, the in-memory state is.

So the worker effectively becomes:

state = cache.get_or_load(item)
for action in ordered_actions:
    state = calculate(state, action)
cache[item] = state
Enter fullscreen mode Exit fullscreen mode

If a million-row file represents only 300,000 unique items, we’ve potentially transformed something approaching a million item reads into roughly 300,000 initial-state reads.

More importantly, we’ve eliminated the need to persist every intermediate mutation.

Multi-threading changed the transaction model

This produced an interesting architectural side effect.

Previously, supporting all-or-nothing behavior required keeping a database transaction open while the job ran.

With the new design, intermediate mutations exist only in memory.

Nothing has been committed yet.

That means after every worker drains its queue, we can decide what to do.

For example:

Strict mode

Any operation failed?
        ↓
Discard everything.
Enter fullscreen mode Exit fullscreen mode

Non-strict mode

Some operations failed?
        ↓
Commit successful final states.
Enter fullscreen mode Exit fullscreen mode

The concurrency architecture therefore solved more than a performance problem.

It gave us a cleaner transaction boundary.

Instead of:

BEGIN TRANSACTION
   ...hours of processing...
COMMIT
Enter fullscreen mode Exit fullscreen mode

we can move toward:

PROCESS EVERYTHING IN MEMORY
        ↓
FINAL DECISION
        ↓
SHORT DATABASE TRANSACTION
Enter fullscreen mode Exit fullscreen mode

That’s a very different relationship with the database.

Write once

At the end of processing, we don’t care about every intermediate version of an item.

We care about its final state.

If an item had ten actions:

State0
  ↓ Action1
State1
  ↓ Action2
State2
  ↓
 ...
  ↓ Action10
State10
Enter fullscreen mode Exit fullscreen mode

only State10 needs to be persisted.

So instead of hundreds of thousands or millions of individual writes, workers produce final states that can be written using a bulk operation.

Depending on the database and requirements, that might mean bulk copy into staging tables followed by a set-based MERGE/UPSERT.

Our architecture has now evolved considerably:

Stage Responsibility
CSV reader Read operations in file order.
Router Assign operations by item key.
FIFO queues and workers Run concurrently across keys, sequentially within each key.
State cache Hold the evolving in-memory item states.
Bulk commit Persist final states to the database.
  1. Read once.
  2. Partition by key.
  3. Process concurrently.
  4. Preserve order within each key.
  5. Mutate in memory.
  6. Write final state in bulk.

The important lesson isn’t “use more threads”

It would be easy to summarize this optimization as:

We replaced a single-threaded process with a multi-threaded one.

But that misses most of the interesting engineering.

Adding threads was probably the easiest part.

The actual design work was figuring out what could safely be parallelized.

We couldn’t simply parallelize rows because rows weren’t independent.

Instead, we had to discover the workload’s natural unit of serialization.

In this case:

Constraint Requirement
Global ordering ❌ Not required
Per-item ordering ✅ Required
Cross-item parallelism ✅ Allowed

Once that distinction became explicit, the architecture became much clearer.

The problem stopped being:

“How do we process a CSV with 90 threads?”

and became:

“How do we partition an ordered stream into independent sequential streams?”

That’s a much better problem to solve.

Threads expose the next bottleneck

There’s another lesson here.

Performance optimization tends to move bottlenecks rather than eliminate them.

Initially:

Single-threaded processing
        ↓
      BOTTLENECK
Enter fullscreen mode Exit fullscreen mode

Add parallelism:

90 workers
   ↓
Database
   ↓
BOTTLENECK
Enter fullscreen mode Exit fullscreen mode

Reduce database round trips:

90 workers
   ↓
In-memory state
   ↓
Bulk database write
Enter fullscreen mode Exit fullscreen mode

Now CPU becomes much more relevant.

And that’s actually what we wanted.

A CPU-heavy calculation engine is something we can scale horizontally or vertically much more predictably than thousands of tiny database transactions.

The goal wasn’t simply to make everything faster.

It was to move the workload toward resources that scale well.

Great. Now where do we get 90 cores?

At this point, the application can actually use a lot of CPU.

So the next question is:

Where should this thing run?

The workload is bursty. We might have many large jobs during peak periods and none at all at other times.

A few obvious AWS options come to mind.

Lambda

Lambda is attractive because it is fully on-demand.

But this workload wants a large amount of CPU inside one execution, and it may run long enough that function-runtime limits become uncomfortable.

So Lambda isn’t a great fit for this particular shape of workload.

Fargate

Fargate gets us closer.

We can package the processor as a container, run it on demand, and avoid managing servers.

The problem is compute size.

If our application benefits from 64, 96, or more CPUs, a single Fargate task becomes limiting. Splitting the work across multiple containers would also force us to solve distributed state and coordination.

That adds complexity we don’t currently need.

EC2

EC2 gives us exactly what the application wants:

one large machine with lots of CPUs and memory.

That means all workers can live inside one process and share the same in-memory state.

No distributed locks.

No remote cache.

No coordination between machines.

From an application perspective, this is the simplest option.

But we don’t want a 96-core server sitting around

A large EC2 instance is great while a job is running.

It’s not great when there are no jobs.

We could use Auto Scaling Groups, but then we’d have to manage things like:

  • when to scale up,
  • when to scale down,
  • which instance size a job needs,
  • how jobs wait for capacity,
  • and how to handle different job sizes.

A small file might need 16 CPUs, and a medium one might need 32, etc.

At that point, we’re starting to build our own batch scheduler.

Which is exactly the problem AWS Batch already solves.

AWS Batch (The Solution)

AWS Batch lets us define the compute environments we want and submit containerized jobs with different CPU and memory requirements.

So the flow becomes:

CSV arrives
    ↓
Determine job size
    ↓
Submit Batch job
    ↓
AWS provisions EC2 capacity
    ↓
Run the container
    ↓
Job finishes
    ↓
EC2 capacity goes away
Enter fullscreen mode Exit fullscreen mode

That fits the workload really well.

The application doesn’t need to know how to launch EC2 instances or manage scaling.

It just says I need this much CPU and memory; run me.

And AWS Batch handles the queueing, scheduling, provisioning, execution, and teardown.

The result is exactly what we wanted:

  • Small job → small compute
  • Large job → large compute
  • No job → no compute

The multi-threaded processor stays simple, while the infrastructure scales around the workload.

And what does all that compute cost?

The nice part about this model is that large compute doesn’t necessarily mean high cost.

We’re not paying for a 96-core machine all day. We only pay while the job is running.

For example, if a 96-core instance costs roughly $6/hour:

$6 / hour
÷ 6
──────────
≈ $1 for a 10-minute job
Enter fullscreen mode Exit fullscreen mode

So we can throw a lot of compute at the problem, finish quickly, and then give the machine back.

And when there are no jobs? No EC2 compute; $0 cost.

That’s one of the more interesting consequences of the architecture: using a bigger machine can actually make economic sense when you only keep it for minutes.

Instead of optimizing for the cheapest server, we’re optimizing for the cost of completing a job.

The pattern is much bigger than CSV processing

None of this is particularly specific to transformations, entities, or even CSV files.

The same pattern appears anywhere you have operations that are:

ordered within a key but independent across keys.

For example:

Customer → ordered account operations
Device → ordered telemetry processing
Order → ordered state transitions
Document → ordered transformations
Portfolio → ordered financial events
Entity → ordered event replay
Enter fullscreen mode Exit fullscreen mode

Whenever you encounter a giant sequential loop, it’s worth asking:

Does the entire workload actually need to be sequential?

Often the answer is no.

Only a much smaller unit requires ordering.

Find that unit, partition around it, and suddenly a sequential workload can become massively parallel without sacrificing correctness.

That’s where multi-threading gets interesting.

Not when you create more threads.

When you figure out where you’re allowed to use them.

Top comments (0)