Comments 46
Честно говоря, мне «Пример 3. Практически идеал: инкапсуляция локов» нравится больше, чем KeyValueStorage как с точки зрения кода, так и с точки зрения дальнейшего использования. Длинная стрелка, которая ещё иногда и короткой становится — совершенно неочевидная штука. Дополнительной проблемой может стать сокрытие локов от программиста, в результате можно получить разный порядок взятия локов (и это будет не сразу видно из-за врапперов), а это — прямой путь к дедлокам.
+4
В целом, инкапсуляция локов, конечно же, самый предпочтительный вариант. Но у него есть один недостаток, который отсутствует в приведенном подходе: это то, что нельзя атомарно сделать несколько операций. Для примера, возьмем Counter и инкапсулируем локи для всех его методов. Тогда возникает вопрос: а как мне атомарно увеличить счетчик на 100? Ответ — делать новый метод и тогда все получится. Но ведь существует еще массу других вариантов использования: уменьшить на число, удвоить, утроить и т.п. Что, добавлять каждый такой метод в класс? Более того, не всегда нужно использовать Counter с учетом многопоточности, иногда просто хочется его использовать в одном потоке.
В описанном подходе просто используем WAccess и сразу получаем результат. Хочется — можно использовать AnLock, а можно и AnRWLock или даже AnCow. Т.е. гораздо большая гибкость, плюс автоматом берутся локи, т.е. не надо в каждый метод напихивать scoped-локи.
В любом случае, выбирать, какой метод использовать целиком и полностью ложится на плечи программиста. Мне хотелось привести более другие подходы к частым вопросам многопоточности.
В описанном подходе просто используем WAccess и сразу получаем результат. Хочется — можно использовать AnLock, а можно и AnRWLock или даже AnCow. Т.е. гораздо большая гибкость, плюс автоматом берутся локи, т.е. не надо в каждый метод напихивать scoped-локи.
В любом случае, выбирать, какой метод использовать целиком и полностью ложится на плечи программиста. Мне хотелось привести более другие подходы к частым вопросам многопоточности.
0
Для решения этой проблемы введем новый оператор длинная стрелка --->Это из этой серии что-то?
Вообще, не очень понятно, стоит ли эта сложность того?
И не будет ли в реальном проекте куча такого кода?
AnRWLock<Counter> c;
c--->set(2);
c--->inc();
c-->somethingElse();
По-моему, стандартных локов и атомиков вполне достаточно. Их хотя бы легко понять.
С COW — вообще отдельная песня. Не всегда он так хорош, как хотелось бы. Иногда скопировать объект быстрее.
+7
С тем же успехом можно сказать: зачем нам C++, когда все можно написать C. Безусловно, можно. Вопрос в простоте и удобстве, в качестве и предсказуемости.
И не будет ли в реальном проекте куча такого кода?Ну если в реальном проекте не смущает использование "." или "->" в приведенных операциях, то я не вижу ничего зазорного использовать --->. Но это — на любителя, конечно.
С COW — вообще отдельная песня.Да, это — не панацея. Собственно, в статье детально изложены плюсы и минусы использования.
-1
Ну если в реальном проекте не смущает использование "." или "->" в приведенных операциях, то я не вижу ничего зазорного использовать --->.Ну вот видите, вы сами не заметили, в чем подвох. А дело в том, что такой код будет большую часть времени заниматься захватом и возвращением мьютексов в ядре ОС, вместо того, чтобы выполнять свои прямые обязанности. Поэтому, я считаю, некоторые вещи лучше делать явно.
Прогресс от С к С++ и от С++ к, там, Java, состоит в упрощении вещей. Ваш код же не упрощает, а усложняет. Да, можно писать чуть меньше кода и сложнее забыть что-нибудь заблокировать. Зато большое количество «магии» делает систему сложной в понимании.
+4
А дело в том, что такой код будет большую часть времени заниматься захватом и возвращением мьютексов в ядре ОС, вместо того, чтобы выполнять свои прямые обязанности.Не совсем понятно, как предлагается работать: без мьютексов? Или имелось в виду частое использование? Для этого описаны способы, как оптимизировать использование.
Поэтому, я считаю, некоторые вещи лучше делать явно.Ну на этот счет есть разные мнения. Возможно, что вам подойдет С-подход: там надо все делать явно. Вы скорее всего подразумеваете, что scoped-локи — это тот уровень автоматизма, дальше которого не стоит продвигаться. Я так не считаю, т.к. чем больше будет делать за тебя компилятор — тем лучше. Если возникнет проблема производительности, то всегда имеется возможность заоптимизировать. Более того, современные реализации мьютексов лезут в ядро только когда объект действительно занят, причем достаточно длительное время.
shared_ptr — это тоже магия. Мало кто знает, как он на самом деле работает. Но тем не менее, его используют и ничего. И на его тормоза тоже смотрят сквозь пальцы. Так что все зависит от задач и общего рецепта я бы не давал. Я лишь рассмотрел подход к штанге многопоточности с другой стороны.
0
Автор, между прочим, неверно понимает RAII (ну или мало его использовал на практике, судя по первому примеру). На его структурах Mutex и Lock можно написать вот такой код:
и после вызова деструктора Lock мы получаем неопределённое поведение. На Windows XP, к примеру, будет deadlock. Пишете о RAII — так реализуйте его уже по-настоящему, с приватными методами lock\unlock и friend-классом.
Статью забросил на середине, мне кажется атомики из С++11 дают не меньше возможностей — их и придумали чтобы избавить нас от необходимости городить и понимать вот такие велосипеды.
{
Mutex m;
Lock l(m);
m.unlock();
}
и после вызова деструктора Lock мы получаем неопределённое поведение. На Windows XP, к примеру, будет deadlock. Пишете о RAII — так реализуйте его уже по-настоящему, с приватными методами lock\unlock и friend-классом.
Статью забросил на середине, мне кажется атомики из С++11 дают не меньше возможностей — их и придумали чтобы избавить нас от необходимости городить и понимать вот такие велосипеды.
+4
и после вызова деструктора Lock мы получаем неопределённое поведение.Почему?
std::mutex и std::lock_guard выглядят точно так же.
0
Потому что нигде не указано, что будет со счетчиком локов при переходе нуля в отрицательную сторону. Ни спецификации ОС (ну по крайней мере Винды), ни объявление вышеуказанных классов не отвечают на этот вопрос. На счёт глюка с этим делом в WinXP — я не фантазирую, попробуйте локнуть критическую секцию, потом дважды релизнуть её, а потом локнуть снова. Поведение неопределённое, в примерно 80% случаев всё ок, в 20% — дедлок.
Это дело, конечно, пофиксили начиная с Висты, но что это за «полезная идиома» такая, если она на 25% компьютеров не будет работать?
Это дело, конечно, пофиксили начиная с Висты, но что это за «полезная идиома» такая, если она на 25% компьютеров не будет работать?
0
То, что было приведено выше — это не «полезная идиома», а пример, который иногда используют (см. [9] Хабрахабр: Многопоточность, общие данные и мьютексы). В приведенной статье такое сделать просто невозможно.
0
Где вы видите критическую секцию и Windows XP? Я вижу только класс Mutex. И если при его использовании возможен повторный unlock, то разработчики об это позаботятся.
0
Критическую секцию я вижу в документации к ОС. Или этот мьютекс будет на святом духе работать? Позаботятся разработчики или нет — откуда я знаю? Вы что — верите разработчика библиотек?
Убранные в приват методы мьютекса были бы явным указанием «юзайте RAII и не выпендривайтесь, всё будет ок»
Отсутствие класса Lock было бы явным указанием «менеджерите локи\анлоки сами вручную»
А так — и не то и не то. Ощущения как от плохо документированного кода — неопределённость.
Убранные в приват методы мьютекса были бы явным указанием «юзайте RAII и не выпендривайтесь, всё будет ок»
Отсутствие класса Lock было бы явным указанием «менеджерите локи\анлоки сами вручную»
А так — и не то и не то. Ощущения как от плохо документированного кода — неопределённость.
+1
Или этот мьютекс будет на святом духе работать?На атомарном счетчике ссылок, как все изветсные мне реализации.
Вы что — верите разработчика библиотек?Да. И это меня еще не подводило.
0
Кому-то ещё охота возиться с кучей локов и мутексов, корректно и быстро работать с которыми получается практически никогда?
Уж либо использовать атомарные примитивы, либо строить приложение на базе более высокоуровневых конструкций вроде тредпулов с фьючерами, очередей с производителями/потребителями или fork/join (одно не исключает другого).
Уж либо использовать атомарные примитивы, либо строить приложение на базе более высокоуровневых конструкций вроде тредпулов с фьючерами, очередей с производителями/потребителями или fork/join (одно не исключает другого).
0
Кому-то ещё охота возиться с кучей локов и мутексов, корректно и быстро работать с которыми получается практически никогда?У меня практически всегда удается корректно и быстро работать. Что я делаю не так?
Уж либо использовать атомарные примитивы, либо строить приложение на базе более высокоуровневых конструкций вроде тредпулов с фьючерами, очередей с производителями/потребителями или fork/join (одно не исключает другого).Очень сильно зависит от задач. Если правильно шарить данные, то никаких проблем не бывает. И мне очень интересно узнать, как реализовать, например, консистентное кеширование данных в памяти с использованием фьючерсов или тредпулов.
0
как реализовать, например, консистентное кеширование данных в памяти
Первое, что приходит в голову — выносим кэш в отдельный поток, вся работа с ним осуществляется через очередь запросов. Запрос на запись в кэш не блокирует клиента, если размер очереди не превысил оговоренный лимит. Запрос на чтение возвращает фьючерс, который пуст до тех пор, пока поток кэша не обработает запрос и не положит в него значение (или null, если ключ протух). Поток, отвечающий за работу с кэшем, периодически выполняет операции по удалению протухших ключей, не особо напрягая клиентов.
К сожалению, у меня нет возможности сравнить производительность такого решения с чем-то вроде Cache из Google.Guava. Хотелось бы услышать мнение профессионалов по этому поводу.
0
Как предлагаете делать очередь без локов? Только давайте практические реализации, проверенные, т.к. во многих учебных примерах, например, есть ABA проблема.
0
Я, может, напишу статью про mapreduce в пределах одного процесса. Там, в том числе, используется кольцевой буффер (по сути реализующий интерфейс очереди) без блокировок.
0
Кто сказал, что очередь обязательно должна быть без локов? Можно и с локами (хотя есть атомарные без локов, взгляните на Google Concurrency Library for C++).
Я не говорю, что локи не нужны. Разумеется, нужны. Я говорю о том, что редко есть необходимость использовать именно их в качестве примитивов для построения приложения. Конкурентная очередь уже написана, бери и используй. Даже если нет, реализовать очередь самому и использовать её в качестве основы гораздо лучше, чем втыкать тут и там блокировки и общедоступные ресурсы.
Я не говорю, что локи не нужны. Разумеется, нужны. Я говорю о том, что редко есть необходимость использовать именно их в качестве примитивов для построения приложения. Конкурентная очередь уже написана, бери и используй. Даже если нет, реализовать очередь самому и использовать её в качестве основы гораздо лучше, чем втыкать тут и там блокировки и общедоступные ресурсы.
+1
Да, но с очередью вы сразу добавляете много накладных расходов. Если потоков больше, чем ядер (а это нормальная ситуация, если вы работаете с сетью) вы получаете +2 кванта времени (80мс на современных серверных ядрах линкса), которые ушли на решедулинг, хотя в случае мьютекса хватило бы одного спинлока и времени бы вообще не потеряли. 6 обращений к кэшу — и вы внезапно получили плюс пол секунды к времени работы функции.
Lock-less структуры данных это штуки, которые практически невозможно оттестировать и крайне сложно отловить в них баги. Избегайте их как только можете, потому что в реалиях многоядерных систем мьютекс, а точнее фьютекс, превращается в спинлок, который работает очень быстро в случае лёгкий операций типа «добавить собщение в очередь».
Lock-less структуры данных это штуки, которые практически невозможно оттестировать и крайне сложно отловить в них баги. Избегайте их как только можете, потому что в реалиях многоядерных систем мьютекс, а точнее фьютекс, превращается в спинлок, который работает очень быстро в случае лёгкий операций типа «добавить собщение в очередь».
+1
UFO just landed and posted this here
На самом деле нет. В этом «идеале» нет composability.Судя по всему, вы пропустили слово «практически». Я не настаиваю на том, что это — идеал, но очень близко к нему. Этот пример приведен в качестве затравки. Более того, моя статья посвящена несколько другим аспектам, где этот недостаток («composability») сильно сглажен (см. комментарий).
Пример: мы пишем сервер, поток, принявший запрос, определяет по round robin алгоритму, какому именно потоку делигировать выполнение запроса, затем кладет в запрос в входящую очередь этого потока, далее, обработка запросов может выполняться поэтапно несколькими потоками используя pipeline парралелизм.Выше приводился пример про кешировние, когда использование такого подхода для доступа к данным приводит к дополнительным накладным расходам.
В общем, цель, это обычно баланс между fine и coarse granularity, а данный подход как раз фокусируется только на первом. Это полхо.С тем же успехом можно сказать, что я в своей статье использовал С++ и ничего не сказал про Java, а это плохо. Или что ничего не сказал о conditional variable или атомарных конструкциях. Странный аргумент. Вместе с тем, подход не исключает использование coarse granularity. Для этого всего лишь нужно создать один An(RW)Lock объект, не включающий в себя другие An(RW)Lock объекты.
0
UFO just landed and posted this here
Если один поток ждет данные а другой — предоставляет, то, если оба потока выполняются, никакого переключения контекста не будет, мьютекс там, либо атомарная переменная.
Если поток пытается захватить мьютекс, а он уже захвачен, то это приведет к переключению контекста. В случае lock free очереди — переключения контекста не будет, поток потратит свой квант времени на busy wait на этой самой очереди. На практике, накладные расходы появляются тогда, когда очереди сильно выростают, начинают жрать память и приводить к кэш промахам.
Если есть только один мьютекс, и 2 потока работают с защищаемой им переменной, то переключение контекста будет в том случае, если оба потока одновременно выполняются (очевидно, многоядерная система должна быть) и лочат на чуть большее время, чем работает спинлок, либо если один залочил и его зарешедулила ОС. Если операция лёгкая, то чаще всего никого не зарешедулят и всё ограничится спинлоком. Если одновременно работает 1 поток — то всё вообще отлично.
Когда появляется очередь, приходится использовать кондишены. Если разгребающий поток не будет активным — то ожидающий наверняка будет зарешедулен. Если очередь более-менее длинная, то ожидающий опять же будет зарешедулен. То есть если у вас 200 потоков на 16 ядер, то с высокой вероятностью постановка задания в очередь и получение результата в одном кванте не выполнится. Если операция долгая, то ничего страшного. Если операция быстрая, типа Key value store — то очередь только замедлит всё.
Пишите, если я где-то не прав. Выше я что-то маху дал на счёт 40мс, при HZ=250 квант всё же 4мс.
0
UFO just landed and posted this here
Вы рассматриваете варинат std::queue + condition variable вместо специализированой очереди?
Какая бы очередь не была, нам всё равно надо каким-то образом получить результат, правда? Это может быть не кондишн, а while(cond) со слипом внутри, не суть важно. Главное, что мы ждём. Вы можете возразить, что мы можем вместо ожидания вернуть в ивентлуп и продолжать делать полезную работу, но это уже совсем другая песня.
При высокой загрузке в очереди постоянно будут элементы, поэтому consumer не будет ждать, он будет просто каждый раз извлекать новый элемент без ожидания. К тому же, ждать элементов в очереди можно с помощью busy wait, если так важна латентность.
Это если потоков столько же, сколько ядер. В реальности, если вы работаете с данными на диске, то потоков будет сильно больше, и consumer будет регулярно вытесняться. Я всё к тому, что реальная latency очереди может оказаться больше, чем кажется на первый взгляд, и про неё уже нельзя забывать, если время ответа всего сервиса измеряется в десятках миллисекунд.
0
UFO just landed and posted this here
latency(положить в очередь) + latency(обработать) + latency(получить результат)
будет больше, чем
latency(взять лок) + latency(обработать) + latency(отпустить лок)
из-за решедулингов. А они, в свою очередь, зависят от количества ядер и количества активных потоков.
Дисковое IO в линуксе на самом деле всегда синхронное, просто при использовании aio_* блокироваться будет не ваш поток, а специально созданный ядерный поток. Я упомянул IO в контексте того, что потоков на порядок, а то и на несколько порядков больше, чем ядер процессора. Начал какой нибудь logrotate или syslogd синкать данные на диске, скушал много процессора — и очередь резко стала работать медленней.
будет больше, чем
latency(взять лок) + latency(обработать) + latency(отпустить лок)
из-за решедулингов. А они, в свою очередь, зависят от количества ядер и количества активных потоков.
Дисковое IO в линуксе на самом деле всегда синхронное, просто при использовании aio_* блокироваться будет не ваш поток, а специально созданный ядерный поток. Я упомянул IO в контексте того, что потоков на порядок, а то и на несколько порядков больше, чем ядер процессора. Начал какой нибудь logrotate или syslogd синкать данные на диске, скушал много процессора — и очередь резко стала работать медленней.
0
UFO just landed and posted this here
То, что исползуя очередь, мы жертвуем латентностью, ради увеличения пропускной способности — не секрет. Я только не пойму, откуда возьмутся решедулинги и почему они испортят производительность.
Проблема не в блокировках consumer на извлечении элементов из очереди, тут то как раз всё хорошо обычно. Проблема в том, что при 160 активных тредах на 16 физических ядер шансы того, что consumer тред активен одновременно с нашим, запихивающим данные, 10%. А когда consumer отработает, в следующий квант, то наш уже будет вытеснен. Производительность, если не использовать спин-вейты, не упадёт, а вот latency вырастет. Можно повысить приоритет consumer треда, и это даже может помочь. А может всё, наоборот, станет медленнее. И прирост от нагрузки может сильно зависеть.
Я совершенно согласен с вами, что если работа по обработке достаточно большая по времени, то очереди становятся неплохой идеей. Что такое «достаточно большая» — вопрос, который надо исследовать на реальной системе с реальной нагрузкой. В таком исследовании я бы отталкивался от цифры в 10 квантов.
0
UFO just landed and posted this here
Всё, я понял ваше недопонимание. В современных операционных системах, работающих на многоядерных процессорах, когда вы создаёте mutex на самом деле создаётся futex. Различие в том, что он сначала делает spin wait некоторое время (достаточное, чтобы несколько раз обратиться к памяти мимо всех кэшей), то есть ведёт себя как спинлок, а если после этого лок всё ещё не взят, то делается уже классический ядерный мьютекс и поток засыпает. В линуксе это точно так, вот страничка из мана: linux.die.net/man/2/futex.
Таким образом в большинстве случаев при правильном использовании (брать мьютекс на чуть-чуть) на многоядерных (это важно! если ядро одно — в любом случае будет вытеснение, если лок уже взят) системах получается очень быстрая реакция.
Таким образом в большинстве случаев при правильном использовании (брать мьютекс на чуть-чуть) на многоядерных (это важно! если ядро одно — в любом случае будет вытеснение, если лок уже взят) системах получается очень быстрая реакция.
0
UFO just landed and posted this here
Это я развивал мысль, которая с комментария roman_kashitsyn началась:
Тредпул с фьючерами и очередью перед пулом — как раз такая конструкция, в которой мы в очередь запихиваем нечто и потом ожидаем ответа. А если очередь рассматривать как элемент пайплайна (вы же такое использование предполагали, да?), то тогда действительно раундтрип может оказаться цифрой, которая не имеет смысла.
P.S. мне кажется, наша с вами переписка могла дать много новых знаний программистам, только начинающим изучать многопоточность
Кому-то ещё охота возиться с кучей локов и мутексов, корректно и быстро работать с которыми получается практически никогда?
Уж либо использовать атомарные примитивы, либо строить приложение на базе более высокоуровневых конструкций вроде тредпулов с фьючерами, очередей с производителями/потребителями или fork/join (одно не исключает другого).
Тредпул с фьючерами и очередью перед пулом — как раз такая конструкция, в которой мы в очередь запихиваем нечто и потом ожидаем ответа. А если очередь рассматривать как элемент пайплайна (вы же такое использование предполагали, да?), то тогда действительно раундтрип может оказаться цифрой, которая не имеет смысла.
P.S. мне кажется, наша с вами переписка могла дать много новых знаний программистам, только начинающим изучать многопоточность
0
P.S. мне кажется, наша с вами переписка могла дать много новых знаний программистам, только начинающим изучать многопоточностьПредлагаю оформить это в виде отдельной статьи, со сравнением и результатами измерений на сценариях, близких к реальным. )
0
в очередь запихиваем нечто и потом ожидаем ответа
Обычно как раз не ожидаем, а делаем что-то ещё (например, посылаем сразу другие таски и потом уже ждём всё сразу), иначе смысла во фьючерсе мало, проще было бы предоставить синхронный интерфейс.
0
практически
Практически тоже не композируются. То есть вообще. Два атомарных действия не равны двум действиям, совершённым атомарно. Такими свойствами обладает STM (реализованная в стандартной библиотеке Clojure, Haskell и в Akka), но у неё всё ещё есть проблемы с производительностью и не только.
0
У Вас в коде из коробки дедлок!
Нельзя сначала залочить на чтение, а потом апгредится до записи! Привожу пример: два потока залочили на чтение, а потом оба решили записать, и мы получили дедлок на одной единственной RW секции.
RWMutex::rlock
Key-value: extracting key: users
RWMutex::wlock
Counter::inc: 4
RWMutex::wunlock
RWMutex::runlock
Нельзя сначала залочить на чтение, а потом апгредится до записи! Привожу пример: два потока залочили на чтение, а потом оба решили записать, и мы получили дедлок на одной единственной RW секции.
0
Sign up to leave a comment.
Полезные идиомы многопоточности С++