Легкий примитив синхронизации потоков очереди

Я собираюсь реализовать рабочий поток с очередью рабочих элементов, и пока я думал о проблеме, я хотел знать, делаю ли я лучшее.

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

псевдокод:

volatile bool run = true;

int WorkerThread(param)
{
    localclassinstance c1 = new c1();
    [other initialization]

    while(true) {
        [LOCK]
        [unqueue work item]
        [UNLOCK]
        if([hasWorkItem]) {
            [process data]
            [PostMessage with pointer to data]
        }
        [Sleep]

        if(!run)
            break;
    }

    [uninitialize]
    return 0;
}

думаю, я сделаю блокировку через критический раздел, так как очередь будет std:: vector или std::queue, но, может быть, есть лучший путь.

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

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

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

P. S.: Я, возможно, потребуется для обработки всей очереди и падение устаревших рабочих элементов на этапе unqueuing.

9 ответов


есть множество способов сделать это.

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

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

третий вариант-иметь невидимое окно только для сообщений, созданное в потоке, и использовать специальный WM_USER или WM_APP сообщение для отправки элементов в очередь, прикрепление элемента к сообщению с помощью указателя.

другой вариант-использовать переменные условие. Собственные переменные условия Windows работают только при настройке Windows Vista или Windows 7, но переменные условия также доступны для Windows XP с Boost или реализацией библиотеки потоков C++0x. Пример очереди с использованием переменных условий boost доступен в моем блоге: http://www.justsoftwaresolutions.co.uk/threading/implementing-a-thread-safe-queue-using-condition-variables.html


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

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

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

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

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

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

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

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

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

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


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

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

импульс.Поток имеет хорошую портативную реализацию обоих, быстрых пользовательских пространственных спин-блокировок и переменных состояния:

http://www.boost.org/doc/libs/1_44_0/doc/html/thread/synchronization.html#thread.synchronization.condvar_ref

рабочая очередь с помощью Boost.Нить может выглядеть примерно так:

template <class T>
class Queue : private boost::noncopyable
{
public:
    void Enqueue(T const& t)
    {
        unique_lock lock(m_mutex);

        // wait until the queue is not full
        while (m_backingStore.size() >= m_maxSize)
            m_queueNotFullCondition.wait(lock); // releases the lock temporarily

        m_backingStore.push_back(t);
        m_queueNotEmptyCondition.notify_all(); // notify waiters that the queue is not empty
    }

    T DequeueOrBlock()
    {
        unique_lock lock(m_mutex);

        // wait until the queue is not empty
        while (m_backingStore.empty())
            m_queueNotEmptyCondition.wait(lock); // releases the lock temporarily

        T t = m_backingStore.front();
        m_backingStore.pop_front();

        m_queueNotFullCondition.notify_all(); // notify waiters that the queue is not full

        return t;
    }

private:
    typedef boost::recursive_mutex mutex;
    typedef boost::unique_lock<boost::recursive_mutex> unique_lock;

    size_t const m_maxSize;

    mutex mutable m_mutex;
    boost::condition_variable_any m_queueNotEmptyCondition;
    boost::condition_variable_any m_queueNotFullCondition;

    std::deque<T> m_backingStore;
};

есть различные способы сделать это

для одного вы можете создать событие вместо "run" , а затем использовать его для обнаружения, когда поток должен завершиться, основной поток затем сигнализирует. Вместо сна вы будете использовать WaitForSingleObject с таймаутом, таким образом, вы выйдете непосредственно вместо ожидания сна ms.

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

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


Я бы немного изменил структуру:

WorkItem GetWorkItem()
{
    while(true)
    {
        WaitForSingleObject(queue.Ready);
        {
            ScopeLock lock(queue.Lock);
            if(!queue.IsEmpty())
            {
                return queue.GetItem();
            }
        }
    }
}

int WorkerThread(param) 
{ 
    bool done = false;
    do
    {
        WorkItem work  = GetWorkItem();
        if( work.IsQuitMessage() )
        {
            done = true;
        }
        else
        {
            work.Process();
        }
    } while(!done);

    return 0; 
} 

достопримечательности:

  1. ScopeLock это RAII класс, чтобы сделать использование критического раздела более безопасным.
  2. блокировать событие, пока workitem (возможно) не будет готов - затем замок в то время как попытка чтобы dequeue его.
  3. не используйте глобальный флаг "IsDone", специальный quitmessageWorkItems.

вы можете взглянуть на другой подход здесь, который использует C++0x атомарные операции

http://www.drdobbs.com/high-performance-computing/210604448


используйте семафор вместо события.


держите сигнализацию и синхронизацию отдельно. Что-то в этом роде...

// in main thread

HANDLE events[2];
events[0] = CreateEvent(...); // for shutdown
events[1] = CreateEvent(...); // for work to do

// start thread and pass the events

// in worker thread

DWORD ret;
while (true)
{
   ret = WaitForMultipleObjects(2, events, FALSE, <timeout val or INFINITE>);

   if shutdown
      return
   else if do-work
      enter crit sec
      unqueue work
      leave crit sec
      etc.
   else if timeout
      do something else that has to be done
}

учитывая, что этот вопрос помечен windows, я отвечу так:

Не создавайте 1 рабочий поток. Ваши рабочие потоки предположительно независимы,поэтому вы можете обрабатывать сразу несколько заданий? Если это так:

  • в главном потоке вызовите CreateIOCompletionPort для создания объекта порта завершения ввода-вывода.
  • создайте пул рабочих потоков. Число, которое необходимо создать, зависит от количества заданий, которые вы хотите обслуживать параллельно. Несколько из количество ядер процессора-хорошее начало.
  • каждый раз, когда задание приходит в вызов PostQueuedCompletionStatus (), передавая указатель на структуру задания как структуру lpOverlapped.
  • каждый рабочий поток вызывает GetQueuedCompletionItem () - извлекает рабочий элемент из Указателя lpoverlapped и выполняет задание перед возвращением в GetQueuedCompletionStatus.

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

Итак, его не легкий, но он очень и очень эффективен... порт завершения io можно связать с ручками трубы и гнезда для пример и может dequeue результаты асинхронных операций на этих дескрипторах. проекты портов завершения ввода-вывода могут масштабироваться до обработки 10 тысяч сокетов на одном сервере , но на настольной стороне мира делают очень удобный способ масштабирования обработки заданий над 2 или 4 ядрами, которые теперь распространены на настольных ПК.