Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 20

Spinlock Critical Sections

Recommended resources

Hello! Welcome back.

In our last lesson, we explored pthread_barrier, a tool for synchronizing threads at specific points in a multi-stage computation. Like mutexes and semaphores, barriers are blocking primitives: when a thread waits, the operating system can put it to sleep, freeing up the CPU for other tasks.

Today, we're diving into a different kind of lock: the pthread_spinlock. This is a lock that doesn't put threads to sleep. Instead, a waiting thread will "spin" in a tight loop, actively and repeatedly checking if the lock is available. This busy-waiting approach might seem wasteful, but as you'll see, it can be a significant performance win in the right circumstances.

Your learning outcome is to implement a critical section using a pthread_spinlock and describe its use case. By the end of this lesson, you'll understand the fundamental trade-off between spinlocks and mutexes and know exactly when to choose this powerful, low-level primitive.

1. The Core Concept: Busy-Waiting vs. Sleeping

To start, let's establish a clear mental model of how a spinlock's waiting strategy differs from that of a mutex.

Parallel C++: Spinlocks

The video "Parallel C++: Spinlocks" from Nick's channel provides an excellent explanation of what spinlocks are and how they contrast with mutexes. Although the title mentions C++, the concepts and the pthread_spin_lock function discussed are directly applicable to C.

Please watch the first 1 minute and 27 seconds. Focus on the explanation of how the two lock types handle waiting for a lock to become free.

To summarize the key distinction:

  • Mutex (pthread_mutex_t): When a thread tries to lock an already-locked mutex, the operating system scheduler is invoked. The thread is put into a "sleep" state, and the CPU is yielded to another ready thread. When the mutex is unlocked, the OS must perform another context switch to "wake up" the sleeping thread. This process has significant overhead.
  • Spinlock (pthread_spinlock_t): When a thread tries to lock an already-locked spinlock, it enters a tight while loop, continuously polling the lock's state. It keeps the CPU busy, consuming cycles while it waits. This is called busy-waiting or "spinning".

This leads to the crucial question: If spinning consumes 100% of a CPU core while waiting, why would it ever be preferable to letting the thread sleep?


2. The Use Case: When is Spinning a Good Idea?

The answer lies in the trade-off between the cost of a context switch and the cost of spinning. A spinlock is more efficient if and only if the time a thread spends spinning is less than the time it would take the OS to put the thread to sleep and wake it up again.

This defines the primary use case for spinlocks:
Protecting very short, highly contended critical sections.

  • Very Short: The work done inside the lock (the critical section) must be extremely fast—typically just a few dozen instructions, like incrementing a counter or modifying a pointer. If the lock is held for only a nanosecond or two, it's much faster to spin for that duration than to pay the microsecond-scale cost of two context switches.
  • Highly Contended: If a lock is rarely contended, the overhead of either lock type is minimal. The performance difference becomes apparent when many threads are frequently trying to acquire the same lock.

If the critical section involves anything time-consuming like I/O, complex calculations, or calling another function that might block, a spinlock is the wrong choice. A spinning thread would hold a CPU core hostage, preventing other threads from doing useful work.

Test your understanding!

You are protecting a data structure with a lock. The critical section involves writing data to a file on disk. Should you use a pthread_mutex_t or a pthread_spinlock_t? Why?

Show answer

You should use a pthread_mutex_t. File I/O is a very slow operation from the CPU's perspective. A thread holding a spinlock while writing to disk would cause any other contending threads to spin uselessly for a long time, wasting immense amounts of CPU time. A mutex would allow the waiting threads to sleep, freeing the CPU for other productive work.


3. Implementing a Critical Section with pthread_spinlock

The Pthreads API for spinlocks is straightforward and mirrors the mutex API.

  • pthread_spinlock_t spin;
    Declares the spinlock variable.
  • int pthread_spin_init(pthread_spinlock_t *lock, int pshared);
    Initializes the spinlock. pshared is typically PTHREAD_PROCESS_PRIVATE, meaning the spinlock is for use by threads within a single process.
  • int pthread_spin_destroy(pthread_spinlock_t *lock);
    Destroys the spinlock and frees its resources.
  • int pthread_spin_lock(pthread_spinlock_t *lock);
    Acquires the lock. Spins if the lock is held.
  • int pthread_spin_unlock(pthread_spinlock_t *lock);
    Releases the lock.

Let's put this into practice with a classic example: a thread-safe counter. This is a perfect use case because the critical section (incrementing an integer) is extremely short.

Here is a complete C program. Study the code, compile it, and run it. Observe how the spinlock ensures the final value of the counter is correct.

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

#define NUM_THREADS 4
#define INCREMENTS_PER_THREAD 1000000

// Shared resources
long long counter = 0;
pthread_spinlock_t spinlock;

// Thread function
void* worker(void* arg) {
    for (int i = 0; i < INCREMENTS_PER_THREAD; i++) {
        // --- Critical Section Start ---
        pthread_spin_lock(&spinlock);
        counter++;
        pthread_spin_unlock(&spinlock);
        // --- Critical Section End ---
    }
    return NULL;
}

int main() {
    pthread_t threads[NUM_THREADS];

    // Initialize the spinlock
    if (pthread_spin_init(&spinlock, PTHREAD_PROCESS_PRIVATE) != 0) {
        perror("pthread_spin_init");
        return 1;
    }

    // Create threads
    for (int i = 0; i < NUM_THREADS; i++) {
        if (pthread_create(&threads[i], NULL, worker, NULL) != 0) {
            perror("pthread_create");
            return 1;
        }
    }

    // Wait for threads to finish
    for (int i = 0; i < NUM_THREADS; i++) {
        pthread_join(threads[i], NULL);
    }

    // Destroy the spinlock
    pthread_spin_destroy(&spinlock);

    long long expected = (long long)NUM_THREADS * INCREMENTS_PER_THREAD;
    printf("Final counter value: %lld\n", counter);
    printf("Expected value:      %lld\n", expected);

    if (counter == expected) {
        printf("Success! The counter is thread-safe.\n");
    } else {
        printf("Failure! Race condition detected.\n");
    }

    return 0;
}

To compile, use: gcc your_filename.c -o spinlock_test -pthread

This program demonstrates the correct implementation of a critical section using a spinlock. Without the lock, you would see a classic race condition, and the final counter value would be incorrect.


4. Under the Hood: A Look at the Assembly

Given your background in computer science, you might find it interesting to see how pthread_spin_lock is actually implemented. For maximum efficiency, it's not written in C but directly in assembly language.

The following article provides a fantastic deep dive into the glibc implementation of pthread_spinlock.

Spinlocks - Part 7

The blog post "Spinlocks - Part 7" from CoffeeBeforeArch dissects the assembly code for pthread_spin_lock and pthread_spin_unlock.

Please read the sections "The Pthread Spinlock", "pthread_spin_lock", and "pthread_spin_unlock". Don't worry about the C++ benchmark code at the top. Focus on: How LOCK decl is used to atomically try to acquire the lock. The clever use of rep nop, which acts as a pause instruction on modern CPUs. The simplicity of the pthread_spin_unlock implementation.

The key takeaway from this low-level view is the pause instruction. Instead of just a tight jmp loop, the processor is told to pause briefly. This is an optimization that helps in several ways: it reduces power consumption, avoids pipeline stalls from memory order violations, and yields execution resources on hyper-threaded cores. It's a "smarter" way to spin.

This shows that even a seemingly simple concept like busy-waiting involves clever, hardware-specific optimizations to be effective.


Conclusion

You've now learned about spinlocks, a specialized but powerful synchronization primitive. While a mutex is your general-purpose workhorse, a spinlock is a finely-tuned tool for high-performance scenarios.

Key Takeaways:

  • Spinlocks use busy-waiting, consuming CPU while waiting, whereas mutexes cause threads to sleep, yielding the CPU.
  • The primary use case for a spinlock is to protect a very short, highly contended critical section, where the overhead of context switching outweighs the cost of spinning.
  • The pthread_spinlock API (init, destroy, lock, unlock) is simple and analogous to the mutex API.
  • As a rule of thumb, when in doubt, use a mutex. A spinlock is an optimization. Only use it when you've identified a specific bottleneck and can prove that the critical section is short enough to benefit.

Preview of the Next Lesson:
In this module, we've examined several synchronization tools: mutexes, read-write locks, semaphores, barriers, and now spinlocks. Each has its own strengths and ideal use cases. In our next lesson, we will synthesize this knowledge. You will be presented with various concurrency problems and your task will be to identify the most appropriate synchronization primitive for each one, justifying your choice. This will solidify your understanding of how to choose the right tool for the job.