Algorithm Search⌘K

Fine-grained concurrent heap in Python

Binary heap with per-node locks and bit-reversed insertion order, letting inserts and deletes proceed in parallel.

File
fine_grained_concurrent_heap.py, 197 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
fine_grained_concurrent_heap.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/fine-grained-concurrent-heap/. The authors are listed in prov/5B1WV2.jsonl.

import threading
import time
from collections.abc import Generator
from typing import Any, TypeVar

R = TypeVar("R")

SPIN = "spin"  # what a step generator yields while it waits
EMPTY_HEAP = "EMPTY"  # what delete-min returns on an empty heap

EMPTY = -1  # tag of an unused slot
AVAILABLE = 0  # tag of an item that is in its place
# any other tag is the id of the thread whose insert is still moving the item up

Steps = Generator[str | None, None, R]


def run(steps: "Steps[R]") -> R:
    """Run a step generator to its end in the calling thread and return its result."""
    while True:
        try:
            if next(steps) == SPIN:
                time.sleep(0)
        except StopIteration as stop:
            return stop.value


def bit_reversed(n: int) -> int:
    """The slot of the n-th item: keep the top bit of n and reverse the bits below it."""
    top = n.bit_length() - 1
    low = n - (1 << top)
    rev = int(format(low, f"0{top}b")[::-1], 2) if top else 0
    return (1 << top) | rev


class Slot:
    __slots__ = ("lock", "tag", "pri")

    def __init__(self) -> None:
        self.lock = threading.Lock()
        self.tag = EMPTY
        self.pri: Any = None


class ConcurrentHeap:
    """The concurrent heap of Hunt, Michael, Parthasarathy and Scott (1996).

    An array heap with a lock per slot and one lock for the size. Insert
    puts its item at the bottom, in bit-reversed order, and moves it up
    while holding only a parent and a child lock at a time. Delete-min takes
    the bottom item, puts it at the root and moves it down, again with two
    locks at a time. Locks are always taken from the root downward, so
    inserts and deletes can run in parallel without deadlock. Every lock
    attempt and every field change is one step of a generator.
    """

    def __init__(self, levels: int = 10) -> None:
        self.capacity = (1 << levels) - 1
        self.slots = [Slot() for _ in range(self.capacity + 2)]  # 1-based, with spare room
        self.size_lock = threading.Lock()
        self.size = 0

    def _lock_steps(self, lock: threading.Lock) -> "Steps[None]":
        while not lock.acquire(blocking=False):
            yield SPIN
        yield None

    def _swap(self, i: int, j: int) -> None:
        a, b = self.slots[i], self.slots[j]
        a.tag, b.tag = b.tag, a.tag
        a.pri, b.pri = b.pri, a.pri

    def insert_steps(self, pri: Any, me: int) -> "Steps[None]":
        """Insert pri; me is the caller's thread id, a number of at least 1."""
        assert me > AVAILABLE
        yield from self._lock_steps(self.size_lock)  # @line L1
        if self.size == self.capacity:
            self.size_lock.release()
            raise OverflowError("heap is full")
        self.size += 1
        i = bit_reversed(self.size)
        yield from self._lock_steps(self.slots[i].lock)  # @line L2
        self.size_lock.release()
        yield None
        s = self.slots[i]  # @line L3
        s.pri, s.tag = pri, me
        yield None
        s.lock.release()
        yield None
        while i > 1:  # @line L4
            parent = i // 2
            yield from self._lock_steps(self.slots[parent].lock)  # @line L5
            yield from self._lock_steps(self.slots[i].lock)
            old = i
            p, c = self.slots[parent], self.slots[i]
            if p.tag == AVAILABLE and c.tag == me:  # @line L6
                if c.pri < p.pri:
                    self._swap(i, parent)  # @line L7
                    i = parent
                else:
                    c.tag = AVAILABLE  # @line L8
                    i = 0
            elif c.tag != me:  # @line L9
                i = parent  # a delete-min moved the item up, or took it; follow it to the root
            yield None
            self.slots[old].lock.release()  # @line L10
            self.slots[parent].lock.release()
            yield None
            if i == old:  # @line L11
                yield SPIN  # the parent holds another insert's item; wait for it to move
        if i == 1:  # @line L12
            yield from self._lock_steps(self.slots[1].lock)
            if self.slots[1].tag == me:
                self.slots[1].tag = AVAILABLE
                yield None
            self.slots[1].lock.release()
        return None

    def delete_min_steps(self) -> "Steps[Any]":
        yield from self._lock_steps(self.size_lock)  # @line L13
        if self.size == 0:
            self.size_lock.release()
            return EMPTY_HEAP
        bottom = bit_reversed(self.size)
        self.size -= 1
        yield from self._lock_steps(self.slots[bottom].lock)  # @line L14
        self.size_lock.release()
        yield None
        b = self.slots[bottom]  # @line L15
        pri = b.pri
        b.tag, b.pri = EMPTY, None
        yield None
        b.lock.release()
        yield None
        if bottom == 1:
            return pri
        yield from self._lock_steps(self.slots[1].lock)  # @line L16
        root = self.slots[1]
        if root.tag == EMPTY:
            root.lock.release()
            return pri
        pri, root.pri = root.pri, pri  # @line L17
        root.tag = AVAILABLE
        yield None
        i = 1
        while 2 * i <= self.capacity:  # @line L18
            left, right = 2 * i, 2 * i + 1
            yield from self._lock_steps(self.slots[left].lock)
            yield from self._lock_steps(self.slots[right].lock)
            ls, rs = self.slots[left], self.slots[right]
            if ls.tag == EMPTY:  # @line L19
                ls.lock.release()
                rs.lock.release()
                yield None
                break
            if rs.tag == EMPTY or ls.pri < rs.pri:
                rs.lock.release()
                child = left
            else:
                ls.lock.release()
                child = right
            yield None
            if self.slots[child].pri < self.slots[i].pri:  # @line L20
                self._swap(child, i)
                yield None
                self.slots[i].lock.release()
                i = child
            else:
                self.slots[child].lock.release()
                yield None
                break
        self.slots[i].lock.release()  # @line L21
        return pri

    def insert(self, pri: Any, me: int = 1) -> None:
        run(self.insert_steps(pri, me))

    def delete_min(self) -> Any:
        return run(self.delete_min_steps())

    # ---- checks, for a quiescent heap ----

    def items(self) -> list[Any]:
        return sorted(self.slots[bit_reversed(n)].pri for n in range(1, self.size + 1))

    def check_invariant(self) -> None:
        assert not self.size_lock.locked()
        used = {bit_reversed(n) for n in range(1, self.size + 1)}
        for i in range(1, self.capacity + 1):
            s = self.slots[i]
            assert not s.lock.locked()
            assert (s.tag == AVAILABLE) == (i in used) and (s.tag == EMPTY) == (i not in used)
            if i > 1 and i in used:
                assert self.slots[i // 2].pri <= s.pri
test_fine_grained_concurrent_heap.py 274 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/fine-grained-concurrent-heap/. The authors are listed in prov/5B1WV2.jsonl.

import random
import threading
from collections import Counter

import pytest
from fine_grained_concurrent_heap import AVAILABLE, EMPTY, EMPTY_HEAP, SPIN, ConcurrentHeap, bit_reversed

# ---- deterministic interleaving driver and linearizability check ----

Op = tuple  # (thread, name, arg, result, invoke time, response time)


def interleave(threads, schedule, limit=100000):
    """Run the operations of each thread, one atomic step at a time.

    threads[t] is a list of (name, arg, make) where make() returns the step
    generator of the operation. schedule is a sequence of thread numbers; a
    pick of a thread with no work left is skipped. After the schedule, the
    remaining steps run round robin. Returns the history as a list of Op.
    """
    todo = [list(reversed(ops)) for ops in threads]
    cur: list = [None] * len(threads)
    history: list = []
    clock = 0

    def step(t):
        nonlocal clock
        if cur[t] is None:
            if not todo[t]:
                return False
            name, arg, make = todo[t].pop()
            cur[t] = (name, arg, make(), clock)
        name, arg, gen, inv = cur[t]
        clock += 1
        try:
            next(gen)
        except StopIteration as stop:
            history.append((t, name, arg, stop.value, inv, clock))
            cur[t] = None
        return True

    for t in schedule:
        step(t)
    steps = 0
    while any(cur) or any(todo):
        for t in range(len(threads)):
            step(t)
        steps += 1
        assert steps < limit, "no progress"
    return history


def linearizable(history, init, apply):
    """Wing and Gong search: is there a legal order that respects real time?

    apply(state, name, arg) returns (new state, result); states are hashable.
    """
    n = len(history)
    full = (1 << n) - 1
    seen = set()

    def dfs(done, state):
        if done == full:
            return True
        if (done, state) in seen:
            return False
        seen.add((done, state))
        for i in range(n):
            if done >> i & 1:
                continue
            inv_i = history[i][4]
            if any(not done >> j & 1 and history[j][5] < inv_i for j in range(n)):
                continue
            s2, r = apply(state, history[i][1], history[i][2])
            if r == history[i][3] and dfs(done | 1 << i, s2):
                return True
        return False

    return dfs(0, init)


def exact_apply(state, name, arg):
    """The exact sequential priority queue on a sorted tuple."""
    if name == "insert":
        return tuple(sorted(state + (arg,))), None
    if not state:
        return state, EMPTY_HEAP
    return state[1:], state[0]


def finish(gen):
    try:
        while True:
            next(gen)
    except StopIteration as stop:
        return stop.value


# ---- tests ----


def test_bit_reversed_order():
    assert [bit_reversed(n) for n in range(1, 16)] == [1, 2, 3, 4, 6, 5, 7, 8, 12, 10, 14, 9, 13, 11, 15]
    # consecutive slots of a level alternate between the left and right subtrees of the root
    assert [bit_reversed(n) < 6 for n in range(4, 8)] == [True, False, True, False]


def test_example():
    h = ConcurrentHeap(4)
    for x in (2, 5, 4, 8, 9, 6):
        h.insert(x)
    # slots 1..7: 2, 5, 4, 8, 6, 9, empty (slot 5 holds the sixth item, slot 6 the fifth)
    assert [h.slots[i].pri for i in range(1, 7)] == [2, 5, 4, 8, 6, 9]
    ins = h.insert_steps(1, me=7)
    for _ in range(6):
        next(ins)  # the item 1 is in slot 7 (bit_reversed(7)), tagged with the inserter
    assert h.slots[7].pri == 1 and h.slots[7].tag == 7
    assert finish(ins) is None  # moves up: slot 7, slot 3, slot 1
    assert [h.slots[i].pri for i in (1, 3, 7)] == [1, 2, 4]
    assert h.delete_min() == 1  # the bottom item, 4 from slot 7, goes to the root and down
    assert [h.slots[i].pri for i in range(1, 7)] == [2, 5, 4, 8, 6, 9]
    h.check_invariant()


def test_two_deletes_are_not_linearizable():
    h = ConcurrentHeap(3)
    for x in (0, 1, 13):
        h.insert(x)  # slots 1, 2, 3
    a = h.delete_min_steps()
    while h.slots[1].pri != 13:
        next(a)  # a took 13 from slot 3 and swapped it with the root, 0
    b = h.delete_min_steps()
    while next(b) != SPIN:
        pass  # b took 1 from slot 2 and waits for the root lock
    assert finish(a) == 0  # a finds slot 2 empty and stops with 13 at the root
    assert finish(b) == 13  # b returns the root, 13, and puts 1 there
    assert h.items() == [1]
    h.check_invariant()
    history = [(0, "delete_min", None, 0, 0, 3), (1, "delete_min", None, 13, 1, 4)]
    assert not linearizable(history, (0, 1, 13), exact_apply)


def test_empty_while_an_item_is_held():
    h = ConcurrentHeap(3)
    for x in (2, 5):
        h.insert(x)  # slots 1 and 2
    d1 = h.delete_min_steps()
    while h.slots[2].tag != EMPTY:
        next(d1)  # d1 took 5 from slot 2 and holds it
    assert finish(h.delete_min_steps()) == 2  # d2 takes the root, the last slot
    assert finish(h.delete_min_steps()) == EMPTY_HEAP  # d3 sees size 0, but 5 is in d1's hands
    finish(h.insert_steps(9, 1))  # the insert puts 9 at the root
    assert finish(d1) == 9  # d1 swaps 5 into the root and returns 9
    assert h.items() == [5]
    h.check_invariant()
    # all four calls overlap d1; no order of exact calls explains EMPTY while 5 stays
    history = [
        (0, "delete_min", None, 9, 0, 9),
        (1, "delete_min", None, 2, 1, 2),
        (2, "delete_min", None, EMPTY_HEAP, 3, 4),
        (3, "insert", 9, None, 5, 6),
    ]
    assert not linearizable(history, (2, 5), exact_apply)


def test_paused_insert_whose_path_empties():
    # An insert writes its item and pauses. Deletes move the item up past its slot and
    # then empty the slots below it. The insert must climb to its item, not stop at an
    # empty parent, or the tag stays and later inserts below it wait for ever.
    for seed in (1017, 1710):
        rng = random.Random(seed)
        h = ConcurrentHeap(6)
        for _ in range(rng.randrange(3, 30)):
            h.insert(rng.randrange(0, 1000), 99)
        paused = h.insert_steps(rng.randrange(0, 1000), 1)
        while not any(s.tag == 1 for s in h.slots):
            next(paused)
        next(paused)
        for _ in range(rng.randrange(1, 15)):
            finish(h.insert_steps(rng.randrange(0, 1000), 98))
        for _ in range(rng.randrange(1, 60)):
            finish(h.delete_min_steps())
        finish(paused)
        h.check_invariant()


def test_edges():
    h = ConcurrentHeap(2)
    assert h.delete_min() == EMPTY_HEAP
    for x in (3, 1, 2):
        h.insert(x)
    with pytest.raises(OverflowError):
        h.insert(0)
    h.check_invariant()
    assert [h.delete_min() for _ in range(4)] == [1, 2, 3, EMPTY_HEAP]
    assert all(s.tag == EMPTY for s in h.slots)
    h.insert(7)
    assert h.slots[1].tag == AVAILABLE and h.items() == [7]


def test_random_against_model():
    for seed in range(30):
        rng = random.Random(seed)
        h = ConcurrentHeap(7)
        model = []
        for _ in range(500):
            if rng.random() < 0.55 and h.size < h.capacity:
                x = rng.randrange(100)
                h.insert(x)
                model.append(x)
            else:
                x = h.delete_min()
                assert x == (min(model) if model else EMPTY_HEAP)
                if model:
                    model.remove(x)
        h.check_invariant()
        assert h.items() == sorted(model)


def test_random_interleavings_conserve_items():
    for seed in range(400):
        rng = random.Random(seed)
        h = ConcurrentHeap(5)
        init = [rng.randrange(20) for _ in range(rng.randrange(0, 6))]
        for x in init:
            h.insert(x, 9)
        threads = []
        for t in range(3):
            ops = []
            for _ in range(rng.randrange(1, 4)):
                if rng.random() < 0.5:
                    x = rng.randrange(20)
                    ops.append(("insert", x, lambda x=x, t=t: h.insert_steps(x, t + 1)))
                else:
                    ops.append(("delete_min", None, h.delete_min_steps))
            threads.append(ops)
        history = interleave(threads, [rng.randrange(3) for _ in range(rng.randrange(10, 200))])
        h.check_invariant()  # a quiescent heap is a valid heap
        inserted = Counter(init) + Counter(e[2] for e in history if e[1] == "insert")
        taken = Counter(e[3] for e in history if e[1] == "delete_min" and e[3] != EMPTY_HEAP)
        assert not taken - inserted  # every item returned was inserted, and at most once
        assert Counter(h.items()) == inserted - taken  # nothing is lost
        # a call that overlaps no other call behaves like the sequential queue
        for e in history:
            if not any(f is not e and f[4] < e[5] and e[4] < f[5] for f in history):
                before = [f for f in history if f[5] <= e[4]]
                state = Counter(init) + Counter(f[2] for f in before if f[1] == "insert")
                state -= Counter(f[3] for f in before if f[1] == "delete_min" and f[3] != EMPTY_HEAP)
                if e[1] == "delete_min":
                    assert e[3] == (min(state.elements()) if state else EMPTY_HEAP)


def test_threads():
    h = ConcurrentHeap(12)
    out = [[] for _ in range(4)]

    def worker(w):
        for i in range(400):
            h.insert(w * 1000 + i, me=w + 1)
            if i % 2:
                out[w].append(h.delete_min())

    ts = [threading.Thread(target=worker, args=(w,)) for w in range(4)]
    for t in ts:
        t.start()
    for t in ts:
        t.join()
    h.check_invariant()
    taken = [x for o in out for x in o]
    assert EMPTY_HEAP not in taken
    assert sorted(taken + h.items()) == sorted(w * 1000 + i for w in range(4) for i in range(400))

What the next level needs

  • the tests did not run in this build

Read the full page about Fine-grained concurrent heap