Лекция 10. Железо и спинлоки

У ядер есть кеш. Это проблема, ибо если одно ядро записало в свой кеш, а другое ядро прочитало старое закешированное значение, то мы проиграли. У современных процессоров кеш когерентен, поэтому таких проблем нет. Это реализовано через протокол когерентности, одним из таких является MESI (в реальных процессорах чуть сложнее).

У каждого кешлайна в кеше есть статус:

  • Modified
  • Exclusive
  • Shared
  • Invalid

Изначально статус I, при получении данных из памяти получается E (только в одном кеше). Если другое ядро читает E-кешлайн первого ядра, то на запрос отвечает кеш первого ядра и в обоих кешах эта линия становится S. При изменении значения статус становится M, а в остальных кешах линия инвалидируется.

Если состояние IM, то первое ядро прочитает из кеша второго и опять станет SS. В память мы пишем при flush из кеша.

Простейший Test-And-Set lock:

class TASLock:
    boolean locked
    
    def lock():
        # сбрасывает все кеши, т.к. требует Exclusive и забивает шину
        while locked.getAndSet(true):
            pass
    
    def unlock():
        locked = false

Применим double-checked locking - добавим while locked: pass. Таким образом, ожидание не создает трафик. Но в момент unlock все ядра пытаются захватить лок и опять кушают ресурсы. Добавим задержку (backoff) в цикл в lock. Тогда не все будут ломиться одновремнно и пока задержка пройдет, возможно другой поток уже возьмет лок, тогда мы не будем пытаться его брать.

CLH Lock

Этот лок first-come first-served.

# padнуто до размера кешлинии
class QNode:
    boolean locked # shared, atomic

class CLHLock:
    tail = QNode() # shared, atomic
    threadlocal my = QNode()
    
    def lock():
        my.locked = True
        pred = tail.getAndSet(my)
        while pred.locked: pass

    def unlock():
        my.locked = False
        my = pred

Получается неявная очередь, где каждый ждет предыдущий поток. Теперь при unlock только один поток побежит в шину.

В NUMA системах это не очень хорошо работает, потому что мы ждём на памяти предыдущего процессора.

MCS

Тоже FCFS, но ждёт на своей памяти. В ноду добавляем указатель на следующую вершину.

class CLHLock:
    tail = QNode() # shared, atomic
    threadlocal my = null
    
    def lock():
        my = QNode() # alloc in my memory!
        my.locked = True
        pred = tail.getAndSet(my)
        if pred != null:
            pred.next = my
            while my.locked: pass
    
    def unlock():
        if my.next == null:
            if tail.CAS(my, null): # мы хвост, действительно никого больше нет
                return
            else: # мы не хвост, просто кто-то не успел выставить
                while my.next == null:  # ждем пока не проставят
                    pass
        my.next.locked = False

И у MCS и у CHL есть одна проблема: tail меняется всеми потоками и это большая конкуренция. Она неисправима, если мы хотим FCFS lock, то нам нужно синхронизироваться насчет порядка. При очень большом числе ядер это слишком проблематично, проще отказаться от FCFS.

Lock cohorting

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

  • Глобальный лок: Thread-Oblivious
    • Его брать и освобождать могут любые потоки
    • Пример: Backoff
  • Локальный лок: Cohort Detection
    • Должен уметь понимать, есть ли другие локи, которые его ждут.
    • Пример: MCS