Асинхронное программирование, ко-рутины и все-все-все

Jun 13, 2016 11:50

Всем известно, что написать асинхронное сетевое приложение, да ещё с поддержкой TLS/SSL - адский труд. По пути придётся преодолеть callback hell, сделать разбор состояний SSL_ERROR_WANT_READ/SSL_ERROR_WANT_WRITE сокета SSL и соотнести их с текущим состоянием асинхронного обработчика, не запутаться в перключениях socket -> TLS/SSL socket и т.п. Всё это очень сложно.

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

Обычно проблема решается в несколько шагов:
1. Код пишется без поддержки SSL/TLS, а перед сервером устанавливается прокси-сервер, на который терминируется SSL/TLS со стороны клиента (хороший пример - https://github.com/bumptech/stud для чего угодно, или всем известный nginx для HTTP/POP3/IMAP4).

2. Берётся готовый асинхронный фреймворк и с помощью него пишется собственный код.

На словах всё прекрасно, однако на самом деле решение - так себе.
Во-первых, появляется лишняя сущность в виде прокси-сервера, который требует ресурсов. Снаружи трафик шифрованный, но внутри кластера - нет, а значит, есть возможность перехвата данных. Особенно актуально это при размещении в облачных сервисах.

Во-вторых, это очень плохо отлаживается, так как основная причина - callback hell асинхронной машины всё равно остаётся.

Можно ли упростить программирование и отладку? Есть мнение, что не только можно, но и нужно.

Решение следующее: сделать по возможности одинаковое API для работы как в асинхронном режиме, так и в линейном. Под линейным подразумевается нечто вроде:

struct pollfd fds;
fds.events = POLLIN;
poll(&fds, 1, timeout);
recv(fds.fd, buf, len);

fds.events = POLLOUT;
poll(&fds, 1, timeout);
send(fds.fd, buf, len);
В примере показано использование устаревшего вызова poll, но так как семантика вызова у poll, epoll и kqueue одинаковые, то принципиальной разницы нет.

Асинхронное API при этом должно мимикрировать под линейное. Приведём фантазийный пример:

struct async_pollfd fds;
fds.events = POLLIN;
async_poll(async_context, &fds, 1, timeout);
recv(fds.fd, buf, len);

fds.events = POLLOUT;
async_poll(async_context, &fds, 1, timeout);
send(fds.fd, buf, len);
Заметим, что глубокой переработке подлежит только вызов ожидания сокета, но не процедуры чтения или записи в него. Это позволит одновременно ожидать готовности нескольких сокетов, а главное - упростит написание самого кода и позволит вызывать любые функции по готовности сокета. Так, не составит практически никакого труда заменить recv на SSL_read и send - на SSL_write.

И самое главное: отлаживать логику программы станет возможно в простейшей для понимания тестовой среде, где без сайд-эффектов работают такие средства как valgrind, gdb, gcov, gprof, strace и т.п.

Читатель работавший с асинхронными фреймворками сразу же задаст резонный вопрос:
- Как, используя асинхронное API, например, libev/libevent "вывернуть наизнанку" callback? Ведь из event loop мы попадаем в процедуру-callback, которая исполняется вне нашего кода, и выйти можно только обратно в event loop?

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

Теперь соберём всё воедино и опишем алгоритм работы:
Изначально исполнение программы идёт в линейном блоке. После захода в функцию async_poll мы настраиваем требуемые события для одного или нескольких сокетов и производим переключение контекста внутрь event loop. Как только один или несколько сокетов будут готовы к чтению или записи, мы переключаем контекст обратно и выходим из async_poll.

Таким образом мы из асинхронного API делаем псевдосинхронное, незначительно отличающееся от линейного. Массовый параллелизм достигается тем, что async_poll может выполняться во многих ко-рутинах и их количество ограничено только размером оперативной памяти.

Все рассуждения описаны на уровне псевдокода. Как же это выглядит в реальности и насколько дороже относительно линейно исполняемого нам обойдётся код с переключением контекстов?

Ответ на первый вопрос доступен по ссылке:
http://pastebin.com/pBudcdg5

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

Результаты можно видеть в таблице:

PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
19765 stellar 20 0 426456 4760 2220 S 39.9 0.2 2:32.68 coro
19864 stellar 20 0 411224 4444 2004 R 37.9 0.2 2:31.21 linear

Мы видим, что разница составляет порядка 5%. Это - хороший результат и очень небольшая цена за радикальное упрощение разработки и отладки программы.

Обсуждение в ФБ: https://www.facebook.com/sloneus/posts/636050909879742

рабочее

Previous post Next post
Up