Накладные расходы из-за использования событий - PullRequest
6 голосов
/ 20 августа 2009

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

Проблема заключается в следующем: у меня есть около 1000 циклов в каждой из 10 000 итераций. Эти циклы должны выполняться последовательно, но у меня есть 4 доступных процессора. Я пытаюсь разделить 10 000 циклов итераций на 4 2 500 циклов итераций, то есть по одному на поток. Но мне нужно дождаться окончания 4 маленьких циклов, прежде чем перейти к следующей «большой» итерации. Это означает, что я не могу связать задания.

Моя проблема заключается в том, что использование пула потоков и 4 потоков намного медленнее, чем последовательное выполнение заданий (выполнение одного цикла отдельным потоком намного медленнее, чем последовательное выполнение его непосредственно в основном потоке).

Я в Windows, поэтому я создаю события с CreateEvent(), а затем жду на одном из них, используя WaitForMultipleObjects(2, handles, false, INFINITE), пока основной поток не вызовет SetEvent().

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

Мой вопрос: нормально ли, что использование событий отнимает «много» времени? Если так, есть ли другой механизм, который я мог бы использовать, и который был бы менее дорогостоящим?

Вот некоторый код для иллюстрации (некоторые соответствующие части скопированы из моего класса пула потоков):

// thread function
unsigned __stdcall ThreadPool::threadFunction(void* params) {
    // some housekeeping
    HANDLE signals[2];
    signals[0] = waitSignal;
    signals[1] = endSignal;

    do {
        // wait for one of the signals
        waitResult = WaitForMultipleObjects(2, signals, false, INFINITE);

        // try to get the next job parameters;
        if (tp->getNextJob(threadId, data)) {
            // execute job
            void* output = jobFunc(data.params);

            // tell thread pool that we're done and collect output
            tp->collectOutput(data.ID, output);
        }

        tp->threadDone(threadId);
    }
    while (waitResult - WAIT_OBJECT_0 == 0);

    // if we reach this point, endSignal was sent, so we are done !

    return 0;
}

// create all threads
for (int i = 0; i < nbThreads; ++i) {
    threadData data;
    unsigned int threadId = 0;
    char eventName[20];

    sprintf_s(eventName, 20, "WaitSignal_%d", i);

    data.handle = (HANDLE) _beginthreadex(NULL, 0, ThreadPool::threadFunction,
        this, CREATE_SUSPENDED, &threadId);
    data.threadId = threadId;
    data.busy = false;
    data.waitSignal = CreateEvent(NULL, true, false, eventName);

    this->threads[threadId] = data;

    // start thread
    ResumeThread(data.handle);
}

// add job
void ThreadPool::addJob(int jobId, void* params) {
    // housekeeping
    EnterCriticalSection(&(this->mutex));

    // first, insert parameters in the list
    this->jobs.push_back(job);

    // then, find the first free thread and wake it
    for (it = this->threads.begin(); it != this->threads.end(); ++it) {
        thread = (threadData) it->second;

        if (!thread.busy) {
            this->threads[thread.threadId].busy = true;

            ++(this->nbActiveThreads);

            // wake thread such that it gets the next params and runs them
            SetEvent(thread.waitSignal);
            break;
        }
    }

    LeaveCriticalSection(&(this->mutex));
}

Ответы [ 9 ]

3 голосов
/ 20 августа 2009

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

Вы можете найти некоторые детали здесь .

3 голосов
/ 20 августа 2009

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

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

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

2 голосов
/ 20 августа 2009

Осторожно, вы все еще просите о следующей работе после того, как endSignal выпущен.

for( ;; ) {
    // wait for one of the signals
    waitResult = WaitForMultipleObjects(2, signals, false, INFINITE);
    if( waitResult - WAIT_OBJECT_0 != 0 )
        return;
    //....
}
1 голос
/ 28 августа 2009

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

С другой стороны, если ваше время обработки 2500 итераций цикла превышает 100 микросекунд (на современных ПК), вы можете столкнуться с ограничениями аппаратного обеспечения. Если ваша обработка использует большую пропускную способность памяти, разделение ее на четыре процессора не даст вам большей пропускной способности, на самом деле это даст вам меньше из-за коллизий. Вы также можете столкнуться с проблемами циклического кеширования, когда каждая из ваших 1000 итераций сбрасывает и перезагружает кеш из 4 ядер. Тогда не существует единого решения, и, в зависимости от вашего целевого оборудования, его может и не быть.

1 голос
/ 24 августа 2009

Если вы просто распараллеливаете циклы и используете vs 2008, я бы посоветовал взглянуть на OpenMP. Если вы используете Visual Studio 2010 beta 1, я бы посоветовал взглянуть на параллельную библиотеку шаблонов , в частности "параллель для" / "параллель для каждого" apis или класс задач ", потому что они, скорее всего, сделают то, что вы пытаетесь сделать, только с меньшим количеством кода.

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

Я бы посоветовал взглянуть на это под профилировщиком, например xperf профилировщиком f1 в visual studio 2010 beta 1 (в нем есть 2 новых режима параллелизма, которые помогают увидеть конфликт) или Intel vtune.

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

Удачи

-Rick

1 голос
/ 20 августа 2009

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

«Дорогой» - относительный термин. Дорогие самолеты? Есть машины? или велосипеды ... обувь ...?

В этом случае возникает вопрос: являются ли события «дорогими» относительно времени, затраченного на выполнение JobFunction? Это помогло бы опубликовать некоторые абсолютные цифры: сколько времени занимает процесс, когда он не обработан? Это месяцы или несколько фемтосекунд?

Что происходит со временем, когда вы увеличиваете размер пула потоков? Попробуйте размер пула 1, затем 2, затем 4 и т. Д.

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

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

1 голос
/ 20 августа 2009

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

Могут быть и другие проблемы, как getNextJob получает свои данные для обработки? Если копируется большое количество данных, вы снова значительно увеличили свои накладные расходы.

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

1 голос
/ 20 августа 2009

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

Кстати, в чем конкретно твой вопрос? Я смогу ответить более точно с более точным вопросом:)

РЕДАКТИРОВАТЬ:

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

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

РЕДАКТИРОВАТЬ: после дополнительных подсказок ...

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

Предположим, что у вас есть 4 ядра и 10000 циклов для вычисления, как в вашем примере (в комментарии). Вы сказали, что вам нужно дождаться окончания 4 потоков, прежде чем продолжить. Тогда вы можете упростить процесс синхронизации. Вам просто нужно дать вашим четырем потокам три-й, n-й + 1, n-й + 2, n-й + 3 цикла, дождаться завершения четырех потоков и продолжить. Вы должны использовать рандеву или барьер (механизм синхронизации, который ожидает завершения n потоков). Boost имеет такой механизм. Вы можете посмотреть реализацию Windows для эффективности. Ваш пул потоков не очень подходит для этой задачи. Поиск доступной темы в критическом разделе убивает ваше процессорное время. Не часть события.

0 голосов
/ 03 сентября 2017

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

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

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...