Β§29.1Concurrent Counters

Part II OSTEP pp. 337–341 Β· ~11 min read

  • thread safe
  • perfect scaling
  • approximate counter

Before we leave locks behind, a practical detour: how do you actually use them to make everyday data structures safe for many threads at once? Adding locks so a structure can be shared is what makes it thread safe β€” but where you put them decides both whether it is correct and whether it is fast.

The Crux: How To Add Locks To Data Structures

Given a particular data structure, how should we add locks to make it work correctly? And how do we add them so the structure performs well, letting many threads access it at once?

This is a decades-deep research area (thousands of papers; Moir and Shavit’s survey is the classic map). We take a guided tour of four everyday structures β€” counter, list, queue, hash table β€” each starting from the dumbest correct design and refining only when measurement forces us to.

29.1 Concurrent Counters

A counter is about the simplest structure there is. Without locks it is trivial β€” and completely unsafe under threads:

Figure 29.1: no locks (unsafe)

typedef struct __counter_t {
    int value;
} counter_t;

void init(counter_t *c) {
    c->value = 0;
}
void increment(counter_t *c) {
    c->value++;
}
void decrement(counter_t *c) {
    c->value--;
}
int get(counter_t *c) {
    return c->value;
}

Figure 29.2: one lock (safe)

typedef struct __counter_t {
    int value;
    pthread_mutex_t lock;
} counter_t;

void init(counter_t *c) {
    c->value = 0;
    Pthread_mutex_init(&c->lock, NULL);
}
void increment(counter_t *c) {
    Pthread_mutex_lock(&c->lock);
    c->value++;
    Pthread_mutex_unlock(&c->lock);
}
// decrement/get: same lock/unlock bracket

The safe version follows the simplest possible pattern: one lock, acquired on entry to every routine and released on return β€” exactly what a monitor does automatically around object methods. You now have a working concurrent counter. If it is fast enough, you are done β€” never add complexity a simple design already handles.

Simple, but not scalable

The trouble is performance. Benchmark it: each thread increments a single shared counter one million times, on a four-CPU iMac, varying the thread count. Ideally, more CPUs finish the same-per-thread work in the same wall-clock time β€” that ideal is called perfect scaling : the work is done in parallel, so more of it does not take longer.

The single-lock counter does the opposite (the β€œPrecise” line):

Figure 29.5: Traditional vs. approximate counters. One thread finishes ~0.03s; two synchronized threads take over 5s β€” every increment fights for the one lock. (Measured on the authors’ 4-CPU iMac β€” an empirical result, not a browser simulation.)

0510151234ThreadsTime (seconds)Precise (single lock)Approximate (S = 1024)

One thread does its million updates in about 0.03 seconds; two threads take over five seconds β€” a catastrophe, and it only worsens with more threads. All the work serializes through a single lock.

Scalable counting: the approximate counter

The fix, studied for years and genuinely load-bearing for real OS performance, is the approximate counter (a.k.a. sloppy counter). Represent one logical counter as many physical counters: one local counter per CPU core (each with its own lock), plus a single global counter (with its own lock).

The approximate counter’s structure: per-CPU locals absorb the traffic; the global is touched only on the rare flush.

CPU 0local L1+ local lockCPU 1local L2+ local lockCPU 2local L3+ local lockCPU 3local L4+ local lockglobal counter G+ global lock Β· flush when a local β‰₯ S

A thread increments its local counter, synchronized only by that local’s lock β€” so threads on different CPUs never contend. Periodically, to keep the global fresh, a local’s value is transferred to the global (acquire global lock, add, reset local to 0). How often? Governed by a threshold S. Walk the book’s exact trace:

Figure 29.3, live: four local counters climb in parallel; a local flushes to the global only when it hits the threshold S = 5.
L1CPU 00SL2CPU 10SL3CPU 20SL4CPU 30Sglobal counter G0

1t = 0 β€” all zero

Four per-CPU local counters L1–L4, each guarded by its OWN lock, plus one global counter G under the global lock. Threshold S = 5. get() returns G β€” approximate by design.

step 1 / 8

The same trace as a table β€” the flush rows carry the punchline:

Figure 29.3: Tracing the approximate counters (threshold S = 5). Time flows downward; a local that reaches S transfers to G and resets. Click the flush cells.
L1L2L3L4G (global)
t = 000000
t = 100110
t = 210210
t = 320310
t = 430320
t = 541330
t = 65 β†’ 01345 (from L1)
t = 70245 β†’ 010 (from L4)
Dotted-underlined cells have explanations β€” click one.

Here is the whole implementation (Figure 29.4). Note update() only reaches for the global lock on the rare threshold crossing, and get() returns the global β€” approximate, on purpose:

typedef struct __counter_t {
    int             global;            // global count
    pthread_mutex_t glock;             // global lock
    int             local[NUMCPUS];    // per-CPU count
    pthread_mutex_t llock[NUMCPUS];    // ... and locks
    int             threshold;         // update frequency
} counter_t;

void update(counter_t *c, int threadID, int amt) {
    int cpu = threadID % NUMCPUS;
    pthread_mutex_lock(&c->llock[cpu]);
    c->local[cpu] += amt;
    if (c->local[cpu] >= c->threshold) {   // transfer to global
        pthread_mutex_lock(&c->glock);
        c->global += c->local[cpu];
        pthread_mutex_unlock(&c->glock);
        c->local[cpu] = 0;
    }
    pthread_mutex_unlock(&c->llock[cpu]);
}

int get(counter_t *c) {
    pthread_mutex_lock(&c->glock);
    int val = c->global;
    pthread_mutex_unlock(&c->glock);
    return val;                            // only approximate!
}

The threshold S is the whole knob

S trades accuracy against performance. Small S β‰ˆ the non-scalable counter (accurate global, constant flushing); large S β‰ˆ excellent scalability, but the global can lag the truth by up to CPUs Γ— S:

Figure 29.6: Four threads, one million updates each, on four CPUs, as S grows. Low S is slow (but the global is nearly exact); high S is fast (but the global lags). (Empirical β€” measured on the authors’ iMac.)

05101512481632641282565121024Approximation Factor (S)Time (seconds)Approximate, 4 threads on 4 CPUs

With S = 1024 the approximate counter’s four-million-updates-on-four-CPUs time is hardly higher than one thread’s million updates on one CPU β€” near-perfect scaling, from a structure that only occasionally synchronizes.

Tip: More Concurrency Isn’t Necessarily Faster

If your scheme adds a lot of overhead (acquiring and releasing locks frequently instead of rarely), being β€œmore concurrent” may not matter. Simple schemes tend to win, especially when they use costly routines rarely. The only sure test: build both β€” simple-but-less-concurrent and complex-but-more-concurrent β€” and measure. You can’t cheat on performance; your idea is either faster or it isn’t.

Check yourself: concurrent counters

1.The single-lock ("Precise") counter is correct, so why does it scale so badly β€” two threads taking over 5s versus 0.03s for one?

2.In the approximate counter, why can threads on different CPUs increment without contending?

3.Threshold S = 5. A local counter is at 4 and its thread increments again. What happens?

4.With four CPUs and threshold S, how far behind the true count can the global value be at worst?

5.You raise S from 4 to 1024. What do you expect?

5 questions