Β§29.2–29.3Concurrent Linked Lists … Concurrent Queues

Part II OSTEP pp. 342–345 Β· ~9 min read

  • hand-over-hand locking

The counter taught the pattern: start with one big lock, refine only if you must. Now the same story for the structures threads actually pass around β€” lists, queues, hash tables β€” where where you lock starts to really matter.

29.2 Concurrent Linked Lists

Focus on insert and lookup. The obvious version takes the lock on entry and drops it on exit β€” but watch the error path:

Figure 29.7: lock the whole routine

int List_Insert(list_t *L, int key) {
    pthread_mutex_lock(&L->lock);
    node_t *new = malloc(sizeof(node_t));
    if (new == NULL) {
        perror("malloc");
        pthread_mutex_unlock(&L->lock); // must
        return -1;                      // unlock
    }                                   // on the
    new->key  = key;                    // error
    new->next = L->head;                // path too!
    L->head   = new;
    pthread_mutex_unlock(&L->lock);
    return 0;
}

Figure 29.8: lock only the critical section

void List_Insert(list_t *L, int key) {
    // synchronization not needed
    node_t *new = malloc(sizeof(node_t));
    if (new == NULL) {
        perror("malloc");
        return;              // no unlock needed!
    }
    new->key = key;

    // just lock the critical section
    pthread_mutex_lock(&L->lock);
    new->next = L->head;
    L->head   = new;
    pthread_mutex_unlock(&L->lock);
}

The rewrite works because malloc() is itself thread-safe β€” only the shared-list update truly needs the lock. Pulling allocation outside the lock means the malloc-failure path no longer has a lock to release, so the easy-to-forget unlock-before-return simply vanishes. That matters: a study of Linux kernel patches found nearly 40% of bugs live on such rarely-taken error paths. lookup gets the same treatment β€” one lock/unlock around a single common exit instead of an unlock at every return.

Tip: Be Wary Of Locks And Control Flow

Control-flow that jumps to a return, exit, or error midway through a function is dangerous: a function that has already grabbed a lock or allocated memory must undo all that state before bailing out β€” and it’s easy to miss one. Structure code to minimize these paths.

Scaling linked lists: hand-over-hand locking

One big lock serializes the whole list. Researchers tried hand-over-hand locking (a.k.a. lock coupling): a lock per node, grabbing the next node’s lock before releasing the current one. Step through the motion:

Hand-over-hand locking (lock coupling): a per-node lock, and the traversal never lets go of one node before grabbing the next.
head β†’11πŸ”’LOCKED22πŸ”“free33πŸ”“freeNULL

1Lock the head node

Instead of one lock for the whole list, every node has its own. The traversal begins by acquiring node 11's lock.

step 1 / 6

Conceptually it enables lots of concurrency. In practice it is hard to beat a single lock: acquiring and releasing a lock at every node of a traversal is so expensive that even large lists and many threads rarely come out ahead. Perhaps a hybrid β€” a new lock every few nodes β€” would pay off. (Measure before believing.)

29.3 Concurrent Queues

For a queue you could just add one big lock (you know how by now). Michael and Scott did better: two locks β€” one for the head, one for the tail β€” so enqueues and dequeues can run at the same time. The enabling trick is a dummy node, allocated at init, that separates the head and tail operations:

typedef struct __queue_t {
    node_t          *head;
    node_t          *tail;
    pthread_mutex_t head_lock, tail_lock;
} queue_t;

void Queue_Init(queue_t *q) {
    node_t *tmp = malloc(sizeof(node_t));  // the dummy
    tmp->next = NULL;
    q->head = q->tail = tmp;               // both point at it
    pthread_mutex_init(&q->head_lock, NULL);
    pthread_mutex_init(&q->tail_lock, NULL);
}

void Queue_Enqueue(queue_t *q, int value) {
    node_t *tmp = malloc(sizeof(node_t));
    tmp->value = value;
    tmp->next  = NULL;
    pthread_mutex_lock(&q->tail_lock);     // tail end only
    q->tail->next = tmp;
    q->tail = tmp;
    pthread_mutex_unlock(&q->tail_lock);
}

int Queue_Dequeue(queue_t *q, int *value) {
    pthread_mutex_lock(&q->head_lock);     // head end only
    node_t *tmp = q->head;
    node_t *new_head = tmp->next;
    if (new_head == NULL) {
        pthread_mutex_unlock(&q->head_lock);
        return -1;                         // queue was empty
    }
    *value = new_head->value;
    q->head = new_head;
    pthread_mutex_unlock(&q->head_lock);
    free(tmp);
    return 0;
}

Watch enqueue touch only tail_lock and dequeue only head_lock β€” and, crucially, run at the same time:

The Michael & Scott two-lock queue: a head lock, a tail lock, and a dummy node that keeps the two ends apart.
dummyNULLβ–² head_lockβ–² tail_lock

1Init: one dummy node

The queue starts with a single DUMMY node (dashed). Both head and tail point to it. An empty queue that still contains a node means head and tail never fight over the same real element β€” the reason two independent locks are safe.

step 1 / 4

This locks-only queue is common, but it still can’t make a thread wait when the queue is empty or full β€” a bounded, blocking queue is exactly the job of condition variables in the next chapter. Watch for it.

Choosing a strategy

Four ways to lock a structure β€” and why "more concurrent" often loses. Click a verdict.
concurrency enabledlock overheadverdict
One big lock (whole routine)one thread inside the entire structure at a timeone acquire/release per operationcorrect + simple β€” the right default
Lock only the critical section (Fig 29.8)same, but malloc / setup run unlockedone acquire/release, held for less timestrictly better β€” fewer bug-prone paths
Hand-over-hand (lock per node)multiple traversals overlap in different regionsan acquire + release PER NODE crossedelegant, but usually slower in practice
Two-lock queue (Michael & Scott)enqueue and dequeue proceed simultaneouslytwo locks + a dummy nodea genuine win β€” for queues
Dotted-underlined cells have explanations β€” click one.

Check yourself: lists and queues

1.Figure 29.8 moves malloc() OUTSIDE the lock and locks only the list update. Why is that not just faster but also less bug-prone?

2.During hand-over-hand traversal, why grab the NEXT node's lock before releasing the current node's β€” briefly holding two at once?

3.Hand-over-hand locking enables lots of concurrency, yet the book says it usually loses to a single big lock. Why?

4.In the Michael & Scott queue, what lets an enqueue and a dequeue run at the same time?

5.Why does the queue keep a dummy node, and what can it still NOT do?

5 questions