среда, 22 августа 2018 г.

Python: асинхронное веб программирование - наглядный и не обычный пример

О том что такое асинхронное программирование написано уже не мало, а вот о практических преимуществах как то не много. Ниже я покажу наглядный пример о том что это такое, как работает и почему это хорошо.

Для эталонного примера возьмем типовой рекурсивный алгоритм нахождения факториала:

def factorial(n):
    if n == 1:
        return 1
    else:
        return n * factorial(n - 1)


assert factorial(1) == 1
assert factorial(5) == 120
assert factorial(10) == 3628800


И напишем используя aiohttp асинхронное приложение для расчета факториала. При этом каждый запрос будет обрабатывать одну итерацию рекурсии. Как это? Будет всего одна функция-обработчик запроса, где входное значение будет передаваться через GET параметр, а результат будет возвращаться в виде JSON.


from logging import getLogger, StreamHandler, DEBUG
import json

import aiohttp
from aiohttp import web


async def handler_factoial(request):
    value = int(request.query['value'])
    if value == 1:
        return web.json_response({'value': 1})

    url = '{}://{}{}'.format(request.scheme, request.host, request.path)

    async with aiohttp.ClientSession() as session:
        resp = await session.get(url, params={'value': value - 1})
        data = json.loads(await resp.text())
        return web.json_response({'value': value * int(data['value'])})


if __name__ == '__main__':
    app = web.Application()
    app.add_routes([web.get('/factorial', handler_factoial)])

    log = getLogger('aiohttp.access')
    log.setLevel(DEBUG)
    log.addHandler(StreamHandler())
    log_format = '%a %t "%r" %s %b "%{User-Agent}i" %Tfsec'

    web.run_app(app, port=8000, access_log=log, access_log_format=log_format)

Попробуем рассчитать факториал числа 10, для этого сделай простой запрос

$ curl http://localhost:8000/factorial?value=10
{"value": 3628800}
Но самое интересное можно наблюдать в логе приложения (вывод слегка обрезан для наглядности)


"GET /factorial?value=1 HTTP/1.1" 200 169 "Python/3.6 aiohttp/3.3.2" 0.001309sec
"GET /factorial?value=2 HTTP/1.1" 200 169 "Python/3.6 aiohttp/3.3.2" 0.005224sec
"GET /factorial?value=3 HTTP/1.1" 200 169 "Python/3.6 aiohttp/3.3.2" 0.009071sec
"GET /factorial?value=4 HTTP/1.1" 200 170 "Python/3.6 aiohttp/3.3.2" 0.012926sec
"GET /factorial?value=5 HTTP/1.1" 200 171 "Python/3.6 aiohttp/3.3.2" 0.016710sec
"GET /factorial?value=6 HTTP/1.1" 200 171 "Python/3.6 aiohttp/3.3.2" 0.021433sec
"GET /factorial?value=7 HTTP/1.1" 200 172 "Python/3.6 aiohttp/3.3.2" 0.025436sec
"GET /factorial?value=8 HTTP/1.1" 200 173 "Python/3.6 aiohttp/3.3.2" 0.029291sec
"GET /factorial?value=9 HTTP/1.1" 200 174 "Python/3.6 aiohttp/3.3.2" 0.033610sec
"GET /factorial?value=10 HTTP/1.1" 200 175 "curl/7.58.0" 0.054997sec

Итак, что здесь примечательного? 

Порядок ответа сервера. От значения 1 до 10, это порядок обратный порядку запросов. Действительно, первым запросом пришедшим на сервер был запрос на /factorial?value=10, но полностью обработан он был последним. Именно так работает алгоритм, который был показан в само начале.

Время ответа сервера. Время ответа от запроса к запросу примерно одинаковое,а в общем изменяется линейно. Самым долгим был обработан запрос на значение 10, а быстрым на значение 1. Это великолепно показывает "вложенность" обработки запросов друг в друга.

Источик запроса. Самым первым принятым и последним обработанным был запрос сделанный руками с помощью curl, а все остальные самим сервером с помощью клиента aiohttp библиотеки.

Но главное здесь другое - один процесс сервера смог принять и обработать одновременно десяток запросов. Действительно, на момент обработки последнего запроса все остальные находились в ожидании ответа сервером.

Ну а что же в случае с синхронным сервером? 
Простой код ниже реализует тот же алгоритм.


import json
from http.server import SimpleHTTPRequestHandler, HTTPServer

import requests


class HttpProcessor(SimpleHTTPRequestHandler):
    def get_value(self):
        s = self.path.partition('?')[-1]
        k, v = s.split('=')
        if k == 'value':
            return int(v)
        else:
            raise RuntimeError()

    def do_GET(self):
        value = self.get_value()
        print('Get /?value={}'.format(value))

        if value == 1:
            return self.return_json_response({'value': 1})

        resp = requests.get('http://localhost:8000/?value={}'.format(value - 1))
        nvalue = resp.json()['value']
        return self.return_json_object({'value': value * nvalue})

    def return_json_response(self, data):
        self.send_response(200)
        self.send_header('content-type', 'application/json')
        self.end_headers()
        s_data = json.dumps(data)
        self.wfile.write(s_data.encode('u8'))


if __name__ == '__main__':
    serv = HTTPServer(("localhost", 8000), HttpProcessor)
    serv.serve_forever()

Но при попытке аналогичного запроса в логах будет только скромное


Get /?value=10
 
и долгое ожидание

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

4 комментария:

  1. > один процесс сервера смог принять и обработать одновременно десяток запросов
    Как же одновременно, если они выполнились все по порядку?

    > Действительно, первым запросом пришедшим на сервер был запрос на /factorial?value=10, но полностью обработан он был последним

    Так в итоге в этом конкретном примере время обработки синхроном режиме во сколько раз больше?

    ОтветитьУдалить
  2. > Как же одновременно, если они выполнились все по порядку?
    в этом примере они выполняются последовательно, так как один запрос порождает другой. Но в целом, это работает примерно так. Пришел тяжелый запрос и в процессе выполнения приложение ждет ответа от бд, то есть простаивает. В этотм момент если приходит второй запрос, то приожение способно переключиться и выполнить его. Причем вполе нормальная ситуация что ответ на товрой запрос был отдан раньше чем вернулось управление к первому запросу. В случае с синхронным приложением обработка первого запроса полностью блокирует выполнение потока и если пришел второй запрос, то он начнет выполняться только после завершения первого.

    > Так в итоге в этом конкретном примере время обработки синхроном режиме во сколько раз больше?
    Вы совсем не поняли видимо последний абзац. Конкретно в этом примере синхронный код не выполнится. Никогда. Он повиснет в бесконечность. Потмоу что произойдет блокирование. Сервер заблокирует сам себя. В случае если используется горизонтальное расширение, например приложение запускатеся через gunicorn с 4 воркерами, то это означает что можно будет посчитать факториал 4, но не 5 так как закончатся свободный воркреры и произойдет снова блокировка. Такой тип блокировок еще называли deadlock. Потмоу что они никогда не разрешатся.

    ОтветитьУдалить

  3. не могу понять, почему этот скрипт вываливает результаты только в конце исполнения, и почему после каждого GET запроса есть ожидание в течение 1ой секунды?

    python factorial.py
    ======== Running on http://0.0.0.0:8000 ========
    (Press CTRL+C to quit)
    127.0.0.1 [25/Dec/2019:15:19:21 +0000] "GET /factorial?value=1 HTTP/1.1" 200 169 "Python/3.7 aiohttp/3.6.2" 0.000000sec
    127.0.0.1 [25/Dec/2019:15:19:20 +0000] "GET /factorial?value=2 HTTP/1.1" 200 169 "Python/3.7 aiohttp/3.6.2" 1.016000sec
    127.0.0.1 [25/Dec/2019:15:19:19 +0000] "GET /factorial?value=3 HTTP/1.1" 200 169 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36" 2.063000sec

    ОтветитьУдалить
  4. и не очень понятно где именно происходит порождение нового запроса для всех value>1

    ОтветитьУдалить