Введение
В статье хотелось бы поднять вопросы отличия использования Python для web-зазработки по сравнению с оной на PHP. Надеюсь, статья не приведет к холиварам, так как она вовсе не о том, какой язык лучше или хуже, а исключительно о технических особенностях Python.
Немного о самих языках
PHP — веб-ориентированный язык, создан, чтобы умирать. С низкоуровневой точки зрения приложение на PHP представляет собой скорее набор отдельных скриптов возможно с единой семантической точкой входа.
Python — унивресальный язык программирования, применимый в том числе и в вебе. С технической точки зрения web-приложение на Python — полноценное приложение, загруженное в память, обладающее своим внутренним состоянием, сохраняемым от запроса к запросу.
Исходя из вышеописанных особенностей вытекают и различия в обработке ошибок в web-приложениях. В PHP существует целый зоопарк типов ошибок (errors, exceptions), далеко не каждую из которых можно перехватить, хотя это (невозможность перехвата) и не имеет большого значения, так как приложение живет ровно столько, сколько обрабатывается один запрос. Неперехваченная ошибка просто приводит к досрочному выходу из обработчика, и удалению приложения из памяти. Новый запрос будет обрабатываться новым «чистым» приложением. В Python же приложение постоянно находится в памяти, обрабатывая множество запросов без «перезагрузки». Таким образом поддерживать правильное предсказуемое состояние приложения крайне важно. Все ошибки используют стандартный механизм исключений и могут быть перехвачены (разве что за исключением SyntaxError). Неперехваченная ошибка приведет к завершению приложения, которое понадобится перезапускать извне.
Существует множество способов «приготовить» PHP и Python для веба. Далее я остановлюсь на двух наиболее мне знакомых (и кажется наиболее популярных) — PHP + FastCGI (php-fpm) и Python + WSGI (uWSGI). Конечно же, перед обоими этими связками предполагается наличие фронтенд-сервера (например, Nginx).
Поддержка многопоточности Python
Запуск сервера приложений (например, uWSGI) приводит к загрузке интерпретатора Python в память, а затем загрузке самого web-приложения. Обычно bootstrap-модуль приложения импортирует необходимые ему модули, производит вызовы инициализации и в итоге экспортирует подготовленный callable объект, соответствующий спецификации WSGI. Как известно, при первом импорте Python-модулей, код внутри них исполняется, в том числе создаются и инициализируются значениями переменные. Между двумя последовательными HTTP-запросами состояние интерпретатора не сбрасывается, следовательно сохраняются значения всех переменных уровня модуля.
Напишем простейшее WSGI-приложение, которое наглядно продемонстрирует вышеописанное на примере:
n = 0
def app(env, start_response):
global n
n += 1
response = "%.6d" % n
start_response('200 OK', [('Content-Type', 'text/plain')])
return [bytes(response, 'utf-8')]
Здесь n является переменной модуля и она будет создана со значением 0 при загрузке приложения в память следующей командой:
uwsgi --socket 127.0.0.1:8080 --protocol http --single-interpreter --processes 1 -w app:app
Само приложение просто выводит на страницу значение переменной n. Для заядлых PHP программистов оно выглядит бессмысленным, так как «должно» каждый раз выводить на страницу строку «000001».
Проведем тест:
ab -n 500 -c 50 http://127.0.0.1:8080/
curl http://127.0.0.1:8080
В результате мы получим строку «000501», что подтверждает наше утверждение, о том, что приложение находится загруженным в память uwsgi и сохраняет свое состояние между запросами.
Если запустить uWSGI с параметром --processes 2 и провести тот же тест, то несколько последовательных вызовов curl покажут, что мы имеем уже 2 различные возрастающие последовательности. Так как ab посылает 500 запросов, примерно половина из них приходится на один процесс uWSGI, а остальные — на второй. Ожидаемые значения, возращаемые curl будут примерно «000220» и «000280». Интерпретатор Python, судя по всему, один на процесс, и мы имеем 2 независимых окружения и реальную параллельную обработку запросов (в случае многоядерного процессора).
Python поддерживает потоки, как часть языка. Классическая реализация (CPython) использует нативные потоки OS, но есть GIL — в один момент времени выполняется только один поток. При этом все равно возможны проблемы race condition, так как даже n += 1 не является атомарной операцией.
import dis
n = 0
def app(env, start_response):
global n
n += 1
return [bytes(str(n), 'utf-8')]
if '__main__' == __name__:
print(dis.dis(app))
8 0 LOAD_GLOBAL 0 (n)
3 LOAD_CONST 1 (1)
6 INPLACE_ADD
7 STORE_GLOBAL 0 (n)
10 10 LOAD_GLOBAL 1 (bytes)
13 LOAD_GLOBAL 2 (str)
16 LOAD_GLOBAL 0 (n)
19 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
22 LOAD_CONST 2 ('utf-8')
25 CALL_FUNCTION 2 (2 positional, 0 keyword pair)
28 BUILD_LIST 1
31 RETURN_VALUE
Видно, что инкремент в нашей программе занимает 4 операции. Прерывание GIL может наступить на любой из них.
Увеличение количества потоков при отсуствии ожидания IO в коде обработчиков HTTP-запросов не приводит к ускорению обработки (а скорее даже ее замедляет, так как потоки могут «толкаться», переключая контексты). Реальной параллельности потоки в следствие ограничеия GIL не создают, хоть и являются не green thread'ами, а настоящими потоками OS.
Проведем еще один тест. Запустим uwsgi с 1 процессом, но 10 потоками-обработчиками в нем:
uwsgi --socket 127.0.0.1:8080 --protocol http --single-interpreter --processes 1 --threads 10 -w app:app
и выполним с помощью ab 5000 запросов к приложению.
ab -n 5000 -c 50 http://127.0.0.1:8080/
Последующие запросы curl 127.0.0.1:8080 покажут, что мы имеем только одну возрастающую последовательность, значение которой <= 5000 (меньше 5000 оно может быть в случае race condition на инкременте).
Влияние языка на архитектуру приложения
Каждый HTTP-запрос обрабатывается в отдельном потоке (справедливо и для процессов, так как процесс имеет минимум 1 поток). При этом каждый поток за время своей жизни (которое в идеальных условиях совпадает со временем жизни всего uwsgi приложения) обрабатывает множество HTTP-запросов, сохраняя свое состояние (т.е. значения переменных уровня модулей) от запроса к запросу. В этом заключается чуть ли не основное отличие от модели обработки HTTP-запросов в PHP, где каждый запрос приходит новое только что проинициализированное окружение и загрузку приложения необходимо выполнять каждый раз заново.
Типичным подходом в крупных web-приложениях на PHP является использование Dependency Injection Container для управления инициализацией и доступом к уровню сервисов приложения. Наглядным примером является Pimple. На каждый HTTP-запрос первым делом выполняется код инициализации, регистрирующий все доступные сервисы в контейнере. Далее по мере необходимости осуществляется доступ к объектом сервисов (lazy) в контроллерах. Каждый сервис может зависеть от других сервисов, зависимости разрешаются опять же через контейнер в коде инициализации сервиса-агрегата.
// Определяем сервисы
$container['session_storage'] = function ($c) {
return new SessionStorage('SESSION_ID');
};
$container['session'] = function ($c) {
return new Session($c['session_storage']);
};
// Используем сервисы
class MyController {
public function __construct() {
// get the session object
$this->session = $container['session']; // "тестриуемость" страдает, но не суть
}
}
Благодаря контейнеру, можно обеспечить единовременное создание объектов и возвращение уже готовых объектов на каждое последующее обращение к сервису (если необходимо). Но эта магия работает только в рамках одного HTTP-запроса, поэтому сервисы можно без проблем инициализировать специфичными для запроса значениями. Такие значения зачастую — это текущий авторизованный пользователь, сессия текущего пользователя, собственно HTTP-запрос и пр. В конце запроса сервисы все равно будут разрушены, а в начале обработки следующего запроса — созданы и проинициализированы новые. К тому же можно практически не беспокоиться об утечках памяти, если обработка одного HTTP-запроса умещается в отведенные для скрипта лимиты, так как создание сервисов происходит по тербованию (lazy) и на один запрос каждый необходимый сервис скорее всего будет создан только в единственном экземпляре.
Теперь, принимая во внимание вышеописанную поточную модель Python, можно заметить, что использование аналогичного подхода в Python web-приложение не возможно без дополнительных усилий. Если контейнер будет являться переменной уровня модуля (что выглядит вполне логично), все сервисы, которые он содержит, не могут быть проинициализированы специфичными для текущего запроса значениями, так как сервис будет являться разделямым ресурсам между несколькими потоками, выполяющими обработку нескольких HTTP-запросов псевдо-параллельно. На первый взгляд существует два способа справиться с этой проблемой — сделать объекты сервисов независимыми от текущего HTTP-запроса (зависимыми остануться вызовы методов сервисов, а стековые переменные, используемые в методах, не являются разделяемыми ресурсами) или же сделать контейнер — ресурсом потока, а не процесса (тогда каждый поток будет общаться только со своим независимым набором сервисов, а в один момент времени один поток может обрабатывать только один HTTP-запрос).
Кажущийся плюс первого подхода — сервисы инициализируются только один раз за все время жизни uwsgi процесса. Возможна также экономия памяти (так как имеем только один набор сервисов на все потоки). С другой стороны обработка конкретного HTTP-запроса требует лишь какого-то (скорее всего небольшого) поднможества всех доступных сервисов. Если же приложение достаточно большое и обладает внушительным количеством сервисов, то после обработки определенного числа HTTP-запросов, подавляющее большинство сервисов окажется проинициализировнными и находящимися в памяти процесса. Выглядит так, что это может оказаться серьезной проблемой.
Второй подход можно реализовать с использованием threading.local. Так, например, поступает flask. Чтобы проиллюстрировать подход, можно реализовать локальное для потока хранилище некоторых событий:
class EventsStore:
def __init__(self):
self._store = threading.local()
def add(self, event):
self.get_all().append(event)
def clear(self):
if self.has():
del self._store.events
def get_all(self):
if not self.has():
self._store.events = []
return self._store.events
def has(self):
return hasattr(self._store, 'events')
def pop_event(self):
return self._store.events.pop() if self.has() and self._store.events else None
Аналогичным образом реализуется scoped_session в SQLAlchemy, которая позволяет иметь уникальное соединение с базой для каждого потока.
В случае использования второго подхода проблемы с памятью можно избежать, разрушая контейнер в конце каждого запроса и создавая новый контейнер перед обработкой новго запроса. Это очень похоже на модель обработки запросов PHP. Минус — контейнер и сервисы инициализируются на каждый новый запрос (постоянно выполняется один и тот же код), т.е. не используется преимущество потоков Python.
Заключение
Языки разные — подходы разные. Python имеет свои особенности и мне пока не до конца понятно, как их использовать правильно. Python предоставляет возможность иметь «состояние» приложения и многопоточную обработку запросов, но за это приходится платить потенциально возможными race condition (в свете того, что даже n += 1 не является атомарной операцией) и возможно более сложной архитектурой приложения. Хотелось бы услышать, как бородатые Python-разработчики строят свои web-приложения.
Автор: Ostrovski