Параллельная структура структуры данных

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

Требования заключаются в том, что он может поддерживать эффективную вставку, в идеале O (1), умеренно эффективное удаление и эффективный обход. Он не нуждается в поддержке операции поиска (кроме необходимости удаления).

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

не рекомендуется использовать блокировку чтения/записи и откладывать операции записи до тех пор, пока все читатели не закончат, так как операции чтения могут быть долговечными. Не имеет значения, видимы ли вставки, которые происходят во время чтения, читателю или нет.

След за память также очень важен, а маленький, очевидно, лучше!

Какие существуют предложения?

Ответ на комментарии:

Спасибо за ответы.

Нет, вставки не могут аннулировать существующие итераторы. Итераторы могут видеть или не видеть новую вставку, но они должны видеть все, что они увидели бы, если бы вставка не произошла.

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

Блокировка за node для курсора окажет слишком большое влияние на производительность. Одновременно может просматриваться несколько потоков, и любое горячее пятно памяти, которое использует несколько потоков в замке, убивает пропускную способность памяти (поскольку мы обнаружили трудный путь!). Даже простой счетчик с несколькими потоками, вызывающий InterlockedIncrement, не может масштабироваться чисто.

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

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

Идея блокировки-разблокировки интересна. Объем данных слишком большой, чтобы это работало по умолчанию для читателей, но оно могло использоваться для писателей, когда они сталкиваются с читателями. Блокировка чтения/записи защитит всю структуру, и запись будет клонировать структуру данных, если она столкнется с читателем. Записи гораздо реже, чем чтение.

Ответы

Ответ 1

Лично я очень люблю постоянные неизменные структуры данных в ситуациях с высокой степенью параллелизма. Я не знаю специально для С++, но Rich Hickey создал некоторые превосходные (и быстро растущие) неизменные структуры данных в Java для Clojure. В частности: вектор, хэш-таблица и hashset. Их не слишком сложно переносить, поэтому вы можете рассмотреть один из них.

Чтобы выработать немного больше, постоянные неизменные структуры данных действительно решают множество проблем, связанных с concurrency. Поскольку сама структура данных неизменна, нет проблем с одновременным чтением/повторением нескольких потоков (до тех пор, пока это итератор const). "Письмо" также может быть асинхронным, потому что оно на самом деле не записывается в существующую структуру, а создает новую версию этой структуры, которая включает в себя новый элемент. Эта операция эффективна (O (1) во всех структурах Хикки) тем фактом, что вы на самом деле не копируете все. Каждая новая версия имеет большую часть своей структуры со старой версией. Это делает вещи более эффективными с точки зрения памяти, а также значительно улучшает производительность по сравнению с простой технологией копирования на запись.

С неизменяемыми структурами данных, единственное время, когда вам действительно нужно синхронизировать, - это фактически писать в контрольную ячейку. Поскольку доступ к памяти является атомарным, даже это обычно может быть заблокировано. Единственное предостережение здесь - вы можете потерять данные между потоками (условия гонки). Структура данных никогда не будет повреждена из-за concurrency, но это не означает, что несогласованные результаты невозможны в ситуациях, когда два потока создают новую версию структуры на основе одного старого и пытаются записать их результаты (один из они будут "побеждать", а другие изменения будут потеряны). Чтобы решить эту проблему, вам нужно либо заблокировать "операции записи", либо использовать

Ответ 2

Связанные списки, безусловно, являются ответом здесь. Вставка и удаление в O (1), итерация от одного node к следующему в O (1) и стабильность между операциями. std::list гарантирует все это, в том числе, что все итераторы действительны, если элемент не удален из списка (сюда относятся указатели и ссылки на элементы). Для блокировки вы можете просто обернуть список в класс блокировки или написать собственный класс списка (в этом случае вы не сможете использовать std::list, который поддерживает блокировку на основе node - например, вы можете заблокировать вниз по некоторым областям списка для использования, в то время как другие потоки выполняют операции в разных областях. Используемые вами в значительной степени зависят от типа параллельного доступа, который вы ожидаете - если несколько операций в разных частях списка будут действительно распространены, напишите свой собственный, но помните, что вы помещаете объект mutex в каждый node, который не является пространственно эффективным.

Ответ 3

Извинения за двойной ответ...

Поскольку записи довольно редки, вам действительно стоит использовать STM вместо блокировки. STM представляет собой форму оптимистической блокировки, что означает, что она сильно смещена в производительности в системах без столкновений (меньше, чем меньше записей). Напротив, пессимистическая блокировка (блокировка-запись-разблокировка) оптимизирована для систем с тяжелыми столкновениями (a.k.a. много записей). Единственный улов с STM - это почти требование, что вы используете неизменные структуры данных в ячейках TVar, иначе вся система распадается. Лично я не думаю, что это проблема, поскольку приличная неизменяемая структура данных будет такой же быстрой, как и измененная (см. Мой другой ответ), но это стоит рассмотреть.

Ответ 4

Я думаю, что связанный список должен отвечать вашим требованиям. Обратите внимание, что вы можете заблокировать только те узлы, которые меняются (т.е. Удалены/добавлены), поэтому читатели большую часть времени смогут работать с parallelism с авторами. Этот подход требует блокировки для каждого связанного списка node, однако это не обязательно. Вы можете ограничить количество блокировок, а затем несколько узлов будут сопоставлены с одной и той же блокировкой. I.e., имея массив N блокировок и узлов с номером 0..M, вы можете использовать lock (NodeId% N) для блокировки этого node. Это могут быть блокировки чтения и записи, и, контролируя количество блокировок, вы можете контролировать количество parallelism.

Ответ 5

Если вам не нужен порядок сортировки, не используйте красное/черное дерево или что-то еще, что по своей сути сортируется.

В вашем вопросе недостаточно хорошо указано w.r.t на взаимодействие между чтением и записью. Было бы хорошо, если "чтение" реализовано блокировкой + копия + разблокировка, а затем использовать новую копию?

Возможно, вы захотите прочитать о seqlocks в http://en.wikipedia.org/wiki/Seqlock и вообще об "незаблокированных" процессах - хотя вы можете хотите как можно больше расслабить ваши требования - реализация хеш-таблицы без блокировки является основным делом.

Ответ 6

У вас есть 3 типа задач:

  • итерация (медленная)
  • Вставка (быстрый)
  • удаление (быстрый)

Если почти согласованность достаточно хороша, тогда отслеживайте # активных задач итерации.

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

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

Если запрос итерации приходит во время ожидания вставки или удаления, то очередь на него.

Если запрос итерации приходит, пока выполняются только итерации, просто запустите его и выполните итерацию.

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

Я бы использовал основную коллекцию с хэш-таблицей или stl: map мог бы быть даже достаточно быстрым. Вставка/удаление запросов может быть поставлена ​​в очередь в списке.

Ответ 7

Единственный способ, которым я думаю, что это достижимо, - это нечто похожее на протокол multiversion concurrency, используемый в таких базах данных, как oracle/postgresql и т.д. Это гарантирует, что читатели не блокируют читателей, писатели не блокируют читателей, а писатели блокировать только тех авторов, которые обновляют один и тот же фрагмент данных. Это свойство писателей, блокирующих писателя (ов), которые обновляют один и тот же фрагмент данных, важно в мире параллельного программирования, в противном случае возможны несоответствия данных/системы. Для каждой операции записи в структуру данных вы делаете снимок структуры данных или по крайней мере часть узлов структуры данных, затронутых операцией записи, в другое место в памяти перед записью. Поэтому, когда выполняется запись, поток читателя запрашивает чтение части данных из части записи, вы всегда ссылаетесь на последний снимок и повторяете этот моментальный снимок, предоставляя постоянный просмотр данных всем читателям. Снимок стоит дорого, так как они потребляют больше памяти, но да, для вашего требования, этот метод является правильным. И да использовать блокировки (mutex/semaphore/spinlock) для защиты операции записи от других потоков/процессов, требующих обновления одного и того же фрагмента данных.

Ответ 8

Я не уверен, что кто-то упомянул об этом, но я бы вдохновил Java ConcurrentHashMap. Он предлагает обход, извлечение и вставку без блокировки или ожидания. Единственная блокировка происходит, когда вы нашли ведро данных, соответствующее хеш-ключу, и вы проходите этот ковш (т.е. Вы ТОЛЬКО блокируете ведро, а не фактическую карту хэша). "Вместо одиночной блокировки коллекции ConcurrentHashMap использует фиксированный пул блокировок, которые образуют раздел над коллекцией ковшей".

Подробнее о фактической реализации здесь. Я считаю, что все вещи, показанные в реализации, можно так же легко сделать с С++.

Итак, перейдите в список требований:

1. High throughput. CHECK
2. Thread safe. CHECK
3. Efficient inserts happen in O(1). CHECK
4. Efficient removal (with no data races or locks). CHECK
5. VERY efficient traversal. CHECK
6. Does not lock or wait. CHECK
7. Easy on the memory. CHECK
8. It is scalable (just increase the lock pool). CHECK

Вот пример записи карты:

protected static class Entry implements Map.Entry {
    protected final Object key;
    protected volatile Object value;
    protected final int hash;
    protected final Entry next;
    ...
}

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

Ответ 9

Ну, чтобы быть потокобезопасным, вам нужно будет что-то заблокировать в какой-то момент. Одна из ключевых моментов - убедиться, что объекты в вашем репозитории могут быть заблокированы отдельно от самой структуры репозитория: например, у вас нет ссылки _next или чего-то подобного в хранимых вами данных. Таким образом, операции чтения могут блокировать содержимое объектов без блокировки структуры репозитория.

Эффективная вставка проста: связанный список, несортированные массивы, hashtables все работает нормально. Эффективное удаление сложнее, поскольку это связано с поиском удаленной вещи в репозитории. Howerver, для простой простоты и скорости, связанный список - хороший выбор. Можно ли отменить перенос для не занятых периодов времени и элементов, отмеченных как "неактивные"? Тогда стоимость поиска/удаления не является настолько ограничивающей.

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

Ответ 10

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

Однако это сложнее решить на С++...

Ответ 11

Я немного опаздываю на вечеринку. Но если кто-то все еще ищет практическое решение этой проблемы, и они еще не решили на сервере, позвольте мне предложить Google App Engine. Их хранилище данных оптимизировано для этих требований.