Неблокирующий ввод-вывод (Non-blocking I/O) — ключ к повышению производительности OpenResty

API7.ai

December 2, 2022

OpenResty (NGINX + Lua)

В главе об оптимизации производительности я проведу вас через все аспекты оптимизации производительности в OpenResty и обобщу фрагменты, упомянутые в предыдущих главах, в комплексное руководство по написанию кода на OpenResty, чтобы вы могли писать код более высокого качества.

Улучшение производительности — это непростая задача. Мы должны учитывать оптимизацию архитектуры системы, оптимизацию базы данных, оптимизацию кода, тестирование производительности, анализ flame graph и другие шаги. Но снизить производительность легко, и, как следует из названия сегодняшней статьи, вы можете снизить производительность в 10 раз или более, добавив всего несколько строк кода. Если вы используете OpenResty для написания кода, но производительность не улучшилась, то, вероятно, это связано с блокирующим I/O.

Поэтому, прежде чем мы углубимся в детали оптимизации производительности, давайте рассмотрим важный принцип программирования на OpenResty: Неблокирующий I/O в приоритете.

С детства нас учили родители и учителя не играть с огнем и не трогать розетку, так как это опасные действия. Подобные опасные действия существуют и в OpenResty. Если вам приходится использовать блокирующие операции I/O в вашем коде, это приведет к резкому падению производительности, и первоначальная цель использования OpenResty для создания высокопроизводительного сервера будет нарушена.

Почему нельзя использовать блокирующие операции I/O?

Понимание того, какие действия являются опасными, и их избегание — это первый шаг к оптимизации производительности. Давайте начнем с рассмотрения того, почему блокирующие операции I/O могут повлиять на производительность OpenResty.

OpenResty может поддерживать высокую производительность благодаря тому, что он заимствует обработку событий NGINX и корутины Lua, поэтому:

  • Когда вы сталкиваетесь с операцией, такой как сетевой I/O, которая требует ожидания возврата перед продолжением, вы вызываете корутину Lua yield, чтобы приостановить себя, а затем регистрируете обратный вызов в NGINX.
  • После завершения операции I/O (или возникновения тайм-аута или ошибки) NGINX вызывает resume, чтобы разбудить корутину Lua.

Такой процесс гарантирует, что OpenResty всегда может эффективно использовать ресурсы CPU для обработки всех запросов.

В этом потоке обработки LuaJIT не передает управление циклу событий NGINX, если не используется неблокирующий метод I/O, такой как cosocket, а вместо этого используется блокирующая функция I/O для обработки I/O. Это приводит к тому, что другие запросы ждут в очереди завершения обработки блокирующего события I/O, прежде чем получат ответ.

Подводя итог, в программировании на OpenResty мы должны быть особенно осторожны с вызовами функций, которые могут блокировать I/O; в противном случае одна строка блокирующего кода I/O может снизить производительность всего сервиса.

Ниже я представлю несколько распространенных проблем, часто неправильно используемых блокирующих функций I/O; давайте также рассмотрим, как использовать самый простой способ "испортить" и быстро снизить производительность вашего сервиса в 10 раз.

Выполнение внешних команд

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

Например, для завершения процесса.

os.execute("kill -HUP " .. pid)

Или для более длительных операций, таких как копирование файлов, использование OpenSSL для генерации ключей и т.д.

os.execute(" cp test.exe /tmp ") os.execute(" openssl genrsa -des3 -out private.pem 2048 ")

На первый взгляд, os.execute — это встроенная функция в Lua, и в мире Lua это действительно способ вызова внешних команд. Однако важно помнить, что Lua — это встраиваемый язык программирования, и в других контекстах он будет иметь разные рекомендации по использованию.

В среде OpenResty os.execute блокирует текущий запрос. Поэтому, если время выполнения этой команды особенно короткое, то влияние не очень велико. Но если команда выполняется сотни миллисекунд или даже секунды, то производительность резко упадет.

Мы понимаем проблему, так как же ее решить? Обычно есть два решения.

1. Если доступна библиотека FFI, то предпочтение отдается вызову через FFI

Например, если мы использовали командную строку OpenSSL для генерации ключа выше, мы можем изменить это на использование FFI для вызова функции OpenSSL на C, чтобы обойти это.

Для завершения процесса можно использовать lua-resty-signal, библиотеку, входящую в состав OpenResty, чтобы решить эту задачу неблокирующим образом. Реализация кода выглядит следующим образом. Конечно, здесь lua-resty-signal также решает задачу с использованием FFI для вызова системных функций.

local resty_signal = require "resty.signal" local pid = 12345 local ok, err = resty_signal.kill(pid, "KILL")

Кроме того, на официальном сайте LuaJIT есть специальная страница, которая представляет различные библиотеки FFI в разных категориях. Например, при работе с изображениями, шифрованием и дешифрованием CPU-интенсивных операций, вы можете сначала посмотреть там, есть ли уже готовые библиотеки, которые можно использовать напрямую.

2. Использование библиотеки lua-resty-shell на основе ngx.pipe

Как было описано ранее, вы можете запускать свои команды в shell.run, что является неблокирующей операцией I/O.

$ resty -e 'local shell = require "resty.shell" local ok, stdout, stderr, reason, status = shell.run([[echo "hello, world"]]) ngx.say(stdout) '

Дисковый I/O

Давайте рассмотрим сценарий обработки дискового I/O. В серверных приложениях это обычная операция — чтение локального конфигурационного файла, например, следующего кода.

local path = "/conf/apisix.conf" local file = io.open(path, "rb") local content = file:read("*a") file:close()

Этот код использует io.open для получения содержимого определенного файла. Однако, хотя это блокирующая операция I/O, не забывайте, что в реальных сценариях нужно учитывать множество факторов. Поэтому, если вы вызываете его в init и init worker, это одноразовое действие, которое не влияет на клиентские запросы, и это вполне приемлемо.

Конечно, если каждый пользовательский запрос вызывает чтение или запись на диск, это становится неприемлемым. В этот момент вам нужно серьезно задуматься о решении.

Во-первых, мы можем использовать lua-io-nginx-module, сторонний C-модуль. Он предоставляет неблокирующий I/O Lua API для OpenResty, но вы не можете использовать его так же свободно, как cosocket. Потому что потребление дискового I/O не исчезает просто так, это просто другой способ выполнения.

Этот подход работает, потому что lua-io-nginx-module использует пул потоков NGINX для перемещения операций дискового I/O из основного потока в другой поток для их обработки, чтобы основной поток не блокировался операциями дискового I/O.

Вам нужно перекомпилировать NGINX при использовании этой библиотеки, так как это C-модуль. Он используется так же, как библиотека I/O Lua.

local ngx_io = require "ngx.io" local path = "/conf/apisix.conf" local file, err = ngx_io.open(path, "rb") local data, err = file: read("*a") file:close()

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

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

В то время разработчики использовали ngx.log для записи этих логов, как показано ниже.

ngx.log(ngx.WARN, "info")

Эта строка кода вызывает Lua API, предоставляемый OpenResty, и на первый взгляд проблем нет. Однако недостаток в том, что вы не можете вызывать его слишком часто. Во-первых, сам ngx.log — это дорогостоящий вызов функции; во-вторых, даже с буфером, частые и большие записи на диск могут серьезно повлиять на производительность.

Так как же это решить? Вернемся к первоначальной потребности — статистика, устранение неполадок, и запись логов на локальный диск была бы лишь одним из способов достижения цели.

Поэтому вы также можете отправлять логи на удаленный сервер логирования, используя cosocket для неблокирующей сетевой коммуникации; то есть передать блокирующий дисковый I/O на сервис логирования, чтобы избежать блокировки внешнего сервиса. Вы можете использовать lua-resty-logger-socket для этого.

local logger = require "resty.logger.socket" if not logger.initted() then local ok, err = logger.init{ host = 'xxx', port = 1234, flush_limit = 1234, drop_limit = 5678, } local msg = "foo" local bytes, err = logger.log(msg)

Как вы, вероятно, заметили, оба метода выше одинаковы: если блокирующий I/O неизбежен, не блокируйте основной рабочий поток; передайте его другим потокам или сервисам за пределами.

luasocket

Наконец, давайте поговорим о luasocket, встроенной библиотеке Lua, которую легко используют разработчики и часто путают с cosocket, предоставляемым OpenResty. luasocket также может выполнять функции сетевой коммуникации. Однако он не имеет преимущества неблокирующего выполнения. В результате, если вы используете luasocket, производительность резко падает.

Однако luasocket также имеет свои уникальные сценарии использования. Например, я не знаю, помните ли вы, что cosocket недоступен в нескольких фазах, и мы обычно можем обойти это, используя ngx.timer. Также вы можете использовать luasocket для функций cosocket в одноразовых фазах, таких как init_by_lua* и init_worker_by_lua*. Чем больше вы знакомы с сходствами и различиями между OpenResty и Lua, тем больше интересных решений, подобных этим, вы найдете.

Кроме того, lua-resty-socket — это вторичная обертка для открытой библиотеки, которая делает luasocket и cosocket совместимыми. Этот контент также заслуживает дальнейшего изучения. Если вам все еще интересно, я подготовил материалы для продолжения обучения.

Итог

В целом, в OpenResty распознавание типов блокирующих операций I/O и их решений — это основа хорошей оптимизации производительности. Так что, сталкивались ли вы с подобными блокирующими операциями I/O в реальной разработке? Как вы их находите и решаете? Поделитесь своим опытом со мной в комментариях, и не стесняйтесь делиться этой статьей.