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 thread safe A data structure with locks added so multiple threads may use it concurrently and still get correct results. Exactly where the locks go decides both correctness and performance. defined in ch. 29 β open in glossary β 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 bracketThe 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 perfect scaling Threads finish work on many CPUs just as fast as one thread does its share on one CPU β more total work, done in parallel, so wall-clock time does not grow. defined in ch. 29 β open in glossary : 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.)
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 approximate counter A scalable counter (a.k.a. sloppy counter) representing one logical count as per-CPU local counters (each with a local lock) plus a single global counter/lock. Threads bump their local contention-free; a local is flushed to the global and reset once it reaches threshold S β trading accuracy (the global lags by up to CPUsΓS) for scalability. defined in ch. 29 β open in glossary (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.
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:
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.
The same trace as a table β the flush rows carry the punchline:
| L1 | L2 | L3 | L4 | G (global) | |
|---|---|---|---|---|---|
| t = 0 | 0 | 0 | 0 | 0 | 0 |
| t = 1 | 0 | 0 | 1 | 1 | 0 |
| t = 2 | 1 | 0 | 2 | 1 | 0 |
| t = 3 | 2 | 0 | 3 | 1 | 0 |
| t = 4 | 3 | 0 | 3 | 2 | 0 |
| t = 5 | 4 | 1 | 3 | 3 | 0 |
| t = 6 | 5 β 0 | 1 | 3 | 4 | 5 (from L1) |
| t = 7 | 0 | 2 | 4 | 5 β 0 | 10 (from L4) |
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.)
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?