Pull to refresh

Comments 13

Классная штука! Какая у неё производительность на поток и overhead? Я обычно не пишу на плюсах когда можно тратить больше микросекунды на message/event/data-row или когда надо память много динамически аллокировать, так как AKKA actors/streams могут пару сотен тысяч сообщений в секунду обрабатывать, CompletableFuture - того же порядка и GC очень хорошо работает в новых JVM (JRE11+).

Привет. Померил производительность.

На моём компе в релиз сборке clang-13 на доставку 100 сообщений в среднем уходит:

test_std_thread execution_time microsec per 100 tasks: 18

test_highway_channel_block_on_wait_holder execution_time microsec per 100 tasks: 89

test_highway_channel_not_block execution_time microsec per 100 tasks: 75

test_highway_channel_direct_send execution_time microsec per 100 tasks: 5

За BaseLine можно брать test_std_thread - хайвеи доставляют помедленней, но всё ещё меньше микросекунды. Я посмотрел где можно было бы урезать фичи в угоду производительности - рука не поднялась :). Добавил возможность чтобы через каналы можно было слать сообщения подписчикам без перепланирования отправки на другой поток - так быстрее всего получается. Спасибо за инфу про AKKA actors/streams - посмотрел, записал себе в TODO.

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

В библиотеке эта проблема решена. В следующем примере используется std::weak_ptr<SelfProtectedTask> в качестве защиты от запуска лямбды если целевой объект уже разрушился при выходе из скоупа

Позволю себе позанудствовать и высказать свои опасения в легальности примененного вами решения. А именно:

struct SharedHolder : public std::enable_shared_from_this<SharedHolder>
{
	alignas(T) unsigned char buffer_[sizeof(T)];
	T * ptr_{nullptr};
	std::shared_ptr<T> construct(Args &&... args)
	{
		auto ptr = std::launder(reinterpret_cast<T *>(buffer_)); // (1)
		auto alias = std::shared_ptr<T>(this->shared_from_this(), ptr); // (2)
		::new (ptr) T(alias, std::forward<Args>(args)...); // (3)
		ptr_ = ptr;
		return alias;
	}

Здесь в точке (1) вы "отмываете" указатель и получаете якобы легальный указатель на объект типа T. Оставим вопрос о том, насколько легально делать это, если по факту в buffer_ на момент вызова launder не было корректно сформированного экземпляра типа T. Допустим, что ptr валиден и передача его в shared_ptr в точке (2) допустима.

Но в точке (3) вы переписываете содержимое buffer_, тем самым делая невалидными все предыдущие указатели, которые указывали на T внутри buffer_. Поэтому и сам ptr после вызова placement new перестает быть валидным. Чтобы он остался таковым вам нужно было бы написать, например, так:

ptr = ::new(buffer_) T(alias, std::forward<Args>(args)...);

Или же сделать еще один std::launder после завершения placement new:

::new (ptr) T(alias, std::forward<Args>(args)...); // (3)
ptr_ = std::launder(reinterpret_cast<T*>(buffer_));

Без этих действий в ptr_ окажется указатель, который является невалидным. По крайней мере в C++17.

Но даже если вы корректно "отмываете" значение для ptr_, то остается вопрос с тем, что в alias вы передали указатель, который перестал быть валидным после вызова placement new.

А это UB.

По крайней мере на уровне моего понимания того, как вся эта механика работает в C++17 (см. пояснения здесь). В C++20 вроде бы стало попроще, но в C++20 у меня познаний нет.

PS. Есть ощущение, что SharedHolder можно было бы сделать проще за счет использования std::optional<T> вместо buffer_ и ptr_.

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

Спасибо за замечание.

На самом деле чтобы заработать UB надо было бы что-нибудь разнообразное поконструировать на одном и том же buffer_

(там где (2) ничего не создаётся на buffer_ - просто связывается время жизни std::shared_ptr<SharedHolder> и указателя на пофиг что, поизучай чем занимается (8)ой конструктор: https://en.cppreference.com/w/cpp/memory/shared_ptr/shared_ptr).

У меня в коде std::launder использовался чтобы помешать компилятору оптимизировать.

Ну, ок, спасибо, второй std::launder не повредит, добавил.

Вроде в этом примере почти также как сейчас у меня: https://en.cppreference.com/w/cpp/memory/destroy_at

там где (2) ничего не создаётся на buffer_

Да, там не создается, но по факту вы передаете в конструктор shared_ptr указатель, который вроде как "отмыт" через std::launder. Но, к сожалению, в buffer_ к моменту вызова std::launder нет сконструированного объекта.

https://en.cppreference.com/w/cpp/utility/launder

the pointer p represents the address A of a byte in memory

an object X is located at the address A

X is within its lifetime

Так вот в точке (2) нет внутри buffer_ еще нет объекта, время жизни которого уже началось.

Посему у меня есть сильные подозрения, что в (2) имеет место быть UB. Но, возможно, я и ошибаюсь.

Сейчас уже создал кучу юнит тестов, нагрузочных тестов и постоянно их гоняю, на каждое изменение в Qt Creator нажимаю кнопку "Прогнать все юнит тесты" пока чай хожу пить или ещё чем занимаюсь. Если поймаю падение, то пойду копать&чинить. Пока не падало (под GCC 9.4.0, Clang-9, Clang-13, MSVC++ 14.32)

Сейчас оно и не упадет, т.к. существующие компиляторы к таким UB пока что относятся лояльно, как и к конструкциям вида:

char buffer[32];
binary_file.read(buffer);
auto * my_object = reinterpret_cast<my_type *>(buffer);

поскольку такие хаки активно используются на протяжении многих лет.

Однако, что будет дальше, лет через 5-10. В свое время и вот этот код компилировался без особых сюрпризов: https://godbolt.org/g/o4HxtU :(

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

Момент первый. Сочетание терминов "канал" и "издатель/подписчик". Я, пожалуй, в первый раз сталкиваюсь с ситуацией, когда эти термины используются совместно. Обычно "канал" -- это всего лишь один писатель и всего лишь один читатель. Никаких подписок, просто в канал кто-то пишет, кто-то читает (причем читает когда ему это удобно). Тогда как с "издатель/подписчик" обычно используется термин "топик". Издатель публикует данные в топик, подписчики подписываются на топик. Понятно, что вы, как автор библиотеки, можете выбирать любые термины, которые вам кажутся наиболее уместными. Но для стороннего читателя создается дополнительная когнитивная нагрузка для того, чтобы привыкнуть, что "канал" здесь, это не то же самое, к чему читатель привык.

Момент второй. Вы в описании достаточно часто начинаете говорить о мутексах или weak_ptr. Для вас, как для автора, достаточно просто понимать что к чему и почему упоминания мутексов/weak_ptr важны. Но для того, кто впервые знакомиться с библиотекой, такие погружения в дебри картину происходящего только усложняют. Ведь сначала хочется понять что это и зачем, что может, как это выглядит. А уже когда общее понимание достигнуто, тогда можно и про какие-то потроха поговорить. Однако, у вас такого последовательного погружения (на мой взгляд) не получилось и вы начинаете нагружать читателя техническими подробностями слишком рано. Из-за этого после первого прочтения я вообще не понял зачем о мутексах и weak_ptr здесь нужно было говорить. Вроде как библиотека нужна чтобы освободить пользователя от всех этих низкоуровневых деталей, но нет, эти самые детали чуть ли не подчеркиваются специально. ИМХО, вполне можно было бы обойтись и без этого.

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

С терминами у меня тоже беда, да - у нас канал(channel) это на что можно подписаться, поток(stream) это итераторы данных от 1 писателя к 1 читателю. Попробую может на Хабре тоже самое на English написать в следующие длинные праздники.

Если позволите, маленький совет.

Сейчас у вас обработчики получают два параметра связанных с информацией о контексте исполнения:

hi::SubscriptionCallback<std::string>::create(
		[&](std::string publication,
        const std::atomic<std::uint32_t> & global_run_id,
        const std::uint32_t your_run_id)
		{

Я бы предложил объединить оба этих параметра в одну структуру и передавать ссылку на нее (или отдавать ее по значению). Что-то вроде:

struct ExecContextInfo {
  std::reference_wrapper<const std::atomic<std::uint32_t>> global_run_id_;
  std::uint32_t your_run_id_;
  ...
};

hi::SubscriptionCallback<std::string>::create(
		[&](std::string publication,
        ExecContextInfo exec_ctx)
		{

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

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

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

Добавил везде группировку параметров в LaunchParameters + юнит тесты на все возможные варианты колбэков.

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

std::string str{"worker"};
str.append(std::to_string(id));

std::string{str}.append(std::to_string(i))

Тогда как привычнее видеть код вроде вот такого:

std::string str{"worker" + std::to_string(id)};

str + std::to_string(i)

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

в общем случае при сложении 2-х строк конструируется 3-я строка — я сразу пишу через append() чтобы исключить лишние операции. Понятно что rvalue скорее всего оптимально складываются, но мне уже привычнее через append() тем более что и при переключении между языками (С++/Java/Kotlin StringBuilder) удобно сохранять эти привычки.

Sign up to leave a comment.

Articles