liveBook · Manning

Fine-grained locking

As explained in C++ Concurrency in Action, Second Edition

Fine-grained locking protects smaller portions of shared data with separate locks rather than one broad lock.

Fine-grained locking gives distinct parts of shared data separate mutexes. A queue wrapped in one mutex is simple, but only one thread can work on it at a time. To let a push at the tail overlap with a pop at the head, the implementation must expose those parts rather than treat a standard queue as one protected item. section 6.2.2 section 6.2.3

A singly linked queue appears to offer a natural split: protect its head pointer with one mutex and its tail pointer with another. The straightforward design has a catch. Removing the last item changes both pointers, and adding to an empty queue does too. Those operations would need both locks, undermining the intended separation. section 6.2.3

A permanent dummy node makes the split workable:

  • Start with head and tail pointing to the dummy node. The queue is empty when those pointers are equal. section 6.2.3
  • To push, prepare a value and a new node, lock the tail, put the value in the old dummy node, link the new dummy node, and advance tail. section 6.2.3
  • To pop, lock the head, check it against the tail, then move head to the next node and return the old head’s value. A nonempty queue keeps a distinct dummy node at the tail. section 6.2.3
Listing 6.6. A thread-safe queue with fine-grained locking
template<typename T>
class threadsafe_queue
{
private:
    struct node
    {
        std::shared_ptr<T> data;
        std::unique_ptr<node> next;
    };
    std::mutex head_mutex;
    std::unique_ptr<node> head;
    std::mutex tail_mutex;
    node* tail;
    node* get_tail()
    {
        std::lock_guard<std::mutex> tail_lock(tail_mutex);
        return tail;
    }
    std::unique_ptr<node> pop_head()
    {
        std::lock_guard<std::mutex> head_lock(head_mutex);

        if(head.get()==get_tail())
        {
            return nullptr;
        }
        std::unique_ptr<node> old_head=std::move(head);
        head=std::move(old_head->next);
        return old_head;
    }
public:
    threadsafe_queue():
        head(new node),tail(head.get())
    {}
    threadsafe_queue(const threadsafe_queue& other)=delete;
    threadsafe_queue& operator=(const threadsafe_queue& other)=delete;
    std::shared_ptr<T> try_pop()
    {
        std::unique_ptr<node> old_head=pop_head();
        return old_head?old_head->data:std::shared_ptr<T>();
    }
    void push(T new_value)
    {
        std::shared_ptr<T> new_data(
            std::make_shared<T>(std::move(new_value)));
        std::unique_ptr<node> p(new node);
        node* const new_tail=p.get();
        std::lock_guard<std::mutex> tail_lock(tail_mutex);
        tail->data=new_data;
        tail->next=std::move(p);
        tail=new_tail;
    }
};

The two-mutex queue shows the key boundary: popping holds head_mutex and briefly obtains tail_mutex to check for emptiness, while pushing updates the tail side. section 6.2.3

Adding a blocking pop

A waiting consumer holds the head lock while checking whether head differs from tail. A condition-variable wait releases that lock until data is available; a producer finishes its tail update and notifies a waiter. The empty check still needs a synchronized read of tail. section 6.2.3

Smaller lock scopes can reduce contention, not eliminate synchronization costs. An operation that genuinely needs several protected items may still need several mutexes, bringing a deadlock risk. Choose lock boundaries around the data each operation actually changes, then check every operation that crosses them. section 3.2.3 section 6.2.3

How C++ Concurrency in Action, Second Edition covers it

How deep it goes
Works through a linked-queue baseline, dummy-node redesign, two-mutex implementation, and condition-variable waiting.
Examples
Compares single-mutex and linked queues, then uses push, try_pop, and wait_and_pop implementations to expose lock boundaries.
What it assumes
Assumes C++ pointers, smart pointers, and mutexes while building the linked-queue layout from a single-threaded starting point.
What you can do after
Build it from scratch as a C++ queue whose producers and consumers can work at opposite ends under separate locks.
How it connects
Connects mutex granularity to contention, deadlock, linked queues, dummy nodes, and condition variables.

Also explained in

Compare how 2 books explain fine-grained locking →

Related concepts

This explainer was generated by Manning's knowledge graph from the text of C++ Concurrency in Action, Second Edition. The section links point to the parts of the book it draws on.