Что делать
119 subscribers
209 photos
3 videos
4 files
133 links
Не смешно
Download Telegram
Я не я, если нет оптимизаций. Для роутинга я решил сделать три уровня поведения:
- Пути все статичные. В таком случае, динамический роутинг нам не нужен, мы его отключаем и фоллбекаемся к текущей реализации
- Пути динамические, но least unique string (самый короткий уникальный префикс) не включает в себя динамическую часть. Тогда мы берём из мапы по этому least unique string нужный нам шаблон, и сверяем
- Пути динамические, и least unique string вмещает в себе динамическую часть. В таком случае, берём это самое наше префиксное дерево, о реализации которого я отпишусь позже
Зашли-вышли, приключение на 20 минут
😁1
Что делать
Я не я, если нет оптимизаций. Для роутинга я решил сделать три уровня поведения: - Пути все статичные. В таком случае, динамический роутинг нам не нужен, мы его отключаем и фоллбекаемся к текущей реализации - Пути динамические, но least unique string (самый…
В общем, с least unique string я решил забить, ибо ну его нафиг. А вот по поводу префиксного дерева, я сделал следующую штуку:

Делим путь на сегменты. Сегмент - это кусочек между слэшами. Каждый сегмент может быть как статическим, так и динамическим. Если статический - мы берём из хэшмапы следующий узел, и идём дальше. А вот если динамический - кладём этот самый сегмент в контекст, и точно также идём дальше

получилось не слишком медленно, что-то около 390нс в самом длинном шаблоне, представленным у меня в тестах. Производительность всего роутера с фоллбеком к префиксному дереву я пока не мерял
А вот интереснее у меня устроен сам роутинг. Ведь если у нас только статические пути, то зачем, спрашивается, использовать префиксное дерево, если оно медленней обычной хешмапы?

Вот и я так подумал. И сделал такую штуку, что у нас динамически, при старте сервера, выбирается имплементация роутинга. Это - просто функция, которая лежит полем в структуре, и мы её вызываем, чтобы получить обработчик для запроса
https://github.com/golang/go/discussions/56010


var all []*Item
for _, item := range items {
all = append(all, &item)
}


Здесь, в all будет len(item) одинаковых указателей, равных указателю на последний элемент в item, потому что item - переменная цикла - является не per-iteration, а per-loop. То есть, переменная item у нас одна-единственная на все итерации, и при каждой итерации её значение затирается.

Это, кстати, как раз та штука, почему надо быть аккуратным с замыканиями в циклах

Кстати, чинится добавлением в теле цикла item := item

А вот ишью, кстати, советую глянуть. Интересная штука
👍1
Голанд хороший, голанд умный

Написал бенчмарки, и можно тут же запустить с профилировщиком. Полезно, теперь я знаю, насколько херовый у меня код
🥰1
Я тут заголовки переделал

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

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

Ну и ещё изменений по мелочи, благодаря которым сервер стал в два раза меньше памяти на запрос тратить
Что делать
Я тут заголовки переделал В первую очередь, все значения заголовков теперь хранятся в одном большом слайсе байт. Это уменьшает нагрузку на гц за счёт того, что теперь меньше указателей и разрозненных участков в куче (не забываем, что слайс держит указатель…
Я ошибся. Моё решение не снижает количество указателей, ведь слайс всё равно указывает на память. Но теперь она хотя бы менее фрагментирована, тут большой слайс для всех всё же хорош
Вот что называется приятно
👍2
Вот значения заголовков у меня лежат в одном-большом пространстве, все вместе. Это решает проблему с уменьшением аллокаций, и убирает надобность в обжект пуле (реализация которого всё равно была бы сложной, либо сам обжект пул был бы тупым). Ну и можно указывать максимальное пространство, доступное для них, прикольно

Но вот с ключами я, почему-то, того же самого сделать не додумался. Я просто шёл по запросу до двоеточия, добавляя каждый символ индивидуально. Потом аллоцировал новую строку под ключ. Зачем? Не знаю

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

А ещё, индюшка теперь в бете🎉
Раз уж заговорили за бету, то держите ликбез о семвере

Semver - semantic versioning. Суть данного подхода к версионированию состоит в том, что у нас есть три разряда - major.minor.patches.

Инкрементируем patches, когда сделали изменения, не добавляющие ничего нового для пользователя - внутренние изменения, не влияющие на использование. Например, оптимизация, или фикс баги

Инкрементируем minor, когда добавили новую фичу, которая НЕ ЛОМАЕТ обратную совместимость - изменения, влияющие на удобство пользования, но не ломающие существующий пользовательский код. Например, добавили новый метод для класса, делающий какую-нибудь полезную работу

Инкрементируем major, когда добавили новую фичу, которая ЛОМАЕТ обратную совместимость - изменения, ломающие существующий пользовательский код. Изменение поведения, которое может сломать существующий код, также должно инкрементировать major-версию

К семверу иногда также добавляют build number (обычно это то самое страшное пятизначное число), добавляется оно, как ещё один разряд версии - major.minor.patches.build-number

Но не номером билда едины, так ведь? А мы докинем ещё tag!. Фактически, это те самые alpha, beta, prerelease, release, и как их там только не называют. Докидывают их через слэш, major.minor.patches[.build-number]-tag

Стоит упомянуть, что при инкрементации старшего разряда, принято обнулять младшие. Но учитывая страшные пятизначные build number, не все любят обнулять все младшие разряды

А, ну и любят докидывать префиксом v - v1.0.0, например.

ПыСы: гитхаб не любит вариант v.1.0.0, лучше пишите без точки между v и мажорной версией, предпочитая v1.0.0
У меня тут память текла. И текла очень ощутимо

Текла она от подключений. А точнее даже - после их закрытия, она не освобождается. Первым делом начал грешить на таймеры в tcp сервере - я их там на каждое чтение создавал. Починил, просто обнуляя один таймер на клиента каждый раз. Сервер стал выдавать на ~10к рпс больше, но проблему это не решило

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

И - да! Две горутины нормально себе завершали выполнение, кроме одной - httpServer.requestProcessor. Она ответственена за то, чтобы когда ядро уведомляет её о готовности запроса, она вызвала обработчик, и при окончании работы которого оповестила обратно ядро

При отключении клиента, ожидалось, что в ядро прилетит nil-слайс, и, соответственно, проверка у нас была if data == nil {}, которая слала сигнал об отключении нашей httpServer.requestProcessor. Посмотрев в tcp сервер, оказалось, что при дисконнекте, он передаёт просто пустой слайс 😃

Поменяв буквально одну строчку кода, проблема утечки памяти решилась. А вот профайлером пользоваться стоит
//go:nosplit

Думаю, все мы знаем про safe- и unsafe-участки функций. Вкратце - unsafe-участком функции считается участок, находясь в котором, гц не может очищать мусор (иначе получим кровь кишки). Такие места также называют "атомарными"

Также все мы знаем, что находясь в unsafe-участке, шедулер не может переключать горутину - даже при условии, что время пришло. И всё, что делает nosplit - указывает компилятору, что функцию не следует прерывать по таймеру. Каким образом? Тело функци целиком помечается, как unsafe-участок
Безусловные переходы

...или просто goto. Ненавистная, однако, штука у разработчиков!

Но почему же? Да потому, что в далёкие и бородатые времена, у программистов не была столь развита культура написания кода. Соответственно, народ бывало переборщивал с безусловными переходами, в следствии чего мы получали отменный спагетти-код, сваренный в аль-денте.

Но ведь сейчас всё не так, сейчас такого не будет, верно? А вот и нет. Программы, построенные на безусловных переходах, легко превращаются в goto hell: это когда непонятно, что и куда идёт.

Но ведь goto hell можно не допускать, если ты знаешь, что ты делаешь, верно? Да, абсолютно верно. Но количество кейсов, где не прибегая к гото, нам приходится костылить жуткий код с булевыми флагами, столь мизерное, что люди стараются не использовать эту штуку совсем с тех пор, когда появились процедурные языки, позволяющие писать Тьюринг-полные программы, не применяя безусловные переходы.
Продолжая тему предыдущего поста, я хочу рассказать, как я пришёл к тому, чтобы переписать парсер под безусловные переходы и получить до 2х буста производительности

Для начала, стоит рассказать, как у меня этот самый парсер вообще устроен. Кратко - это обычный for-range по слайсу байт, и вложенный большой свитч по состояниям парсера.

Занимаясь предельной оптимизацией парсера (в который у меня всё по итогу и упирается), я додумался, что вместо того, чтобы при каждой итерации делать свитч по состоянию, я могу воспользоваться тем, что состояние обычно редко меняется (проход по пути запроса, ключу/значению заголовка, etc.), а значит, свитч в теле цикла у меня лишний. А свитч-то не маленький, на 32 состояния.

Подумал, и решил вынести независимые циклы по слайсу байт в кейсы свитча по состояниям. И тут я столкнулся с проблемой: я в парсере сохраняю негласную конвенцию о написании кода - практически везде, где нужно сравнение байта с 1 и более вариантов, я пишу свитч. А как известно, break в свитче прерывает этот самый свитч. А мне надо прерывать также цикл. Идея использовать if-else цепочку меня категорически отталкивает.

И тут я вспомнил о безусловных переходах! Панацея, подумал Штирлиц. И начал вставлять лейблы в парсер. Как оказалось позже, это вам не шахматы, тут думать надо. Решил проблему кардинально: убрал полностью главный цикл, свитч по состояниям вынес в начало функции для того, чтобы исходя из последнего состояния, сделать переход в нужную часть кода. Для "одноразовых" участков (участков, которые сразу же меняют состояние дальше), правда, пришлось немного покопипастить, но в целом терпимо. Запустил бенчмарки - парсер стал от 14% до 300% быстрее. Великолепно, подумал Миллер
Встретился с выбором: а реализовывать ли https и ограничение на максимальное количество подключений с одного ip-адреса. С одной стороны, веб-фреймворк этим заниматься не должен, а должен реверс-прокси. С другой - такой подход теряет в самостоятельности. То есть, если условно какому-нибудь проекту понадобится сервер для вебхуков (привет фреймворкам для телеграм-ботов), никто тащить в поставке нжинкс не будет. Заставлять пользователя его ручками настраивать - и подавно.

А что об этом думаете вы?
У меня во фреймворке постепенно начал вырисовываться пакет functools. Да, именно с функциональными штучками - с дженериками, такая штука очень даже может существовать, как оказалось, даже в го. И этим очень удобно пользоваться! Во всяких мелких деталях, например, "применить на каждый элемент слайса функцию". Или, например, скопировать слайс, как на пикриле🤭
Ого, к полю с типом types.DefaultHeaders (newtype от []string) можно спокойно присваивать результат функции с типом []string
Ну и чего оно мне вот этот вот текст некрасивый рисует. Причём исключительно под виндой
Ого, если спотифай играет на ПК, то переключать трэки и ставить их на паузу можно с мака
Только что починил очень интересный баг.

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

Как это работало раньше: у нас была хэшмапа со значениями типа struct { Seen bool, Value string }. Когда мы проходились по заголовкам ответа, мы проверяли на вхождение в эту мапу, и если есть совпадение, ставили флаг Seen в true. Дальше проходились по всем значениям мапы с Seen = false и рендерили.
По части производительности - это в целом было терпимо. Но что не было терпимо, так это то, что с каждым новым заголовком она деградировала. Потому я пришёл к новому решению.

Заголовки по-умолчанию отныне так же, как и обычные заголовки ответа, представляют из себя слайс строк. Проходя по обычным заголовкам, проверяем на вхождение в дефолтные, если есть - затираем ключ (устанавливаем значение в ""). Это решение показало себя очень хорошо - дало очень неплохой буст в производительности. Да, теперь и проверка на вхождение линейная, однако обычно этот слайс настолько маленький, что найти значение в нём быстрее, чем с хэшмапой.

А теперь - к сути проблемы. Тесты у меня начали быть нестабильными - их результат являлся недетерминированным. Проще говоря, рандомным - проходило 70/30.
Как я уже сказал, заголовки представляют из себя слайс строк в формате [Key value Key value]. Соответственно, итерирование по заголовкам должно быть представлено в виде:

for i := 0; i < len(headers); i += 2 {
...
}


у меня же, в свою очередь, условие было записано, как len(headers)/2. Этот баг затрагивал также обычные заголовки (вторая половина не рендерилась). Однако не рендерилась константно - тут же результаты нестабильные, хотя хэшмап никаких не использую, рандомизации поведения никакого быть не должно.

Через примерно час я смотрю на функцию, которая переводит передаваемую извне хэшмапу с заголовками по-умолчанию в формат слайса строк. И тут меня осеняет. Смотрю в соседний файлик, где лежала функция, проверяющая ключ на вхождение в слайс заголовков - тот же баг с len(headers)/2. Занавес.

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