TornadoWeb - это легковесный веб-сервер, написанный на Python и использующий неблокирующие сокеты и epool/kqueue для работы с сетью.Сам по себе сервер неплохой, довольно популярный. Привлекает своим крайне простым API (по сравнению с тем-же Twisted) и высокой производительностью.
Первый вопрос, который может возникнуть - "зачем раздавать большие файлы через Tornado?" Ведь даже официальная документация рекомендует использовать для этого Nginx. Что-ж, в большинстве случаев так и есть. Но ситуации могут быть разные. В моём случае через Tornado успешно раздавалось большое количество мелких страничек, хранящихся в SQLite и один большой файл на 200МБ со списком всех доступных URL. Поднимать ради одного этого файла Nginx совершенно не хотелось.
Второй вопрос - "ну так в чем проблема - раздавай на здоровье!". Вот тут мы и сталкиваемся с неприятной особенностью этого сервера - стандартный
StaticFileHandler
загружает весь файл
целиком в память перед тем как отдать его клиенту (
пруфлинк). Помимо этого, занятая память не освобождается, если клиент разорвал подключение не скачав весь файл целиком.
Вот эти 2 проблемы и будем решать.
Демонстрация проблемы
Для начала подготовим тестовый стенд:
Установим Tornado
mkdir tornado-bigfile
cd tornado-bigfile/
virtualenv .env
source .env/bin/activate
pip install tornado
Сгенерируем достаточно большой файл
head -c 100M /dev/urandom > static_file.bin
Напишем стандартный файл-сервер на Tornado
# file=server.py
# -*- coding: utf8 -*-
import tornado.ioloop
import tornado.web
application = tornado.web.Application([
(r"/(big_file.bin)", tornado.web.StaticFileHandler, {"path": "."}),
])
if __name__ == "__main__":
application.listen(8888)
tornado.ioloop.IOLoop.instance().start()
Запускаем
python server.py
Пробуем скачать файл
from urllib2 import urlopen
host = "http://localhost:8888"
print len(urlopen(host + "/big_file.bin").read())
Всё работает нормально. А теперь посмотрим список процессов
$ ps xa -o rss,command | grep server.py 110784 python server.py
Сервер занимает больше 100МБ! То есть весь файл даже после отдачи клиенту висит в памяти.
Но это еще мелочи. Попробуем загружать не весь файл, а только несколько первых байт
[urlopen(host + "/big_file.bin").read(1) for _ in xrange(2000)]
Мало того, что работает крайне медленно, так еще и сервер отожрал 3.5ГБ памяти на моей машине
$ ps xa -o rss,command | grep server.py 3587920 python server.py
Очевидно, это нам не подходит.
Решение
Решение очевидное - будем считывать файл по мере необходимости. Считали 0.5МБ - скормили клиенту, считали следующий кусок, скормили... И так пока не отдадим весь файл. При этом параллельно может обслуживаться множество клиентов.
В Tornado это обеспечивается декоратором
@tornado.web.asynchronous
в сочетании с
self.flush()
. Ну и добавить заголовков по вкусу. В идеале, для улучшения кеширования, нужно скопировать в начало нашей
get()
функции весь код из оригинального Tornado, начиная со строки
1544 и до
1591, но мы ограничимся только установкой заголовка
Content-Length
.
import os
class FileStreamerHandler(tornado.web.RequestHandler):
CHUNK_SIZE = 512000 # 0.5 MB
def initialize(self, file_path):
self.path = file_path
@tornado.web.asynchronous # не закрывать сокет когда отработает эта функция (закрывать self.finish())
def get(self):
self.set_header("Content-Length", os.path.getsize(self.path))
self._fd = open(self.path, "rb")
self.flush() # отправляем заголовки
self.write_more()
def write_more(self):
data = self._fd.read(self.CHUNK_SIZE)
if not data: # если весь файл отправлен...
self.finish() # сбрасываем буфер и закрываем сокет
self._fd.close() # закрываем файл
return
self.write(data)
# зацикливаемся - вызываем write_more только когда данные полностью уйдут клиенту
self.flush(callback=self.write_more)
application = tornado.web.Application([
#...
(r"/big_stream.bin", FileStreamerHandler,
{"file_path": "big_file.bin"}),
])
Пробуем
[urlopen(host + "/big_stream.bin").read() for _ in xrange(2000)]
[urlopen(host + "/big_stream.bin").read(1) for _ in xrange(2000)]
Замечаем, что вторая команда отработала моментально!
ps xa -o rss,command | grep server.py 29684 python server.py
Размер занимаемой памяти почти не изменился!
Задача решена. Однако можно еще немного улучшить код, чтобы обойтись без коллбеков. Ну в самом деле, у нас же Python, а не NodeJS какой-то!
Делаем красиво
Избавиться от коллбеков нам помогут со-процедуры в виде питоньего
yield
в комбинации с модулем
tornado.gen. Этот модуль позволяет упростить использование функций, которые выполняются асинхронно и по завершении вызывают коллбек, переданный аргументом "callback" (примеры есть в документации).
import tornado.gen
class GenFileStreamerHandler(tornado.web.RequestHandler):
CHUNK_SIZE = 512000 # 0.5 MB
def initialize(self, file_path):
self.path = file_path
@tornado.web.asynchronous
@tornado.gen.engine # отмечаем, что этот метод yield-ит Task-и
def get(self):
self.set_header("Content-Length", os.path.getsize(self.path))
self.flush()
fd = open(self.path, "rb")
data = fd.read(self.CHUNK_SIZE)
while data:
self.write(data)
yield tornado.gen.Task(self.flush) # нет коллбекам!
data = fd.read(self.CHUNK_SIZE)
fd.close()
self.finish()
application = tornado.web.Application([
#...
(r"/big_stream_gen.bin", GenFileStreamerHandler,
{"file_path": "big_file.bin"}),
])
Работает так же хорошо, выглядит, на мой взгляд, гораздо читаемее и аккуратнее.
Весь код одним файлом есть на github gist.
Пробовал использовать
with open():
вместо
fd = open()
+
fd.close()
, но возникали проблемы при не полном считывании ответа клиентом. Видимо
with
и
yield
плохо совместимы. Кто подскажет?
[…] переписать сервер из статьи Раздача больших файлов через сервер TornadoWEB с использованием shortgen, скомпилировать и […]
Может так поможет:
try:
with open() as fd:
….
finally:
fd.close()
Если клиент отвалился в процессе получения файла то по идее торнадо должен генерить exception какой -нить. Поэтому независимо от того что случится внутри секции with — отработает finally и закроет файл освободив дескриптор.
Может еще покопать в сторону __enter__ и __exit__ — но файловый объект их и так предоставляет. Надеюсь помог. =)
Спасибо. Сейчас уже проверять не буду, но думаю, что в комбинировании
try: finally
иwith
нет смысла. Либо одно, либо второе.Но да, по хорошему нужно проверять оба варианта.