Pull to refresh

Comments 46

Честно говоря, мне «Пример 3. Практически идеал: инкапсуляция локов» нравится больше, чем KeyValueStorage как с точки зрения кода, так и с точки зрения дальнейшего использования. Длинная стрелка, которая ещё иногда и короткой становится — совершенно неочевидная штука. Дополнительной проблемой может стать сокрытие локов от программиста, в результате можно получить разный порядок взятия локов (и это будет не сразу видно из-за врапперов), а это — прямой путь к дедлокам.
В целом, инкапсуляция локов, конечно же, самый предпочтительный вариант. Но у него есть один недостаток, который отсутствует в приведенном подходе: это то, что нельзя атомарно сделать несколько операций. Для примера, возьмем Counter и инкапсулируем локи для всех его методов. Тогда возникает вопрос: а как мне атомарно увеличить счетчик на 100? Ответ — делать новый метод и тогда все получится. Но ведь существует еще массу других вариантов использования: уменьшить на число, удвоить, утроить и т.п. Что, добавлять каждый такой метод в класс? Более того, не всегда нужно использовать Counter с учетом многопоточности, иногда просто хочется его использовать в одном потоке.

В описанном подходе просто используем WAccess и сразу получаем результат. Хочется — можно использовать AnLock, а можно и AnRWLock или даже AnCow. Т.е. гораздо большая гибкость, плюс автоматом берутся локи, т.е. не надо в каждый метод напихивать scoped-локи.

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

Вообще, не очень понятно, стоит ли эта сложность того?

И не будет ли в реальном проекте куча такого кода?

AnRWLock<Counter> c;
c--->set(2);
c--->inc();
c-->somethingElse();


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

С COW — вообще отдельная песня. Не всегда он так хорош, как хотелось бы. Иногда скопировать объект быстрее.
С тем же успехом можно сказать: зачем нам C++, когда все можно написать C. Безусловно, можно. Вопрос в простоте и удобстве, в качестве и предсказуемости.

И не будет ли в реальном проекте куча такого кода?
Ну если в реальном проекте не смущает использование "." или "->" в приведенных операциях, то я не вижу ничего зазорного использовать --->. Но это — на любителя, конечно.

С COW — вообще отдельная песня.
Да, это — не панацея. Собственно, в статье детально изложены плюсы и минусы использования.
Ну если в реальном проекте не смущает использование "." или "->" в приведенных операциях, то я не вижу ничего зазорного использовать --->.
Ну вот видите, вы сами не заметили, в чем подвох. А дело в том, что такой код будет большую часть времени заниматься захватом и возвращением мьютексов в ядре ОС, вместо того, чтобы выполнять свои прямые обязанности. Поэтому, я считаю, некоторые вещи лучше делать явно.

Прогресс от С к С++ и от С++ к, там, Java, состоит в упрощении вещей. Ваш код же не упрощает, а усложняет. Да, можно писать чуть меньше кода и сложнее забыть что-нибудь заблокировать. Зато большое количество «магии» делает систему сложной в понимании.
А дело в том, что такой код будет большую часть времени заниматься захватом и возвращением мьютексов в ядре ОС, вместо того, чтобы выполнять свои прямые обязанности.
Не совсем понятно, как предлагается работать: без мьютексов? Или имелось в виду частое использование? Для этого описаны способы, как оптимизировать использование.

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

shared_ptr — это тоже магия. Мало кто знает, как он на самом деле работает. Но тем не менее, его используют и ничего. И на его тормоза тоже смотрят сквозь пальцы. Так что все зависит от задач и общего рецепта я бы не давал. Я лишь рассмотрел подход к штанге многопоточности с другой стороны.
Автор, между прочим, неверно понимает RAII (ну или мало его использовал на практике, судя по первому примеру). На его структурах Mutex и Lock можно написать вот такой код:

{
    Mutex m;
    Lock l(m);
    m.unlock();
}


и после вызова деструктора Lock мы получаем неопределённое поведение. На Windows XP, к примеру, будет deadlock. Пишете о RAII — так реализуйте его уже по-настоящему, с приватными методами lock\unlock и friend-классом.

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

и после вызова деструктора Lock мы получаем неопределённое поведение.
Почему?
std::mutex и std::lock_guard выглядят точно так же.
Потому что нигде не указано, что будет со счетчиком локов при переходе нуля в отрицательную сторону. Ни спецификации ОС (ну по крайней мере Винды), ни объявление вышеуказанных классов не отвечают на этот вопрос. На счёт глюка с этим делом в WinXP — я не фантазирую, попробуйте локнуть критическую секцию, потом дважды релизнуть её, а потом локнуть снова. Поведение неопределённое, в примерно 80% случаев всё ок, в 20% — дедлок.

Это дело, конечно, пофиксили начиная с Висты, но что это за «полезная идиома» такая, если она на 25% компьютеров не будет работать?
А, ну в качестве «пример как не надо делать» вполне пойдёт. Просто я как-то не сразу это уловил.
Где вы видите критическую секцию и Windows XP? Я вижу только класс Mutex. И если при его использовании возможен повторный unlock, то разработчики об это позаботятся.
Критическую секцию я вижу в документации к ОС. Или этот мьютекс будет на святом духе работать? Позаботятся разработчики или нет — откуда я знаю? Вы что — верите разработчика библиотек?

Убранные в приват методы мьютекса были бы явным указанием «юзайте RAII и не выпендривайтесь, всё будет ок»
Отсутствие класса Lock было бы явным указанием «менеджерите локи\анлоки сами вручную»

А так — и не то и не то. Ощущения как от плохо документированного кода — неопределённость.
Или этот мьютекс будет на святом духе работать?
На атомарном счетчике ссылок, как все изветсные мне реализации.

Вы что — верите разработчика библиотек?
Да. И это меня еще не подводило.
Ну, мы с Вами расходимся во мнениях только потому, что лично я видел и библиотечный мьютекс, написанный на критической секции. И именно это меня и подвело когда-то. Опыт у всех свой.
Кому-то ещё охота возиться с кучей локов и мутексов, корректно и быстро работать с которыми получается практически никогда?
Уж либо использовать атомарные примитивы, либо строить приложение на базе более высокоуровневых конструкций вроде тредпулов с фьючерами, очередей с производителями/потребителями или fork/join (одно не исключает другого).
Кому-то ещё охота возиться с кучей локов и мутексов, корректно и быстро работать с которыми получается практически никогда?
У меня практически всегда удается корректно и быстро работать. Что я делаю не так?

Уж либо использовать атомарные примитивы, либо строить приложение на базе более высокоуровневых конструкций вроде тредпулов с фьючерами, очередей с производителями/потребителями или fork/join (одно не исключает другого).
Очень сильно зависит от задач. Если правильно шарить данные, то никаких проблем не бывает. И мне очень интересно узнать, как реализовать, например, консистентное кеширование данных в памяти с использованием фьючерсов или тредпулов.
как реализовать, например, консистентное кеширование данных в памяти

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

К сожалению, у меня нет возможности сравнить производительность такого решения с чем-то вроде Cache из Google.Guava. Хотелось бы услышать мнение профессионалов по этому поводу.
Как предлагаете делать очередь без локов? Только давайте практические реализации, проверенные, т.к. во многих учебных примерах, например, есть ABA проблема.
Я, может, напишу статью про mapreduce в пределах одного процесса. Там, в том числе, используется кольцевой буффер (по сути реализующий интерфейс очереди) без блокировок.
Кто сказал, что очередь обязательно должна быть без локов? Можно и с локами (хотя есть атомарные без локов, взгляните на Google Concurrency Library for C++).

Я не говорю, что локи не нужны. Разумеется, нужны. Я говорю о том, что редко есть необходимость использовать именно их в качестве примитивов для построения приложения. Конкурентная очередь уже написана, бери и используй. Даже если нет, реализовать очередь самому и использовать её в качестве основы гораздо лучше, чем втыкать тут и там блокировки и общедоступные ресурсы.
Да, но с очередью вы сразу добавляете много накладных расходов. Если потоков больше, чем ядер (а это нормальная ситуация, если вы работаете с сетью) вы получаете +2 кванта времени (80мс на современных серверных ядрах линкса), которые ушли на решедулинг, хотя в случае мьютекса хватило бы одного спинлока и времени бы вообще не потеряли. 6 обращений к кэшу — и вы внезапно получили плюс пол секунды к времени работы функции.

Lock-less структуры данных это штуки, которые практически невозможно оттестировать и крайне сложно отловить в них баги. Избегайте их как только можете, потому что в реалиях многоядерных систем мьютекс, а точнее фьютекс, превращается в спинлок, который работает очень быстро в случае лёгкий операций типа «добавить собщение в очередь».
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 объекты.
UFO just landed and posted this here
Если один поток ждет данные а другой — предоставляет, то, если оба потока выполняются, никакого переключения контекста не будет, мьютекс там, либо атомарная переменная.
Если поток пытается захватить мьютекс, а он уже захвачен, то это приведет к переключению контекста. В случае lock free очереди — переключения контекста не будет, поток потратит свой квант времени на busy wait на этой самой очереди. На практике, накладные расходы появляются тогда, когда очереди сильно выростают, начинают жрать память и приводить к кэш промахам.

Если есть только один мьютекс, и 2 потока работают с защищаемой им переменной, то переключение контекста будет в том случае, если оба потока одновременно выполняются (очевидно, многоядерная система должна быть) и лочат на чуть большее время, чем работает спинлок, либо если один залочил и его зарешедулила ОС. Если операция лёгкая, то чаще всего никого не зарешедулят и всё ограничится спинлоком. Если одновременно работает 1 поток — то всё вообще отлично.

Когда появляется очередь, приходится использовать кондишены. Если разгребающий поток не будет активным — то ожидающий наверняка будет зарешедулен. Если очередь более-менее длинная, то ожидающий опять же будет зарешедулен. То есть если у вас 200 потоков на 16 ядер, то с высокой вероятностью постановка задания в очередь и получение результата в одном кванте не выполнится. Если операция долгая, то ничего страшного. Если операция быстрая, типа Key value store — то очередь только замедлит всё.

Пишите, если я где-то не прав. Выше я что-то маху дал на счёт 40мс, при HZ=250 квант всё же 4мс.
UFO just landed and posted this here
Вы рассматриваете варинат std::queue + condition variable вместо специализированой очереди?

Какая бы очередь не была, нам всё равно надо каким-то образом получить результат, правда? Это может быть не кондишн, а while(cond) со слипом внутри, не суть важно. Главное, что мы ждём. Вы можете возразить, что мы можем вместо ожидания вернуть в ивентлуп и продолжать делать полезную работу, но это уже совсем другая песня.

При высокой загрузке в очереди постоянно будут элементы, поэтому consumer не будет ждать, он будет просто каждый раз извлекать новый элемент без ожидания. К тому же, ждать элементов в очереди можно с помощью busy wait, если так важна латентность.

Это если потоков столько же, сколько ядер. В реальности, если вы работаете с данными на диске, то потоков будет сильно больше, и consumer будет регулярно вытесняться. Я всё к тому, что реальная latency очереди может оказаться больше, чем кажется на первый взгляд, и про неё уже нельзя забывать, если время ответа всего сервиса измеряется в десятках миллисекунд.
UFO just landed and posted this here
latency(положить в очередь) + latency(обработать) + latency(получить результат)
будет больше, чем
latency(взять лок) + latency(обработать) + latency(отпустить лок)
из-за решедулингов. А они, в свою очередь, зависят от количества ядер и количества активных потоков.

Дисковое IO в линуксе на самом деле всегда синхронное, просто при использовании aio_* блокироваться будет не ваш поток, а специально созданный ядерный поток. Я упомянул IO в контексте того, что потоков на порядок, а то и на несколько порядков больше, чем ядер процессора. Начал какой нибудь logrotate или syslogd синкать данные на диске, скушал много процессора — и очередь резко стала работать медленней.
UFO just landed and posted this here
То, что исползуя очередь, мы жертвуем латентностью, ради увеличения пропускной способности — не секрет. Я только не пойму, откуда возьмутся решедулинги и почему они испортят производительность.

Проблема не в блокировках consumer на извлечении элементов из очереди, тут то как раз всё хорошо обычно. Проблема в том, что при 160 активных тредах на 16 физических ядер шансы того, что consumer тред активен одновременно с нашим, запихивающим данные, 10%. А когда consumer отработает, в следующий квант, то наш уже будет вытеснен. Производительность, если не использовать спин-вейты, не упадёт, а вот latency вырастет. Можно повысить приоритет consumer треда, и это даже может помочь. А может всё, наоборот, станет медленнее. И прирост от нагрузки может сильно зависеть.

Я совершенно согласен с вами, что если работа по обработке достаточно большая по времени, то очереди становятся неплохой идеей. Что такое «достаточно большая» — вопрос, который надо исследовать на реальной системе с реальной нагрузкой. В таком исследовании я бы отталкивался от цифры в 10 квантов.
UFO just landed and posted this here
Всё, я понял ваше недопонимание. В современных операционных системах, работающих на многоядерных процессорах, когда вы создаёте mutex на самом деле создаётся futex. Различие в том, что он сначала делает spin wait некоторое время (достаточное, чтобы несколько раз обратиться к памяти мимо всех кэшей), то есть ведёт себя как спинлок, а если после этого лок всё ещё не взят, то делается уже классический ядерный мьютекс и поток засыпает. В линуксе это точно так, вот страничка из мана: linux.die.net/man/2/futex.

Таким образом в большинстве случаев при правильном использовании (брать мьютекс на чуть-чуть) на многоядерных (это важно! если ядро одно — в любом случае будет вытеснение, если лок уже взят) системах получается очень быстрая реакция.
UFO just landed and posted this here
Это я развивал мысль, которая с комментария roman_kashitsyn началась:
Кому-то ещё охота возиться с кучей локов и мутексов, корректно и быстро работать с которыми получается практически никогда?
Уж либо использовать атомарные примитивы, либо строить приложение на базе более высокоуровневых конструкций вроде тредпулов с фьючерами, очередей с производителями/потребителями или fork/join (одно не исключает другого).

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

P.S. мне кажется, наша с вами переписка могла дать много новых знаний программистам, только начинающим изучать многопоточность
P.S. мне кажется, наша с вами переписка могла дать много новых знаний программистам, только начинающим изучать многопоточность
Предлагаю оформить это в виде отдельной статьи, со сравнением и результатами измерений на сценариях, близких к реальным. )
К сожалению, мой писательский талант практически не талант :( Вот голосом могу рассказывать, а как начинаю буковки писать — мысли в голову не лезут.
Для этого есть очень простое решение (сам использую): писать, как будто рассказываешь перед кем-то.
в очередь запихиваем нечто и потом ожидаем ответа

Обычно как раз не ожидаем, а делаем что-то ещё (например, посылаем сразу другие таски и потом уже ждём всё сразу), иначе смысла во фьючерсе мало, проще было бы предоставить синхронный интерфейс.
Если рассматривать в контексте времени обработки одного запроса — то разницы особо нету, просто ждать или делать другую полезную работу.
практически

Практически тоже не композируются. То есть вообще. Два атомарных действия не равны двум действиям, совершённым атомарно. Такими свойствами обладает STM (реализованная в стандартной библиотеке Clojure, Haskell и в Akka), но у неё всё ещё есть проблемы с производительностью и не только.
См. «Пример 4. Поддержка атомарности изменений» в статье при рассмотрении key-value хранилища.
У Вас в коде из коробки дедлок!
RWMutex::rlock
Key-value: extracting key: users
RWMutex::wlock
Counter::inc: 4
RWMutex::wunlock
RWMutex::runlock

Нельзя сначала залочить на чтение, а потом апгредится до записи! Привожу пример: два потока залочили на чтение, а потом оба решили записать, и мы получили дедлок на одной единственной RW секции.
На самом деле там 2 разных лока:
первый — это лок на таблице key-value
второй — на самом Counter

Поэтому никакого дедлока нет. Это называется fine-grained locks. Про это, собственно, и статья (но и не только про это).
Упс, и правда разные, прошу прощения. Давно дело было.
Sign up to leave a comment.

Articles

Change theme settings