Преимущества и недостатки `string` в OpenResty
API7.ai
December 8, 2022
В предыдущей статье мы познакомились с распространенными блокирующими функциями в OpenResty, которые часто неправильно используются новичками. Начиная с этой статьи, мы перейдем к сути оптимизации производительности, которая будет включать множество техник, способных помочь нам быстро улучшить производительность кода OpenResty, так что не стоит относиться к этому легкомысленно.
В этом процессе нам нужно будет писать больше тестового кода, чтобы понять, как использовать эти техники оптимизации и проверить их эффективность, чтобы мы могли грамотно их применять.
За кулисами советов по оптимизации производительности
Техники оптимизации относятся к "практической" части, поэтому перед тем как приступить к ним, давайте поговорим о "теории" оптимизации.
Методы оптимизации производительности будут меняться с итерациями LuaJIT и OpenResty. Некоторые методы могут быть напрямую оптимизированы на уровне базовых технологий и больше не требуют изучения; в то же время будут появляться новые техники оптимизации. Поэтому самое важное — понять постоянные концепции, лежащие в основе этих техник оптимизации.
Давайте рассмотрим некоторые ключевые идеи, касающиеся производительности в программировании на OpenResty.
Теория 1: Обработка запросов должна быть короткой, простой и быстрой
OpenResty — это веб-сервер, поэтому он часто обрабатывает 1000+, 10000+ или даже 100000+ клиентских запросов одновременно. Поэтому для достижения максимальной общей производительности мы должны гарантировать, что отдельные запросы обрабатываются быстро, а различные ресурсы, такие как память, освобождаются.
- "Короткая" здесь означает, что жизненный цикл запроса должен быть коротким, чтобы не занимать ресурсы на долгое время без их освобождения; даже для длительных соединений следует установить порог по времени или количеству запросов, чтобы регулярно освобождать ресурсы.
- Второе "простая" означает, что в одном API следует делать только одну вещь. Разделяйте сложную бизнес-логику на несколько API и держите код простым.
- Наконец, "быстрая" означает, что не следует блокировать основной поток и не выполнять слишком много операций, требующих процессорного времени. Даже если это необходимо, не забывайте использовать другие методы, которые мы обсуждали в предыдущей статье.
Эти архитектурные соображения подходят не только для OpenResty, но и для других языков и платформ разработки, поэтому я надеюсь, что вы сможете их понять и тщательно обдумать.
Теория 2: Избегайте создания промежуточных данных
Избегание ненужных данных в промежуточных процессах — это, пожалуй, самая важная теория оптимизации в программировании на OpenResty. Давайте рассмотрим небольшой пример, чтобы объяснить, что такое ненужные данные в промежуточных процессах.
$ resty -e 'local s= "hello" s = s .. " world" s = s .. "!" print(s) '
В этом фрагменте кода мы выполнили несколько операций конкатенации с переменной s, чтобы получить результат hello world!. Но только конечное состояние s, то есть hello world!, является полезным. Начальное значение s и промежуточные присваивания — это все промежуточные данные, которые следует генерировать как можно меньше.
Причина в том, что эти временные данные приводят к потерям производительности при инициализации и сборке мусора (GC). Не стоит недооценивать эти потери; если это происходит в горячем коде, таком как циклы, производительность заметно снизится. Позже я также объясню это на примере строк.
Строки неизменяемы
Теперь вернемся к теме этой статьи — строкам. Здесь я хочу подчеркнуть, что строки в Lua неизменяемы.
Конечно, это не означает, что строки нельзя конкатенировать, изменять и т.д., но когда мы изменяем строку, мы не меняем исходную строку, а создаем новый объект строки и изменяем ссылку на строку. Поэтому, если исходная строка больше не имеет ссылок, она будет освобождена сборщиком мусора Lua (GC).
Очевидное преимущество неизменяемых строк заключается в экономии памяти. Таким образом, в памяти будет только одна копия одинаковой строки, и разные переменные будут указывать на один и тот же адрес памяти.
Недостаток этого дизайна заключается в том, что при добавлении и освобождении строк каждый раз, когда вы добавляете строку, LuaJIT должен вызывать lj_str_new, чтобы проверить, существует ли строка; если нет, нужно создать новую строку. Если это происходит очень часто, это сильно влияет на производительность.
Давайте рассмотрим конкретный пример операции конкатенации строк, подобный тому, что приведен в этом примере, который встречается во многих проектах с открытым исходным кодом OpenResty.
$ resty -e 'local begin = ngx.now() local s = "" -- Цикл `for`, использующий `..` для конкатенации строк for i = 1, 100000 do s = s .. "a" end ngx.update_time() print(ngx.now() - begin) '
Этот пример кода выполняет 100000 операций конкатенации строк с переменной s и выводит время выполнения. Хотя пример немного экстремальный, он хорошо демонстрирует разницу в производительности до и после оптимизации. Без оптимизации этот код выполняется за 0.4 секунды на моем ноутбуке, что все еще относительно медленно. Так как же нам его оптимизировать?
В предыдущих статьях был дан ответ: использовать table для промежуточной упаковки, удаляя все временные промежуточные строки и оставляя только исходные данные и конечный результат. Давайте посмотрим на конкретную реализацию кода.
$ resty -e 'local begin = ngx.now() local t = {} -- Цикл `for`, использующий массив для хранения строк, каждый раз подсчитывая длину массива for i = 1, 100000 do t[#t + 1] = "a" end -- Конкатенация строк с использованием метода `concat` массива local s = table.concat(t, "") ngx.update_time() print(ngx.now() - begin) '
Мы видим, что этот код сохраняет каждую строку по очереди в table, а индекс определяется как #t + 1, то есть текущая длина table плюс 1. В конце используется функция table.concat для объединения элементов массива. Это естественным образом пропускает все временные строки и избегает 100000 вызовов lj_str_new и GC.
Это был наш анализ кода, но как работает оптимизация? Оптимизированный код выполняется всего за 0.007 секунд, что означает улучшение производительности более чем в 50 раз. В реальном проекте улучшение производительности может быть еще более заметным, так как в этом примере мы добавляли только один символ a за раз.
Какова будет разница в производительности, если новая строка будет длиной в 10 раз больше a?
Достаточно ли хороши 0.007 секунд для нашей работы по оптимизации? Нет, код все еще можно оптимизировать. Давайте изменим еще одну строку кода и посмотрим на результат.
$ resty -e 'local begin = ngx.now() local t = {} -- Цикл `for`, использующий массив для хранения строк, поддерживая длину массива самостоятельно for i = 1, 100000 do t[i] = "a" end local s = table.concat(t, "") ngx.update_time() print(ngx.now() - begin) '
На этот раз мы изменили t[#t + 1] = "a" на t[i] = "a", и всего одной строкой кода мы избежали 100000 вызовов функции для получения длины массива. Помните операцию получения длины массива, которую мы обсуждали в разделе о table ранее? Она имеет временную сложность O(n), что довольно дорого. Поэтому здесь мы просто поддерживаем индекс массива самостоятельно, чтобы избежать операции получения длины массива. Как говорится, если не можешь справиться с этим, просто избегай этого.
Конечно, это более простой способ записи. Следующий код более наглядно показывает, как мы можем самостоятельно поддерживать индекс массива.
$ resty -e 'local begin = ngx.now() local t = {} local index = 1 for i = 1, 100000 do t[index] = "a" index = index + 1 end local s = table.concat(t, "") ngx.update_time() print(ngx.now() - begin) '
Уменьшение других временных строк
Ошибки, о которых мы только что говорили, связанные с временными строками из-за конкатенации строк, очевидны. С несколькими напоминаниями из примеров кода выше, я уверен, что мы больше не будем совершать подобных ошибок. Однако в OpenResty есть и более скрытые временные строки, которые гораздо труднее обнаружить. Например, функция обработки строк, которую мы обсудим ниже, часто используется. Можете ли вы представить, что она также генерирует временные строки?
Как мы знаем, функция string.sub извлекает указанную часть строки. Как мы упоминали ранее, строки в Lua неизменяемы, поэтому извлечение новой строки включает вызов lj_str_new и последующие операции GC.
resty -e 'print(string.sub("abcd", 1, 1))'
Функция этого кода — извлечь первый символ строки и вывести его. Естественно, это неизбежно генерирует временную строку. Есть ли лучший способ достичь того же эффекта?
resty -e 'print(string.char(string.byte("abcd")))'
Конечно, есть. В этом коде мы сначала используем string.byte, чтобы получить числовой код первого символа, а затем используем string.char, чтобы преобразовать число в соответствующий символ. Этот процесс не генерирует никаких временных строк. Поэтому использование string.byte для сканирования и анализа строк является наиболее эффективным.
Использование поддержки SDK для типа table
После того как мы узнали, как уменьшить количество временных строк, вам не терпится попробовать? Тогда мы можем взять результат примера кода выше и вывести его клиенту в качестве содержимого тела ответа. На этом этапе вы можете остановиться и попробовать написать этот код самостоятельно.
$ resty -e 'local begin = ngx.now() local t = {} local index = 1 for i = 1, 100000 do t[index] = "a" index = index + 1 end local response = table.concat(t, "") ngx.say(response) '
Если вы можете написать этот код, вы уже опережаете большинство разработчиков OpenResty. Lua API OpenResty уже учитывает использование table для конкатенации строк, поэтому в ngx.say, ngx.print, ngx.log, cosocket:send и других API, которые могут принимать много строк, он принимает не только строку в качестве параметра, но и table.
resty -e 'local begin = ngx.now() local t = {} local index = 1 for i = 1, 100000 do t[index] = "a" index = index + 1 end ngx.say(t) '
В этом последнем фрагменте кода мы опустили шаг local response = table.concat(t, ""), конкатенации строк, и передали table напрямую в ngx.say. Это переносит задачу конкатенации строк с уровня Lua на уровень C, избегая еще одного поиска, генерации и GC строк. Для длинных строк это еще один значительный прирост производительности.
Заключение
После прочтения этой статьи мы видим, что большая часть оптимизации производительности OpenResty связана с различными деталями. Поэтому нам нужно хорошо знать LuaJIT и Lua API OpenResty, чтобы достичь оптимальной производительности. Это также напоминает нам, что если мы забыли предыдущий материал, мы должны своевременно его повторить и закрепить.
Наконец, подумайте над задачей: запишите строки hello, world и ! в журнал ошибок. Можем ли мы написать пример кода без конкатенации строк?
Также не забудьте другой вопрос в тексте. Какова будет разница в производительности в следующем коде, если новые строки будут длиной в 10 раз больше a?
$ resty -e 'local begin = ngx.now() local t = {} for i = 1, 100000 do t[#t + 1] = "a" end local s = table.concat(t, "") ngx.update_time() print(ngx.now() - begin) '
Вы также можете поделиться этой статьей с друзьями для изучения и обсуждения.