# теория · шаг 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 - очередь Майкла - Скотт; на ней и её потомках стоят пулы
потоков и очереди сообщений между потоками. Приём «помогать доделать чужую операцию» - общий: он есть в
неблокирующих хеш-таблицах и списках, в универсальной конструкции следующего урока и в базах данных, где
транзакция доводит до конца чужую незавершённую запись в журнал, а не ждёт её автора.
Теперь сам
- Голова и хвост указывают на один узел, у которого есть следующий. Что это за состояние и что делает поток, решивший взять элемент?
- Почему второй CAS вставки (передвинуть хвост) может не пройти, и это не ошибка?
- Где точка линеаризации вставки - в первом CAS или во втором?
Ответ. 1. Хвост отстал: элемент привязан, но хвост не передвинут. Поток помогает - передвигает хвост CAS - и начинает попытку заново.
-
Хвост мог уже передвинуть помогавший поток. CAS увидит, что хвост не
t, и не запишет - работа уже сделана. -
В первом: с этого момента элемент виден любой выемке, проходящей по
следующий. Передвижка хвоста - уборка, которую может сделать кто угодно.
Главное
Если операцию не сделать одним CAS, её промежуточное состояние должно быть таким, чтобы любой поток мог его распознать и доделать. Помощь соседу - то, что превращает «два CAS подряд» в настоящую неблокирующую структуру.
Комментарии
Пока нет комментариев. Будь первым!
Оставить комментарий