Реализация мьютекса в userver
Всем привет.
Давайте знакомиться.
Меня зовут Василий Куликов.
Как меня уже представили, я работаю в Яндексе уже несколько лет, шесть лет, являюсь ведущим разработчиком.
Вхожу в группу общих компонентов технологической платформы и зарабатываю фреймфорг UCerver вместе с моими коллегами.
Поднимите, пожалуйста, руку тех, кто что-нибудь слышал о UCerver.
А поднимите руку, пожалуйста, тех, кто что-нибудь пробовал на нем написать.
Угу, спасибо.
Ну, для тех, кто не поднял руку или для тех, кто слушает нас в трансляции, я напомню, что U-Server — это такой фреймворк, с помощью которого можно писать отказоустойчивые высокопроизводительные приложения на языке C++ — веб-приложение, но не только, например, ГРПЦ.
И он основан на крутиновском движке.
которые реализуют стекфул Крутина.
Мы реализовали свой собственный Крутинский движок для того, чтобы реализовать Юсервер.
Поверх этого движка реализована большое количество примитивов синхронизации, начиная с каких-то простых типа Event Future и заканчивая сложными вроде очередей и редкопии Update.
С использованием этих примитивов синхронизации построены асинхронные драйвера и драйвера сетевых протоколов.
То есть это драйвера для Postgres, Mongo, Edis, Kafka, YDB и прочих.
Он активно используется в Яндексе, особенно в бизнес-группе Райтеха и Якома.
То есть это Яндекс.го, еда, лавка, доставка, банк, самокаты и прочее.
И обслуживает более тысячи квестеров.
То есть это кластерами сервисов, микросервисов и более десятка тысяч костов.
То есть это решение, которое используется повсеместно в Яндексе.
И мы часто выступаем на различных ивентах, на различных конференциях, метапах, посвященных C++, и рассказываем про Юсервер.
И вот нас периодически спрашивают, а что же происходит в Юсервере под капотом?
Что же там внутри такое происходит?
И сегодня мы про это как раз поговорим.
Мы сегодня поговорим про то, каким образом происходит переключение контекста в карутиныском движке, как вообще что из себя представляет карутиныский движок.
И посмотрим на один из примитивов синхронизации, на Мьютокс.
В принципе, я буду рассказывать специфику, связанную с U-сервером, но в принципе те же самые принципы работы можно перенести также и на другие фреймворки, на другие карутины, на другие грин-триды, файберы и так далее, то есть на другие единственные исполнения в юзерспейсе.
То есть этот доклад будет интересен не только любителем юсервера, но также и гораздо более широкому кругу лиц, я надеюсь.
Итак, давайте приступим, собственно, к основной части.
Значит, из чего состоит у нас крутинский движок?
Самый главный класс крутинского движка — это таск процессор.
Он реализует в себе тред-фул, то есть это пул тредов операционной системы, на которых запускаются крутины.
И есть у него также некоторая очередь крутин, который готовы к исполнению.
То есть это крутина, которая говорит «все, я дождалась своего события, я хочу выполнить свой код».
И эта очередь является практически FIFO очередью за несколькими исключениями.
То есть в целях оптимизации она теряет свои гарантии FIFO.
И, собственно, как работает TaskProcessor?
Он берет периодически из этой очереди задачи, помещает на 3D и выполняет на этих 3D эти задачи, крутины.
И есть также некоторый набор задач, некоторый набор карутин, который не принадлежат ни 3DAM таск процессора, ни очереди таск процессора, а расположены где-то еще.
То есть это карутины, которые дожидаются каких-то внешних событий, например, ответа из базы данных или ответа из другого микросервиса, или какого-нибудь примитивой синхронизации дожидаются.
Все вроде бы хорошо.
Мы можем посмотреть на само взаимодействие крутин и тредов следующим образом.
Есть набор тредов, есть набор крутин, и они каким-то образом хитрым взаимодействуют.
Крутины периодически мигрируют на потоке, периодически там выполняются, освобождают поток, потом мигрируют на какой-то другой трет.
и таким образом мигрируют по тридам.
То есть в общем случае у нас корутины могут мигрировать между тридами.
Это достаточно важное свойство корутин.
И всё было бы ничего, если бы не примитивы синхронизации.
Операционная система предоставляет нам свои собственные механизмы и синхронизации.
Операционная система, стандартная библиотека, стандартная библиотека C++, некоторые прикладные библиотеки.
И, в принципе, здесь, казалось бы, нет никаких проблем.
То есть, это достаточно мейч библиотеки высокоэффективные, производительные.
отложены и так далее.
Но есть одна небольшая проблема.
Тот, что стандартные примитивы синхронизации могут блокировать поток операционной системы.
То есть они по-простому могут спать.
То есть если мы пытаемся взять мьютекс, а мьютекс уже занят, то мы засыпаем.
И поток операционной системы дальше ничего не делает.
Для нас это непозволительная ситуация, поскольку у нас заявляется кооперативная многозадачность.
То есть мы хотим добиться такой ситуации, чтобы при
Чтобы при достижении ситуации сна у нас поток освобождался, и на нем продолжала выполняться какая-то другая карутина.
То есть никакой поток не простаивал бы.
То есть получается, что карутином требуются свои собственные примитивы синхронизации.
И в U-сервере уже есть целый набор примитивов синхронизации под самые разные нужды.
И мы сегодня как раз-таки будем рассматривать некоторые примитивы синхронизации.
Эти примитивы синхронизации можно разделить, грубо говоря, на две категории.
На низкоуровневые и высокоуровневые.
Нескоуровневые непосредственно взаимодействуют с коротинуским дышком.
они часто используются в драйверах баз данных, в драйверах связанных с сетевыми протоколами или с какими-то подобными низколуровыми подсистемами и редко используются в прикладном коде, то есть в коде сервисов.
В качестве примеров можно привести ивенты, фьючи, симафоры, мьютаксы и некоторые другие.
Также у нас есть высокоуровневая примитива и синхронизация.
Они уже непосредственно с карутинским движком не взаимодействуют.
Они используют другие примитивы и синхронизации в качестве строительных блоков.
Они уже часто используются непосредственно в коде сервисов, то есть в прикладном коде.
К ним относятся различные очереди, RedCopyUpdate, ConcurrentVariable — это обертка над мьютексом, и некоторые другие.
И вот мы сегодня будем рассматривать как раз такие низкоуровневые примитив синхронизации под названием Mutex.
Поскольку, во-первых, он является низкоуровневым, то есть мы можем посмотреть то, каким образом он заимодействует с корутинским движком.
И, во-вторых, он достаточно сложный для того, чтобы использовать вайт-лист, который нам тоже будет интересен.
Итак, напомню в двух словах, что такое мьютекс и чему он знаменит.
У нас есть структура данных примитив синхронизации, у которой есть 2 основных операции.
Это лог и анлог.
При заходе в критическую секцию крутина или единиц исполнения берет мьютекс, берет лог.
И после того, как она взяла лог, она находится в критической секции.
До тех пор, пока не вызвала анлог, после чего она выходит из критической секции.
И в критической секции может находиться не более чем одна...
не более чем одна единица исполнения.
И при попытке взять мьюток с какой-то другой единицей исполнения мы получаем ситуацию засыпания.
То есть карутина или там, поток или кто-то еще, кто пытается зайти в критическую секцию, засыпает.
На основе, ну и соответственно с помощью такой критической секции мы можем защитить данные от одновременного доступа со стороны нескольких карутин.
На основе мьютекса построен такой примитив синхронизации, как concurrent variable.
Это такая выскоурневая обертка над мьютексом, которая позволяет обойти некоторые сложности, связанные с работой мьютекса.
То есть с помощью него мы не можем забыть взять мьютекс при работе с нашими расшаренными данными.
Мы не можем взять не тот мьютекс и некоторые другие ваги также нивелируются.
Итак, давайте построим наш собственный мьютекс.
Что для этого нам нужно сделать?
Нам нужно определить, какие данные он будет хранить и какие операции он будет поддерживать.
В качестве данных у нас будут две сущности.
Первое связано с владением мьютекса, то есть кто находится в критической секции.
И второе связано с ожиданием, то есть кто ожидает на этом мьютексе.
Мы будем реализовывать сначала упрощенный вариант, связанный с единственной ожидающей к рутиной, а после этого усложним этот процесс и это требование снимем.
Поэтому мы начинаем с ожидания на одной единственной к рутине.
Владением Ютекса мы можем реализовать через булевую перемену или через указатель на таз-контекст, то есть указатель на к рутину.
Таз-контекст — это метаинформация к рутине.
Ожидания мы можем реализовать тоже через Atomic Tascontec звездочка, тоже через указатель на крутину.
И в качестве операции мы можем подобрать LOG, Unlock, TryLock, TryLockUntil и целый набор других операций, которые свойственны Mutex.
Но мы будем немного ленивыми.
и выберем самый минимальный вариант, то есть лок и анлок.
И в качестве данных мы выберем STD-атомик, то есть контекст-звездочка как для владельца мьютокса, то есть того, кто находится в критической секции, и для того, кто ожидает нам мьютокса.
То есть по счастливому совпадению это два одинаковых типа.
Чтобы двигаться дальше, нам нужно рассмотреть
Такую структуру данных как WaitStrategy.
Это специфичная для U-сервера структура данных, которая связана с тем, что нам нужно каким-то образом рассказать карутиновскому движку о том, каким образом мы сейчас собираемся спать.
Мьютекс будет спать одним образом, дождаться одного события, какой-нибудь ивент другим образом, обычный сон еще каким-то образом.
Это специфический способ сказать карутиныскому движку о том, каким образом мы собираемся засыпать и пробуждаться.
У него есть четыре метода, включая специальные.
Два метода связаны с засыпанием, с процессом засыпания и два с процессом пробуждения.
В конструкторе у нас отрабатывает до засыпания и устанавливает все, что ему нужно.
Сетап в Викапс отрабатывает после засыпания и может сообщить о раннем пробуждении.
То есть он может сказать, что я не хочу спать, давайте продолжать работать.
Иногда это требуется, как FastPath.
дизей был в Вайкапс, отрабатывает до пробуждения, и он отключает основной источник пробуждения, то есть мы больше не хотим пытаться, мы не хотим, например, ожидать на мьютоксе, мы уже подождали на нем.
И деструктор в Вейкстреттедже отрабатывает после пробуждения и зачищает все, что было сделано первыми тремя методами.
Давайте посмотрим, как этот вейтстретеджи выглядит для самого простого примера.
Самый простой пример — это на самом деле обычный сон.
То есть вейтстретеджи используются для любого переключения контекста, не только для примитива синхронизации, а еще и для обычного сна.
То есть engine sleep for, engine sleep until, yield и так далее.
И вот для всех этих случаев мы можем посмотреть на реализацию WaitStrategy.
Этот класс будет достаточно такой куци.
В нём ничего особенного нет.
То есть все тела, всех методов пусты, кроме setup wakeups, который говорит о том, что не, мы собираемся дальше спать, не нужно заранее нас будить.
Для мьютекса эта картина будет выглядеть чуть более сложным образом.
То есть у нас здесь появляется ссылка на мьютекс, ссылка на карутину, которая является текущей.
В конструкторе мы инициализируем оба эти поля.
В setup wakeups мы устанавливаем вейтера в текущую карутину, то есть говорим о том, что текущая карутина теперь ожидает на этом мьютексе.
После чего происходит выход с значением false.
То есть мы не хотим прерывать это ожидание.
И в disable wakeups мы говорим о том, что waiter теперь равен null ptr.
То есть мы больше не хотим ожидать на этом utox.
И теперь самый узловой центральный элемент самого мьютокса – это функция LOG.
Вначале мы устанавливаем некоторые переменные, некоторые значения, после чего запускаем Task Concelation Blocker.
То есть мы не хотим, чтобы отменены влияли на синхронизацию.
Зачем это нужно?
То есть во многих примитивых синхронизаций мы расставляем Task Insulation Blocker.
По той причине, что примитивые синхронизации используются для того, чтобы реализовать, к примеру, какие-нибудь RAI-стратегии.
И если в деструкторе мы попытаемся взять мьютекс, а у нас не получилось из-за того, что случилось отмена,
то это будет какая-то очень нехорошая RAI-реализация.
И для того, чтобы не смущать пользователя и не составлять ему квалки в колеса, мы автоматически расставляем Task Installation Blocker, по крайней мере, в низкоуровневых примитивах синхронизации, для того, чтобы подобных проблем не случалось.
И после этого мы создаем Mutex Weight Strategy, на которую мы уже посмотрели.
и переходим к основному циклу, который связан с попытками взять мьютекс.
Если у нас получилось взять мьютекс, то есть мы, Овнера, у нас получилось записать текущую куртину, то мы молодцы, и мы выходим из этого цикла.
Все, мьютекс взят.
Если у нас не получилось это сделать, мы заходим в волшебную функцию currentSleep, то есть это taskContext 4.sleep.
Это функция, связанная с переключением контекста.
То есть, переключение контекста с одной карутины на другую карутину.
О ней мы поговорим чуть позже.
и функция Unlock, что она делает.
Она гораздо проще, она берет, вычленяет из Вейтера ту каротину, которая ожидала на данном ютексе и вызывает ей вайкап.
Сразу вопрос в зал, кто-нибудь здесь видит ошибку на слайде, какую-то опечатку, какую-то неточность тому, что я сказал.
Она звездочка, да, но авто вейтер тоже можно написать.
Здесь проблема заключается в том, что сам вейтер может быть на lptr в пятой строке.
То есть, у нас не обязательно, что на данном мьютаксе кто-то еще ожидал, кроме непосредственно владельца мьютакса.
Поэтому здесь нужно еще вставить проверку, что ifVator, thenVatorWakeUp.
Окей, давайте посмотрим, что такое WakeUp.
Здесь проведена некоторая партянка кода, с самой главной строчкой, из которых является 20-я строка.
То есть эта партянка призвана просто показать, что в итоге у нас вызывается таск QPush в помещении Caroutine в очередь задач, которые готовы выполниться.
То есть это та очередь задачи, о которой я говорил в самом начале.
То есть та очередь задач, та очередь карутин, которые хотят выполниться на таск процессора.
и она огорожена некоторыми проверками.
Там, в частности, shouldSchedule.
ShouldSchedule проверяет, что нужно ли эту задачу шедулить еще раз.
То есть у нас может задача пробудиться сразу по нескольким источникам.
То есть это может быть пробуждение по таймеру, это может быть пробуждение по отмени, это может быть пробуждение по основному источнику.
То есть, ну, например, по mutex.
по событиям Ютокса.
И во всех этих ситуациях нам нужно проверить, а не разбудили ли эту куртину до нас.
Если разбудили, то дополнительно шедулить ее не нужно.
Окей, мы разобрались с простой реализацией мьютакса, у которой есть только одна ожидающая карутина.
Теперь давайте посмотрим на пример с мьютаксом, у которого есть неограничный набор тасок, на который ожидают этот мьютакс.
Мы здесь видим следующее изменение.
Вне одного атомика мы получаем вейт-лист.
Это, грубо говоря, STD-лист над тазк-контекстом с некоторым синтоксическим сахаром.
Но он является не потокобезопасным, поэтому мы его вынуждены оградить с помощью STD-мьютекса.
Тут у некоторых людей может возникнуть вопрос, а как же так?
У нас карутины, а здесь и стедэмьютекс.
Но на самом деле это не такая большая проблема, потому что контентшн на этом мьютексе он будет минимальным, а сами данные нам нужно каким-то образом защищать.
И об этой проблеме мы еще поговорим чуть позже.
Ну, и для полнатой картины, для того, чтобы показать, как этот класс можно расширять, мы также реализуем трайлок антиу, которая передается Deadline.
Deadline — это таймпоинт с некоторым синтактическим сахаром, то есть у него есть методы для получения метки времени, которые уже случилось, для получения метки времени, которые никогда не случится, и некоторые другие плюшки.
Теперь посмотрим на WaitStrategy, как она изменилась.
Значит, с точки зрения данных у нас добавился Uniclock, связанный с STD-мьютоксом.
И в конструкторе этого WaitStrategy мы берем этот самый Uniclock.
В setup wakeups мы вместо простой записи в Atomic под мьютоксом помещаем элемент текущую крутину в waitlist.
И он лочим mutex.
После этого в disable wakeups мы, наоборот, лочим mutex и удаляем текущую куртину из этого списка, из waitlist.
И destructor срабатывает destructor Uniclock и тем самым освобождает mutex.
То есть здесь у нас появляется усложнение, связанное с синхронизацией std mutex и работой с waitlist.
Окей.
Что изменилось в локе?
В принципе, изменения тут минимальные.
Для того, чтобы мы работали с страйлок-антил, мы реализуем лок через страйлок-антил с дедлайном пустые скобочки.
То есть это non-reachable deadline, deadline, который никогда не случится.
То есть мы пытаемся дожидаться момента времени, который никогда не случится, то есть до бесконечности.
И в этом случае у нас трайлок.antil и себя представляет копипасту старого mutex-лог с некоторым дополнением.
После того, как мы были разбужены с помощью слипа, мы проверяем wake-up source.
Если мы были разбужены по таймеру,
то значит все наше время кончилось, мы больше не можем взять NewTex.
В этом случае мы выходим с false.
Если же наоборот мы разбудились по waitlist, то в этом случае мы приходим на очередную итерацию цикла и пытаемся взять NewTex еще раз.
Unlock тоже изменился не сильно.
То есть вместо того, чтобы работать с атомарной переменной, мы работаем с waitlist под STDmutux.
Верем STDmutux через Uniclock и вызываем wakeup1 у waitlist, который берет один из элементов waitlist, зовет ему wakeup и удаляет этот элемент из листа.
Окей, давайте теперь посмотрим на то, насколько эффективна текущая реализация.
Если кто-то внимательно смотрел на слайды на то, каким образом был мьютекс реализован, есть ли у кого-то какие-то идеи по поводу того, каким образом можно было бы ускорить, что-то оптимизировать, каким-то образом ускорить мьютекс, есть ли какие-то идеи у кого-нибудь?
Так, да, в принципе, все, что было сказано верно, значит, я пройдусь по списку в моем порядке, как у меня записано на слайде, значит, то, что было сказано где-то в глубине, когда у нас мьютекс не занят, мы совершаем какие-то лишние действия, то есть мы можем проверить то, что Овнар пустой или непустой и после этого сразу же его взять без блокирования эстеда мьютекса.
То, что мне, кажется, не сказали, это связано с Memory Order.
Мы используем Memory Order Sequential во всех случаях, но это часто является Overkill, то есть мы можем использовать гораздо более дешевые Memory Order, то есть Memory Order Acquire Release, или даже иногда Relaxed, плюс некоторые Barrier.
Но при этом мы должны будем очень внимательно следить за тем, какие перемены видны в каких трыдах, на какой момент времени.
Это немножечко затратно, но может существенно ускорить работу нашего движка.
Еще одна проблема, которая здесь есть, это лишнее обращение к локатору.
Мы постоянно дергаем вайтлист, помещаем туда какую-то карутину, достаем карутину, помещаем, достаем, и тем самым мы вызываем много раз New Delete.
Это затратно, и мы можем ориентироваться на следующее наблюдение, что одна карутина может находиться не более чем в одном вейтлесте одновременно.
Поэтому мы можем использовать интрузивные типы данных для того, чтобы хранить ноду листа.
И мы можем, например, использовать boost intrusive make list для этого.
Как уже сказали, STD Mutex — это что-то не очень хорошее, можно было бы от этого избавиться.
В теории, да, можно было бы от этого избавиться, но на самом деле, нам это не сильно мешает, поскольку в каких-то высокопроизводительных приложениях Mutex не стоит на hot path.
То есть чаще всего там находятся какие-то очереди,
или future, или что-то такое, то есть mutex.siz на hotpfs не находится, поэтому это не тот код, который требуется оптимизировать.
Но если вдруг вы захотите оптимизировать этот код и захотите прислать нам патча, то пожалуйста, патча со welcome мы с радостью примем ваш патч, посмотрим, как вы эту проблему решили.
Ну а первые три проблемы у нас уже решены, то есть на слайдах был упрощенный код для простоты понимания, для простоты восприятия, но на самом деле все эти проблемы, первые три проблемы уже решены в самом движке.
Ну вот, второй пункт по крайней мере частично.
Окей, мы рассмотрели то, каким образом реализован MuteX в точке зрения кода.
Теперь давайте посмотрим на то, каким образом у нас переключается контекст карутины.
То есть мы посмотрели реализацию MuteX, посмотрели то, что он использует TaskContextSleep.
Теперь давайте посмотрим, что же скрывается за этим TaskContextSleep, то есть за процессом переключения карутины.
С одной карутины на другую.
Чтобы рассмотреть этот процесс переключения, нам нужно определиться с тем, что мы вообще переключаем.
То есть, что такое контекст выполнения карутины, что в него входит.
В него входит большое количество всяких разнообразных вещей, основанными из которых являются.
Во-первых, это регистры процессора.
То есть основные из них это instruction pointer, указатель на текущую инструкцию, или точнее указатель на инструкцию, которая следует за текущей stack pointer, указатель на вершину stack, ну и некоторые другие регистры.
Это стек корутины, то есть не стек 3D операционной системы, а именно стек корутины.
То есть некоторая область памяти, которая алацирована специально под эту корутину.
Операционная система ничего не знает про то, что это область памяти связана с корутиной.
Просто какая-то область памяти алацированная приложением.
Это указатель на текущую корутину, т.е.
thread-local данные, ну, thread-local указатель на task-контекст.
И еще одна очень прикольная штука, которая называется stack-исключение корутины.
о которой мы поговорим чуть позже.
Но вначале, перед исключениями, мы поговорим о регистрах процессора.
Мы хотим переключить контекст исполнения с одной карутины на другую.
У первой карутины есть некоторый набор регистров, значение регистров, у второй карутины тоже есть некоторый набор значения регистров.
Мы хотим осуществить переключение.
Соответственно, нам нужно поменять значение регистров.
Мы это можем делать с помощью некоторых ассамблерных инструкций.
В случае Instruction Pointer это Jump, Call или Red.
В случае Stack Pointer это POP RSP или какая-то синонимичная какая-то команда.
Остальные регистры мы можем поменять с помощью команд POP, MOV или некоторых других команд, специалистичных для регистров или архитектуры.
И нам также нужно некоторое хранилище под эти регистры в память.
То есть, когда мы переключили контекст выполнения на вторую крутину, нам старые значения от первой крутины тоже нужно где-то хранить для того, чтобы можно было восстановить эти значения и продолжить выполнение первой крутины.
Это можно за велосипедить, но мы не хотим велосипедить.
Мы будем использовать уже готовые реализации.
Мы будем использовать реализации, которые называются U-контекст и F-контекст.
U-контекст является универсальной реализацией, то есть она работает с большинством архитектур, она работает с большинством санитайзеров, но она при этом является медленной реализацией.
f-контекст же, наоборот, не работает с санитайзерами, она не работает практически несколькими архитектурами, кроме x864 и некоторых парочки других, но зато является гораздо более быстрой.
То есть, по заверениям автора этой библиотеки она быстрее u-контекста, более чем на порядок.
Соответственно, мы будем использовать f-контекст для продакшена, а использовать u-контекст мы будем только в дебаге для работы с санитайзерами.
Но, опять-таки, мы ленивые и будем использовать их не напрямую, а через выскалуруемую обертку Boost Routine 2, которая, помимо реализации, помимо некоторых других преимуществ, реализует работу с пользовательским стеком.
То есть нам не нужно аллацировать память под стека, только задать параметры этого стека.
Она сохраняет информацию о регистрах процессора и совершает другую некоторую хаоскипинг работу.
Соответственно, мы будем использовать Boost Routine 2 для всего этого дела.
Окей, мы разобрались тем, каким образом у нас происходит переключение контекста с точки зрения регистра процессора и что мы будем использовать для этого.
Теперь давайте вернемся к исключениям.
То есть, как я сказал уже, в контекст выполнения карутины входит также стек исключений.
Что это такое?
Это такая штука.
которая специфична для языка C++.
Все мы знаем, что в C++ есть механизм исключений, который является достаточно сложной мошенерией.
То есть у нас может случиться какое-то исключение, в результате которого будет раскручиваться стек, будут срабатывать какие-то деструкторы, может начаться заход в кэдж-блоке.
В кэдж-блоке у нас опять-таки может выкинуться еще одно исключение, и так регурсивно.
То есть вся эта машинерия достаточно сложная.
И нам хотелось бы сделать так, чтобы она не зависела от переключения контекста.
То есть чтобы можно было в процессе работы, обычной работы пользователя, и переключать контекст, и выкидывать исключения.
То есть чтобы это было прозрачено для пользователя.
поскольку в некоторых framework возможно выкидывать исключение, но при этом не переключать контекст.
Сначала выкидывание исключений до окончания обработки этого исключения не должно происходить никаких переключений контекста.
В нашем случае это не так, и давайте посмотрим, каким образом мы этого добились.
Мы залезли в Патроха, Ронтайма, Филанго и ГЦЦ.
Если посмотреть на структуру CXA EH Globals, то мы увидим, что она как раз таки реализует те самые данные, которые специфичны для текущего потока, связанные с обработкой исключений.
Она относительно маленькая, то есть это два-три указателя в зависимости от архитектуры и она зависит естественным образом от компилятора.
То есть для ГЦЦ она одна, для Силанга другая, для какого-нибудь третьего компилятора она будет еще каким-то образом выглядеть.
Мы реализовали эту штуку для ГЦЦ и для Силанга, для других компиляторов требуется какая-то самостоятельная адаптация.
Что мы с этой структурой хотим делать?
Мы хотим ее подменить для наших карутин.
И мы это делаем с помощью функции CXA Get Globals.
Начинается с двух нижних подчеркиваний.
Эта функция предоставляется runtime-ом языка.
И она помечена эта функция как WIG, то есть ее можно подменить на свою собственную реализацию.
Что мы и делаем.
То есть мы подменяем на свою собственную реализацию, которая возвращает указатель на YashGlobal в случае крутины на крутиновское, а в противном случае на просто trade local значение.
То есть, если у нас CXA GetGlobalS вызвалась из какого-то некорутиноского окружения, например, из шедулира или из другого потока, то мы возвращаем просто 3Dlockless значение.
Но мы будем возвращать не полноценную законченную структуру, заполненную всеми полями от компилятора, мы вернем лишь некоторый storage для этой структуры, YashGlobalS, который инициализируем четырьмя указателями.
И это, в принципе, нам хватает для того, чтобы комфортно работать.
С одной стороны, этот механизм с подменой выглядит немножко корявенько, но с другой стороны, он у нас реализован уже многие года, то есть получается больше 5 лет.
И последние годы он у нас не вызывал никаких проблем.
То есть на данный момент это достаточно надежная конструкция.
Ну и давайте теперь посмотрим, как осуществляется переключение контекста.
Вот точно посмотрим на сам TaskContextSleep, что это такое, как он реализован.
Ему передается WaitStrategy, как уже говорилось, и Deadline.
Что он делает?
Что он делает с точки зрения пользователя?
Он переключает контекст с одной карутины на другую.
Он планирует текущую карутину согласно weight strategy.
Он планирует текущую карутину согласно таймеру, вот этому самому deadline, второму аргументу.
И возвращает способ пробуждения после того, как вернул управление.
Способ пробуждения может зависеть от того, каким образом мы пробудились.
То есть это может быть отмена, это может быть таймер, это может быть основной источник пробуждения, waitlist, это может быть bootstrap.
В случае TaskContextSleep не может быть, но в качестве wake-up source это вполне себе валинное значение.
То есть это пробуждение после рождения карутина.
На всякий случай я его здесь привожу для полноты.
Окей, с точки зрения пользователя понятно, а с точки зрения реализации, что у нас происходит?
С точки зрения реализации происходит следующее.
У нас есть не два, а целых три контекста исполнения.
У нас есть карутина,
У нас есть шедулир, и у нас есть другая карутина, на которую происходит переключение контекста.
То есть у нас закрадывается еще среднее звено, которое осуществляет временное переключение контекста.
что происходит в первой карутине, который засыпает.
В ней мы вызываем внутри слипа setup.wacups.
Мы говорим о том, что все мы засыпаем, устанавливаем все, что тебе нужно, мы заканчиваем работать.
Возводит таймер, то же самое только с таймером, и осуществляет переключение контекста на шедулир.
переключили управление на шедулир, то есть мы находимся в контексте шедулира.
В этот момент времени, что у нас происходит?
Мы выставляем яжглобол и карен-таск в значении специфичное для шедулира.
После этого мы проверяем событие пробуждения, то есть вдруг мы, пока вот это все совершали, у нас возникло событие пробуждения, нас разбудили по Вайт-листу, старую карутину,
и нужно эту карутину будить и собственно шедулить.
После этого мы получаем очередную карутину готовую к исполнению, то есть из-за списка, из-за очереди карутин готовых к исполнению мы выцепляем очередную карутину, если она есть, если ее нет, то просто спим банально на 3D, не отпуская трет.
После этого мы выставляем яжглоболосый карантаск в значение специфичное для карутины, которое мы только шло вычинение из этого списка, и осуществляем переключение контекста на эту карутину.
И в этой карутине мы вызываем функции специфичные для weight strategy, для пробуждения, то есть disable wakeups, и получаем актуальный источник пробуждения.
о котором я уже говорил.
И мы выходим из слипа.
И в целом мы можем посмотреть с другой стороны на сам процесс переключения контекста следующим образом.
У нас есть карутина, которая выполняет слип до тех пор, пока не дойдет до собственной функции переключения контекста.
Контекст переключается на какой-то трет таск процессора, после чего продолжается выполнение кода таск процессора.
И в каком-то другом потоке у нас в Дустрепе, в Шедуллере, опять происходит переключение контекста на эту самую крутину, и ее выполнение продолжается, начиная с того места, где мы остановились.
Ну, в принципе, на этом можно было бы завершить.
Еще можно было бы рассказать про некоторые специфичные связанные с карутиными вещи.
Например, Task Local Variable, Analog, Trade Local, но для карутин.
Но об этом я, наверное, расскажу в следующий раз на каких-нибудь будущих конференциях.
А на этом у меня всё.
Спасибо вам за внимание.
Если есть какие-то вопросы, могу на них ответить.
Василий, спасибо за доклад.
Ну что, ждём ваших вопросов.
Поднимайте руку, к вам подойдут.
Раз раз слышно в конце зала.
Василий, спасибо большое за доклад.
В начале было описание мьютекса, что это примитив синхронизации.
И что над ним, возможно, конкарнт в аре болтан, не кондишен конкарнт.
Можно они пару слов?
Ну, это на самом деле простая обертка, которая похожа на растовый мьютекс.
То есть, это структура данных, через которую можно... Ну, то есть это контейнер, внутрь которого можно залезть только через взятие мьютекса.
Соответственно, если не использовать Proxy-класс, который берет Newtux, вы просто не достанете до содержимого.
То есть, на словах, это достаточно сложно объяснить.
Можно посмотреть на отдельную страничку в документации по примитивам синхронизации.
Там это достаточно подробно описано.
Всё, вопрос ответили.
Идём дальше.
Следующий вопрос будет из нашего онлайн-чата.
Не боитесь ли вы положить прод с обновлением на новую версию компилятора, где подмена стандартной реализации выстрелит?
Нет, не боимся, поскольку мы обложены со всех сторон тестами и такие мажорные изменения со стороны компилятора мы сразу же заметим.
Теоретически это возможно, то есть теоретически какие-то проблемы со стороны новых версий компиляторов.
Это не то, чтобы невозможная ситуация, но при этом у нас есть целый набор тестов разных уровней, на которых это все будет заметно.
Тем более сами символы, связанные с «Яж Глоболс», являются публичными.
Какое-то рациональное зерно со стороны разработчиков-компиляторов мы ожидаем.
Это даже не какие-то разрушители, которые пойдут все крушить и ломать.
Скорее всего, какая-то обратная совместимость здесь сохранится.
Спасибо.
Давайте еще один вопрос из зала.
Я вижу, здесь человек долго-долго тянет руку.
Да, расскажи, пожалуйста, почему мьютекс на очереди не является критичным.
Ведь, собственно, крутиновый мьютекс нужен для того, чтобы в хай-конденджен среде прийти и всем сложиться аккуратно в очередь, после чего нас разрули.
Так получается, что мы почти ничего не выигрываем в сравнении с просто блокировкой мьютекса.
Нет, здесь особенно не совсем в этом.
Просто сам mutex не используется в high-contention среде.
То есть там используются очереди, которые реализованы по-другому.
То есть там используются специальные примитивы синхронизации не FIFO очереди, log3, которые уже не содержат никаких эстадо-мьютексов.
Спасибо.
Давайте вот из той части зала.
Человек тянет руку.
— Добрый день.
Спасибо за доклад.
Было очень интересно.
Хотел задать вопрос не совсем по теме доклада.
Вот кратко коснулись стеков в карутин, которые, очевидно, где это должны быть элацированные.
Элацируются очевидные моменты создания карутины.
И чаще всего, наверное, те стеки, они меньше по объему, чем системный стек.
И вот в связи с этим вопрос.
Так как у вас framework, который, в принципе, предполагается, что пользователь сам какой-то код в эти карутины дописывает, если какой-то контроль
за превышение размера этого стека, или предполагается полагаться на там каком-нибудь адрес санитайзер, который гарантии проверки здесь никаких не даст.
Ну, во-первых, сам размер стек у нас настраивается.
То есть это не то, что какое-то захардкоженное значение.
Во-вторых, мы предполагали некоторым механизм по созданию дополнительных гард-страниц на конце стека для того, чтобы это дело контролировать.
Но, насколько я помню, до конца мы это дело не довели.
Получается, что через месяц будет.
Получается, сейчас фиксированы ажмежстека и предполагается, что пользователь просто покрывает код тестами и надеется на то, что он этот размер не превысил.
Да, в принципе, мы очень много где в тест-юте и вообще в тестовой среде полагаемся на то, что пользователь пишет хоть какие-то тесты и
самые простые проблемы, они как раз таки всплывают сразу же при старте работы программы.
То есть, если вы хоть раз зашли в какую-то функцию и превысили какой-то размер стека, то у вас там что-то сработает.
Не всегда сработает.
Ну ладно, спасибо.
Спасибо.
Еще вопросы.
Человек тянет руку.
Простите, можно я немного позанутствую, есть вопрос про то, считали ли вы какие-то бенчмарки, какие у вас в итоге число получились, например, против стандартного мютикса?
Значит, если сравнивать
Маленький контенсион, то мы выигрываем, если большое, то проигрываем, потому что мы, ну, подкапотным у нас эстедо-мьютакс.
То есть, как только начинается контенсион на эстедо-мьютаксе, мы начинаем проигрывать.
И, соответственно, с большим количеством тридов мы проигрываем.
А в среднем, на одном триде, на сколько, или на двух, на сколько выигрывать?
Смотри, здесь суть не в том, что он быстрее или медленнее, а в том, что он вообще предполагает корректную работу.
То есть здесь скорость работы не является первостепенно величиной.
Я не могу на памяти сказать, во сколько раз мы обыгрываем или на сколько процентов.
У меня просто в голове таких цифр сейчас нет.
Сообщается, что есть документация.
Спасибо.
Вопрос из нашего чата.
Вопрос про дебак, про то, как дебажить карутины движок.
Есть ли какие-то отдельные прийти принтеры для ГДБ, которые позволяют делать дебак легче?
При этом принтер есть, они не являются частью официального юсервера, вы можете их найти поиском по open-source учатику.
в телеграмме.
Там есть, если я даже не ошибаюсь, две реализации различных предметов принтеров, которые связаны с специфичными для U-сервера типами данных.
И в целом, кто-то их использует и доволен.
Но у нас пока не было ресурсов для того, чтобы заточить их непосредственно в сам U-сервер.
А сами хотите заточить в U-сервер?
Хотим, но рук не достаточно.
Рук конечное число.
Хорошо.
Давайте, может быть еще есть вопросы в зале?
Давайте, третий ряд.
А у меня слышно?
Слышно.
Вопрос следующий.
А что происходит при выходе из mutex?
Там происходит переключение контекста на следующей картины.
Либо текущая картина продолжает работу, а ждущая помечается как готовое к исполнению на следующем трейде.
Понятен вопрос.
не совсем.
То есть, когда мьютекс освобождается, то есть там у вас было входит что-то типа вызов в wakeup 1 или в wakeup 1, то есть, соответственно, тут происходит suspension point, текущая карутина она расшедуливается или она продолжает работать?
Нет, она продолжает работать, то есть это как бы у нас нет
Ну да, текущая карутина предложает работу, а карутина, которую мы шедулим, она просто помещается как карутина, которую можно выполнить, она помещается в очередь в задачу на выполнение и всё дальше происходит.
А еще такой второй вопрос, а вот было слайд с буст типа «Крутин 2».
Вот он сделал поверх буст типа контекст или что-то такое.
Мне казалось, что «Крутин 2», он, соответственно,
объявляется надстройка над контекстом и, собственно, содержит всю машинарию с исключениями.
Это что, не так, или, может быть, я как-то... Нет, с исключениями... Мне казалось, как раз можно выбросить исключения и, в отличие от бус-контекста, там, соответственно, в другой культине благополучно это исключение можно поймать.
Мне казалось.
Скорее всего вы говорите о том, что исключение в принципе можно ловить, а не о том, что в процессе обработки исключений можно осуществлять переключение контекста.
В процессе обработки это как?
Ну, то есть, например, в кетч-блоке, когда вы поймали исключение, его обрабатываете, но еще не обработали до конца.
И в это время вы там делаете запрос к базе и засыпаете.
Окей, надо подумать над этим.
Это немножко, наверное, да.
И в диструкторе тоже можно... В диструкторе объекту у вас тоже можно запрос к базе.
Да.
Интересающе.
А далее все вопросы мы будем обсуждать в чате.
Пишите, задавайте свои вопросы.
Василия обязательно ответит, указывая, что это вопрос относится им к именно его докладу.
На этом поблагодарим нашего спикера аплодисментами.