Лекция 4.2. Алгоритмы без блокировок: Консенсус

Консенсус

На практике не используется, это сугубо теоретическая конструкция. Консенсус одноразовый.

class Consensus:
    def decde(val):
        return val
  • Согласованность: всем потокам возвращается одно и то же значение из метода decide.
  • Обоснованность: возвращенное значение это входное значение какого-то из потоков.

С помощью блокировки консенсус тривиален:

shared int decision // init NA
Mutex mutex

def decide(val):
    mutex.lock()
    if decision == NA:
        decision = val
    mutex.unlock()
    return decision

А что если мы хотим без ожидания? Для произвольного числа потоков это невозможно.

Если с помощью класса (атомарных) объектов C и атомарных регистров можно реализовать консенсусный протокол без ожидания с помощью детерминированного алгоритма для N потоков (и не больше), то говорят, что у класса C консенсусное число равно N.

Теорема: атомарные регистры имеют консенсусное число 1.

Доказательство: пусть два потока решают задачу бинарного консенсуса.

  • Рассмотрим граф состояний. Он конечен и без циклов, т.к. алгоритм без ожидания.
  • Листья графа - 0 или 1.
  • Состояние системы x-валентно, где x=0 или 1, если консенсус во всех листьях ниже будет x.
  • Состояние бивалентно если оно не 0-валентно и не 1-валентно.
  • Критическое состояние — бивалентное состояние, дети которого не бивалентны
  • Состояние (decide(1), decide(1)) 1-валентно, т.к. оно может выдать только 1.
  • Состояние (decide(0), decide(1)) бивалентно, т.к. если переходить всегда по левому аргументу, то получится 0. Если по второму, то 1.
  • Критическое состояние есть в силу конечности дерева и существования бивалентного
  • Если из критического состояния мы переходим коммутирующими операциями, то мы придём в одну и ту же вершину.
  • Тогда пусть мы идём не коммутирующими операциями, то есть чтение/запись + запись.
  • Пусть одна операция над регистром \(w\), а другая над регистром \(g\). Тогда пусть мы выполним дальше только действия над регистром \(w\). Система будет в том же состоянии, что и после той операции над \(w\). Следовательно, мы придём в состояние валентности после той операции, что невозможно.

Таким образом, для консенсуса нужны более сильные примитивы.

Read-Modify-Write регистры

class RMWRegister:
    private shared int reg
    
    def read():
        return reg
    
    def getAndF(f):
        atomic:
            old = reg
            reg = f(reg)
            return old

Здесь f это простая операция, наподобие set или add.

Консенсусное число нетривиального RMW регистра >= 2. Нетривиально в данном случае — существование хотя бы одной подвижной точки функции, т.е. \(f(v_0) = v_1 \neq v_0\).

Common2 RMW регистры

  • \(f_1\) и \(f_2\) коммутируют, если \(f_1(f_2(x)) = f_2(f_1(x))\).
  • \(f_1\) перезаписывает \(f_2\), если \(f_1(f_2(x)) = f_1(x)\)
  • Класс C RMW регистров принадлежит Common2, если любая пара функций либо коммутирует, либо одна из функций перезаписывает другую.

Теорема: нетривиальный класс Common2 RMW регистров имеет консенсусное число 2.

Пример операций: compareAndSet: Boolean, compareAndExchange: old. Они позволяют привести к консенсусу произвольное число потоков, т.е. их консенсусное число — бесконечность. Такие объекты называются универсальными.

def decide(val):
    if CAS(NA, val):
        return val
    else:
        return read()

Универсальность консенсуса

Теорема: любой последовательный объект можно реализовать без ожидания для N потоков, используя консенсусный протокол для N потоков. Такое построение называется универсальная конструкция.

Универсальная конструкция без блокировки через CAS

shared CASRegister reg

def concurrentOperationX(args):
    loop:
        old = reg.read()
        upd = old.deepCopy() # все поля структуры
        res = upd.serialOperationX(args) # последовательная спецификация
    until reg.CAS(old, upd)
    return res

Но у нас есть две проблемы — нам нужно wait-free и на консенсусе.

Представим объект в виде списка состояний, где последний элемент — текущее состояние.

class Node:
    val # readonly
    Consensus next # init fresh cons

shared Node root # readonly
threadlocal Node last # init root

def concurrentOperationX(args):
    loop:
        old = last.val
        upd = old.deepCopy() # все поля структуры
        res = upd.serialOperationX(args) # последовательная спецификация
        node = new Node(upd)
        last = last.next.decide(node)
    until last == node # until we're accepted into the list
    return res

Мы очевидно не блокаемся, но не без ожиданий.

Идеи:

  1. Храним в узле операцию, которую надо выполнить, а не результат ее выполнения. Тогда каждый поток будет хранить и обновлять свою локальную копию объекта.
  2. Занумеруем операции
  3. Какой-то поток может отстать, проигрывая консенсус и из-за этого ожидая. Пусть каждый потом шарит другим потокам последнее известное ему значение конца списка в know[id].
  4. Нужна помощь от быстрых: будем анонсировать свою операцию, а выполнять чужую. Тогда за N шагов каждому потоку помогут.
shared Node[] know // init root

def concurrentOperationX(args):
    announce[id] = new Node(args)
    know[id] = maxSeqFrom(know)
    # loop until we’re is in list
    while announce[id].seq == 0:
        Node help = announce[know[id].seq % N]
        Node prev = help if help.seq == 0 else announce[id]
        know[id] = prev.next.decide(node)
        know[id].seq = prev.seq + 1
    know[id] = announce[id]
    return updateMyLastTo(announce[id])

def updateMyLastTo(node):
    while last != node:
        res = my.serialOperationX(last.args)
        last = last.next
    return res

В реальности строят более эффективный алгоритм на CASах, а скорее просто используют уникальный алгоритм для каждой структуры.

Очень хорошо эта идея работает на персистентных структурах (привет clojure). На самом деле Treiber stack персистентный. Для алгоритмов на CAS циклах очень просто доказать линеаризуемость — точка успешного CAS это точка линеаризации.