Блог - Linux, программирование, Я!

PythonРаздача больших файлов через сервер TornadoWEB

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 плохо совместимы. Кто подскажет?

  1. 2012-10-08 00:54:40 | #

    […] переписать сервер из статьи Раздача больших файлов через сервер TornadoWEB с использованием shortgen, скомпилировать и […]

  2. hummermania
    2013-12-23 17:25:31 | #

    Может так поможет:
    try:
    with open() as fd:
    ….
    finally:
    fd.close()

    Если клиент отвалился в процессе получения файла то по идее торнадо должен генерить exception какой -нить. Поэтому независимо от того что случится внутри секции with — отработает finally и закроет файл освободив дескриптор.
    Может еще покопать в сторону __enter__ и __exit__ — но файловый объект их и так предоставляет. Надеюсь помог. =)

    • 2013-12-23 18:19:00 | #

      Спасибо. Сейчас уже проверять не буду, но думаю, что в комбинировании try: finally и with нет смысла. Либо одно, либо второе.
      Но да, по хорошему нужно проверять оба варианта.