суббота, 30 мая 2020 г.

[prog.c++] Насколько дорого дергать раз в секунду некий метод у целой кучи объектов?

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

Скорее всего каждая активность будет реализована неким объектом (внутри которого будет какой-то конечный автомат, а снаружи каждый объект будет иметь некий общий интерфейс).

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

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

Для оценки была сделана небольшая программа, в которой создается несколько рабочих нитей, на каждой из которых создается множество объектов handler-ов, а затем с темпом ~1s у этих handler-ов вызывается метод on_next_turn.

Реализация on_next_turn у handler-ов не совсем пустая. А что-то типа вот такого:

void
actual_handler_one_t::on_next_turn( trigger_t & trigger )
{
   const auto now = std::chrono::steady_clock::now();
   if( now > m_border )
   {
      trigger.triggered();
      m_border = now + m_step;
   }
}

Т.е. в каждом вызове on_next_turn вызывается еще и метод now из steady_clock.

Так же в этом тесте я постарался сделать так, чтобы компилятор не повыбрасывал код, который компилятору мог бы показаться неимеющим наблюдаемых эффектов. Поэтому интерфейсы и реализации объектов handler-ов были разнесены по разным файлам, сделана пара разных реализаций handler-ов (в каждой из которых дергается steady_clock::now()), введен абстрактный тип trigger_t, у которого handler-ы время от времени вызывают triggered. Что позволяет думать, что проведенный замер таки показывает именно то, что мне нужно.

А показывает он вот что: на i7-6600U с Kubuntu 18.04 и GCC-8.4 при запуске с тремя рабочими нитями и 50K объектами handler-ами на каждой из рабочих нитей, среднее время цикла вызова on_next_turn для всех 50K handler-ов составляет 7-8ms. Худшие из увиденных результатов -- 10ms.

При этом 50K объектов -- это где-то раза в 3 больше, чем нужно на данный момент.

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

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

Stas Mischenko комментирует...

Если нужно запускать что-нибудь по истечении таймаута, то я предпочитаю для этого использовать одну нить, которая по истечению таймаута отправляет очередной таск в threadpool, затем ждёт следующий таймаут и так далее. Иными словами это scheduler + threadpool. Таким образом код становится "чище", потому что логика по отслеживанию таймаутов в одном месте, да и нити из threadpool-а не заняты их проверкой.

eao197 комментирует...

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

Unknown комментирует...

Почему бы не использовать отдельную очередь под таймауты? Задоно вместо списка можно выбрать чтото более подходящее, например priority_queue

eao197 комментирует...

Очередь ведь так же будет иметь накладные расходы. Когда 50K объектов на одной нити начнут вставлять/изымать объекты-таймеры в эту очередь, то это так же будет отнимать время и ресурсы.

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

Unknown комментирует...

Ну если примерно каждый воркер хоть раз да и оставит какую-нибудь таску то да... Но обычно бывает так, что 99% воркеров полностью пассивны, и только 1 процент хоть чтото делает.
Я тоже пару раз напорывался на "а давай оставлю здесь линейный алгоритм". Не понравилось. Больше не буду

eao197 комментирует...

Так задача как раз и появилась потому, что каждому воркеру таймер и нужен. Так что при создании воркера, как минимум, одна заявка будет ставится. И при уничтожении, скорее всего, заявка должна будет изыматься (т.к. для 99% воркеров время жизни истечет до прихода
этой таймерной заявки). Поэтому и возникло желание посмотреть, а что будет, если тупо раз в секунду вызывать некий on_timer у всех живых воркеров.

Unknown комментирует...

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

eao197 комментирует...

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

Понятное дело, что лишнюю работу делать не хочется. Поэтому я проверил стоимость самого "ленивого" варианта: простой цикл с вызовом on_timer вместо прикручивания какого-то timer-queue.

Если бы такой "ленивый" вариант показал расходы на уровне сотни миллисекунд, то нужно было бы экспериментировать с различными таймерными механизмами (будь то timer-heap на базе priority-queue или более простой timer-list). Но расходы оказались достаточно малыми, чтобы пока не заморачиваться и перейти к решению других насущных проблем.

Ну а работа, в принципе, имитируется более-менее реальная. В on_timer нужно взять текущее время и проверить, а не истекло ли время жизни воркера.