1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10

# теория · шаг 3 из 10

Очередь Майкла - Скотт: два конца и помощь соседу

Коротко

У очереди два конца, и одним CAS их не поменять. Очередь Майкла - Скотт (1996) держит фиктивный узел в начале и два указателя: голова - на фиктивный, хвост - на последний узел или на предпоследний. Вставка делает два CAS: сначала привязывает новый узел к хвост.следующий, потом передвигает хвост; между ними хвост отстаёт на один узел. Главный приём - помощь: поток, заметивший отставший хвост, сам передвигает его и только потом пробует своё. Без помощи один поток, уснувший между двумя CAS, останавливает остальных навсегда - в модели урока соседу не хватило 100 шагов; с помощью сосед закончил за 7. На этой очереди построен ConcurrentLinkedQueue в Java.

Если ты знаешь, зачем очереди Майкла - Скотт фиктивный узел, почему хвост может отставать и как помощь делает очередь lock-free, листай до «Теперь сам».

Как думаешь, что случится

Вставка в связную очередь - это две записи: прицепить новый узел к последнему и передвинуть указатель хвоста. Каждую можно сделать CAS, но вместе атомарно - нельзя. Поток сделал первую и уснул. Другой поток хочет вставить свой элемент: он читает хвост и видит, что у «последнего» узла уже есть следующий. Что ему делать?

Ответь себе до чтения. Ждать, пока первый проснётся, - значит снова зависеть от чужого сна, как с замком. Ответ Майкла и Скотт - доделать чужую работу за него.

На что это похоже

Очередь в поликлинике по талонам и табло «последний выданный номер». Новый пациент берёт талон и сам обновляет табло. Если кто-то взял талон, но не обновил табло и отошёл, следующий не стоит в растерянности: он видит, что после «последнего» на табло уже есть человек, переводит табло на него и берёт свой талон. Ничья забывчивость не останавливает очередь.

Механизм: фиктивный узел, два CAS и помощь

Узел - [значение, следующий]. Очередь начинается с фиктивного узла без значения: голова всегда указывает на него, первый настоящий элемент - его следующий. После выемки фиктивным становится бывший первый узел: его значение уже выдано, и голова теперь указывает на него. Фиктивный узел убирает особый случай пустой очереди: голове и хвосту всегда есть на что указывать.

поставить(x):
    узел = новый узел x
    повторять:
        t = хвост;  n = t.следующий
        если хвост != t: заново
        если n пусто:
            # точка линеаризации - этот CAS
            если cas(t.следующий, пусто, узел):
                cas(хвост, t, узел)     # может не пройти - не страшно
                выйти
        иначе:
            cas(хвост, t, n)            # ПОМОЩЬ: хвост отстал

взять():
    повторять:
        h = голова;  t = хвост;  n = h.следующий
        если голова != h: заново
        если h == t:
            если n пусто: вернуть «пусто»
            cas(хвост, t, n)            # ПОМОЩЬ
        иначе:
            # n станет новым фиктивным узлом
            если cas(голова, h, n): вернуть n.значение

Инвариант: хвост указывает на последний узел или на предпоследний. Отставание на один - нормальное промежуточное состояние, и любой поток, который его видит, исправляет его одним CAS. «Взять» помогает тоже: если голова и хвост совпадают, но у узла есть следующий, хвост отстал, и снять элемент, не передвинув хвост, значит оставить хвост указывать на снятый узел.

Почему lock-free: каждый провал CAS - либо чужая успешная вставка или выемка, либо чужая помощь, которая двигает хвост. Ни один поток не ждёт другого.

Правило

Очередь Майкла - Скотт: фиктивный узел, голова на него, хвост на последний или предпоследний. Вставка - CAS хвост.следующий (точка линеаризации), затем CAS хвоста; выемка - CAS головы на следующий узел. Увидел отставший хвост - передвинь его сам. Без помощи остановка одного потока между двумя CAS останавливает всех вставляющих.

Разбор: все чередования трёх потоков

Модель как в прошлых уроках: шаг - одно атомарное действие с общей памятью. Поток 0 ставит X, поток 1 - Y, поток 2 берёт один элемент. Обходим все достижимые состояния и собираем итоги. Второй опыт: поток 0 привязал свой узел и уснул до CAS хвоста; поток 1 вставляет один, без помощи и с ней.

# очередь Майкла - Скотт в модели: узел [значение, следующий], узел 0 -
# фиктивный; голова указывает на фиктивный, хвост - на последний или
# отстаёт на один. Шаг потока - одно атомарное действие.

def nachalo(programmy):
    k = len(programmy)
    return {"uzly": [[None, -1]], "golova": 0, "hvost": 0,
            "pc": [0] * k, "op": [0] * k, "h": [-1] * k, "t": [-1] * k,
            "n": [-1] * k, "x": [-1] * k, "vyshlo": []}

def kopiya(s):
    uzly = []
    for u in s["uzly"]:
        uzly.append(u[:])
    s2 = {"uzly": uzly, "golova": s["golova"], "hvost": s["hvost"],
          "vyshlo": s["vyshlo"][:]}
    for pole in ["pc", "op", "h", "t", "n", "x"]:
        s2[pole] = s[pole][:]
    return s2

def gotovo(s, p):
    s["op"][p] = s["op"][p] + 1
    s["pc"][p] = 0

def shag(s, p, programmy, pomoshch):
    op = programmy[p][s["op"][p]]
    pc = s["pc"][p]
    if op != "deq":                              # поставить в очередь op
        if pc <= 1:
            if pc == 0:            # свой узел - приватный шаг, склеен
                s["uzly"].append([op, -1])       # с чтением хвоста
                s["x"][p] = len(s["uzly"]) - 1
            s["t"][p] = s["hvost"]
            s["pc"][p] = 2
        elif pc == 2:
            s["n"][p] = s["uzly"][s["t"][p]][1]
            s["pc"][p] = 3
        elif pc == 3:
            t = s["t"][p]
            s["pc"][p] = 1                       # по умолчанию - заново
            if s["hvost"] != t:
                return
            if s["n"][p] == -1:
                if s["uzly"][t][1] == -1:        # CAS t.next: -1 -> x
                    s["uzly"][t][1] = s["x"][p]
                    s["pc"][p] = 4
            elif pomoshch and s["hvost"] == t:   # хвост отстал: помочь
                s["hvost"] = s["n"][p]
        else:
            if s["hvost"] == s["t"][p]:          # CAS хвоста: t -> x
                s["hvost"] = s["x"][p]
            gotovo(s, p)
    else:                                        # взять из очереди
        if pc == 0:
            s["h"][p] = s["golova"]
            s["pc"][p] = 1
        elif pc == 1:
            s["t"][p] = s["hvost"]
            s["pc"][p] = 2
        elif pc == 2:
            s["n"][p] = s["uzly"][s["h"][p]][1]
            s["pc"][p] = 3
        else:
            h = s["h"][p]
            n = s["n"][p]
            s["pc"][p] = 0
            if s["golova"] != h:
                return
            if h == s["t"][p]:
                if n == -1:
                    s["vyshlo"].append("пусто")
                    gotovo(s, p)
                elif pomoshch and s["hvost"] == h:
                    s["hvost"] = n
            elif s["golova"] == h:               # CAS головы: h -> n
                s["golova"] = n
                s["vyshlo"].append(s["uzly"][n][0])
                gotovo(s, p)

def soderzhimoe(s):
    out = []
    v = s["uzly"][s["golova"]][1]
    while v != -1:
        out.append(s["uzly"][v][0])
        v = s["uzly"][v][1]
    return out

def obojti(s, programmy, vidennye, itogi):
    klyuch = str(s)
    if klyuch in vidennye:
        return
    vidennye[klyuch] = True
    hodit = []
    for p in range(len(programmy)):
        if s["op"][p] < len(programmy[p]):
            hodit.append(p)
    if len(hodit) == 0:
        itog = str(soderzhimoe(s)) + " выдано " + str(s["vyshlo"])
        if itog not in itogi:
            itogi.append(itog)
        return
    for p in hodit:
        s2 = kopiya(s)
        shag(s2, p, programmy, True)
        obojti(s2, programmy, vidennye, itogi)

programmy = [["X"], ["Y"], ["deq"]]
vidennye = {}
itogi = []
obojti(nachalo(programmy), programmy, vidennye, itogi)
itogi.sort()
print("состояний:", len(vidennye), "итоги:")
for i in itogi:
    print("  ", i)

# поток 0 связал узел X и уснул до CAS хвоста; поток 1 работает один
for pomoshch in [False, True]:
    programmy = [["X"], ["Y"]]
    s = nachalo(programmy)
    for i in range(3):
        shag(s, 0, programmy, pomoshch)
    shagov = 0
    while s["op"][1] < 1 and shagov < 100:
        shag(s, 1, programmy, pomoshch)
        shagov = shagov + 1
    print("помощь:", pomoshch, "- поток 1 сделал", shagov,
          "шагов, закончил:", s["op"][1] == 1, "очередь", soderzhimoe(s))
состояний: 1059 итоги:
   ['X', 'Y'] выдано ['пусто']
   ['X'] выдано ['Y']
   ['Y', 'X'] выдано ['пусто']
   ['Y'] выдано ['X']
помощь: False - поток 1 сделал 100 шагов, закончил: False очередь ['X']
помощь: True - поток 1 сделал 7 шагов, закончил: True очередь ['X', 'Y']

Четыре итога - ровно те, что возможны при последовательном исполнении: выемка успела до обеих вставок («пусто», а X и Y в любом порядке) или после одной из них (выдан первый вставленный, второй остался). Нет ни потерянного элемента, ни выданного дважды, ни выдачи второго при оставшемся первом.

Разбор: зачем помогать

Второй опыт - ради него и придумана помощь. Поток 0 привязал X к фиктивному узлу и уснул, хвост отстал. Без помощи поток 1 читает хвост, видит, что у хвоста уже есть следующий, и начинает заново - и так сто шагов подряд, и тысячу: он ждёт, пока поток 0 передвинет хвост, то есть зависит от чужого сна, как от замка. С помощью поток 1 сам передвигает хвост на X (один CAS), читает его снова, видит пустой «следующий» и вставляет Y - за 7 шагов. Граница правила «CAS делает структуру неблокирующей»: CAS в каждом шаге ещё не гарантия; если промежуточное состояние может исправить только его автор, структура блокирующая.

Что ломается без этого

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

Ожидание вместо помощи. Цикл «пока хвост отстал - перечитай» выглядит неблокирующим, но поток, уснувший между двумя CAS, останавливает всех вставляющих.

Освобождение снятого узла. Узел, ставший фиктивным, ещё читают другие потоки; в C и C++ здесь та же проблема освобождения и ABA, что в прошлом уроке, - Херлихи и Шавит показывают ABA именно на этой очереди с пулами узлов.

Зачем это в работе

java.util.concurrent.ConcurrentLinkedQueue - очередь Майкла - Скотт; на ней и её потомках стоят пулы потоков и очереди сообщений между потоками. Приём «помогать доделать чужую операцию» - общий: он есть в неблокирующих хеш-таблицах и списках, в универсальной конструкции следующего урока и в базах данных, где транзакция доводит до конца чужую незавершённую запись в журнал, а не ждёт её автора.

Теперь сам

  1. Голова и хвост указывают на один узел, у которого есть следующий. Что это за состояние и что делает поток, решивший взять элемент?
  2. Почему второй CAS вставки (передвинуть хвост) может не пройти, и это не ошибка?
  3. Где точка линеаризации вставки - в первом CAS или во втором?

Ответ. 1. Хвост отстал: элемент привязан, но хвост не передвинут. Поток помогает - передвигает хвост CAS - и начинает попытку заново.

  1. Хвост мог уже передвинуть помогавший поток. CAS увидит, что хвост не t, и не запишет - работа уже сделана.

  2. В первом: с этого момента элемент виден любой выемке, проходящей по следующий. Передвижка хвоста - уборка, которую может сделать кто угодно.

Главное

Если операцию не сделать одним CAS, её промежуточное состояние должно быть таким, чтобы любой поток мог его распознать и доделать. Помощь соседу - то, что превращает «два CAS подряд» в настоящую неблокирующую структуру.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.