Поточно-безопасная структура данных

Мне нужно разработать структуру данных, которая будет использоваться в многопоточной среде. Базовый API прост: вставка элемента, удаление элемента, извлечение элемента, проверка существования элемента. Реализация структуры использует неявную блокировку, чтобы гарантировать атомарность одного вызова API. После того, как я реализовал это, стало очевидно, что мне действительно нужна атомарность для нескольких вызовов API. Например, если вызывающей стороне необходимо проверить существование элемента перед попыткой вставить его, он не может сделать это атомарно, даже если каждый отдельный вызов API является атомарным:

if(!data_structure.exists(element)) {
   data_structure.insert(element);
}

Пример несколько неуклюж, но суть в том, что мы больше не можем доверять результату вызова «exists» после возврата из атомарного контекста (сгенерированная сборка ясно показывает небольшую вероятность переключения контекста между двумя вызовами).

Что я в настоящее время имею в виду, чтобы решить эту проблему, так это раскрытие блокировки через общедоступный API структуры данных. Таким образом, клиентам придется явно блокировать вещи, но, по крайней мере, им не придется создавать свои собственные блокировки. Есть ли лучшее общеизвестное решение подобных проблем? И пока мы этим занимаемся, не могли бы вы посоветовать какую-нибудь хорошую литературу по потокобезопасному проектированию?

РЕДАКТИРОВАТЬ: у меня есть лучший пример. Предположим, что поиск элемента возвращает либо ссылку, либо указатель на сохраненный элемент, а не его копию. Как можно защитить вызывающую сторону, чтобы безопасно использовать этот указатель\ссылку после возврата вызова? Если вы считаете, что отсутствие возврата копий является проблемой, подумайте о глубоких копиях, то есть объектах, которые также должны копировать другие объекты, на которые они указывают внутри.

Спасибо.


person Inso Reiges    schedule 19.03.2010    source источник
comment
О том, что вы редактируете: подумайте о ситуации, когда возвращается указатель на сохраненный элемент, а другой поток пытается удалить этот элемент из data_structture. Вам, по крайней мере, нужно выбрать, какое поведение должно быть реализовано в отношении модели блокировки oof. Вернуть ошибку потоку, пытающемуся удалить объект? дождитесь, пока объект станет нереферентным и т. д.   -  person drlazy    schedule 19.03.2010


Ответы (5)


Вы либо предоставляете механизм внешней блокировки (плохо), либо переделываете API, как putIfAbsent. Последний подход, например, используется для параллельные структуры данных.

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

[править] Для уточнения: внешняя блокировка вредна для пользователя класса, поскольку она представляет еще один источник потенциальных ошибок. Да, бывают случаи, когда соображения производительности действительно ухудшают ситуацию с параллельными структурами данных, чем с внешней синхронизацией, но такие случаи редки, и тогда их обычно могут решить/оптимизировать только люди с гораздо большими знаниями/опытом, чем у меня.

Один, возможно, важный совет по производительности можно найти в ответе Уилла ниже. [/редактировать]

[edit2] Учитывая ваш новый пример: в основном вы должны стараться максимально разделить синхронизацию коллекции и элементов. Если время жизни элемента привязано к его наличию в одной коллекции, вы столкнетесь с проблемами; при использовании GC эта проблема становится проще. В противном случае вам придется использовать своего рода прокси вместо необработанных элементов, чтобы быть в коллекции; в простейшем случае для C++ вы бы использовали boost::shared_ptr, который использует атомарный счетчик ссылок. Вставьте здесь обычный отказ от ответственности. Когда вы используете C++ (как я подозреваю, поскольку вы говорите об указателях и ссылках), комбинации boost::shared_ptr и boost::make_shared должно хватить на какое-то время. [/edit2]

person gimpf    schedule 19.03.2010
comment
Чем плоха внешняя блокировка? Может случиться так, что пользователь API знает больше о том, когда что-то должно быть заблокировано (например, у вас может быть экземпляр коллекции, который даже не используется совместно между потоками). Из соображений производительности было бы вполне разумно использовать внешнюю блокировку. - person Michael Aaron Safyan; 19.03.2010
comment
@Майкл: Да, правда. Но внешняя блокировка перекладывает бремя на пользователя структуры данных. Все зависит от пользователя. Но вы правы, в некоторых случаях требуется внешняя блокировка, но даже в этом случае я бы использовал простую неконкурентную структуру данных и сделал бы всю блокировку извне. - person Frunsi; 19.03.2010
comment
@frunsi, это хороший момент ... объект должен быть либо полностью потокобезопасным, либо не должен быть вообще. Когда я думал о внешней блокировке, я предполагал, что структура данных будет сделана непараллельной, но теперь, когда я перечитал пост, я вижу, что автор предлагает какой-то гибрид, который был бы действительно очень неприятным. - person Michael Aaron Safyan; 19.03.2010
comment
Извините за минус. Я бы проголосовал за это, но SO не позволит мне сделать это сейчас. - person Michael Aaron Safyan; 19.03.2010
comment
Да, это случилось со мной тоже однажды; SO позволяет вам повторно отдать свой голос только в течение определенного срока или после существенного редактирования (или любого редактирования, не знаю). Я надеюсь, что теперь с добавленным текстом проблема с производительностью немного улучшилась. - person gimpf; 20.03.2010

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

Один из подходов заключается в том, что метод insertIfAbsent() возвращает заблокированный «курсор» — он вставляет заполнитель во внутреннюю структуру, чтобы ни один другой поток не мог поверить, что он отсутствует, но не вставляет новый объект. Заполнитель может содержать блокировку, так что другие потоки, которые хотят получить доступ к этому конкретному элементу, должны ждать его вставки.

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

Еще один подход заключается в том, что вызывающая сторона создает объект, которого нет, но который иногда терпит неудачу при фактической вставке, если другой поток «выиграл гонку». Так, например, выполняются обновления memcache. Это может работать очень хорошо.

person Will    schedule 19.03.2010

Как насчет переноса проверки существования в метод .insert()? Клиент вызывает его, и если он возвращает false, вы знаете, что что-то пошло не так. Очень похоже на то, что делает malloc() в простом старом C - вернуть NULL, если не удалось, установить ERRNO.

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

Но, пожалуйста, не полагайтесь на то, что пользователь устанавливает свои собственные блокировки.

person lorenzog    schedule 19.03.2010

В стиле RAII вы можете создавать объекты доступа/обработчика (не знаю, как это называется, вероятно, существует такой шаблон), например. список:

template <typename T>
class List {
    friend class ListHandle<T>;
    // private methods use NO locking
    bool _exists( const T& e ) { ... }
    void _insert( const T& e ) { ... }
    void _lock();
    void _unlock();
public:
    // public methods use internal locking and just wrap the private methods
    bool exists( const T& e ) {
        raii_lock l;
        return _exists( e );
    }
    void insert( const T& e ) {
        raii_lock l;
        _insert( e );
    }
    ...
};

template <typename T>
class ListHandle {
    List<T>& list;
public:
    ListHandle( List<T>& l ) : list(l) {
        list._lock();
    }
    ~ListHandle() {
        list._unlock();
    }
    bool exists( const T& e ) { return list._exists(e); }
    void insert( const T& e ) { list._insert(e); }
};


List<int> list;

void foo() {
    ListHandle<int> hndl( list ); // locks the list as long as it exists
    if( hndl.exists(element) ) {
        hndl.insert(element);
    }
    // list is unlocked here (ListHandle destructor)
}

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

person Frunsi    schedule 19.03.2010
comment
Это излишество... если он вообще хочет использовать блокировки, то он может просто написать метод insertIfNotFound: bool insertIfNotFound(T element){ lock.lock(); попробуйте { если (! data_structure.exists (элемент)) { data_structure.insert (элемент); вернуть истину; } вернуть false } наконец { lock.unlock(); } } - person Kiril; 19.03.2010
comment
@Lirik: излишество это или нет, во многом зависит от использования. На практике я вообще не использую параллельные структуры данных, но более или менее работаю с внешней блокировкой. Но эта вещь здесь имеет смысл, если у вас есть более сложные требования, чем insertIf[not]Found (такие варианты использования существуют), то этот способ подходит. Кроме того, лишнее будет оптимизировано, так что это просто много набора текста. - person Frunsi; 19.03.2010
comment
@frunsi insertIfNotFound в основном известен как putIfAbsent, который является распространенным методом в параллельных структурах данных (уточняю там, я уверен, что вы его видели). Итак, ваша альтернатива параллельной структуре данных — внешняя блокировка? - person Kiril; 19.03.2010
comment
@Lirik: Моя альтернатива подробно описана в моем ответе! Прочтите... в нем содержится ответ. putIfAbsent и/или любые другие doIfBlabla методы подходят для обычных случаев и помогают решить большинство возникающих проблем, но мой метод (опять же, прочитайте мой ответ) является общим решением. - person Frunsi; 20.03.2010

Прежде всего, вы должны действительно разделить свои заботы. У вас есть две вещи, о которых нужно беспокоиться:

  1. Структура данных и ее методы.
  2. Синхронизация потоков.

Я настоятельно рекомендую вам использовать интерфейс или виртуальный базовый класс, который представляет тип реализуемой вами структуры данных. Создайте простую реализацию, которая вообще не делает никаких блокировок. Затем создайте вторую реализацию, обертывающую первую и добавляющую блокировку поверх нее. Это позволит реализовать более производительную реализацию, в которой блокировка не требуется, и значительно упростит ваш код.

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

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

person Michael Aaron Safyan    schedule 19.03.2010