Test-and-set, swap and atomic instructions
Mutual exclusion with hardware help: disabling interrupts, the atomic instructions test-and-set, swap, compare-and-swap and fetch-and-add, the spinlocks built on them, why a plain test-and-set lock allows starvation, the bounded-waiting algorithm, the ticket lock, and how a lock that looks right can still fail.
Why it exists
The software solutions of the previous page work only for two processes (Peterson) or are slow (the bakery), and they assume that memory behaves in a simple order that real processors do not promise. The cure is a single machine instruction that reads a memory location and writes it again with nothing able to come in between: an atomic read-modify-write. With it, mutual exclusion for any number of threads needs only a few lines. GATE asks what these instructions do, which properties of the locks built on them hold (mutual exclusion, progress, bounded waiting, first-come first-served), and whether a given lock is correct.
Prerequisites
You need the three requirements of a solution to the critical-section problem: mutual exclusion, progress and bounded waiting.
Precise definitions
Disabling interrupts. On one processor a thread can be switched out only when an interrupt (the timer) arrives. If the thread disables interrupts on entry and enables them on exit, nobody else runs in between. It works only in the kernel (a user program must not be able to do it), only on a uniprocessor (another processor keeps running), and it delays the handling of real events if the section is long.
Atomic instruction. An instruction whose read and write of one memory location happen as a single step: no other access to that location, by any processor, is interleaved. The four classic ones, in C-like notation (each function body is ONE hardware step):
boolean test_and_set(boolean *target) { boolean old = *target; *target = true; return old; }
void swap(boolean *a, boolean *b) { boolean t = *a; *a = *b; *b = t; }
int compare_and_swap(int *v, int expected, int new)
{ int old = *v; if (old == expected) *v = new; return old; }
int fetch_and_add(int *v, int i) { int old = *v; *v = old + i; return old; }
Spinlock with test-and-set. lock is a shared variable, false (0) when free.
entry: while (test_and_set(&lock)) ; /* spin until it returns false */
critical section
exit: lock = false;
The first thread to execute test_and_set gets false back and has set the lock; everyone after it gets true and spins. The same lock can be written with swap (key = true; while (key) swap(&lock, &key);) or with compare_and_swap(&lock, 0, 1) != 0 as the spin condition.
What it guarantees.
- Mutual exclusion: yes. Only one
test_and_setcan ever return false between two releases. - Progress: yes. If the lock is free, some spinner gets it; a thread outside the competition holds nothing. The system cannot get stuck.
- Bounded waiting: no. A waiting thread can be overtaken as often as the scheduler likes, because the winner of each race is arbitrary. Waiting is not first-come first-served.
- Two threads in the critical section: impossible.
Bounded waiting with test-and-set. Shared lock and waiting[0..n-1], private key:
waiting[i] = true; key = true;
while (waiting[i] && key) key = test_and_set(&lock);
waiting[i] = false;
critical section
j = (i + 1) % n;
while (j != i && !waiting[j]) j = (j + 1) % n;
if (j == i) lock = false; else waiting[j] = false;
A leaving thread does not just free the lock: it looks for the next waiting thread in the order and hands the critical section to it by clearing its waiting flag (the lock stays true). So a waiting thread is overtaken at most times.
Ticket lock with fetch-and-add. Two shared counters, next (the next ticket to give) and now (the ticket being served):
entry: my = fetch_and_add(&next, 1); while (now != my) ;
exit: now = now + 1;
Threads enter in the order of their tickets: first come, first served, and a thread is overtaken only by those that took a ticket before it.
A lock that looks right and is wrong. Consider
acquire: while (fetch_and_add(&L, 1)) L = 1; release: L = 0;
with meaning free. A thread that finds non-zero adds 1 to it and then writes . If the holder writes between the spinner's add and the spinner's write, the spinner puts back: the lock is free but looks taken, and since nobody holds it nobody will ever write 0. Mutual exclusion still holds; the lock is simply lost. The same happens to any lock whose waiter writes the lock variable without an atomic test.
Atomic instructions and semaphores. A binary semaphore's P can be written as a spin on fetch-and-set (do fetch_and_set(x, y) while (y)) and V as x = 0: this implements mutual exclusion. Replacing the atomic instruction by an ordinary load and store (while (x) ; x = 1) is wrong, for the same reason that test-then-set fails in the earlier page. On a uniprocessor a busy-waiting P can also be useless: if context switching is disabled while it spins, the thread that would run V is never scheduled.
Busy waiting and blocking. A spinning thread burns CPU. Spinlocks pay off when the critical section is shorter than a context switch and each thread has its own processor; otherwise the thread should block (a semaphore queue), as the kernel does. Real hardware: a spinner that repeats the atomic instruction floods the memory bus with writes; implementations first spin on an ordinary read of the lock and execute the atomic instruction only when it looks free ("test and test-and-set").
How it works
The animations are real schedules found by exploring every interleaving of three looping threads. Test-and-set first: T3 is ready to try, but the shortest schedule that overtakes it three times lets T1 and T2 win the race over and over. The search shows this can go on for ever.
A test-and-set spinlock: T3 is overtaken three times: start
Interleaving found by search, not typed▶remainder sectionwhile (test_and_set(&lock)) ;critical sectionlock = 0
▶remainder sectionwhile (test_and_set(&lock)) ;critical sectionlock = 0
▶remainder sectionwhile (test_and_set(&lock)) ;critical sectionlock = 0
Step 0
lock = 0. Each thread repeats: remainder section, then spin on test_and_set(&lock) until it returns 0, critical section, lock = 0. The scheduler is free to pick any thread. T3 has reached its spin loop, but whenever the lock is free the scheduler lets T1 or T2 run test_and_set first, so T3 never gets its turn. This is the shortest schedule in which T3 is overtaken three times; the search shows it can be continued for ever, so nothing bounds T3's waiting.
The bounded-waiting version: this is the shortest schedule in which a thread enters through the hand-over (its key is still true when it enters, it did not win a test_and_set).
Bounded waiting with test-and-set: the lock is handed to a waiting thread: start
Interleaving found by search, not typed▶remainder sectionwaiting[0] = true; key = truewhile (waiting[0] && key)key = test_and_set(&lock);waiting[0] = falsecritical sectionj = (0 + 1) % 3while (j != 0 && !waiting[j])j = (j + 1) % 3;if (j == 0) lock = 0; else waiting[j] = false;
▶remainder sectionwaiting[1] = true; key = truewhile (waiting[1] && key)key = test_and_set(&lock);waiting[1] = falsecritical sectionj = (1 + 1) % 3while (j != 1 && !waiting[j])j = (j + 1) % 3;if (j == 1) lock = 0; else waiting[j] = false;
▶remainder sectionwaiting[2] = true; key = truewhile (waiting[2] && key)key = test_and_set(&lock);waiting[2] = falsecritical sectionj = (2 + 1) % 3while (j != 2 && !waiting[j])j = (j + 1) % 3;if (j == 2) lock = 0; else waiting[j] = false;
Step 0
lock = 0, waiting[] and key false. A thread announces itself with waiting[i] = true and spins until it either wins test_and_set (key = false) OR another thread, when it leaves, sets its waiting[i] to false. This is the shortest schedule in which a thread enters through the HAND-OVER (its key is still true): the leaving thread scans in the order i + 1, i + 2, ... and picks the first waiting thread, so every waiting thread is served within n - 1 turns.
The ticket lock: whatever the scheduler does between the steps, the threads enter in the order of their tickets.
A ticket lock: the threads enter in the order of their tickets: start
Interleaving found by search, not typed▶remainder sectionmy = Fetch_And_Add(&next, 1)while (now != my) ;critical sectionnow = now + 1
▶remainder sectionmy = Fetch_And_Add(&next, 1)while (now != my) ;critical sectionnow = now + 1
▶remainder sectionmy = Fetch_And_Add(&next, 1)while (now != my) ;critical sectionnow = now + 1
Step 0
next = 0 is the next ticket to give, now = 0 is the ticket being served. Each thread takes a ticket with Fetch_And_Add, waits until now equals its ticket, enters, and then adds 1 to now. Here T2 asks first, then T1, then T3, and that is exactly the order in which they enter, whatever the scheduler does in between.
The tempting fetch-and-add lock: the shortest schedule that ends with nobody inside and . From here no schedule at all ever lets a thread enter.
A tempting fetch-and-add lock: L stays non-zero although nobody holds the lock: start
Interleaving found by search, not typed▶remainder sectionwhile (Fetch_And_Add(L, 1))L = 1;critical sectionL = 0
▶remainder sectionwhile (Fetch_And_Add(L, 1))L = 1;critical sectionL = 0
▶remainder sectionwhile (Fetch_And_Add(L, 1))L = 1;critical sectionL = 0
Step 0
L = 0 means the lock is free. AcquireLock is: while (Fetch_And_Add(L, 1)) L = 1; ReleaseLock is L = 0. A thread that finds L non-zero adds 1 to it and then WRITES L = 1. This is the shortest schedule that ends with nobody in the critical section and L = 1: the lock is free, but no thread can ever get it, because every attempt now returns 1 and no thread will ever write L = 0.
A semaphore written with an ordinary load and store instead of fetch_and_set: the shortest schedule with two threads inside.
The same lock written with an ordinary test and a store: two threads enter: start
Interleaving found by search, not typed▶remainder sectionwhile (x) ;x = 1critical sectionx = 0
▶remainder sectionwhile (x) ;x = 1critical sectionx = 0
▶remainder sectionwhile (x) ;x = 1critical sectionx = 0
Step 0
x = 0. P is now: while (x) ; then x = 1, two separate instructions instead of one atomic fetch-and-set. This is the shortest schedule that puts two threads in the critical section: both see x = 0 before either stores 1.
And the properties of six locks, found by the search (not argued):
Six locks built on atomic instructions: what the search of all interleavings finds (3 threads, each looping): start
Computed by a script, not typed| lock | mutual exclusion | times a waiting thread can be overtaken | can the lock get stuck for ever? |
|---|---|---|---|
| test-and-set spinlock | |||
| swap (exchange) spinlock | |||
| compare-and-swap spinlock | |||
| test-and-set with bounded waiting | |||
| fetch-and-add lock with a reset (while (FAA(L, 1)) L = 1) | |||
| ticket lock (Fetch_And_Add) |
Step 0
Computed by exploring every interleaving of three looping threads. 'Overtaken' counts the entries of OTHER threads into the critical section while a thread is waiting (after it has registered its request); 8 is the limit at which the search stops counting.
Worked examples
EX 1What the instructions return
EasyC11 provides these operations as library functions (atomic_flag_test_and_set, atomic_exchange, atomic_compare_exchange_strong, atomic_fetch_add). This program applies each to a variable and prints what they return and what the variable holds afterwards.
#include <stdatomic.h>
#include <stdio.h>
int main(void) {
atomic_flag f = ATOMIC_FLAG_INIT;
int a = atomic_flag_test_and_set(&f);
int b = atomic_flag_test_and_set(&f);
atomic_flag_clear(&f);
int c = atomic_flag_test_and_set(&f);
printf("test_and_set: returns %d, then %d, after clear %d\n", a, b, c);
atomic_int x = 5;
int old = atomic_exchange(&x, 9);
printf("swap: returns the old value %d, x is now %d\n", old, atomic_load(&x));
int expected = 9;
int ok1 = atomic_compare_exchange_strong(&x, &expected, 12);
printf("compare_and_swap(x, 9, 12): success %d, x is now %d\n", ok1, atomic_load(&x));
expected = 9;
int ok2 = atomic_compare_exchange_strong(&x, &expected, 20);
printf("compare_and_swap(x, 9, 20): success %d, x stays %d (it found %d)\n", ok2, atomic_load(&x), expected);
int before = atomic_fetch_add(&x, 3);
printf("fetch_and_add(x, 3): returns %d, x is now %d\n", before, atomic_load(&x));
return 0;
}test_and_set: returns 0, then 1, after clear 0
swap: returns the old value 5, x is now 9
compare_and_swap(x, 9, 12): success 1, x is now 12
compare_and_swap(x, 9, 20): success 0, x stays 12 (it found 12)
fetch_and_add(x, 3): returns 12, x is now 15Reading it. test_and_set returns the old value and leaves the flag set: 0 the first time (the caller got the lock), 1 afterwards, until it is cleared. swap hands back the old value. compare_and_swap changes the variable only if it holds the expected value, and tells you what it found. fetch_and_add returns the value before the addition. Every one of them returns the OLD value; that is what makes a lock out of them.
EX 2Real locks protecting a real counter
MediumThree threads each add 1 to a plain shared counter 20000 times, protected in turn by a test-and-set spinlock, by a ticket lock, and (for comparison) by a single atomic increment. If a lock fails, the final value is below 60000.
#include <pthread.h>
#include <sched.h>
#include <stdatomic.h>
#include <stdio.h>
#define THREADS 3
#define ROUNDS 20000
static atomic_flag spin = ATOMIC_FLAG_INIT;
static atomic_int next_ticket, now_serving;
static atomic_int atomic_counter;
static int plain_a, plain_b;
static void *with_spinlock(void *arg) {
(void)arg;
for (int i = 0; i < ROUNDS; i++) {
while (atomic_flag_test_and_set(&spin)) sched_yield(); /* sched_yield only keeps a busy machine responsive */
plain_a++; /* critical section on a PLAIN variable */
atomic_flag_clear(&spin);
}
return NULL;
}
static void *with_ticket(void *arg) {
(void)arg;
for (int i = 0; i < ROUNDS; i++) {
int my = atomic_fetch_add(&next_ticket, 1);
while (atomic_load(&now_serving) != my) sched_yield();
plain_b++;
atomic_fetch_add(&now_serving, 1);
}
return NULL;
}
static void *with_fetch_add(void *arg) {
(void)arg;
for (int i = 0; i < ROUNDS; i++) atomic_fetch_add(&atomic_counter, 1);
return NULL;
}
static void run(void *(*fn)(void *)) {
pthread_t t[THREADS];
for (int i = 0; i < THREADS; i++) pthread_create(&t[i], NULL, fn, NULL);
for (int i = 0; i < THREADS; i++) pthread_join(t[i], NULL);
}
int main(void) {
run(with_spinlock);
run(with_ticket);
run(with_fetch_add);
printf("expected %d\n", THREADS * ROUNDS);
printf("test-and-set spinlock around a plain counter: %d\n", plain_a);
printf("ticket lock around a plain counter: %d\n", plain_b);
printf("atomic fetch-and-add on its own: %d\n", atomic_load(&atomic_counter));
return 0;
}expected 60000
test-and-set spinlock around a plain counter: 60000
ticket lock around a plain counter: 60000
atomic fetch-and-add on its own: 60000Reading it. All three come out at exactly 60000 on every run: the two locks serialise the plain increments, and atomic_fetch_add does the whole read-modify-write as one step, with no lock at all. The ticket lock additionally serves the threads in the order in which they took tickets. (The same program with the lock lines removed would normally print a smaller number that differs from run to run, though no single run is guaranteed to show it: that is the race of the critical-section page.)
EX 3Which statements are true of the test-and-set lock?
MediumFor while (test_and_set(&X)) ; as entry and X = 0 as exit, with X initially 0, judge the statements. (I) The solution is deadlock-free. True: whenever the lock is free some spinner acquires it, and the holder always finishes (the table shows 0 stuck states). (II) The solution is starvation-free. False: a waiting thread can be overtaken for ever (the table: no bound; the animation shows three overtakes). (III) Processes enter in FIFO order. False: a thread that arrives later can win the race first. (IV) More than one process can enter the critical section at the same time. False: mutual exclusion holds in all interleavings. So only (I) is true. A question that asks which property is missing from a test-and-set lock wants bounded waiting.
EX 4Is this lock correct?
GATE levelFor the lock while (fetch_and_add(&L, 1)) L = 1; with release L = 0, check three things in order. Can two threads be inside? Only a thread whose fetch_and_add returned 0 enters, and that happens only when was 0 and is then at least 1: no (the search confirms mutual exclusion in every interleaving). Can the lock be free while ? Yes: holder T1 in the critical section (); T2 adds 1 (, it saw 1) but has not yet written; T1 writes ; T2 now writes (the animation). The lock is free and . What then? Every thread's fetch_and_add returns non-zero, every thread writes 1, nobody writes 0: nobody ever enters. The lock fails because can take a non-zero value while the lock is actually available, not because can overflow, and it is not merely a fairness problem. A lock that is merely unfair (the test-and-set lock) still lets somebody in.
EX 5P and V from fetch-and-set
GATE levelA binary semaphore S is implemented as a variable x, with P doing do fetch_and_set x, y; while (y); and V doing x = 0. Judge: (a) it may not work if context switching is disabled in P; (b) a pair of ordinary load and store could replace fetch-and-set; (c) V is wrong; (d) it does not implement a binary semaphore. (b) is false: the animation of the load-and-store version shows two threads inside. (c) is false: releasing a spin lock is just writing 0 (nobody else can be writing it, since only the holder is past P). (d) is false: P waits while x is 1 and takes it, V frees it: a binary semaphore with busy waiting. (a) is true: on one processor, if the spinning P cannot be switched out, the thread that holds the semaphore and would call V never runs, and the spinner spins for ever (this is argued, not simulated, since the model lets the scheduler pick any thread).
Cheat box
Cheat box
- Atomic instructions return the old value:
test_and_set(sets true),swap,compare_and_swap(writes only if equal),fetch_and_add. - Test-and-set spinlock: mutual exclusion yes, progress / deadlock-free yes, bounded waiting NO, not FIFO.
- Bounded waiting needs a
waiting[]array and a hand-over to the next waiter: overtaken at most times. - Ticket lock (
fetch_and_add): FIFO, bounded waiting. - Disabling interrupts: kernel only, uniprocessor only.
- Spinning wastes CPU: good for very short sections on multiprocessors, otherwise block.
- A waiter that WRITES the lock variable without an atomic test can lose the lock.
Traps and GATE patterns
Connections
Builds on
Across subjects
These instructions are what the semaphore implementation uses inside the kernel to protect its own queue; the software solutions show what can be done with plain reads and writes, and why it is not enough. The classic problems are solved with semaphores built on these locks. Interrupt disabling, the other tool of the kernel, is the subject of interrupts.
PYQs
3 previous year questions from your book. I re-solved each one myself (0 by running or simulating, 3 by reasoning only); the book's key is shown next to my answer. All PYQs
Fetch_And_Add (X, i) is an atomic Read-Modify-
Write instruction that reads the value of memory
location X, increments it by the value i, and returns
the old value of X. It is used in the pseudocode
shown below to implement a busy-wait lock. L is
an unsigned integer shared variable initialized to 0.
The value of 0 corresponds to lock being available,
while any non-zero value corresponds to the lock
being not available.
AcquireLock(L) {
while (Fetch_And_Add(L,1))
L = 1;
}
ReleaseLock(L) {
L = 0;
}
This implementation........- (a)fails as L can overflow
- (b)fails as L can take on a non-zero value when the lock is actually available.
- (c)works correctly but may starve some ` processes.
- (d)works correctly without starvation
The enter_CS() and leave_CS() functions to
implement critical section of a process are realized
using test-and-set instruction as follows:
void enter_CS(X)
{
while test-and-set(X) ;
}
void leave_CS(X)
{
OPERATING SYSTEMS
X = 0;
}
In the above solution, X is a memory location
associated with the CS and is initialized to 0. Now
consider the following statements:
I. The above solution to the CS problem is
deadlock-free.
II.
The
solution
is
starvation
free.
III. The processes enter CS in FIFO order.
IV. More than one process can enter CS at the same
time.
Which of the above statements is TRUE?- (a)I only
- (b)I and II
- (c)II and III
- (d)IV only
The
atomic
fetch-and-set
x,
y
instruction
unconditionally sets the memory location x to 1 and
fetches the old value of x in y without allowing any
intervening access to the memory location x.
Consider the following implementation of P and V
functions on a binary semaphore S.
void P (binary_semaphore *s) {
unsigned y;
OPERATING SYSTEMS
unsigned *x = &(s->value);
do {
fetch-and-set x, y;
} while (y);
}
void V (binary_semaphore *s) {
S->value = 0;
}
Which one of the following is true?- (a)The implementation may not work if Context switching is disabled in P
- (b)Instead of using fetch-and-set, a pair of normal load/store can be used
- (c)The implementation of V is wrong
- (d)The code does not implement a binary semaphore
Self-check
Question 1
What does test_and_set(&lock) return the second time it is called, if nobody cleared the lock?
Show answerHide
1 (the old value, which the first call set).
Question 2
Which of mutual exclusion, progress and bounded waiting does the plain test-and-set spinlock lack?
Show answerHide
Bounded waiting: a waiting thread can lose every race.
Question 3
In a ticket lock, next is 5 and now is 3. How many threads hold a ticket and have not yet left the critical section?
Show answerHide
Tickets 3 and 4: two threads, one inside (ticket 3) and one waiting (ticket 4). The next arrival gets ticket 5 and waits behind them.
Question 4
compare_and_swap(&v, 0, 1) is called when v is 7. What is returned and what is v afterwards?
Show answerHide
It returns 7 (the old value) and v stays 7, since the old value was not the expected 0.
Question 5
Why does disabling interrupts fail as a general lock on a computer with two processors?
Show answerHide
It stops the other threads only on the processor that disabled them; the second processor keeps running and can enter the critical section.
Revision card
One-screen revision
- Atomic read-modify-write:
test_and_set,swap,compare_and_swap,fetch_and_add; all return the old value. - Test-and-set lock: mutual exclusion and progress yes; bounded waiting no; not FIFO.
- Bounded waiting:
waiting[]and hand-over, overtaken at most times. Ticket lock: FIFO. - Disabling interrupts: kernel, uniprocessor only.
- A free lock must never look taken: do not write the lock variable without an atomic test.
References
- ·Silberschatz, Galvin, Gagne, Operating System Concepts, Chapter 6 Synchronization Tools (hardware support for synchronization, mutex locks)
- ·Tanenbaum and Bos, Modern Operating Systems, Chapter 2 Processes and Threads (mutual exclusion with busy waiting: the TSL instruction)
- ·Stallings, Operating Systems: Internals and Design Principles, Chapter 5 Concurrency (mutual exclusion: hardware support)
Verification
The properties of the six locks (mutual exclusion in every interleaving, how often a waiting thread can be overtaken, whether the lock can get stuck) come from exhaustively exploring the interleavings of three looping threads with the model checker of scripts/gen-os-sync-traces.py (scripts/gen-os-synchw-traces.py); scripts/enum-checks/os_synchw.py encodes the locks again as explicit transition functions with a separate search and agrees on the bounds, on the number of stuck states (371) and on the number of reachable states of the bounded-waiting lock (27040) and the ticket lock (1814). The animations are real paths of that search; tests/os-synchw-traces.test.ts rechecks them. The instruction semantics and the three working locks are compiled and run by npm run verify:c (C11 atomics, POSIX threads; the output of a run with threads is the final counter, which is the same on every run if the lock works). The model assumes sequentially consistent memory and one atomic step per instruction; real processors also need the right memory ordering, which the C atomics provide. Textbook chapter titles are from memory. SECOND PASS (2026-11-02, done by the assistant, not a human check): every Self-check question was solved before the printed answer was read and all agreed; the definitions, worked examples, cheat box and traps were read claim by claim and the numbers re-derived by hand. No defect found. Status raised to self-checked; verified needs a textbook or human check.
last verified: never
My notes (saved in this browser)