Nginx / [Из песочницы] MySQL в NGINX: использование блокирующих библиотек в неблокирующем сервере

в 8:07, , рубрики: Новости, метки: ,

Как известно, при разработке высоконагруженных серверов часто применяется событийная модель работы с сокетами. Ключевым компонентом системы при этом является epoll (во FreeBSD и Windows есть свои решения, но остановимся на Линуксе). Функция epoll_wait, будучи единственным блокирующим вызовом, возвращает нам информацию обо всех сетевых событиях, которые нас интересуют. Подобным образом, конечно, работает и всем известный сервер NGINX.
Событийная модель программирования делает код весьма своеобразным, как будто выворачивает его наизнанку. Но эта проблема не так страшна. Есть другая проблема — использование в событийно-ориентированном коде существующих библиотек, изначально не предназначенных для него. Если подобная библиотека делает блокирующие вызовы (например, connect, recv и т.д.), вся событийная модель может потерять смысл т.к. окончания одного такого вызова будут ждать все остальные клиенты, что совершенно неприемлемо, если вы пишете серьезный продукт.
Одной из библиотек, изначально не предназначенных для использования в неблокирующем окружении, является клиентская библиотека libmysqlclient. Вместе с тем, она бывает часто нужна в NGINX. Существуют несколько решений, позволяющих получить доступ к MySQL из NGINX, например drizzle и HandlerSocket (тривиальный протокол довольно просто реализуется при помощи стандартного механизма NGINX upstream). Однако, все же, наиболее удобным является использование всей мощи стандартной библиотеки libmysqlclient и языка SQL.
Переключение контекстов и перехваты

Существует простое решение проблемы блокирующих вызовов. Чтобы преобразовать блокирующий код в неблокирующий, достаточно перехватить блокирующий вызов и заменить его на неблокирующий, а в случае, если нужна блокировка, перейти к выполнению основного цикла сервера, но так, чтобы при появлении ожидаемого нами события вернуться обратно на то самое место, которое мы покинули. Т.е. создать такой вот юзерспейсный тред. Обойдется нам он довольно дешево т.к. вытесняющая многозадачность ему ни к чему, а все переключения контекстов будем делать сами в нужные моменты времени.
Для начала, выясним, какие вызовы могут блокировать поток выполнения.
Вот основные из них:accept

connect

read/recv

write/send

poll

Последняя функция в этом списке — poll — выглядит не очень честно т.к. сама иногда является признаком неблокирующего поведения. Однако, libmysqlclient ее использует, так что нам придется перехватывать и ее. Очевидно, epoll_wait также является блокирующей, но мы надеемся, что ее не будет использовать блокирующий код. Еще есть вызов select, однако он имеет ряд проблем и поэтому (слава богу!) используется все реже. Тоже исключаем.
Эти функции определены в libc, так что, если наш код линкуется динамически, у нас есть все возможности для того, чтобы воспользоваться стандартным приемом для перехвата. Приведу пример для read:
typedef ssize_t (*read_pt)(int fd, void *buf, size_t count);
static read_pt orig_read;

ssize_t read(int fd, void *buf, size_t count) {

ssize_t ret;

for(;;) {

/* вызываем настояший read */
ret = orig_read(fd, buf, count);

if (!mtask_scheduled || ret != -1 || errno != EAGAIN)
return ret;

/* здесь самое важное; будем переключать контекст */
if (mtask_yield(fd, NGX_READ_EVENT)) {
errno = EIO;
return -1;
}
}
}

...

/* где-то в начале */
orig_read = (read_pt)dlsym(RTLD_NEXT, "read");

Здесь mtask_yield — это функция, которая осществляет переключение контекста в основной цикл обработки событий. Она вызывается тогда, когда обычный блокирующий код должен был заблокироваться; mtask_scheduled — макрос, позволяющий определить, надо ли имитировать блокирующее поведение при помощи переключения контекстов, или же вести себя стандартным образом. Очевидно, вне нашего обработчика все переключения контекстов будут лишь мешать. Более того, вызовы, которые осуществляет сам NGINX (например, для получения и отправки запросов), очевидно, не нуждаются в нашей помощи и изначально раcсчитаны на неблокирующее поведение.
Таже надо отметить, что сокет, на котором выполняется данная операция read, должен быть переведен в неблокирующий режим, для чего надо сделать соответствующий вызов fcntl в перехвачанных connect и accept.
Контексты

Что такое юзерспейсный контекст выполнения? Это стек+регистры (есть еще маска приема сигналов, но мы не прыгаем из сигналов, так что нам сейчас это не интересно). Если все так просто, очевидно, что контекст можно переключать в рамках одного процесса тогда, когда нам вздумается. Для этого есть стандартные средства
makecontext — создает контекст, ей указываем стек и функцию

swapcontext — переключает контексты

setcontext/getcontext — устанавливает/считывает контекст

Прикручиваем к NGINX

В NGINX контент для отдачи генерится обработчиком вида:static ngx_int_t ngx_http_mtask_handler(ngx_http_request_t *r);
Этот обработчик добавляется в список обработчиков фазы NGX_HTTP_CONTENT_PHASE:
h = ngx_array_push(&cmcf->phases[NGX_HTTP_CONTENT_PHASE].handlers);
*h = ngx_http_mtask_handler;

При обычном использовании обработчик создает цепочки (ngx_chain_t) с буферами для отдачи клиенту, после чего зовет функции
ngx_http_send_header — для отдачи HTTP заголовка

ngx_http_output_filter — для отдачи тела.

Тело, очевидно, может не «влезть» в сокет целиком, однако блокировки, конечно, не возникает, и досылкой занимается сам NGINX уже после завершения клиентского обработчика.
Итак, мы хотим, чтобы обработчик мог выполнять блокирующие операции. Для этого делаем следующее.
/* создаем контекст, аллоцируем стек */
getcontext(&ctx->wctx);
ctx->wctx.uc_stack.ss_sp = ngx_palloc(r->pool, mlcf->stack_size);
ctx->wctx.uc_stack.ss_size = mlcf->stack_size;
ctx->wctx.uc_stack.ss_flags = 0;
ctx->wctx.uc_link = NULL;
makecontext(&ctx->wctx, &mtask_proc, 0);

/* переключаемся в новый контекст */
mtask_wake(r, MTASK_WAKE_NOFINALIZE);

/* говорим NGINX'у, что запрос не надо завершать при завершении обработчика,
даже при том, что данных для отдачи еще нет; они будут позже */
r->main->count++;

Функция mtask_wake делает следующие основные вещи:
/* сохраняем указатель на текущий запрос */
/* после этой операции перехваченные вызовы начинают работать по-новому */
mtask_setcurrent( r );

/* переходим в контекст */
/* вернемся мы в эту же самую точку! */
swapcontext(&ctx->rctx, &ctx->wctx);

/* если запрос сброшен, это значит, что наш обработчик завершился! */
if (!mtask_scheduled) {

/* завершаем соединение */
if (!(flags & MTASK_WAKE_NOFINALIZE))
ngx_http_finalize_request(r, NGX_OK);

return 1;
}

/* обработчик не завершился, но мы как-то сюда попали */
/* вариант лишь один - произошел блокирующий вызов */
/* сбрасываем запрос чтобы вернуть перехваченным функциям нормальное поведение */
mtask_resetcurrent();

Самую важную работу делает функция mtask_yield — она преобразует блокирующий вызов в события NGINX и возвращает управление в основной поток:
/* берем из пула NGINX соединение и прописываем в него наш дескриптор */
c = ngx_get_connection(fd, mtask_current->connection->log);
c->data = mtask_current;

/* регистрируем событие ввода/вывода средствами NGINX */
if (event == NGX_READ_EVENT)
e = c->read;
else
e = c->write;
e->data = c;
e->handler = &mtask_event_handler;
e->log = mtask_current->connection->log;
ngx_add_event(e, event, 0);

/* переключаемся в основной поток и ждем когда наступит событие, которое вернет нас обратно */
swapcontext(&ctx->wctx, &ctx->rctx);

/* мы вернулись! значит уже можно писать/читать. продолжаем */
ngx_del_event(e, event, 0);

ngx_free_connection( c );

Обработчик событий NGINX делает одну основную вещь: переключает контекст при наступлении события ввода-вывода:
static void mtask_event_handler(ngx_event_t *ev) {
...
mtask_wake(r, wf);
...
}

Стоит также упомянуть, что в юзерспейсном треде нельзя вызывать функции самого NGINX, изначально рассчитанные на неблокирующее поведение. Однако, такие функции с большой вероятностью могут быть вызваны из ngx_http_send_header и ngx_http_output_filter. Для того, чтобы предотвратить эти вызовы, переводим текущее соединение в режим буферизации следующим образом:
c->write->delayed = 1

По окончании треда этот флаг сбрасывается и данные передаются клиенту. Очевидно, такое решение не подходит для вывода больших объемов данных, однако в большинстве случаев такая задача не стоит (а когда стоит, ее все же можно решить чуть менее красивым способом).
Прикручиваем libmysqlclient

Имея механизм исполнения блокирующего кода, обращение к MySQL становится простым. Для начала создается самый обычный обработчик CONTENT_PHASE. Вспомним, что прототип функции юзерспейсного треда полностью совпадает с прототипом обычного обработчика. Таким образом, забыв про блокирующую природу нашего кода, используем стандартные средства библиотеки libmysqlclient в стандартного вида обработчике:
ngx_int_t ngx_http_mysql_handler(ngx_http_request_t *r) {

...
mysql_real_connect(...)
...
mysql_query(...)
...
mysql_store_result(...)
...
mysql_fetch_row(...)
...
}

Данные выводим в простом текстовом виде, поле за полем, причем используем по одному звену цепочки ngx_chain_t на поле. Это дает нам простую возможность воспользоваться результатами запросов внутри самого NGINX. Для этого реализована директива mysql_subrequest, которая осуществляет выполнение MySQL запроса, описанного в другом локейшине, после чего присваивает его результаты по порядку переменным, переданным в качестве аргументов этой команде (см. пример далее). Переменные затем можно использовать, к примеру, для проксирования соединения на нужный (полученный из бд) бекенд или для передачи их значений какому-либо скрипту, не имеющего доступа к базе данных.
Сам обработчик регистрируем не как обычно (в фазe CONTENT_PHASE — тут нужен «честный» неблокирующий код), а в конфигурации модуля mtask.
ngx_http_mtask_loc_conf_t *mlcf;

mlcf = ngx_http_conf_get_module_loc_conf(cf, ngx_http_mtask_module);
mlcf->handler = &ngx_http_mysql_handler;

Примеры

Пример конфига nginx.conf, демонстрирующего возможности данного модуля.
server {

...

# параметры подключения описываем в блоке server

# unix socket access (default)
mysql_host localhost;

#mysql_user theuser;
#mysql_password thepass;
#mysql_port theport;

mysql_database test;
mysql_connections 32;

# Стек не должен быть слишком маленьким для отладочных версий
# т.к. NGINX активно использует его для форматирования логов
# Для релизных конфигураций размер стека можно уменьшить.
mtask_stack 65536;

# сами запросы!
location /select {

mysql_query "SELECT name FROM users WHERE id='$arg_id'";
}

location /insert {

mysql_query "INSERT INTO users(name) VALUES('$arg_name')";
}

location /update {

mysql_query "UPDATE users SET name='$arg_name' WHERE id=$arg_id";
}

location /delete {

mysql_query "DELETE FROM users WHERE id=$arg_id";
}

# используем данные из подзапросов
location /pass {

# значение name из таблицы будет помещено в переменную $name
mysql_subrequest /select?id=$arg_id $name;

# делаем с $name что хотим
proxy_pass http://myserver.com/path?name=$name;
}

...

}

На модули можно посмотреть и скачать их по адресамgithub.com/arut/nginx-mtask-modulegithub.com/arut/nginx-mysql-module
Всем спасибо!

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js