Kodokon kodokon.com

Производительность: профилирование, GIL, генераторы против списков, ловушки памяти

Сначала измеряй, потом оптимизируй: timeit и cProfile, что на самом деле блокирует GIL, экономность генераторов и ловушки памяти со строками и срезами.

11 мин · 3 вопросов

Открыть этот урок в Kodokon

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

BASH
python -m timeit "sum(x * x for x in range(1000))"
python -m timeit -s "s = 'abc' * 100" "s.upper()"
python -m cProfile -s cumtime -m timeit "min(range(50))"
Опция -s у timeit готовит контекст за пределами измерения.

GIL (Global Interpreter Lock) гарантирует, что в рамках одного процесса байт-код Python в каждый момент выполняет только один поток. Следствие: потоки не ускоряют чистые вычисления. Зато они по-прежнему отлично подходят для ввода-вывода, потому что на время чтения из сети или с диска GIL освобождается, как и во многих библиотеках на C вроде NumPy. Чтобы загрузить вычислениями несколько ядер, используй multiprocessing или concurrent.futures.ProcessPoolExecutor. В Python 3.13 есть экспериментальный вариант «free-threaded» без GIL, пока редкий в продакшене.

PYTHON
import sys

squares_list = [x * x for x in range(1_000_000)]
squares_gen = (x * x for x in range(1_000_000))

print(sys.getsizeof(squares_list), "bytes")
print(sys.getsizeof(squares_gen), "bytes")
print(sum(squares_gen))
Около 8 МБ для списка и около 200 байт для генератора.

Генератор весит несколько сотен байт независимо от длины последовательности: он хранит только своё состояние выполнения и выдаёт каждое значение по запросу. Соединяй их в конвейеры - sum(x * x for x in data) - чтобы обрабатывать потоки данных, ни разу не материализуя промежуточный список. Компромисс, о котором стоит знать: генератор одноразовый. Как только он исчерпан, он больше ничего не выдаёт, и молча - классический источник багов, когда второй проход выглядит «пустым».

PYTHON
import timeit

def concat(n: int) -> str:
    out = ""
    for i in range(n):
        out += str(i)
    return out

def join(n: int) -> str:
    return "".join(str(i) for i in range(n))

t1 = timeit.timeit(lambda: concat(20_000), number=10)
t2 = timeit.timeit(lambda: join(20_000), number=10)
print(f"concat: {t1:.3f} s / join: {t2:.3f} s")
Измерь на своей машине: join остаётся переносимым и безопасным выбором.

Проверка знаний

Убедись, что запомнил ключевые моменты этого урока.

  1. Что именно предотвращает GIL?
    • Любую форму параллелизма в Python, включая multiprocessing
    • Одновременное выполнение байт-кода Python несколькими потоками одного процесса - ввод-вывод и многий код на C его освобождают
    • Использование нескольких потоков в одной программе
    • Одновременную запись в одну и ту же переменную
  2. Почему sys.getsizeof для генератора остаётся крошечным по сравнению с эквивалентным списком?
    • Генератор не хранит ни одного элемента: он держит только своё состояние выполнения и вычисляет каждое значение по запросу
    • getsizeof не умеет измерять генераторы
    • Генераторы внутри себя сжимают данные
  3. Почему ''.join(parts) выигрывает у out += part, повторяемого в цикле?
    • join использует несколько потоков, чтобы собрать строку
    • Это миф: начиная с Python 3 производительность одинакова
    • Так как str неизменяемы, каждое += переписывает всю строку целиком (квадратичная стоимость); join вычисляет итоговый размер и выделяет память один раз
    • join избавляет от создания промежуточных объектов str в генераторе