Всем известно, что написать асинхронное сетевое приложение, да ещё с поддержкой 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