Одна из главных проблем при написании крупных (относительно) программ на Python — минимизация потребления памяти. Однако управлять памятью здесь легко — если вас вообще это волнует. Память в Python выделяется прозрачно, управление объектами происходит с помощью системы счётчиков ссылок (reference count), и память высвобождается, когда счётчик падает до нуля. В теории всё прекрасно. А на практике вам нужно знать несколько вещей об управлении памятью в Python, чтобы ваши программы эффективно её использовали. Первая вещь, надо хорошо в ней разбираться: размеры основных объектов в Python. И вторая вещь: как устроено управление «под капотом» языка.


Начнём с размеров объектов. В Python есть много примитивных типов данных: целые числа (int), long (версия int с неограниченной точностью), числа с плавающей запятой (они же числа с двойной точностью, double), кортежи (tuple), строковые значения, списки, словари и классы.


Основные объекты


Каков размер int? Программист, пишущий на C или C++, вероятно, скажет, что размер машинно-зависимого (machine-specific) int — около 32 бит, возможно, 64; а следовательно, занимает не более 8 байтов. Но так ли это в Python?


Давайте напишем функцию, показывающую размер объектов (рекурсивно, если нужно):


import sys
def show_sizeof(x, level=0):
    print "\t" * level, x.__class__, sys.getsizeof(x), x
    if hasattr(x, '__iter__'):
        if hasattr(x, 'items'):
            for xx in x.items():
                show_sizeof(xx, level + 1)
        else:
            for xx in x:
                show_sizeof(xx, level + 1)

Теперь с помощью этой функции можно исследовать размеры основных типов данных:


show_sizeof(None)
show_sizeof(3)
show_sizeof(2**63)
show_sizeof(102947298469128649161972364837164)
show_sizeof(918659326943756134897561304875610348756384756193485761304875613948576297485698417)

Если у вас 32-битный Python 2.7x, то вы увидите:


8 None
12 3
22 9223372036854775808
28 102947298469128649161972364837164
48 918659326943756134897561304875610348756384756193485761304875613948576297485698417

А если 64-битный Python 2.7x, то увидите:


16 None
24 3
36 9223372036854775808
40 102947298469128649161972364837164
60 918659326943756134897561304875610348756384756193485761304875613948576297485698417

Давайте сосредоточимся на 64-битной версии (в основном потому, что в нашем случае она более востребована). None занимает 16 байтов. int — 24 байта, в три раза больше по сравнению с int64_t в языке С, хотя это в какой-то мере machine-friendly целое число. Минимальный размер значений типа long (с неограниченной точностью), используемых для представления чисел больше 263 – 1, это — 36 байтов. Затем они увеличиваются линейно, как логарифм представляемого числа.


Числа с плавающей запятой в Python зависят от реализации, но похожи на числа с двойной точностью в C. Однако они не занимают всего лишь 8 байтов:


show_sizeof(3.14159265358979323846264338327950288)

На 32-битной платформе выдаёт:


16 3.14159265359

И на 64-битной:


24 3.14159265359

Это опять втрое больше, чем предположил бы программист на C. А что насчёт строковых значений?


show_sizeof("")
show_sizeof("My hovercraft is full of eels")

На 32-битной платформе:


21
50 My hovercraft is full of eels

И на 64-битной:


37
66 My hovercraft is full of eels

Пустое строковое значение занимает 37 байтов в 64-битной среде! Затем потребление памяти увеличивается в соответствии с размером (полезного) значения.




Давайте разберёмся и с другими часто востребованными структурами: кортежами, списками и словарями. Списки (реализованные как списки массивов, а не как связные списки, со всеми вытекающими) — это массивы ссылок на Python-объекты, что позволяет им быть гетерогенными. Их размеры:


show_sizeof([])
show_sizeof([4, "toaster", 230.1])
На 32-битной платформе выдаёт:
32 []
44 [4, 'toaster', 230.1]
И на 64-битной:
72 []
96 [4, 'toaster', 230.1]

Пустой список занимает 72 байта. Размер пустого std::list() в 64-битном С — всего 16 байтов, в 4—5 раз меньше. Что насчёт кортежей? И словарей?


show_sizeof({})
show_sizeof({'a':213, 'b':2131})

На 32-битной платформе выдаёт:


136 {}
 136 {'a': 213, 'b': 2131}
        32 ('a', 213)
                22 a
                12 213
        32 ('b', 2131)
                22 b
                12 2131

И на 64-битной:


280 {}
 280 {'a': 213, 'b': 2131}
        72 ('a', 213)
                38 a
                24 213
        72 ('b', 2131)
                38 b
                24 2131

Последний пример особенно интересен, потому что он «не складывается». Пары ключ/значение занимают 72 байта (их компоненты занимают 38 + 24 = 62 байта, а ещё 10 тратится на саму пару), но весь словарь весит уже 280 байтов (а не минимально необходимые 144 = 72 ? 2 байта). Словарь считается эффективной структурой данных для поиска, и две вероятные реализации будут занимать памяти больше, чем необходимый минимум. Если это какое-то дерево, то приходится расплачиваться за внутренние ноды, содержащие ключ и два указателя на дочерние ноды. Если это хеш-таблица, то ради хорошей производительности нужно иметь место для свободных записей.


Эквивалентная (относительно) структура std::map из C++ при создании занимает 48 байтов (пока ещё пустая). А пустое строковое значение в C++ требует 8 байтов (затем размер линейно растёт вместе с размером строки). Целочисленное значение — 4 байта (32 бит).




И что нам всё это даёт? Тот факт, что пустое строковое значение занимает 8 или 37 байтов, мало что меняет. Действительно. Но лишь до тех пор, пока ваш проект не начнёт разрастаться. Тогда вам придётся очень аккуратно следить за количеством создаваемых объектов, чтобы ограничить объём потребляемой приложением памяти. Для настоящих приложений это проблема. Чтобы разработать действительно хорошую стратегию управления памятью, нам нужно следить не только за размером новых объектов, но и за количеством и порядком их создания. Для Python-программ это очень важно. Давайте теперь разберёмся со следующим ключевым моментом: с внутренней организацией выделения памяти в Python.


Внутреннее управление памятью


Чтобы ускорить выделение памяти (и её повторное применение), Python использует ряд списков для маленьких объектов. Каждый список содержит объекты одного размера: может быть один список для объектов от 1 до 8 байтов, другой — для объектов 9—16 байтов и т. д. Когда нужно создать маленький объект, мы вновь используем свободный блок в списке или выделяем новый.
Есть несколько нюансов, как Python распределяет эти списки по блокам, пулам и «аренам»: несколько блоков формируют пул, пулы собираются в арену и т. д. Но мы в это углубляться не будем (если хотите, то можете почитать мысли Эвана Джонса о том, как улучшить выделение памяти в Python). Нам важно знать, что эти списки неуменьшаемы.


В самом деле: если элемент (размером x) удалён из памяти (стёрта ссылка на него), то занимавшийся им объём не возвращается в пул глобальной памяти Python (в том числе и в систему), а помечается свободным и добавляется к списку свободных элементов размером x. Занимаемый мёртвым объектом объём может быть использован вновь, если понадобится другой объект подходящего размера. А если подходящего мёртвого объекта нет, то создаётся новый.


Если память с маленькими объектами никогда не освобождается, то мы приходим к неизбежному выводу, что эти списки с маленькими объектами могут только расти, они никогда не уменьшаются, а значит, в каждый момент времени в памяти вашего приложения преобладают размещённые в ней многочисленные маленькие объекты.




Следовательно, старайтесь размещать в памяти только то количество маленьких объектов, которое нужно для какой-то одной задачи, отдавая предпочтение циклам, в которых создаётся/обрабатывается небольшое количество элементов, а не паттернам, где списки сначала создаются с помощью синтаксиса генерирования, а потом обрабатываются.


Хотя второй вариант более соответствует духу Python, он менее удачен: в конце концов появится большое количество маленьких объектов, которые заполнят соответствующие списки, и даже если какой-то список станет мёртвым, то объекты в нём (теперь уже все находящиеся в списке свободных объектов) всё ещё будут занимать много памяти.




Увеличить списки свободных элементов — не особая проблема, потому что эта память всё ещё доступна для Python-программы. Но с точки зрения ОС размер вашей программы равен общему размеру выделенной для Python памяти. И только под Windows память возвращается в кучу ОС (и применяется для размещения и других объектов, помимо маленьких), а под Linux общий объём используемой вашим приложением памяти будет только расти.




Докажем это утверждение с помощью memory_profiler, модуля для Python (зависящего от пакета python-psutil) (страница на Github). Он добавляет декоратор @profile, позволяющий отслеживать какое-то конкретное применение памяти. Пользоваться им крайне просто. Рассмотрим следующую программу:


import copy
import memory_profiler

@profile
def function():
    x = list(range(1000000))  # allocate a big list
    y = copy.deepcopy(x)
    del x
    return y

if __name__ == "__main__":
    function()
invoking
python -m memory_profiler memory-profile-me.py

На 64-битном компьютере она выводит:


Filename: memory-profile-me.py

Line #    Mem usage    Increment   Line Contents
================================================
     4                             @profile
     5      9.11 MB      0.00 MB   def function():
     6     40.05 MB     30.94 MB       x = list(range(1000000)) # allocate a big list
     7     89.73 MB     49.68 MB       y = copy.deepcopy(x)
     8     82.10 MB     -7.63 MB       del x
     9     82.10 MB      0.00 MB       return y

Программа создаёт n = 1 000 000 целых чисел (n ? 24 байта = ~23 Мб) и дополнительный список ссылок (n ? 8 байтов = ~7,6 Мб), и в сумме получаем ~31 Мб. copy.deepcopy копирует оба списка, и копии занимают ~50 Мб (не знаю, откуда берутся лишние 50 – 31 = 19 Мб). Любопытно, что del x удаляет x, но потребление памяти снижается лишь на 7,63 Мб! Причина в том, что del удаляет только список ссылок, а реальные целочисленные значения остаются в куче и приводят к избыточному потреблению в ~23 Мб.


В этом примере в сумме занято ~73 Мб, что более чем вдвое превышает объём, необходимый для хранения списка, весящего ~31 Мб. Как видите, при потере бдительности порой возникают очень неприятные сюрпризы с точки зрения потребления памяти!


Вы можете получить иные результаты на других платформах и других версиях Python.


Pickle


Кстати, а что насчёт pickle?


Pickle — стандартный способ (де)сериализации Python-объектов в файл. Каково его потребление памяти? Он создаёт дополнительные копии данных или работает умнее? Рассмотрим короткий пример:


import memory_profiler
import pickle
import random

def random_string():
    return "".join([chr(64 + random.randint(0, 25)) for _ in xrange(20)])

@profile
def create_file():
    x = [(random.random(),
          random_string(),
          random.randint(0, 2 ** 64))
         for _ in xrange(1000000)]

    pickle.dump(x, open('machin.pkl', 'w'))

@profile
def load_file():
    y = pickle.load(open('machin.pkl', 'r'))
    return y

if __name__=="__main__":
    create_file()
    #load_file()

При первом вызове мы профилируем создание pickled-данных, а при втором вызове заново считываем их (можно закомментировать функцию, чтобы она не вызывалась). При использовании memory_profiler в ходе создания данных потребляется много памяти:


Filename: test-pickle.py

Line #    Mem usage    Increment   Line Contents
================================================
     8                             @profile
     9      9.18 MB      0.00 MB   def create_file():
    10      9.33 MB      0.15 MB       x=[ (random.random(),
    11                                      random_string(),
    12                                      random.randint(0,2**64))
    13    246.11 MB    236.77 MB           for _ in xrange(1000000) ]
    14
    15    481.64 MB    235.54 MB       pickle.dump(x,open('machin.pkl','w'))

А при считывании — немного меньше:


Filename: test-pickle.py

Line #    Mem usage    Increment   Line Contents
================================================
    18                             @profile
    19      9.18 MB      0.00 MB   def load_file():
    20    311.02 MB    301.83 MB       y=pickle.load(open('machin.pkl','r'))
    21    311.02 MB      0.00 MB       return y

Так что picklе очень плохо влияет на потребление памяти. Исходный список занимает около 230 Мб, а при сериализации потребляется ещё примерно столько же.


C другой стороны, десериализация выглядит более эффективной. Потребляется больше памяти, чем исходный список (300 Мб вместо 230), но это хотя бы не вдвое больше.


В целом лучше избегать (де)сериализации в приложениях, чувствительных к потреблению памяти. Какие есть альтернативы? Сериализация сохраняет всю структуру данных, так что позднее вы сможете полностью восстановить её из получившегося файла. Но это не всегда нужно. Если файл содержит список, как в предыдущем примере, то, возможно, целесообразно использовать простой, текстовый формат. Давайте посмотрим, что это даёт.


Простейшая (naive) реализация:


import memory_profiler
import random
import pickle

def random_string():
    return "".join([chr(64 + random.randint(0, 25)) for _ in xrange(20)])

@profile
def create_file():
    x = [(random.random(),
          random_string(),
          random.randint(0, 2 ** 64))
         for _ in xrange(1000000) ]

    f = open('machin.flat', 'w')
    for xx in x:
        print >>f, xx
    f.close()

@profile
def load_file():
    y = []
    f = open('machin.flat', 'r')
    for line in f:
        y.append(eval(line))
    f.close()
    return y

if __name__== "__main__":
    create_file()
    #load_file()

Создаём файл:


Filename: test-flat.py

Line #    Mem usage    Increment   Line Contents
================================================
     8                             @profile
     9      9.19 MB      0.00 MB   def create_file():
    10      9.34 MB      0.15 MB       x=[ (random.random(),
    11                                      random_string(),
    12                                      random.randint(0, 2**64))
    13    246.09 MB    236.75 MB           for _ in xrange(1000000) ]
    14
    15    246.09 MB      0.00 MB       f=open('machin.flat', 'w')
    16    308.27 MB     62.18 MB       for xx in x:
    17                                     print >>f, xx

Считываем файл:


Filename: test-flat.py

Line #    Mem usage    Increment   Line Contents
================================================
    20                             @profile
    21      9.19 MB      0.00 MB   def load_file():
    22      9.34 MB      0.15 MB       y=[]
    23      9.34 MB      0.00 MB       f=open('machin.flat', 'r')
    24    300.99 MB    291.66 MB       for line in f:
    25    300.99 MB      0.00 MB           y.append(eval(line))
    26    301.00 MB      0.00 MB       return y

При записи потребляется гораздо меньше памяти. Всё ещё создаётся много временных маленьких объектов (примерно 60 Мб), но это не сравнить с удвоенным потреблением. Чтение сравнимо по затратам (используется чуть меньше памяти).


Этот пример тривиален, но он обобщает стратегии, при которых вы сначала не загружаете данные целиком с последующей обработкой, а считываете несколько элементов, обрабатываете их и заново используете выделенную память. Загружая данные в массив Numpy, к примеру, можно сначала создать массив Numpy, затем построчно считывать файл, постепенно заполняя массив. Это позволит разместить в памяти только одну копию всех данных. А при использовании pickle данные будут размещены в памяти (как минимум) дважды: один раз pickle, второй раз при работе с Numpy.


Или, ещё лучше, применяйте массивы Numpy (или PyTables). Но это уже совсем другая история. В то же время в директории Theano/doc/tutorial вы можете почитать другое руководство по загрузке и сохранению.




Цели архитектуры Python никак не совпадают, допустим, с целями архитектуры C. Последний спроектирован так, чтобы дать вам хороший контроль над тем, что вы делаете, за счёт более сложного и явного программирования. А первый спроектирован так, чтобы вы могли писать код быстрее, но при этом язык прячет большинство подробностей реализации (если не все). Хотя это звучит красиво, но игнорирование неэффективных реализаций языка в production-среде порой приводит к неприятным последствиям, иногда неисправимым. Надеюсь, что знание этих особенностей Python при работе с памятью (архитектурных особенностей!) поможет вам писать код, который будет лучше соответствовать требованиям production, хорошо масштабироваться или, напротив, окажется горящим адом для памяти.

Комментарии (57)


  1. nikitadanilov
    23.08.2017 01:37
    +3

    Вывод show_sizeof() не соответствует коду: нет x.__class__.


  1. tyomitch
    23.08.2017 04:47
    +5

    Пожалуй, тут уместно будет упомянуть мою прошлогоднюю статью о том, почему парсинг python-скрипта требует стократного объёма памяти. По мотивам этой статьи я месяц назад выступил на EuroPython 2017, и один из комментариев к моему выступлению был «твоему скрипту не хватило 2ГБ памяти? ха! ну купи ты ещё 2ГБ, это же копейки!»
    Не привыкли питонисты память считать :-/


    1. werevolff
      23.08.2017 05:04
      +2

      Да. Python программирование живёт по принципу «Ресурсы сейчас дешевле, чем время специалиста». Если программист, получающий 2 000 USD в месяц потратит 8 часов на оптимизацию памяти, мы получим около 95 долларов за решение этой проблемы. Это гораздо дороже, чем покупка 2ГБ ОЗУ. Так что, в этом есть рациональное звено.


      1. Areso
        23.08.2017 07:04
        +1

        Не везде Питонисты получают такие деньги. Где-то получают меньше.
        Да и потом, одно дело 1 раз потратить 8 часов, другое дело — каждому пользователю данного скрипта докупать дополнительно ОЗУ, потому что на оптимизации сэкономили.


        1. werevolff
          23.08.2017 08:31
          +1

          Не знаю вариантов, при которых могло бы потребоваться докупать ОЗУ на пользовательских машинах ради исполнения python скрипта. У языка немного другая прикладная область и иные принципы использования. Python используется для быстрого решения сложных задач. Точнее, для быстрого кодинга. Он всегда занимает много памяти, жрёт ресурсы и медленно работает. Но реализация на нём сложного алгоритма обходится дешевле по времени, чем реализация на многих других языках. Сам принцип работы python — запустить его там, где требуется особый функционал, и не смотря на ресурсы. Поэтому, почти никто не пишет на нём клиентский софт.


          1. Self_Perfection
            24.08.2017 01:16
            +2

            Поэтому, почти никто не пишет на нём клиентский софт.

            Ничего себе! Ну-ка, попробую-ка я прикинуть, каким несерверным софтом на питоне я пользуюсь или активно пользовался раньше: Zim, Gramps, Gajim, Deluge, Puddletag, Calibre, MComix… И это только на ПК, без учёта смартфона, на котором у меня тоже было что-то написанное на питоне.

            И представьте себе, меньше месяца назад репортил баг об утечке памяти в менеджере заметок Zim. Да, мне существенно мешало, что он начинал отжирать гигабайты памяти вместо обычных 50-100 МБ.


            1. werevolff
              24.08.2017 06:43

              Gajim когда-то пользовался. Сейчас из такого чисто пайтоновского использую Ansible (кстати, весьма неплохо работает). Gajim в своё время был оооочень глючным в сравнении с Pidgin. А вообще, удивительно, что вы не вспомнили самый яркий пример коммерческого софта на пайтоне: GuitarPro. Вроде, 6-й версии. Но, в любом случае, реальные масштабные приложения можно по пальцам сосчитать. Поскольку те же Gajim, Deluge выглядят пока разработкой энтузиастов или мелких команд. И, думаю, до решения вопросов с памятью такими они останутся. Вроде бы, сейчас решена проблема с многопоточностью. Не сказать, что до конца, но некоторые паттерны позволяют рулить несколькими процессами и потоками. В любом случае, не решается проблема веса приложения. Пайтон же модульный.


              1. Self_Perfection
                24.08.2017 09:49

                Я перечислял только то, чем сам активно пользовался. GuitarPro я не пользовался, поэтому не вспомнил.

                Впрочем кое-что я действительно забыл указать: Anki.


            1. shutovds
              24.08.2017 15:35

              Поддерживаю! Zim, Calibre, Anki… — нормально используется!


        1. Goury
          23.08.2017 14:27
          +3

          Если питонисту в 2017 году не хватило ума найти работу с нормальной зарплатой, то и на оптимизацию потребления памяти и подавно не хватит


      1. remzalp
        23.08.2017 07:59

        Умножаем на количество установок, количество экземпляров на серверах — и внезапно может оказаться, что программисту доплатить дешевле.


        1. kozzztik
          23.08.2017 12:19
          +3

          там где высокое количество установок, там и время программиста стоит дороже. К ним прибавляются налоги, время других специалистов (qa, operations), вторичные расходы (офис и т.д.). Но все это обычно копейки по сравнению с недополученной прибылью. Оптимизируя код, программист не пишет фичи, которые приносят деньги. В тех проектах, где используется питон, обычно это главный мотиватор.
          Вообще говоря с фразы «Одна из главных проблем при написании крупных (относительно) программ на Python — минимизация потребления памяти» я неплохо посмеялся. Сколько себя помню, во всех проектах на питоне, которые я знаю — плевали на память от слова совсем. В крупных проектах, например, даже отключают сборщик мусора, на столько память не важна.
          Хотя статья, безусловно, интересна, спасибо автору. Побольше бы статей о том, что происходит под капотом.


          1. BHYCHIK
            23.08.2017 12:42
            +1

            Отключив сборщик мусора, они как раз выиграли по памяти в том числе.


            1. kozzztik
              23.08.2017 12:51

              да, но выиграли они его на уровне инициализации форков, т.е. самого интерпретатора, а на уровне самого приложения, то о чем говорит автор — пожертвовали. То, что потребляет само приложение своими интами, строками и словарями — это почти всегда копейки. Если приложение вдруг упирается в память — тут же меняют алгоритм на поточный, что бы не брать все данные в память сразу, и идут дальше. Не влезло сейчас — дальше будет только хуже. А что бы кто-то прямо оптимизацией по памяти занимался в питоне — это задача на уровне экзотики, но никак не «одна из главных проблем».


          1. werevolff
            23.08.2017 12:47

            Ну вообще, такая проблема есть при разработке приложений, которые выполняются постоянно или продолжительное время. Но таких решений очень и очень мало. Проблема оптимизации использования памяти в Python всегда решалась тем, что python скрипт быстро выполняется и завершает работу. И это актуально почти для всех областей применения. Плагин? Он выполнил расчёты и закрылся. Web-приложение? Оно вернуло результат и закрылось. Сложные операции в Web? Обработчик запустился в новом процессе, старый процесс вернул Response и умер, новый процесс отработал минуту и умер. На этом принципе основаны даже сложные многопоточные и многопроцессные фрэймворки: нужно запустить скрипт, который быстро отработает и сдохнет. Нет долгоиграющего скрипта — нет проблем с памятью.


            1. kozzztik
              23.08.2017 12:57
              +1

              У меня питон приложение работает на сотнях серверов без всяких сдыханий (asyncio). Прод, хайлоад, перезапускаем раз в несколько недель для наката новой версии. Никаких проблем с памятью, хотя у меня один запрос занимает до 50Мб.
              Перезапуском решают обычно не проблему зажористости по памяти, а проблему утечек. Очень часто под капотом питон приложений работают Си библиотеки, которые имеют неприятную привычку подтекать. В uwgi это решают ребутом приложения после определенного количества запросов, и обычно оно измеряется тысячами. Ребутать его после каждого запроса — это невероятно накладно.


              1. werevolff
                23.08.2017 13:03
                -1

                Так я не говорю про перезапуск uWSGI. Но сам uWSGI разве держит постоянно работающие процессы бэкенда? Бэк живёт от Request до Response. Собственно, и сам принцип работы uWSGI основан на том, что некий демон получает Request, запускает на его основе процесс или поток, ждёт N секунд. Если ответ приходит, возвращает его и убивает процесс (поток), который вернул этот ответ. Если ответ не приходит, убивает процесс (поток) и возвращает Timeout Error.


                1. TimTowdy
                  23.08.2017 13:58
                  +1

                  Да, uwsgi держит бэкенд процессы. А то, что вы описали — это что-то из 2000х. Веб-приложения уже давно так не работают. (и не только в python)


                  1. werevolff
                    23.08.2017 14:15
                    -3

                    Ага, а на Рождество Санта-Клаус приносит детям подарки. То что вы сейчас написали — бред. Ни один здравомыслящий разработчик не будет держать python бэкенд постоянно запущенным. Если только это не websocket. Видимо, что вы, что kozzztik называете highload web-приложениями какие-то простенькие форумы. У kozzztik вообще один запрос занимает 50 мегабайт, хотя все нормальные прогеры стараются кешировать любой чих. Желательно, в Redis или RabbitMQ. Python вообще должен работать на твоём приложении только тогда, когда надо обработать данные. Всё остальное должны делать Nginx + Redis. И не надо говорить фигни об удержании процесса в uWSGI. Сам принцип работы web-сервера основан на том, что либо бэкенд возвратит Response, либо он будет убит.


                    1. kozzztik
                      23.08.2017 14:25

                      Веб, это, внезапно, не только HTTP. В моем случае это фильтрация почты и SMTP протокол, а 50Мб это собственно письмо. Удачи кешировать запросы.


                    1. kozzztik
                      23.08.2017 15:30
                      +1

                      кстати говоря о HTTP и кешах. Тот же Django кеширует разобраные шаблоны страниц в памяти приложения. Частый рестарт бекенда очень сильно убивает производительность, которая достигается этим кешем. Давным давно я даже писал статью с кучей графиков на эту тему.


                      1. werevolff
                        23.08.2017 15:45

                        Джанга не висит демоном при работе WSGI. Пруф!


                      1. werevolff
                        23.08.2017 15:49

                        Впрочем, мы можем говорить об одном и том же, только по-разному. Получается, uWSGI получает некий объект. На этом этапе уже загружены настройки. Т.е. что-то хранится в памяти. Но в памяти uWSGI демона. Лишь при срабатывании __call__() (кстати, это требование к реализации интерфейса WSGI) запускается основной процесс. Т.е. условно Джанга существует в памяти в виде переменных, но выполнение скриптов запускается только во время запроса. Как я понимаю, uWSGI отвечает за то, чтобы call был осуществлён в отдельном потоке или процессе, который дохнет после возврата response. О том и речь веду: да, сам демон uWSGI висит. Но приложение — скрипт запускается в отдельном потоке и благополучно умирает после возврата Response.


                        1. kozzztik
                          23.08.2017 16:10
                          +1

                          Может быть вы все таки до конца разберетесь в вопросе, прежде чем будете весьма категорично высказывать мнение? Очень сложно понять что вы имеете ввиду под «условно джанга существует в памяти в виде переменных». Как насчет импортированных модулей, инициализировнных django приложений, middleware? Django вообще довольно занятно заводится и производит при этом целую кучу разных действий. Но что могу сказать точно, это то что uwsgi не прибивает дочерний процесс после исполнения запроса, если это явно не указать в конфигурации uwsgi.


                          1. werevolff
                            23.08.2017 16:19

                            А может, вы сперва разберётесь с тем как работает импорт в пайтоне? Все импортированные модули будут храниться в памяти в виде статичных ссылок, пока к ним не произойдёт обращение. Поэтому, сам вызов обработчика обёрнут в функцию. Т.е. когда uWSGI получает объект WSGIHandler, в его памяти сторятся те переменные, которые до перезапуска демона не будут высвобождены. Там будут только статичные данные. Все динамические данные приходят только во время Request. А все данные, которые получаются в процессе обработки Request, будут созданы в дочернем процессе, который запускает uWSGI. Это и обращения к БД, и рендер шаблонов, и что там ещё будет наговнокожено джангистом. В основном потоке этих данных не будет. Они обрабатываются отдельным процессом и умирают вместе с ним, когда он возвращает Response.

                            Аналогично, в памяти uWSGI не хранится код импортированных модулей. Ещё стоит вспомнить реализацию apps в джанге. Обратите внимание на то, что INSTALLED_APPS — это массив строк.


                            1. kozzztik
                              23.08.2017 16:33
                              +1

                              Серьезно? «разберитесь как работает импорт»? Что простите означает «модули хранятся в памяти в виде статичных ссылок пока к ним не произойдет обращение» это что? Это вы так разобрались в импорте? Это у вас модуль импортирован или нет? Разберитесь сначала сами, может начнете писать что-то внятное.

                              Остальное по большей части все правильно за исключением самой концовки. Дочерний процесс не умрет на возвращении Response.
                              Ну и про uWSGI и INSTALLED_APPS это вообще отлично. Это вещи между собой вообще не связанные. uWSGI может запускать не django, а flask. Да, кстати, покурите еще такую тему, как постоянные подключения к БД в обоих фреймворках. Пруф docs.djangoproject.com/en/1.11/ref/databases
                              Интересно как вы это объясните в рамках своей гениальной теории одноразовых процессов.


                              1. werevolff
                                23.08.2017 16:38

                                Да, с импортом не так сказал: код джанги в виде текста сохраняется в памяти (code object). Только эти данные не изменяются и не выгружаются из памяти.


                                1. kozzztik
                                  23.08.2017 16:43
                                  +1

                                  Одна чушь вместо другой. Теперь код джанги сохраняется в виде текста. Что это? Как насчет результата синтаксического анализа? То что модуль это объект и у него есть свои локальные переменные?


                              1. werevolff
                                23.08.2017 16:41

                                uWSGI демон в своём основном потоке не держит подключения к БД. Сперва ему приходит Request. Потом создаётся отдельный поток. В поток передаются данные. Запускается код джанги из code object. Инициируются приложения. В процессе инициации может произойти создание подключения. А может и не произойти. Но это будет в отдельном потоке или процессе.


                                1. kozzztik
                                  23.08.2017 16:44

                                  Так где же будет жить соединение между запросами по вашему? Даю подсказку — в дочернем потоке.


                            1. BHYCHIK
                              23.08.2017 17:01

                              Я особо в архитектуру питоняки не вникал. Но не дорого ли на каждый запрос форкэкзекать процесс? По описанию libphp напоминает. В чем отличие?


                              1. werevolff
                                23.08.2017 17:07

                                Ну мне уже самому стало интересно: вдруг, я совсем не прав. Читаю документацию по uWSGI. Вот. Получается, воркеры блокируются на время Request. Воркеры как-то очищаются. Это уже противоречит некоторым тезисам моего оппонента. Но я не исключаю, что он прав. Только он не хочет пруфы давать.


                                1. TimTowdy
                                  23.08.2017 17:21
                                  +1

                                  Это уже противоречит некоторым тезисам моего оппонента.
                                  он не хочет пруфы давать.

                                  Что ж в оппонента критикуете, а бревно у себя в глазу не замечаете?

                                  Документация противоречит всем вашим тезисам. Вы сначала пишете «учите матчасть», а потом оказывается что вы сами-то документацию не читали и матчасть толком не знаете? Стыдно должно быть.


                                  1. werevolff
                                    23.08.2017 17:27

                                    Должно быть? У нас тут не институт благородных девиц.

                                    Впрочем, kozzztik действительно прав. И перед вами хочу извиниться за рождественский сарказм.


                                    1. BHYCHIK
                                      23.08.2017 17:32

                                      Ну тогда я спокоен. А то мой уютный мир начал рушиться.


                                    1. TimTowdy
                                      23.08.2017 18:13


                                      1. werevolff
                                        23.08.2017 18:14
                                        -1

                                        Ага, и вы ещё говорили что-то за хамство :-)


                      1. werevolff
                        23.08.2017 16:09

                        По поводу кеша: сейчас можно не использовать шаблонизатор Django вообще. Лично я предпочитаю отдельный фронтэнд на React или Angular. Всю статику возвращает Nginx, а соединение с сервером происходит посредством REST. Не всегда это хорошо. Но мы можем использовать отдельный фрэймворк для кеширования. Например, посмотри на такой вариант. Архитектура здесь проста до боли: администратор меняет контент в админке. После этого срабатывает сигнал, который компилирует страницу и помещает её в Redis. Это можно делать с частью страниц. Все динамические элементы будет отрабатывать JS. Например, авторизацию. А всё остальное у тебя лежит в кеше и джанга вообще не запускается.

                        А что касается SMTP, то вообще не понятно причём тут пайтон? Сервер SMTP на пайтоне? в любом случае, если ты откроешь исходник демона, то обнаружишь, что висит там только listener, а сам процесс обработки запроса стартует в отдельном потоке, либо вообще процессе. Т.е. фактически память вычищается после того, как скрипт отработает.


                        1. kozzztik
                          23.08.2017 16:19

                          Во первых, для вас — на вы. Во вторых разговор не о том что можно сделать. Можно ракету в космос запустить, какое это имеет отношение в делу?
                          И да, SMTP сервер на питоне. И в том же приложении еще и HTTP сервер, и много чего еще. Вот, например доклад на PyCon Portland 2017 на тему SMTP сервера на питоне www.youtube.com/watch?v=1Uyo2c2GYKQ. Там упоминаюсь и я, и между строк мой проект.
                          Исходиники «демона» я читал подробно и очень внимательно. И трейсил, и дебажил. Обработка запроса действительно висит в отдельном процессе или потоке, но где вы наши «память фактически очищается», я не знаю.


                          1. werevolff
                            23.08.2017 16:20

                            Учите матчасть.


                            1. kozzztik
                              23.08.2017 16:24
                              +1

                              Я то как раз мат часть прекрасно знаю. И как работает связка uwsgi джанго тоже. То что вы говорите весьма очевидная чушь, которую вы непонятно откуда взяли. Я даже привел кучу графиков которые это подтверждают. А толку?


                              1. werevolff
                                23.08.2017 16:28

                                Чушь — это доказывать графиками по mod_wsgi тот факт, что вы знаете связку Django + uWSGI. Всё. Надоело. Я на выход. Когда проснутся гуру, я надеюсь, они вам объяснят что вы знаете, а что нет. Без обид.


                          1. werevolff
                            23.08.2017 16:27

                            Обработка запроса действительно висит в отдельном процессе или потоке


                            Вот отсюда идёт вывод о фактическом очищении памяти. Когда процесс умирает, связанные с ним данные должны удалиться. Да, есть риск, что этого не произойдёт. Но в контексте самого треда получается, что это, как-раз, происходит. Поскольку претензия идёт к управлению освобождаемой памятью внутри процесса. А когда процесс умирает, он перестаёт блокировать области памяти.

                            Что касается доклада, то обратите внимание на то, что вы и ваш проект упоминаетесь между строк. Да, возможно, у вас хороший проект, но это не делает ваше мнение единственно верным. И, как я помню, после смерти процесса ссылки на него в памяти убивает ось. Т.е. да, память должна вычищаться. На том и основаны применяемые методы. Методы, при которых основная работа Python скрипта производится в отдельном процессе, а в родительском лежат, преимущественно, неизменяемые данные.


                            1. kozzztik
                              23.08.2017 16:40
                              +1

                              Все ваши размышления базируются на тезисе, что после исполнения запроса дочерний процесс умирает. Я могу повторить еще раз — это не правда. Процесс потом принимает следующий запрос, а за ним еще и еще. Он может вообще не умирать — это поведение uwsgi по умолчанию.


                              1. werevolff
                                23.08.2017 16:43

                                В любом случае, этот дочерний процесс должен как-то очищаться и приводиться к дефолтному состоянию. Не так ли?


                                1. kozzztik
                                  23.08.2017 16:44
                                  +1

                                  Зачем?


                                  1. werevolff
                                    23.08.2017 17:56
                                    +1

                                    Прошу прощения. Да, спорил как дурак. Воркер, действительно, содержит код программы для запуска и завершается либо с перезагрузкой, либо если ставится таймаут / принудительное завершение. Вы были правы, а я тупил.


                                1. TigraSan
                                  23.08.2017 17:12
                                  +1

                                  Ничего не очищается.
                                  wsgi(который, в частности реализует uwsgi, так же как и gunicorn и встроенный debug режим фласка например) просто передает построенный обьект запроса (Request) в хендлер приложения

                                  Хендлер должен вернуть Response

                                  Само приложение бежит, запущенное в процессе сервера реализующего wsgi

                                  Сервер может запустить n-ое колва приложений, убивать каждое после n-ого кол-ва запросов (как костыль против утечек памяти)

                                  Для того чтоб лично убедиться — достаточно выставить 1 процесс в uwsgi/gunicorn, убедиться что нет перезапуска после одного запроса(это никогда не дефолт) и записать какие либо данные в какой либо глобальный объект

                                  При другом запросе — считать.
                                  Данные будут там

                                  Более того, подсказка, именно так держатся подключения к БД и именно так в принципе могут работать всякие функции вроде memoized, исключительно в памяти, без редисов и прочего


                                  1. TigraSan
                                    23.08.2017 17:31

                                    Более того, ему(python-у) бедному и так сложно. Импорты на большом проекте могут с легкостью занять более секунды. Импорты без побочных эффектов, просто импорты…

                                    Я уже не говорю про инициализацию подключений и другие netI/O задержки, которые вполне могут быть при старте.

                                    И если бы каждый запрос делал все это — лежали бы сервера со 100% оверхедом по cpu, очень тщательно занимаясь построением тяжеловесных объектов(о чем кстати эта статья), вместо того, чтоб обрабатывать предельно простые запросы юзверей, о том по какому же url-у котики лежат, занимая при этом фиксированное значение в легкодоступной памяти, без каких либо значимых овераллокаций.


                                    1. TimTowdy
                                      23.08.2017 18:00

                                      Точно уверены что без побочных эффектов? Каждый раз когда я подобное наблюдал, всегда в итоге находил побочные эффекты.
                                      Вот например (внезапно, оказывается автор с хабра): github.com/pyca/pyopenssl/issues/137


                                      1. werevolff
                                        23.08.2017 18:20

                                        With latest released pyopenssl, cryptography and cffi «import OpenSSL» takes about 0.5s on a modern i7 CPU with SSD (OS X):


                                        Для 2014 года пацан грамотно понтанулся


                                      1. TigraSan
                                        23.08.2017 18:36

                                        Тут конечно следует определить что такое побочные эффекты.

                                        Построение различных объектов для быстрого доступа в работе — как по мне ничем не отличается от определения классов и функций. И там и там при импорте уже бежит много чего. И это неизбежное зло, как следствие хороших сторон языка… В том числе и monkey_patching при импорте. Абсолютно все сделать lazy нельзя… либо ценой неудобного кода

                                        В этом контексте под побочными эффектами подразумеваю I/O, то есть — сеть и ненужные обращения к диску

                                        В таком понимании — все чисто, импорты ~200ms на большом проекте. Но это только импорт
                                        Запуск приложения уже подразумевает намного больше, а там уже вполне допустимо несколько уровней кеша(память-диск) с оригинальными данными из сети, различные библиотеки подгрузят свои конфигурационные файлы для автогенирации API…
                                        И тут секунды для запуска уже реальны. Что явно расточительно, для обработки одиночного запроса :)


                    1. TimTowdy
                      23.08.2017 16:45
                      +1

                      То что вы сейчас написали — бред. Ни один здравомыслящий разработчик не будет держать python бэкенд постоянно запущенным.

                      Лол, а в чем проблема держать его запущенным, если он никакой работы в фоне не выполняет? Чему там течь?
                      Вы правда где-то в 2000-х застряли, видимо свое хамство оттуда же принесли.


      1. AxisPod
        23.08.2017 17:00

        А если предположить, что по 2Гб надо установить на сотню серверов?


  1. werevolff
    23.08.2017 04:54
    +2

    Я прочитал теги


  1. shutovds
    24.08.2017 15:41

    Интересная тема! Не каждый день приходится сталкиваться с управлением памятью в Python!


  1. AndreyRubankov
    27.08.2017 16:54

    И при этом я не раз слышал от Python-разработчиков, что Java плохая, потому, что требует много памяти. Хм, Забавно.