Внедрение мьютекса FIFO в pthreads

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

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

1 for(;;) {
2   lock(mutex)
3   do_stuff
4   unlock(mutex)
5 }

То есть, если Thread # 1 выполняет команды 4- > 5- > 1- > 2 в одном пакете "cpu", то Thread # 2 получает голод от выполнения.

С другой стороны, если в pthreads существует опция блокировки типа FIFO-типа для мьютексов, то такой проблемы можно было бы избежать. Итак, есть ли способ реализовать блокировку мьютекса FIFO в pthreads? Может ли это изменить приоритеты потоков?

Ответ 1

Вы можете сделать что-то вроде этого:

  • определяют "блокировку в очереди", состоящую из флага free/busy плюс связанный список переменных условия pthread. доступ к queued_lock защищен мьютексом

  • чтобы заблокировать queued_lock:

    • захватить мьютексы
    • отметьте флаг "занято"
    • если не занят; set busy = true; релиз мьютекса; Готово
    • если занят; создать новое условие @конец очереди и ждать на нем (высвобождение мьютекса)
  • чтобы разблокировать:

    • захватить мьютексы
    • если никакой другой поток не поставлен в очередь, busy = false; релиз мьютекса; Готово
    • pthread_cond_signal первое условие ожидания
    • не очистить флаг "занято" - право собственности переходит в другой поток
    • релиз мьютекса
  • при ожидании разблокировки нити pthread_cond_signal:

    • удалите наше условие var из заголовка очереди
    • релиз мьютекса

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

Ответ 2

Вы можете реализовать систему честного ожидания, в которой каждый поток добавляется в очередь, когда он блокируется, и первый поток в очереди всегда получает ресурс, когда он становится доступным. Такой "честный" замок билетов, построенный на примитивах pthreads, может выглядеть так:

#include <pthread.h>

typedef struct ticket_lock {
    pthread_cond_t cond;
    pthread_mutex_t mutex;
    unsigned long queue_head, queue_tail;
} ticket_lock_t;

#define TICKET_LOCK_INITIALIZER { PTHREAD_COND_INITIALIZER, PTHREAD_MUTEX_INITIALIZER }

void ticket_lock(ticket_lock_t *ticket)
{
    unsigned long queue_me;

    pthread_mutex_lock(&ticket->mutex);
    queue_me = ticket->queue_tail++;
    while (queue_me != ticket->queue_head)
    {
        pthread_cond_wait(&ticket->cond, &ticket->mutex);
    }
    pthread_mutex_unlock(&ticket->mutex);
}

void ticket_unlock(ticket_lock_t *ticket)
{
    pthread_mutex_lock(&ticket->mutex);
    ticket->queue_head++;
    pthread_cond_broadcast(&ticket->cond);
    pthread_mutex_unlock(&ticket->mutex);
}

Ответ 3

Пример, когда вы публикуете его, не имеет решения. В основном у вас есть только один критический раздел, и нет места для parallelism.

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

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

Ответ 4

Решение может заключаться в использовании атомных операций. Отсутствие блокировки, отсутствие переключения контекста, отсутствие сна и многое другое быстрее, чем мьютексы или переменные условия. Atomic ops не является окончательным решением для всего, но мы создали много потокобезопасных версий общих структур данных, используя только атомарные операционные системы. Они очень быстры.

Atomic ops - это серия простых операций, таких как приращение или уменьшение или присвоение, которые гарантированно выполняются атомарно в многопоточной среде. Если два потока одновременно попадают в операционную систему, процессор гарантирует, что один поток выполняет операцию за раз. Atomic ops - это аппаратные инструкции, поэтому они быстрые. "Compare and swap" очень полезен для потокобезопасных структур данных. в нашем тестировании атомное сравнение и обмен происходит примерно так же, как 32-битное целочисленное назначение. Может быть, в 2 раза медленнее. Когда вы рассматриваете, сколько процессор потребляется с помощью мьютексов, атомарные операции бесконечно быстрее.

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

Как атомные операторы работают над созданием структур, не содержащих блокировки, могут быть визуализированы с помощью простого примера связанных ссылок на потоки. Чтобы добавить элемент в глобальный связанный список (_pHead) без использования блокировок. Сначала сохраните копию _pHead, pOld. Я считаю эти копии "государством мира" при выполнении параллельных операций. Затем создайте новый node, pNew и установите его pNext в КОПИРОВАНИЕ. Затем используйте атомную "сравнить и обменивать", чтобы изменить _pHead на pNew ТОЛЬКО ЕСЛИ pHead IS STILL pOld. Атомный op будет успешным, только если _pHead не изменился. Если он не сработает, вернитесь назад, чтобы получить копию нового _pHead и повторите.

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

Глобальный материал:

typedef struct _TList {
  int data;
  struct _TList *pNext;
} TList;

TList *_pHead;

Добавить в список:

TList *pOld, *pNew;
...
// allocate/fill/whatever to make pNew
...
while (1) { // concurrency loop
  pOld = _pHead;  // copy the state of the world. We operate on the copy
  pNew->pNext = pOld; // chain the new node to the current head of recycled items
  if (CAS(&_pHead, pOld, pNew))  // switch head of recycled items to new node
    break; // success
}

CAS является сокращением для __ sync_bool_compare_and_swap или тому подобное. Смотрите, как легко? Нет Mutexes... нет замков! В редком случае, когда 2 потока ударили по этому коду одновременно, он просто петли второй раз. Мы видим только второй цикл, потому что планировщик меняет поток в контуре concurrency. поэтому он редок и несущественен.

Аналогичным образом можно потянуть за голову связанного списка. Вы можете атомизировать несколько значений, если вы используете союзы, и вы можете использовать uup для 128-битных атомных операций. Мы протестировали 128 бит на 32-битном Redhat Linux, и они ~ той же скоростью, что и 32, 64-битные атомы.

Вам нужно будет выяснить, как использовать этот тип техники с вашим деревом. Дерево b node будет иметь два ptrs для дочерних узлов. Вы можете использовать их для их изменения. Проблема балансировки жесткая. Я могу видеть, как вы могли анализировать ветвь дерева, прежде чем добавлять что-то, и сделать копию ветки с определенной точки. когда вы закончите изменение ветки, вы CAS новый. Это будет проблемой для больших ветвей. Возможно, балансировка может быть выполнена "позже", когда нити не сражаются за дерево. Возможно, вы можете сделать так, чтобы дерево по-прежнему находилось в поиске, даже если вы не каскадировали поворот полностью... другими словами, если поток A добавил a node и рекурсивно вращающиеся узлы, поток b все еще может читать или добавлять узлы. Просто некоторые идеи. В некоторых случаях мы создаем структуру, которая имеет номера версий или блокирует флаги в 32 битах после 32 бит pNext. Затем мы используем 64-битный CAS. Возможно, вы можете сделать дерево безопасным для чтения всегда без блокировок, но вам, возможно, придется использовать технику управления версиями в ветке, которая изменяется.

Вот несколько сообщений, которые я рассказывал о преимуществах атомных операций:

Pthreads и мьютексы; блокирующая часть массива

Эффективный и быстрый способ для аргумента потока

Конфигурация автоматической перезагрузки с помощью pthreads

Преимущества использования переменных условий над мьютексом

однобитовая манипуляция

Является ли распределение памяти в неблокирующем режиме linux?

Ответ 5

Вы можете получить справедливый Mutex с идеей, набросанной @caf, но используя атомарные операции, чтобы получить билет до фактической блокировки.

#if defined(_MSC_VER)
typedef volatile LONG Sync32_t;
#define SyncFetchAndIncrement32(V) (InterlockedIncrement(V) - 1)
#elif (__GNUC__ * 10000 + __GNUC_MINOR__ * 100 + __GNUC_PATCHLEVEL__) > 40100
typedef volatile uint32_t Sync32_t;
#define SyncFetchAndIncrement32(V) __sync_fetch_and_add(V, 1)
#else
#error No atomic operations
#endif

class FairMutex {
private:
    Sync32_t                _nextTicket;
    Sync32_t                _curTicket;
    pthread_mutex_t         _mutex;
    pthread_cond_t          _cond;

public:
    inline FairMutex() : _nextTicket(0), _curTicket(0), _mutex(PTHREAD_MUTEX_INITIALIZER), _cond(PTHREAD_COND_INITIALIZER)
    {
    }
    inline ~FairMutex()
    {
        pthread_cond_destroy(&_cond);
        pthread_mutex_destroy(&_mutex);
    }
    inline void lock()
    {
        unsigned long myTicket = SyncFetchAndIncrement32(&_nextTicket);
        pthread_mutex_lock(&_mutex);
        while (_curTicket != myTicket) {
            pthread_cond_wait(&_cond, &_mutex);
        }
    }
    inline void unlock()
    {
        _curTicket++;
        pthread_cond_broadcast(&_cond);
        pthread_mutex_unlock(&_mutex);
    }
};

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

Ответ 6

Вы можете взглянуть на функцию pthread_mutexattr_setprioceiling.

int pthread_mutexattr_setprioceiling
(
    pthread_mutexatt_t * attr, 
    int prioceiling,
    int * oldceiling
);

Из документации:

pthread_mutexattr_setprioceiling (3THR) устанавливает атрибут потолка приоритета для объекта атрибута mutex.

attr указывает на объект атрибута mutex, созданный более ранним вызовом pthread_mutexattr_init().

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

oldceiling содержит старое значение приоритета приоритета.