{ "cells": [ { "cell_type": "markdown", "id": "d3abcf81", "metadata": {}, "source": [ "# Исполнение кода в одном потоке, модуль select #" ] }, { "cell_type": "markdown", "id": "5f549add", "metadata": {}, "source": [ "Итак, на этой лекции мы поговорим об исполнении кода в один поток. В предыдущих лекциях мы рассмотрели использование процессов и потоков для организации взаимодействия большого количества клиентов по сети сервера. Исполнение кода в один поток — это немного другой подход, и сегодня мы остановим внимание на использовании модуля `select`, поговорим про неблокирующий ввод-вывод в Python, а также обсудим некоторые популярные фреймворки в Python.\n", "\n", "В операционной системе существует модуль `select`, который позволяет организовать работу с неблокирующим вводом-выводом. Что это значит, мы сейчас будем разбирать на примерах.\n", "\n", "Отдельно хочется сказать, что существует несколько подходов или механизмов опроса всех файловых дескрипторов для организации неблокирующего ввода-вывода. Эти методы перечислены на слайде: это `select.select`, `select.poll`, `select.epoll` и `select.kqueue`. Все эти методы связаны с особенностями операционных систем. Например, в Linux, как правило, используют `epoll`, в других операционных системах могут использоваться другие механизмы. Мы будем рассматривать примеры на основе `epoll`.\n", "\n", "Итак, давайте вспомним наш предыдущий пример и попробуем реализовать его немного по-другому. Вспомним, что у нас была проблема создания сокета и обработки нескольких входящих соединений одновременно. Давайте попробуем сделать это при помощи модуля `select`." ] }, { "cell_type": "code", "execution_count": null, "id": "b3bd1dd7", "metadata": {}, "outputs": [], "source": [ "# Неблокирующий ввод/вывод, обучающий пример\n", "import socket\n", "import select\n", "\n", "sock = socket.socket()\n", "sock.bind((\"\", 10001))\n", "sock.listen()\n", "\n", "# как обработать запросы для conn1 и conn2\n", "# одновременно без потоков?\n", "conn1, addr = sock.accept()\n", "conn2, addr = sock.accept()\n", "\n", "conn1.setblocking(0)\n", "conn2.setblocking(0)\n", "\n", "epoll = select.epoll()\n", "epoll.register(conn1.fileno(), select.EPOLLIN | select.EPOLLOUT)\n", "epoll.register(conn2.fileno(), select.EPOLLIN | select.EPOLLOUT)\n", "\n", "conn_map = {\n", " conn1.fileno(): conn1,\n", " conn2.fileno(): conn2,\n", "}" ] }, { "cell_type": "markdown", "id": "f492bfd6", "metadata": {}, "source": [ "Итак, предположим, мы создали объект класс `socket`, вызвали метод `bind`, вызвали метод `listen` и смогли вызвать метод `accept` для двух соединений. Теперь у нас есть два объекта соединения — `connection1` и `connection2`, и нам необходимо одновременно читать или записывать данные из этих соединений без использования потоков или процессов. Как можно это организовать в Linux? Для этого необходимо перевести наше соединение, во-первых, в неблокирующий режим. Делается это при помощи вызова `setblocking(0)`. Это равносильно тому, что мы сделаем вызов `settimeout(0)`. Теперь наши сокеты в неблокирующем режиме, и если мы попробуем что-то прочитать из этого сокета, а там данных нет, то наш вызов `recv` не заблокируется, а вернет нам некую системную ошибку, которая будет говорить о том, что пока данных нет, они еще не поступили. Но как узнать, какие сокеты готовы читать, а какие сокеты готовы записывать данные? Для этого как раз нам понадобится объект `epoll`. Итак, создаем объект `epoll` при помощи модуля `select`. Далее мы регистрируем в этом объекте `epoll` наши файловые дескрипторы от созданных коннектов клиентских, а также мы регистрируем некую маску. Говорим, на какие события мы подписываемся от этих файловых дескрипторов. В данном случае это чтение из сокетов и запись в сокет. Далее для организации цикла опроса событий нам необходимо запомнить наши объекты и смапить их по файловым дескрипторам. То есть формируем словарик, в него записываем файловые дескрипторы и объекты наших соединений. Давайте рассмотрим цикл обработки событий для нашего `epoll`, еще иногда его называют `event_loop`." ] }, { "cell_type": "code", "execution_count": null, "id": "18ada3b9", "metadata": {}, "outputs": [], "source": [ "# Неблокирующий ввод/вывод, обучающий пример\n", "# Цикл обработки событий в epoll\n", "\n", "while True:\n", " events = epoll.poll(1)\n", " \n", " for fileno, event in events:\n", " if event & select.EPOLLIN:\n", " # обработка чтения из сокета\n", " data=conn_map[fileno].recv(1024)\n", " print(data.decode(\"utf8\"))\n", " \n", " elif event & select.EPOLLOUT:\n", " # обработка записи в сокет\n", " conn_map[fileno].send(\"pong\".encode(\"utf8\"))" ] }, { "cell_type": "markdown", "id": "30878ebd", "metadata": {}, "source": [ "Итак, мы в бесконечном цикле постоянно опрашиваем наш объект `epoll`. Это системный вызов, который нам возвращает список событий, и эти события содержат файловый дескриптор и непосредственно то событие, которое произошло с этим файловым дескриптором. Другими словами, мы постоянно просим наш объект `epoll`, говорим ему: верни нам список сокетов, которые готовы читать либо которые готовы записывать данные. После того как нам `epoll` вернул этот список, мы должны проверить, что за событие пришло, из нашего словаря получить нужный нам объект для работы с ним и сделать нужные нам действия. В данном случае, например, мы читаем данные из этого сокета и просто выводим их в консоль. если пришло событие, которое говорит нам, что сокет готов принять данные от нас, мы должны записать данные в этот сокет. Давайте попробуем запустить этот пример и посмотрим, как он действительно работает в консоли.\n", "\n", "Итак, нам потребуется код нашего сервера, запускаем его при помощи команды python3, и для того чтобы работал, необходимо создать два клиентских подключения." ] }, { "cell_type": "code", "execution_count": null, "id": "82247ea9", "metadata": {}, "outputs": [], "source": [ "! python ex1.py" ] }, { "cell_type": "markdown", "id": "eafad08e", "metadata": {}, "source": [ "Давайте попробуем это сделать прямо в консоли, создаем объект класса `socket`, передаем адресную пару, порт. Итак, один клиент у нас уже готов для взаимодействия с нашим сервером. Давайте создадим еще один клиент. Точно так же мы создали второй клиент Теперь мы можем записать данные непосредственно в `socket`. Итак, записываем данные во втором клиенте, например, строчку `client2`, отправляем, смотрим — наш сервер обработал второе соединение. Затем можем сделать то же самое и в первом клиенте." ] }, { "cell_type": "code", "execution_count": 1, "id": "def7b30c", "metadata": {}, "outputs": [], "source": [ "import socket\n", "\n", "sock = socket.create_connection((\"127.0.0.1\", 10001))\n", "sock.sendall(b\"client1\")" ] }, { "cell_type": "code", "execution_count": 2, "id": "d6563052", "metadata": {}, "outputs": [], "source": [ "sock2 = socket.create_connection((\"127.0.0.1\", 10001))\n", "sock2.sendall(b\"client2\")" ] }, { "cell_type": "code", "execution_count": 3, "id": "c2f19409", "metadata": {}, "outputs": [], "source": [ "sock2.close()\n", "sock.close()" ] }, { "cell_type": "markdown", "id": "222607a8", "metadata": {}, "source": [ "Как мы видим, сервер успешно обработал оба соединения, причем он не использовал ни процессы, ни потоки.\n", "\n", "Основная идея заключается в том, что у нас есть `event_loop`, есть `epoll` который позволяет нам получить все нужные события, и при помощи `epoll` можно организовать параллельную обработку всех запросов.\n", "\n", "Итак, давайте обсудим плюсы и минусы того подхода, который мы сейчас обсудили и запустили в консоли. Итак, код выглядит уже не таким простым, как в случае с потоками или процессами. Появился цикл событий, появились вот эти условия для обработки типов событий, которые там произошли. Наш код носит обучающий характер, и он достаточно простой, то есть если он будет приближен к настоящему коду, который работает с сетевыми соединениями, он очень сильно усложнится. Например, в нашем коде нет обработки закрытия сокетов, а также отсутствует обработка новых входящих соединений. Заметьте, мы ведь точно так же должны перевести и сокет, на котором ждем входящее соединение, в неблокирующий режим и точно так же поместить его в наш цикл обработки событий для того, чтобы ожидать новых клиентов.\n", "\n", "Давайте предположим, что в обработке наших запросов будет не только вывод данных, которые мы получили в сети, в `log` или в терминал, а нам нужно будет, например, записать их в какую-то базу данных. Или, например, выполнить http-запрос. В таком случае нам нужно будет организовать ввод-вывод и всю работу с ним точно так же в неблокирующем режиме. То есть нам надо все сокеты из базы данных поместить в наш `event_loop`, или в наш цикл событий, и точно так же работать с ними в неблокирующем режиме. Тогда код станет уже не таким простым. Точно так же любые сторонние библиотеки, если мы будем использовать, например, `urllib` или библиотеку `requests`, мы тоже будем вынуждены как-то заставить их работать с нашим циклом событий и использовать `epoll` для ожидания готовности чтения и записи в сокеты, которые они используют.\n", "\n", "Но тем не менее, очевидным плюсом данного подхода является то, что мы, во-первых, не создаем ни процессы, ни потоки, мы не тратим память дополнительную, а также, что немаловажно, мы не тратим никаких усилий для организации взаимодействия между нашими потоками: у нас нет блокировок, код будет исполняться максимально быстро.\n", "\n", "Также у нас нет проблем с `GIL`. Как же можно поступить, чтобы упростить наш код? Можно спрятать все вызовы `select.epoll` в какие-то библиотеки. Так поступили и написали существующие в Python фреймворки. Давайте обсудим некоторые из них.\n", "\n", "Наиболее популярным был долгое время фреймворк `Twisted`, его работа очень похожа на наш пример, но код на `Twisted` выглядит достаточно сложным из-за наличия вот этих `callback`'ов и логики обработки запросов.\n", "\n", "Чуть позже появился фреймворк под названием `Gevent`. `Gevent` напоминал клон Python'а, который назывался `Stackless Python`, и в основу фреймворка `Gevent` положили работу гринлетов — это так называемые легковесные потоки. Они очень похожи на наши потоки, которые мы рассматривали на предыдущей лекции, но внутри они устроены более легковесно, и переключение между ними не требует почти никаких ресурсов операционной системы.\n", "\n", "Также один из известных фреймворков — это `Tornado`. Он был основан на `api` генераторов Python и устроен немного иначе, чем фреймворк `Gevent`.\n", "\n", "После Tornado в Python3 появился фреймворк `AsyncIO`, и он сейчас является мейнстримом. В его основе, в принципе, лежит то же самое, что и в `Tornado`, но тем не менее `AsyncIO` поставляется вместе с Python3 Core, он также основан на работе генераторов. Его поддерживают официальные сообщества, и поэтому все наши дальнейшие разговоры о программировании в один поток будут привязаны к фреймворку `AsyncIO`.\n", "\n", "Итак, мы обсудили, как работает неблокирующий ввод-вывод, иначе его еще называют мультиплексированием. Такой подход иначе называют мультиплексированием ввода-вывода.\n", "\n", "Мы рассмотрели пример простой программы, как использовать объект `select.epoll`, а также немного обсудили популярные фреймворки. И в дальнейших лекциях мы продолжим изучать работу `AsyncIO` для того, чтобы разобраться с работой кода в один поток." ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.6.8" } }, "nbformat": 4, "nbformat_minor": 5 }