Безопасна ли эта вставка dlist без блокировки?

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

struct Node {
  std::atomic<Node*> next;
  std::atomic<Node*> prev;
};
Node head;

// concurrently insert `first`..`last` sublist after `head`
void insertSublist(Node* first, Node* last) {
  first->prev = &head;
  Node* current_next = head.next;

  while (true) {
    last->next = current_next;
    if (head.next.compare_exchange_weak(current_next, first)) {
      current_next->prev = last;
      return;
    }
  }
}

Мне нужно проверить этот дизайн при следующих обстоятельствах:

Вариант 1

Удаление списка не выполняется, все потоки просто выполняют вставку в цикле.

Вариант 2

Существует 1 поток, который удаляет узлы из списка в случайном порядке, но он никогда не удалит узел, стоящий сразу после головного узла.

for (auto* node : nodes_to_be_removed) {
  if (node->prev == &head)
    continue;
  // perform removal 
}

Когда вставка завершена, node->prev является последней измененной ссылкой. Таким образом, после его изменения никакой другой поток (кроме удаления) не может получить доступ к узлу или его предшествующему узлу next связи. Верно ли это рассуждение или я что-то упускаю?


Некоторые пояснения после ответа @peter-cordes.

  • Список не является линейно проходимым, поэтому с этой точки зрения несогласованное состояние списка не является проблемой.
  • #P6#
    #P7#
  • Are removals safe under these conditions?
    • There is only 1 remover thread
    • Remover имеет отдельный рабочий список узлов для удаления
    • Узел может быть добавлен в рабочий список только после того, как этап его вставки полностью завершен.

person Viacheslav Kroilov    schedule 02.01.2019    source источник
comment
Также есть фиктивный хвостовой узел, так что это не проблема.   -  person Aconcagua    schedule 02.01.2019
comment
Я ожидаю, что проверка _1_ предотвратит этот случай. Это правда? О, да, я так думаю. У всех еще не измененных элементов указатель _2_ указывает на _3_. Это означает, что _4_ не работает для частично вставленных элементов, и мы определенно ограничены вставкой только в начале. (В противном случае мы теряем этот способ избежать ожидающих изменений.)   -  person Viacheslav Kroilov    schedule 02.01.2019
comment
Прежде всего, спасибо за ваше драгоценное время, чтобы написать такой отличный ответ. Я разъяснил некоторые моменты, касающиеся удаления. Вы можете утверждать, что с этими правилами удаление безвредно?   -  person Peter Cordes    schedule 02.01.2019


Ответы (1)


TL:DR: вставки сами по себе в порядке, в зависимости от того, что делают считыватели (без долговременного повреждения), но удаление, вероятно, невозможно без блокировки или гораздо более изощренных действий, и, безусловно, эта простая вставка является преувеличением. алгоритм.


Это двусвязный список, поэтому вставка неизбежно требует изменения двух областей памяти, которые уже видны другим потокам: head.next и указатель .prev в старом первом узле. Это не может быть выполнено атомарно+без блокировки, если аппаратное обеспечение не имеет DCAS (двойной CAS, два отдельные несмежные местоположения одновременно). Как говорится в статье в Википедии, это упрощает создание двусвязных списков без блокировки.

В какой-то момент у m68k был DCAS, но его нет ни в одной современной основной архитектуре ЦП. ISO C++11 не раскрывает операцию DCAS через std::atomic, потому что вы не можете эмулировать ее на аппаратном обеспечении, в котором ее нет, не сделав все atomic<T> без блокировки. За исключением оборудования с транзакционной памятью, доступного на некоторых последних процессорах x86 от Intel (например, Broadwell и более поздних версиях), но не AMD. Была проведена некоторая работа по добавлению синтаксиса для TM в C++, см. https://en.cppreference.com/w/cpp/language/transactional_memory


Конечно, наблюдатель также не может наблюдать за двумя местоположениями одновременно, атомарно, без транзакционной памяти или чего-то вроде DCAS. Таким образом, любые потоки, читающие список, должны ожидать, что он будет меняться из-под их, особенно если список также должен поддерживать удаление.

Настройка указателей в новых узлах (еще не опубликованных в других потоках) перед публикацией, очевидно, хороша, и вы делаете это. first->prev и last->next установлены правильно до того, как CAS попытается опубликовать эти новые узлы. CAS имеет последовательное упорядочивание памяти, поэтому он гарантирует, что эти предыдущие хранилища видны другим потокам, прежде чем он сам. (Таким образом, эти «частные» хранилища также могут быть std::memory_order_relaxed для эффективности).

Ваш выбор изменить указатель .prev старого первого после изменения head имеет большой смысл. По сути, вы публикуете сначала в прямом направлении, а затем в обратном направлении. Но помните, что поток может длительное время находиться в состоянии сна в любом месте, поэтому нельзя на 100 % предполагать, что это всегда будет временным несоответствием. Представьте себе остановку. один поток в отладчике в любой момент внутри этой функции, и даже пошагово, в то время как другие потоки выполняются. В этом случае есть только 2 интересные операции: CAS и безусловное сохранение в старом первом нефиктивном узле.

Если поток перемещался вперед и зависел от возможности вернуться назад, следуя .prev (вместо запоминания своего предыдущего в локальной переменной), это могло выглядеть для него так, как будто новые узлы были снова удалены. Он может найти .prev, указывающий на head. Это надуманный пример, потому что обычно было бы более эффективно просто запомнить предыдущий узел, если вы хотите иметь возможность найти его снова, особенно в незаблокированном списке. Но, возможно, есть ненадуманные случаи, например, может быть, одна нить движется вперед, а другая - назад, и, возможно, взаимодействуют прямо или косвенно, где несоответствие будет видно.


Пока все потоки согласны в том, в каком порядке изменять вещи, я думаю, что вставка сама по себе безопасна. Делая это только в голове, легче проверить, но я думаю, что разрешать произвольные точки вставки все еще безопасно.

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

Без удаления каждая ожидающая запись в .prev имеет допустимое место назначения, и это место назначения является узлом, в который никакой другой поток не хочет писать. вставка односвязного списка без блокировки проста, а список переадресации (.next ссылок) всегда актуален.

Поэтому каждая операция вставки "требует" узел, который она использует в качестве точки вставки в обратный список, когда его хранилище в current_next->prev становится видимым.


Цикл do{}while(!CAS()) — хорошая идиома, обычно упрощающая код. Я ослабил упорядочение в памяти других операций, особенно приватных, в первую и последнюю очередь, потому что требовать от компилятора использования медленных барьеров после сохранения элементов, которые другие потоки еще не могут видеть, неэффективно. На x86 релизные хранилища «бесплатны» (без дополнительных барьеров), а хранилища seq-cst стоят дороже. (Хранилище seq-cst на x86 стоит примерно столько же, сколько атомарное чтение-изменение-запись в неконкурентном случае).

// no change in logic to yours, just minimize memory ordering
// and simplify the loop structure.
void insertSublist(Node* first, Node* last)
{
  first->prev.store(&head, std::memory_order_relaxed);
  Node* current_next = head.next.load(std::memory_order_relaxed);

  do {
    // current_next set ahead of first iter, and updated on CAS failure
    last->next.store(current_next, std::memory_order_relaxed);
  }while (!head.next.compare_exchange_weak(current_next, first));
   // acq_rel CAS should work, but leave it as seq_cst just to be sure.  No slower on x86

  current_next->prev.store(last, std::memory_order_release);  // not visible before CAS
}

Кроме того, do{}while(!CAS) с конечным хранилищем, полностью выходящим за пределы цикла, вероятно, легче для людей читать и сразу видеть логику.

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


head.next - а если это единственный узел в списке?

(Кроме того, если вы переработаете память для этого узла, хранилище вставщика затем наступит на что-то.)

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

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

Если бы у вас была блокировка вставки/удаления (несколько вставок/одно удаление, точно так же, как блокировка чтения/записи), вы могли убедиться, что при удалении не было ожидающих вставок. Но по-прежнему разрешать вставки без блокировки . Возможно, поместите блокировку в ту же строку кеша, что и head, потому что при вставке потоков всегда потребуется изменять и ее, и head. Или, может быть, нет, потому что вы можете просто получить больше состязаний за эту строку, если ядра иногда теряют право собственности на линию после взятия блокировки, но до фиксации своей модификации в head.

Удаление — это серьезное осложнение

person Peter Cordes    schedule 02.01.2019
comment
Это компилируется для x86 с нулевыми _17_ инструкциями вместо 3 для вашего, в обозревателе компиляторов Godbolt. (Остальная часть asm буквально идентична, включая _18_.) Таким образом, в несостязательном случае без RFO (например, повторные вставки с одного и того же ядра) это может быть примерно в 4 раза быстрее. Или лучше, потому что _19_ на самом деле даже медленнее, чем префикс _20_ на процессорах Intel. - person Viacheslav Kroilov; 02.01.2019