Мистецтво управління пам’яттю в Python: розуміння, використання та оптимізація

«Твоя пам’ять — монстр; ти забуваєш — вона не забуває. Вона просто відкладає речі на безвік. Вона зберігає інформацію для тебе або ховає її від тебе — і викликає її в голові за власним бажанням. Ти думаєш, що володієш пам’яттю, але насправді вона володіє тобою!»
Джон Ірвінг

Привіт усім шукачам істини! Якщо ви прагнете опанувати мистецтво ефективного використання об’єктів у Python, обирати найкращі підходи для конкретних завдань і загалом глибше розуміти цю мову програмування, вам неодмінно потрібно розібратися з тим, як працює пам’ять у Python та яку роль у цьому процесі відіграє garbage collector. Саме про це сьогодні й поговоримо.

Пам’ять — це один з найважливіших складників комп’ютера, що дозволяє зберігати й отримувати інформацію. Коли ми говоримо про пам’ять, зазвичай одразу спадає на думку RAM та жорсткі диски.

  • Оперативна пам’ять (RAM) — це тимчасове сховище даних, до якого процесор має швидкий доступ під час виконання програм. Вона зберігає дані лише тоді, коли комп’ютер увімкнений. Це дає програмам можливість швидко обробляти інформацію, з якою вони працюють у конкретний момент.
  • Постійна пам’ять (жорсткі диски або SSD), навпаки, зберігає дані навіть після вимкнення комп’ютера. Це довготривале сховище, де зберігаються файли, програми, операційна система та інші важливі дані.

Коли ми програмуємо на Python, то часто не замислюємося, як саме працює пам’ять. Однак розуміння цього процесу може значно покращити ефективність програм та оптимізувати код. Розглянемо детальніше, як Python працює з пам’яттю, які є особливості й на що варто звертати увагу.

Структура пам’яті в Python

У цьому розділі буде багато технічної нудьги, але раджу не пропускати його: розуміння цих основ є чудовим підґрунтям, щоб глибше занурюватись у тему пам’яті в Python.

Перше, що слід сказати: пам’ять у Python — це купа. Що це означає?

Купа — це область пам’яті, яка керується спеціальним механізмом виділення пам’яті. На відміну від стека, де пам’ять виділяється і звільняється в певному порядку (LIFO — останній прийшов, перший пішов), у купі пам’ять виділяється та звільняється динамічно, за потреби. Організація стека дуже ефективна, але підходить лише для даних, які мають короткочасне життя, як-от локальні змінні функцій. Об’єкти в купі можуть створюватися в будь-який момент, і Python зберігає їх у купі, поки вони потрібні. Пам’ять під об’єкти в купі може бути звільнена в будь-який час, коли ці об’єкти більше не використовуються. Ця гнучкість дозволяє зберігати об’єкти з довшим життям і різними розмірами, що не завжди зручно або можливо зробити в стеку.

Пам’ять у Python можна розділити на кілька категорій, кожна з яких відповідає за зберігання різних типів даних і структур.

Об’єктно-специфічна пам’ять — це частина пам’яті, яка виділяється для об’єктів, що використовують спеціалізовані механізми управління пам’яттю, відмінні від стандартних. Це дозволяє оптимізувати зберігання та доступ до даних для певних типів об’єктів.

Вона містить спеціальні алокатори, тобто деякі об’єкти, такі як списки, словники або масиви, можуть використовувати власні алокатори пам’яті, оптимізовані для їхніх потреб. Про те, що таке алокатор, ми поговоримо пізніше.

Об’єкти можуть мати власні внутрішні структури даних, які потребують спеціального управління пам’яттю, наприклад дерева, графи тощо. Для деяких об’єктів пам’ять може виділятися з попередньо зарезервованих пулів для швидкого виділення та звільнення.

Необ’єктна пам’ять ядра Python — це частина пам’яті, яка виділяється для зберігання даних та структур, що не є об’єктами Python. Вона використовується самим інтерпретатором для забезпечення своєї роботи та управління виконанням програм.

Що охоплює? Дані, необхідні для роботи самого інтерпретатора Python, такі як стеки викликів, таблиці символів, інформація про потоки виконання тощо. Інформація, яка використовується для відстеження стану інтерпретатора, наприклад конфігураційні параметри, налаштування середовища виконання.

Пам’ять об’єктів — це частина пам’яті, яка виділяється для зберігання самих об’єктів Python. Кожен об’єкт у Python (числа, рядки, кастомні класи тощо) займає певний обсяг пам’яті, який необхідний для зберігання його даних та метаданих.

Ця частина пам’яті містить у собі реальні дані, які зберігає об’єкт (наприклад, значення числа, текст рядка тощо). Може охоплювати інформацію, необхідну для управління об’єктом, як-от тип об’єкта, кількість посилань на нього (для відстеження життєвого циклу, про це також пізніше детальніше), стан об’єкта тощо.

Внутрішні буфери — це області пам’яті, які виділяються інтерпретатором Python для тимчасового зберігання даних під час виконання різних операцій. Вони потрібні для оптимізації роботи та забезпечення ефективності різних процесів.

  • Буфери вводу/виводу зберігають дані, що читаються з файлів або мережі, а також дані, що готуються для запису. Це дозволяє оптимізувати операції вводу/виводу, зменшуючи кількість системних викликів.
  • Буфери інтерпретації коду зберігають проміжні результати під час компіляції та виконання байт-коду Python.
  • Кеші слугують для зберігання даних, що часто використовуються, або результатів обчислень, щоб уникнути повторних витратних операцій.

Розподільники пам’яті

Коли програма запитує пам’ять, CPython-інтерпретатор використовує malloc метод для запиту цієї пам’яті в операційної системи, і розмір приватної купи збільшується.

Щоб уникнути виклику malloc і free для кожного створення і видалення невеликого об’єкта, CPython визначає кілька алокаторів і деалокаторів для різних цілей.

Простими словами алокатор пам’яті — це «менеджер», що відповідає за виділення і звільнення пам’яті для програм, які її потребують. Щоб стало ще зрозуміліше, уявіть, що у вас є шафа з поличками, де кожна поличка — це частина пам’яті. Алокатор — це людина, яка ставить на полички речі (дані) і прибирає їх, коли вони більше не потрібні, щоб звільнити місце для нових.

Отже, щоб уникнути частого виклику методів malloc і free, CPython визначає ієрархію алокаторів.

Універсальний алокатор (метод CPython malloc) — це основний механізм виділення пам’яті, який використовує стандартну функцію malloc із C для виділення пам’яті. Він застосовується для виділення великих блоків пам’яті та для системних викликів. Усі інші розподільники можуть використовувати цей універсальний алокатор як основу для своїх операцій.

Алокатор необробленої пам’яті (для об’єктів розміром більш як 512 байтів): якщо об’єкт має розмір понад 512 байтів, то пам’ять для нього виділяється окремо, без додаткової оптимізації, просто з купи. Це забезпечує швидке виділення великих блоків пам’яті, але без спеціальної оптимізації для менших об’єктів, що може призвести до фрагментації пам’яті.

Фрагментація пам’яті — це проблема, коли вільне місце в пам’яті розбивається на дрібні шматочки, і стає важко знайти достатньо великого безперервного простору для нових даних. Розглянемо простий приклад: якби ми мали багато маленьких вільних місць у валізі, але не могли б покласти туди великий предмет, бо всі ці місця розкидані й не з’єднані в єдиний простір.

Алокатор об’єктів (для об’єктів розміром до 512 байтів включно) використовує певну структуру (арени, пули, блоки) для оптимізації зберігання та мінімізації фрагментації пам’яті. Він забезпечує швидкий доступ до невеликих блоків пам’яті, що Python часто використовує для роботи зі змінними та іншими об’єктами.

Специфічні для об’єктів алокатори: деякі типи об’єктів, такі як списки, словники або кортежі, мають спеціалізовані алокатори, які налаштовані на ефективне зберігання та управління пам’яттю для цих конкретних типів даних. Вони можуть використовувати особливі алгоритми або структури даних для оптимізації, зважаючи на специфіку використання відповідних об’єктів.

Тож ми розібрали кожен з прошарків цієї ієрархії. Але залишилося питання, як це все в результаті працює?

Коли об’єкт потребує пам’яті і для нього визначені специфічні алокатори, виділення пам’яті відбувається саме через ці спеціальні алокатори.

Якщо для об’єкта немає визначених алокаторів і потрібен обсяг пам’яті понад 512 байтів, менеджер пам’яті Python безпосередньо звертається до алокатора необробленої пам’яті. Якщо ж обсяг запитуваної пам’яті менший за 512 байтів, то для її виділення використовуються стандартні алокатори, оптимізовані для роботи з дрібними об’єктами.

Алокатор об’єктів

Розгляньмо детальніше, як працює стандартний алокатор. Алокатор складається з принципово важливих трьох компонентів: арени, пули та блоки.

  • Арена — це велика область пам’яті (зазвичай 256 кБ), яка виділяється операційною системою. Python керує кількома аренами, кожна з яких може містити кілька пулів.
  • Пул — це фрагмент пам’яті фіксованого розміру (зазвичай 4 кБ), що виділяється з арени. Кожен пул спеціалізується на виділенні пам’яті під об’єкти певного розміру. Наприклад, один пул може використовуватися для об’єктів розміром 16 байтів, інший — для об’єктів розміром 32 байти тощо. Це дозволяє ефективно використовувати пам’ять і зменшити фрагментацію (згадуємо приклад з валізою).
  • Блок — це найменша одиниця пам’яті, яка виділяється для об’єктів. Кожен пул ділиться на блоки однакового розміру. Коли створюється об’єкт, пам’ять під нього виділяється у вигляді одного або кількох блоків з відповідного пулу.

Як це працює разом? Коли потрібно виділити пам’ять під новий об’єкт, Python шукає потрібну арену. Якщо не знаходить, запитує нову в операційної системи. Для того щоб додати новий об’єкт, обирається найбільш заповнена арена для компактнішого розміщення об’єктів у пам’яті.

Далі вибирається необхідний пул (відповідно до розміру об’єкта) і виділяється потрібна кількість блоків. Якщо в пулі немає вільних блоків, виділяється новий пул із наявної арени або створюється нова арена.

Як саме це вирішується? Пул має три можливі стани: Full, Used, Empty.

  • Full-пули не використовуються для виділення нової пам’яті, оскільки всі блоки вже зайняті.
  • Used-пулам Python надаємо перевагу під час створення нових об’єктів, тобто якщо потрібний розмір блоку доступний, пам’ять буде виділена з цього пулу.
  • Empty-пули використовуються, якщо всі пули зі станом used заповнені або не підходять за розміром, пул переходить у стан used після виділення першого блоку.

Блоки всередині пулу слугують безпосередньо для зберігання об’єкта. Кожен блок може бути розміром 8, 16, 24, 32 ..., 512 байтів. Коли потрібно розмістити об’єкт, береться перший вільний блок, що підходить за розміром (наприклад, для об’єкта розміром 15 байтів буде виділено блок на 16 байтів). Вільні блоки можуть бути повторно використані для інших об’єктів того ж розміру. Якщо ж всі блоки в пулі вільні, пул може бути повернутий до арени та звільнений.

Така структура дозволяє ефективно управляти пам’яттю, зменшуючи фрагментацію та прискорюючи виділення пам’яті. Це особливо корисно для малих об’єктів, які часто створюються і знищуються в Python.

Як Python працює з пам’яттю

Ми звикли сприймати вираз `a = 10`, як запис числа `10` у змінну `a`. Насправді ж у Python немає змінних як таких.

  • Коли ми присвоюємо змінній якесь значення, Python не зберігає число `10` у змінній `а`, натомість він створює об’єкт (у цьому випадку ціле число `10`) в пам’яті та зберігає посилання на цей об’єкт у змінній `а`.
  • Якщо ми присвоїмо `a` нове значення, наприклад `а = 20`, Python створить новий об’єкт (число `20`) і змінить посилання в `а`, щоб воно вказувало на цей новий об’єкт. Попередній об’єкт (число `10`) залишається в пам’яті, поки на нього будуть існувати інші посилання. Як тільки посилань не залишиться, його пам’ять може бути звільнена.
  • Якщо ми присвоїмо `b = a`, то змінна `b` тепер теж буде посилатися на той самий об’єкт, на який посилається `a`. Тобто і `a`, і `b` вказуватимуть на одне і те ж число `20` в пам’яті.

У Python кожен об’єкт у пам’яті містить поля ob_refcnt та ob_type. Вони є частинами внутрішньої структури об’єктів і відіграють ключову роль в управлінні пам’яттю та визначенні типу об’єкта.

  • ob_refcnt (Reference Count) — це лічильник посилань на об’єкт. Він зберігає кількість посилань, які вказують на цей об’єкт.

Коли ми створюємо нове посилання на об’єкт (наприклад, присвоюємо його іншій змінній), ob_refcnt інкрементується. Коли одне з посилань видаляється або змінюється (наприклад, коли змінна, яка посилалася на об’єкт, змінює своє значення), ob_refcnt зменшується. Коли лічильник досягає нуля (тобто на об’єкт більше немає посилань), Python автоматично звільняє пам’ять, зайняту цим об’єктом. Цей процес є частиною механізму збирання сміття (garbage collection), про що поговоримо пізніше.

  • ob_type (Type Information) — це вказівник на структуру, яка описує тип об’єкта.

Цей вказівник посилається на відповідний тип об’єкта, наприклад ціле число, рядок, список тощо. ob_type визначає, які операції можна виконувати над об’єктом і як ці операції слід реалізовувати. Наприклад, для числа і рядка визначаються різні способи обчислення, копіювання та перетворення.

Нащо нам це все? ob_refcnt забезпечує автоматичне керування пам’яттю, допомагаючи Python звільняти пам’ять, коли вона більше не потрібна. ob_type гарантує, що кожен об’єкт має правильний тип і що всі операції з ним виконуються відповідно до цього типу.

Як Python очищає пам’ять

Уявіть, що ви готуєте на кухні. Спочатку у вас є чистий стіл і все необхідне під рукою. Але поступово посуд, обрізки овочів і використані інгредієнти починають накопичуватися, і робочий простір стає все меншим. Нарешті, стає важко знайти місце для роботи. Так само відбувається, коли в програмі накопичуються об’єкти, що не використовуються, це може перевантажити пам’ять і уповільнити роботу програми.

І тут у нас є два рішення: прибрати кухню самостійно або ж викликати прибиральника, який зробить усе за нас.

Перший спосіб — поприбирати непотрібні об’єкти в пам’яті самостійно — ми можемо реалізувати за допомогою функції del. Важливо сказати, що насправді del — це ключове слово, а не функція, тому краще казати «оператор del». Утім, у багатьох джерелах послуговуються терміном «функція del», тож використовуватимемо його і ми.

Функція del у Python слугує для видалення посилань на об’єкти. Варто розуміти, що del не знищує сам об’єкт, а видаляє посилання (змінну), яке вказує на цей об’єкт.

Як це працює? Якщо ми використовуємо del для змінної, ця змінна перестає існувати.

a = 10
print(id(a)) # 11167752
del(a)
print(a)  # NameError: name 'a' is not defined
print(id(10)) # 11167752

Коли del видаляє посилання на об’єкт, це зменшує лічильник посилань (ob_refcnt) цього об’єкта. Якщо після цього на об’єкт більше не залишилося посилань, Python може звільнити пам’ять, зайняту цим об’єктом. Але якщо на об’єкт є інші посилання, пам’ять не буде звільнена доти, доки всі посилання не будуть видалені. А також слід сказати, що функція del не може видалити вбудовані типи об’єктів або деякі системні об’єкти, наприклад числові літерали.

Підрахунок посилань та пошук циклів

Оскільки ефективно поприбирати непотрібні об’єкти в пам’яті самостійно навряд чи вийде, звернімося до готового клінінг-сервісу, і в Python такий існує.

Для того щоб очистити пам’ять, є два методи: підрахунок посилань та пошук циклів.

Як працює метод підрахунку посилань? Цей механізм відстежує, скільки разів об’єкт використовується у коді рантайму, тобто скільки посилань налічується в ob_refcnt, і автоматично звільняє пам’ять, коли об’єкт більше не потрібен. Згадуємо: у кожного об’єкта в Python є лічильник посилань, який зберігає кількість посилань на цей об’єкт. Коли ми створюємо посилання на об’єкт, цей лічильник збільшується, під час видалення — відповідно зменшується. Якщо лічильник посилань досягає нуля, тобто на об’єкт більше немає посилань, пам’ять, що займає цей об’єкт, звільняється автоматично.

Що ж до пошуку циклів? Іноді може виникати ситуація, коли об’єкти посилаються один на одного, утворюючи цикли (A посилається на В, В посилається на С, С посилається на А). У таких випадках, навіть якщо всі зовнішні посилання на ці об’єкти видалені, їхні лічильники посилань не стають нульовими, і пам’ять не звільняється. Щоб вирішити цю проблему, триває перевірка об’єктів на наявність циклічних посилань, і пам’ять звільняється, якщо такі цикли є.

До слова, скільки посилань має той чи інший об’єкт у пам’яті, можна легко перевірити за допомогою бібліотеки sys.

import sys

print(sys.getrefcount('new object')) # 3
print(sys.getrefcount(0)) # 1000000791

a = 0
b = 'new object'

print(sys.getrefcount('new object')) # 4
print(sys.getrefcount(0)) # 1000000792

Функція sys.getrefcount сама посилається на об’єкти, тому не дивно, що на тільки-но створений об’єкт маємо одразу три посилання. На невеликі числа часто посилається сам Python-інтерпретатор, тому кількість посилань на них буде досить великою.

Повернемося до підрахунку посилань та пошуку циклів. Усе ніби гарно працює, але завжди має бути якесь «але». Тож метод підрахунку посилань чудово працює з незмінними об’єктами, які посилаються тільки на одну комірку пам’яті (рядки, числа тощо) або на кілька незмінних об’єктів (кортежі, frozen множини тощо), але об’єкти-контейнери можуть обдурити цей механізм, посилаючись самі на себе або один на одного.

import sys

# Об’єкт посилається сам на себе
offices = []
offices.append(offices)

print(sys.getrefcount(offices)) # 3

# Об’єкти посилаються один на одного
departments = []
companies = []
departments.append(companies)
companies.append(departments)

print(sys.getrefcount(departments)) # 3
print(sys.getrefcount(companies)) # 3

З цього можемо зробити висновок, що лічильник посилань ніколи не досягне 0, а отже, пам’ять не буде очищено. Для цього Python пропонує збирач сміття (garbage collector).

Garbage collector

Garbage collector у Python періодично збирає сміття, перевіряючи пам’ять на наявність циклічних посилань і очищаючи їх.

Об’єкти діляться на три «покоління» на основі того, як довго вони існують. Нові об’єкти належать до першого покоління, а старіші переміщуються в друге і третє. Нові об’єкти перевіряються частіше, оскільки вони, найімовірніше, стануть непотрібними. Старіші об’єкти перевіряються рідше. Коли Python виявляє, що об’єкти взаємно посилаються один на одного, але не мають зовнішніх посилань, ці об’єкти можуть бути позначені як «сміття», і їхня пам’ять буде звільнена.

Розгляньмо детальніше, як об’єкти переміщуються між різними поколіннями, коли вони переживають процес збору сміття, і як ці покоління використовуються для ефективного очищення пам’яті.

1. Перше покоління (Покоління 0):

  • Нові об’єкти створюються та додаються в Покоління 0.
  • Коли GC запускається, він перевіряє всі об’єкти в Поколінні 0. Якщо об’єкт більше не використовується (немає посилань), він підлягає видаленню (зелений об’єкт).
  • Якщо об’єкт усе ще має посилання (червоний об’єкт), він переміщується в Покоління 1.

2. Друге покоління (Покоління 1):

  • Тут збираються об’єкти, які пережили хоча б один збір сміття.
  • GC перевіряє об’єкти в цьому поколінні рідше, ніж у Поколінні 0. Якщо об’єкт більше не використовується, він видаляється.
  • Якщо об’єкт усе ще використовується, він може бути переміщений у Покоління 2.

3. Третє покоління (Покоління 2):

  • Це найстаріші об’єкти, які існують у пам’яті найдовше (жовтий об’єкт).
  • Збір сміття тут відбувається ще рідше, ніж у Поколінні 1, оскільки вважається, що ці об’єкти з меншою ймовірністю стануть непотрібними.
  • Після запуску GC об’єкти, які все ще мають посилання, залишаються, а непотрібні видаляються.

У кожного покоління є свій лічильник, який збільшується після виділення пам’яті об’єктам цього покоління та зменшується після її звільнення. Щойно це значення перевищує певний поріг, запускається процес збору сміття. Ці порогові значення можна переглянути та змінити за допомогою бібліотеки gc.

import gc

print(gc.get_threshold()) # (700, 10, 10)
gc.set_threshold(500, 10, 10)
print(gc.get_threshold()) # (500, 10, 10)

Скільки пам’яті займають об’єкти

Тепер перейдемо на наступний крок знайомства з пам’яттю та проведемо експеримент, скільки пам’яті буде займати той чи інший об’єкт у Python. Розглянемо виключно CPython як основну реалізацію мови програмування Python. Експерименти та висновки тут не стосуються інших реалізацій Python, таких як IronPython, Jython і PyPy.

Вимірювання розміру об’єкта

Спершу сформуймо розуміння щодо фактичного використання пам’яті об’єктами Python.

Модуль sys у стандартній бібліотеці Python надає функцію getsizeof, яка дозволяє дізнатися, скільки пам’яті займає певний об’єкт у байтах. Це корисно для аналізу використання пам’яті в програмі, особливо якщо ми працюємо з великими даними або оптимізуємо використання пам’яті. Функція викликає дандер-метод __sizeof__ об’єкта. Цей магічний метод визначає кількість байтів, яку об’єкт займає в пам’яті.

У випадку з вбудованими типами даних (списки, словники, кортежі тощо), __sizeof__ визначає основний розмір об’єкта, але не включає розмір об’єктів, на які він посилається. Тому для складних об’єктів (наприклад, вкладених списків) getsizeof може не повністю відображати весь використовуваний обсяг пам’яті.

import sys

from decimal import Decimal

print(sys.getsizeof(42))  # 28
print(sys.getsizeof(Decimal(5.3)))  # 104
print(sys.getsizeof([1, 2, 3, 4, 5]))  # 104
print(sys.getsizeof('Hello, World!'))  # 62

Розглянемо детальніше кожен з основних типів даних та трохи подискутуємо на цю тему. Спершу — рядки.

import sys

print(sys.getsizeof('')) # 49
print(sys.getsizeof('Hi')) # 51
print(sys.getsizeof('Hello, World!')) # 62
print(sys.getsizeof('Kharkiv is the best city in the world!')) # 87

Тож бачимо, що пустий рядок займає 49 байтів, а кожен новий символ додає по 1 байту. Хм, але різниця не така велика, чому? Зберігання кількох коротких рядків у Python має додаткові накладні витрати, оскільки кожен рядок створює окремий об’єкт із власними метаданими. Наприклад, короткий рядок «Hi» займає 51 байт, де більшість із цих байтів використовуються для управління об’єктом. Натомість один довгий рядок зберігає більше тексту на одиничний об’єкт, що зменшує накладні витрати на кожен символ, але може бути менш гнучким у використанні. Тому робимо висновок, що за можливості краще зберігати один великий рядок, ніж кілька малих.

З рядками зрозуміло. Як щодо колекцій?

import sys

print(sys.getsizeof([]))  # 56
print(sys.getsizeof([1]))  # 64
print(sys.getsizeof([1, 2, 3, 4]))  # 88
print(sys.getsizeof([1, 2, 3, 4, 5]))  # 104
print(sys.getsizeof([1, 2, 3, 4, 5, 6]))  # 104
print(sys.getsizeof(['Kharkiv is the best city in the world!']))  # 64

Та сама історія: навіть порожній список має накладні витрати на зберігання метаданих, таких як інформація про тип об’єкта, розмір, кількість елементів і посилання на самі елементи.

На цьому моменті може виникнути питання, чому коли список став налічувати один елемент у вигляді числа, кількість пам’яті збільшилась лише на 8 байтів? Додавання числа до списку додає лише 8 байтів, тому що список зберігає лише посилання на об’єкт `int`, а не сам об’єкт. Сам `int` займає 24 байти, але ці байти не дублюються для кожного списку, який посилається на це число. Те саме сталося і в останньому випадку з рядком. Бачимо, що список з одним елементом у вигляді рядка займає лише 64 байти, як і у випадку з числом, бо тут так само пам’ять зайняло посилання на об’єкт, а не його розмір у пам’яті як такий.

Це означає, що функція getsizeof не повертає фактичну пам’ять списку і всіх об’єктів, які він містить, а тільки пам’ять списку і посилання на об’єкти, що в ньому містяться. У наступному розділі ми поговоримо, як вирішити цю проблему.

Звернемо увагу, що для списків [1, 2, 3, 4, 5] та [1, 2, 3, 4, 5, 6] ми отримали однаковий результат щодо використання пам’яті. Для збереження списку в пам’яті Python використовує додаткову динамічну структуру. Ця структура охоплює не лише посилання на елементи списку, але й додатковий простір для можливих майбутніх елементів. Це дозволяє Python швидко додавати нові елементи до списку без необхідності кожного разу перевиділяти всю пам’ять. Ця динамічна структура збільшує накладні витрати пам’яті порівняно з незмінними структурами, такими як кортежі.

Отож переходимо далі — до кортежів.

import sys

print(sys.getsizeof(()))  # 40
print(sys.getsizeof((1, )))  # 48
print(sys.getsizeof((1, 2, 3, 4)))  # 72
print(sys.getsizeof(('Kharkiv is the best city in the world!', )))  # 48

Перше, що слід зауважити, це те, що кортежі займають менше місця, ніж списки. Це відбувається тому, що кортежі є незмінними структурами даних. Оскільки їхній вміст не може змінюватися після створення, Python може оптимізувати їх зберігання. Списки, на відміну від кортежів, є змінними, і для підтримки цієї гнучкості їм потрібно більше накладних витрат, причини чого ми обговорили вище. А друге — ситуація з порожнім кортежем і заповненим та сама, що й у минулих експериментах.

Тепер поговоримо про множини та словники.

import sys

print(sys.getsizeof(set())) # 216
print(sys.getsizeof({1})) # 216
print(sys.getsizeof({1, 2, 3, 4})) # 216

print(sys.getsizeof({})) # 64
print(sys.getsizeof({'a': 1})) # 184
print(sys.getsizeof({'a': 1, 'b': 2, 'c': 3})) # 184

Бачимо, що під час збільшення кількості елементів у множині чи словнику витрати пам’яті не змінюються. Чому так?

У Python множини мають певний початковий розмір, і цей розмір не змінюється під час додавання елементів до певної кількості. Це тому, що множини використовують хеш-таблиці для зберігання елементів, і хеш-таблиці виділяють фіксовану кількість пам’яті на початку. Коли множина досягає певного розміру, вона може динамічно збільшуватися, але у нашому прикладі пам’ять залишається стабільною.

Словники в Python використовують хеш-таблиці для зберігання пар ключ-значення. Порожній словник займає менше пам’яті, але коли ми додаємо елементи, витрати пам’яті зростають. У нашому випадку розмір словника залишався стабільним на рівні 184 байтів після додавання одного або кількох елементів, оскільки Python оптимізує розподіл пам’яті для словників і може використовувати блоки пам’яті, які вже виділені.

Але зауважимо, що накладні витрати на зберігання об’єктів не маленькі. Усе тому, що в об’єктів Python величезні фіксовані накладні витрати. Якщо структура даних складається з великої кількості об’єктів колекцій, таких як рядки, списки та словники, які містять невелику кількість елементів кожен, ми багато платимо.

Вимірювання розміру об’єкта рекурсивно

Повернемося до питання, що відкладали. getsizeof може сказати, скільки пам’яті займає примітивний об’єкт, але є спосіб, що рекурсивно виконує деталізацію та обчислює фактичне використання пам’яті графом об’єктів Python. Це — deep_getsizeof.

Функція рекурсивно обходить кожен елемент об’єкта (наприклад, елементи списку, значення у словниках, атрибути об’єкта тощо). Вона відстежує вже побачені об’єкти, щоб уникнути повторного підрахунку пам’яті для тих самих об’єктів (як-от у випадку посилань на один і той самий об’єкт у різних частинах структури). Результат показує повний розмір об’єкта, зокрема всі вкладені об’єкти, що дає точніше уявлення про споживання пам’яті.

import sys
from sys import getsizeof
from typing import Any, Container, Mapping

def deep_getsizeof(obj: Any, ids: set[int] | None = None) -> int:
   if ids is None:
       ids = set()

   if id(obj) in ids:
       return 0

   size = getsizeof(obj)
   ids.add(id(obj))

   if isinstance(obj, str):
       return size

   if isinstance(obj, Mapping):
       return size + sum(deep_getsizeof(k, ids) + deep_getsizeof(v, ids) for k, v in obj.iteritems())
   if isinstance(obj, Container):
       return size + sum(deep_getsizeof(item, ids) for item in obj)
   return size

example = [[1, 2, 3], 4, 5, '6', '7']
print('getsizeof for list:', sys.getsizeof(example))
print('deep_getsizeof for list:', deep_getsizeof(example))

Час експериментувати. Почнемо з рядків. Наш минулий експеримент на пустому рядку показав результат 49.

example = '1234567'
print(deep_getsizeof(example)) # 56

Перевіривши фактичний розмір цього рядка, бачимо, що результат — 56 (тобто 49 байтів накладних витрат на створення й збереження пустого рядка та ще 7 байтів на кожен з літералів рядка).

Тепер до списків. З минулих результатів пам’ятаємо, що розмір накладних витрат на пустий список становить 56 байтів. Можемо це ще раз перевірити: функція deep_getsizeof для пустого списку покаже нам аналогічний результат.

print(deep_getsizeof([])) # 56
print(deep_getsizeof([example])) # 120
print(deep_getsizeof(5 * [example])) # 152

Тож отримуємо 120 байтів для списку з одного елемента: 56 байтів накладних витрат для створення списку + 8 байтів для зберігання посилання на елемент списку example + розмір самого example, що у минулому прикладі, ми з’ясували, становить також 56 байтів. Сума збігається, дійсно 120!

Так само для другого прикладу (56 + 5 * 8 + 56) = 152, не забуваємо, що значення зберігається один раз, а той факт, що в списку п’ять елементів, змушує нас лише помножити на 5 розмір пам’яті для зберігання посилань.

Особливість зберігання чисел у діапазоні між —5 та 256

У Python є цікава оптимізація щодо збереження чисел у діапазоні від —5 до 256. Ці числа зберігаються в пам’яті особливим чином завдяки механізму інтернування. Що це та як працює?

Коли Python запускається, він автоматично створює об’єкти для чисел у діапазоні від —5 до 256 і зберігає їх у спеціальному пулі. Ці об’єкти постійно присутні в пам’яті та використовуються повторно кожного разу, коли ми створюємо змінну з таким значенням. Завдяки цьому підходу Python заощаджує пам’ять і прискорює роботу з числами, що часто використовуються. Наприклад, коли ми присвоюємо змінній значення `100`, Python не створює новий об’єкт, а використовує вже наявний об’єкт з пулу.

Саме цей діапазон був обраний з практичних міркувань. Ці числа часто використовуються в більшості програм, тому їх інтернування дозволяє зменшити навантаження на пам’ять і підвищити продуктивність.

Тобто це означає, що числа в діапазоні перевикористовуються з кожним запуском програми, а ось числа поза діапазоном кожного разу займатимуть різну комірку пам’яті, бо створюватимуться щоразу заново. Можемо це перевірити на практичному експерименті.

print(id(202))
print(id(472))

Роздрукуємо ідентифікатор у пам’яті для числа з діапазону та поза ним. Запустимо програму кілька разів. Отримані результати:

11173896
140209190427568

11173896
140406733347760

11173896
139721092848560

Бачимо доказ того, що число `202` не створюється заново на початку кожного виконання програми, на відміну від `472`, ідентифікатор якого кожного разу різний.

Висновки

Знання про управління пам’яттю в Python є важливим елементом для розуміння того, як ефективно працює ваша програма.

Коли ви знаєте, як Python управляє пам’яттю, то можете краще контролювати ресурси, уникати витоків пам’яті та оптимізувати код для досягнення максимальної продуктивності. Це знання допомагає не лише в написанні ефективного коду, але й у розробці масштабованих і надійних систем.

Пам’ять у Python охоплює різні аспекти, їх розуміння допомагає вам краще адаптувати ваші програми до різних умов і сценаріїв використання. Зокрема, знання про роботи garbage collector, arenas, pools і blocks дозволяє виявляти та виправляти потенційні проблеми, що виникають під час управління ресурсами.

Врешті-решт, ефективне управління пам’яттю сприяє створенню швидших і більш стабільних програм, що працюють на будь-якому рівні — від простих скриптів до складних систем. Розуміння основ пам’яті у Python може стати потужним інструментом для кожного розробника, допомагаючи досягти високих результатів у програмуванні.

Дякую за увагу! Не прощаємось.

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось19
До обраногоВ обраному16
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Кілька зауважень.

1. З самого початку вказати, що це все про CPython. Бо вже в PyPy все інакше. Ви вказали це тільки про розмір обʼєктів, хоча принципи вже різні з самого початку. В PyPy, наприклад, нема лічильників посилань, взагалі.

2. На блок-схемах краще поставити помітки true/false на if-розгалуженнях (можуть бути +/- або як завгодно, тільки щоб однозначно). Без них ну взагалі то зрозуміло, але не для зовсім «зелених».

3. Приклади з числами 10 і 20 погані бо вони пред-алоковані в рантаймі. Краще взяти 1000 і 2000.

У вас самих в прикладі у 10 не змінився id(). Якщо б його створило заново, він скоріш за все був би іншим.

Самі ж потім пишете про числа від −5 до 256. А першу частину статті не переробили під це.

4. Покоління обʼєктів всередині нумеровані від 0 до 2, а не 1 до 3. Краще це одразу вказати. У вас це якось мимохідно вже потім.

5. Наскільки знаю, фактичного переміщення обʼєктів між поколіннями в CPython немає. Це не JVM і не .NET машина. Вони перекласифікуються.

6. Незрозуміло, чому пустий set товстіше пустого dict. Обидва на хеш-таблицях. Ті пояснення, що в статті, невалідні.

В решті — виглядає дуже якісно.

Дякую за увагу до моєї статті. Насправді можу сказати, що Ваші коментарі доволі валідні, вважаю їх чудовими поінтами, щоб врахувати у наступних своїх статтях

Решил все таки расписать, почему это утверждение афтора не совсем верно!
Во-первых, в Python нет необходимости вручную управлять памятью, как в некоторых других языках программирования, например цешка, или мой любимый руст. Python использует автоматический сборщик мусора, который автоматически освобождает память, когда она больше не нужна.
Во-вторых, «искусство» управления памятью предполагает наличие сложных техник и приемов, которые нужно освоить. Однако в Python большинство аспектов управления памятью происходит автоматически и не требует от программиста глубокого понимания внутренних механизмов.

Походу у афтора отстутствует инженерное мышление, более точным названием для этой статьи могло бы быть что-то вроде «Основы управления памятью в Python: как Python автоматически управляет ресурсами» или «Понимание автоматического управления памятью в Python». Это лучше отражает тот факт, что в Python нет необходимости «управлять» памятью вручную, а вместо этого программист должен понимать, как Python самостоятельно решает эти задачи. Хлопцы, что скажите?

«Якщо ти вб’ш себе об стіну — світ стане кращім місцем.» Лінус Торвальдс

Ну правка названия — скорее да.
А вот наезжать так сразу я бы не стал. Все тормозят и глючат, вопрос в качестве личной обратной связи по уже полученным замечаниям.

хеш-таблиці

Все чекаю, коли ж Г-філи/h-гейтери доберуться нарешті до наших улюблених хеш-таблиць та змусять нас писати їх як «геш-таблиці».

Тсс не підказуй.
Хоча коли придовбуться, можна їх послати до хуситів (і проконтролювати, щоб пішли не к гуситам).

Знання про управління пам’яттю в Python є важливим елементом для розуміння того, як ефективно працює ваша програма.

Це цікаво, але не думаю, щоб дуже важливо. Тому усе це нагадує ChatGPT, а не реальне життя.

Коли ви знаєте, як Python управляє пам’яттю, то можете краще контролювати ресурси, уникати витоків пам’яті та оптимізувати код для досягнення максимальної продуктивності. Це знання допомагає не лише в написанні ефективного коду, але й у розробці масштабованих і надійних систем.

Ну... не думаю, що хтось буде копати так глибоко. Дешевше розгорнути новий сервер або додати потік.

Взагалі, перевага Python у тому, що не треба про все це думати. Якщо треба, то вже питання. Це не кажучи про те, що такі речі можуть погано переноситися між різними версіями. Так, можливо щось з цього може бути корисним, якщо деякі модулі реалізовувати на Сі, але і там в принципі ти довіряєш менеджеру пам’яті та GC.

Як на мене, більше проблема це розуміння того, де відбувається копіювання даних, та як цього уникати. Я бачив, як в деякий пайплайнах один фрейм копіювався разів п’ять :-)

Це цікаво, але не думаю, щоб дуже важливо. Тому усе це нагадує ChatGPT, а не реальне життя.

Не згоден, це може бути дуже корисним — щоб не писати гімнокод.

Ну... не думаю, що хтось буде копати так глибоко. Дешевше розгорнути новий сервер або додати потік.

Це усе красиві розмови, які йдуть до того моменту — коли ви не починаєте питати в менеджера додаткової оплати за клауд чи хостінг, або взагалі треба плати з власного рахунку і там суми — після яких постають мислі про організацію власної стійки з серверів. Бо один місяць коштує еквівалент одного блайнд сервера при такому собі проекті.

Взагалі, перевага Python у тому, що не треба про все це думати.

Відносно менше думати, це скріпотова мова задумана під конкретний тип сугубо прикладних задач, тим не менше це усе ще загальна мова програмування, а не скажімо повний DSL типу Matlab. Тобто від програміста усе ще вимагається бути програмістом, навіть коли він користується лише Tesnsorflow або PyTorch — і усе що програмує, це виймає данні з різних походжень які потім підуть під автоматичний аналіз та надання бізнесу якоїсь статистки та закономірностей.

Так, можливо щось з цього може бути корисним, якщо деякі модулі реалізовувати на Сі, але і там в принципі ти довіряєш менеджеру пам’яті та GC.

От якраз при роботі із нативною інтеграцією — ви маєте максимально чітко знати модель пам’яті та особливості роботи managed мови програмування коли робите інтеграцію. Якраз і цьому криється 99% процентів дуже важких до RCA проблем, які ще навіть після з’ясування root cause — потім виявляється дуже не просто виправити.

Як на мене, більше проблема це розуміння того, де відбувається копіювання даних, та як цього уникати.

Тут плюс, та швидше не уникати — а як буде вірно для конкретного алгоритму. Для цього треба знати — що відбувається, а це прерше — теорія з цієї та побідбних статей, друге — практика технології та інструменти профайлери, аналіз логів і т.д. щоби мати змогу роздивитись, що трапляється.

Це усе красиві розмови, які йдуть до того моменту — коли ви не починаєте питати в менеджера додаткової оплати за клауд чи хостінг

Або коли йдеш до менеджера и вимагаєш декілька місяців чисто R&D, а потім ще час на виправлення, якщо це можливо.

Не згоден, це може бути дуже корисним — щоб не писати гімнокод.

А де саме корисним? В який момент? Як це можна контролювати?

От якраз при роботі із нативною інтеграцією — ви маєте максимально чітко знати модель пам’яті

Ну... PyObject_MALLOC, Py_TYPR(self)->tp_free((PyObject*)self) це треба. А інше... Ну що ти з цим зможеш зробити?

Ну... PyObject_MALLOC, Py_TYPR(self)->tp_free((PyObject*)self) це треба. А інше... Ну що ти з цим зможеш зробити?

Угу — а от скажімо є у вас тензор, який лежить в host ptr, далі він йде в буфер кадра відеокарти, потім з нього коду треба взяти слой як матрицю і т.д. І виявиться, що там далеко не malloc а аллокатор з NUMA який дергає mmap чи VirtuaАlloc на пряму і т.д. Коротше задля цього наприклад таку штуку зробили www.boost.org/...​ython/doc/html/index.html хоча тим хто добре шарить, вона теж не подобається.

Ну... в такому разі треба знати numpy API, щось на кшталт PyArray_New, та правильно передати туди параметри. Також не завадить знати про існування Py_buffer. Але знову ж таки це не скільки про знання того, як там працює GC та деталі як воно під капотом, а скільки про знання API.

print(sys.getsizeof([’Kharkiv is the best city in the world!’]))

Put your hands up for Kharkiv, our lovely city
Оформлення класне, видно дуже складні абстракції як то розподілювачи пам’яті та сбірка сміття. Так само показано як робити базове профілювання.

Ця гнучкість дозволяє зберігати об’єкти з довшим життям і різними розмірами, що не завжди зручно або можливо зробити в стеку.

Я б сказав не «різного» розміру, а «динамічного» розміру, бо С рантайм не може створювати масив не визначеного розміру на стеку, так як це веде до не визначеного положення стек поінтеру.

Ви маєте на увазі не визначеного розміру на етапі компіляції ? Тоді ще як може є функція _aloca а GCC та Clang підтримують ексткншн який дозволяє робити отак void foo(size_t x) {char foo[x];}
Так це вважається поганою практикою. Тим не менше — розмір стеку потоку контролює програміст, а не компілятор. Тому С фактично немає ніяких офіційних перевірок від переповнення стеку. Звісно сучасні компілятори та літнери вам скажуть, коли ви явно робите щось не те. Тим не менше
С інструмент гострий як бритва, з його допомогою ви можете робити як чудові речі так і кроваве місиво.

ага, цікаво, особливо що msvc має свою «safe» версію цієї функції: learn.microsoft.com/...​library/reference/malloca.

Наск я розумію, такі речі юзаються в середовищах де заборонено або взагалі немає як юзати heap memory.

Ще раз, C99 дає синтаксис виділяти масиви на стеку. Більшість компіляторів це підтримує, цим активно користуються, це не є чимось дуже поганим.

Проблема malloc це потенційне блокування потоків. Тому якщо нам треба виділити 100 байт на рядок, і це може бути конкурентним, то простіше використати стек.

Так це вважається поганою практикою.

Відносно поганою. Якщо у нас embedded, рівеня ядра, що у нас часто дуже обмежений фіксований стек. Специфіка полягає у тому, що контекст може бути переключаємий, або ні. В останньому разі ми не можемо перехопити відсутні сторінки, тому будь яке переповнення це kernel panic, а не видилення сторінки та перезапуск операції. Тому використання стеку має бути обережним.

Якщо брати звичайне життя користувацького режиму, то єдина проблема alloca розумного розміру це макроси та цикли, бо якщо написати щось

for (int i=0; i<N; ++i) {
   int * tmp = alloca(sizeof(int) * (i + 1));
   /* Code ... */
}

то це призведе до перевикористання пам’яті. Тому починаючи з C99 (опціонально з C11) є можливість дати компілятору це розрулити:

for (int i=0; i<N; ++i) {
   int tmp[i+1];
   /* Code ... */
}
Тому С фактично немає ніяких офіційних перевірок від переповнення стеку.

Там зазвичай мегабайти, які ростуть за потреби. Обмеження чисто прикладного характеру, щоб нескінчена рекурсія не віджирала усі ресурси. Але, якщо треба, можна поставити будь який максимальний розмір. Перевіряє переповнення сама OS зазвичай, та просто кіляє процес.

C99, variable length arrays (VLA)

float read_and_process(int n)
{
    float vals[n];

    for (int i = 0; i < n; ++i)
        vals[i] = read_val();

    return process(n, vals);
}
так як це веде до не визначеного положення стек поінтеру.

Значення EPS і так не визначене, бо в будь який момент застосунок може туди щось закинути. Зазвичай на початку виклику EBP ініціаліхується значенням ESP та використовується як база для всіх локальних змінних. Цій практиці як мінімум 35+ років. А в Сі стек завжди був доступний до аллокацій.

Дякую за уточнення, VLA — в стандарті для С99, а розширення для С++ та Clang.
gcc.gnu.org/...​/gcc/Variable-Length.html
clang.llvm.org/compatibility.html#vla

Слушно підмічено, дякую! Я обов’язково врахую це у наступних своїх статтях.
Та вдячна за позитивний відгук

Англійською ціла купа дуже схожих матеріалів з назвою типу Mastering Python Garbage Collection, та більш глибокі які розказують, наприклад що Python комбінує підрахунок посилань та Generational Mark and Sweep GC (тут це видно на діагрмах та пояснюється простою мовою з прикладами). Саме тому підхід — коли усе об’єкт, а усі типи данних — класс. Напевно на базі чогось же треба було писати статтю, про мову розробка якої почалась в 1992 і в Голандії.
Оці усі підходи з одного боку дозволяють реалізувати дуже складні алгоритми буквально трьома строкам коду без заморочок, усі низькорівневі речі бере на cебе мова та бібліотеки. Тим не менше — є ціна за абстракції, оверхед як по CPU time — так і по пам’яті суттєвий. Коли йдеться про GC по картинках побачимо, що окрім самого GC — на один 4 байтний integer в 64 розрядній архітектурі ще прийдеться принаймні один лічильник посилань у 8 байт, і один вказівник на 8 байт. Тобто щоб зберігати 4 байта, треба використати принаймні 16 додаткових. А потім ще треба буде витрачати час в райнтаймі, на те щоби дивитись, що з пам’ятю і викидувати сміття. Так само плаваюче сміття яке впливає на CPU кеши, призводить до більш частого звертання до пам’яті і т.д. що загально навантажує систему.

До речі, стаття цікава.
Но пайтон та перфоманс по роботі з пам,ятю то зовсім різні речі.

Ще додам до статті, що якщо треба ефективно зберігати масиви простих даних, то можна використати пакет array. Особливо, коли програміст розуміє, які дані будуть в масиві.

>>> import array
>>> a1 = []
>>> a2 = array.array('H')
>>> for i in range(50000):
...     a1.append(i)
...     a2.append(i)
>>> sys.getsizeof(a1)
444376
>>> sys.getsizeof(a2)
104750
>>> a2.itemsize
2

У цьому прикладі я обрав ’H’ — unsigned short (С type) — int (Python type), де кожен елемент займає 2 байта в памʼяті. Та це у 4 рази компактніше ніж стандартний масив.

Ще ці масиви зберігаються компактно у памʼяті, та центральний процесор буде ефективно використовувати кеш-памʼять.

Цілком дякую за дуже цікаву та змістовну статтю. Кожен програміст повинен знати, як воно там працює «під капотом». :)

Дякую за коментар та схвальний відгук. Дійсно array буде оптимальніше, дана бібліотека — гарний поінт, щоб поресьочити)

Подививсь я на цей array знову (дуже давно не було для чого) і зараз здивований, чому нема resize(). Невже ніхто не питав?

А що ви очікуєте від ресайз? Є append, insert, extend. Зробити одразу потрібний розмір?

Саме потрібний розмір без проміжних кроків.
Бо наприклад якщо хочу фіксований масив на 1000, для чого робити спочатку список на 1000 нулів або генератор якого будуть 1000 разів звати?
Краще один раз викликати, хай хоч нулі заллє.

Очень полезно и интересно 🙏

Коли ви знаєте, як Python управляє пам’яттю, то можете краще контролювати ресурси, уникати витоків пам’яті та оптимізувати код для досягнення максимальної продуктивності.

«Пайтон» и «продуктивность» — это слова антонимы.

Если коду, написанному на пайтоне не хватает производительности — значит, пора переходить на С, С++ и прочий раст.

Не відкидаючи Ваші слова, відзначу, що на Пайтоні можна писати код з дуже різною швидкістю виконання. Наприклад, я за нещодавно «трохи відрефакторив» пайтон код, що виконувався >24 години, й він став працювати 30 хв, що цілком Ок. Зрозуміло, що на C++/Rust той самий алгоритм відпрацьовував би за лічені хвилини, але для цього потрібно витратити помітно часу. А от якщо ще й переписати на PTX для GPU (колись таким займався), то можливо й секунди працював — але навіщо витрачати тижні для його переносу на GPU, якщо 30хв усіх цілком влаштовує.

код, що виконувався >24 години, й він став працювати 30 хв

Кошмар какой. Если в Штатах победит Камала Харрис и продолжит климатическую повестку — непременно доберутся до запрета Пайтона.
Это ведь сколько лишней энергии тратится, на исполнение такого «производительного» кода.

непременно доберутся до запрета Пайтона.

Я скорее «за». Потому что писать действительно экономными средствами сейчас несложно, а пользы реально на порядки.

Другой вопрос, что Python пострадает не первым. Первым будет Javascript. И вот на фоне страданий вебовцев доля Python будет как-то затёрта из виду.

Дякую за Вашу думку, з якою складно не погодитись. Але я у статті не мала намір порівняти мови програмування, у контексті чого, звісно ж, кожна матиме свої переваги та недоліки у тому чи іншому аспекті роботи. У статті я маю за мету освітити особливості роботи саме з пам’яттю у Python, щоб вправніше володіти цим інструментом.

Підписатись на коментарі