Python продолжает сегодня набирать популярность. Он используется в DevOps, Data Science, веб-разработке и информационной безопасности. Но как бы широко он не использовался, он никогда не выигрывал медалей за скорость, так как работает Python сравнительно медленно.
Как нам сравнить Python по скорости с C, C++,C# или Java? Ответ во многом будет зависеть от типа приложения, которое мы используем. Ни один из эталонов скорее всего не окажется идеальным, но The Computer Language Benchmarks Game будет для нас хорошей отправной точкой.
Если смотреть на результаты от The Computer Language Benchmarks Game за более чем 10 лет, по сравнению с другими языками, такими как Java, C#, Go, JavaScript и C — Python является одним из самых медленных. Эти результаты включают в себя анализ компиляторов JIT (C#, Java) и AOT (C, C++), а также интерпретируемые языки, такие как JavaScript.
Говоря «Python», мы имеем в виду эталонную реализацию языка CPython. Также в этой статье мы будем ссылаться и на другие интерпретаторы.
Когда Python завершает сопоставимое приложение в 2–10 раз медленнее, чем другой язык, мы задаемся вопросом, почему это так и можем ли мы что-нибудь с этим сделать?
Вот основные теории:
- Это GIL (Global Interpreter Lock)
- Это потому, что он интерпретируется, а не компилируется
- Это потому, что это динамически типизированный язык
Какая из этих причин оказывает наибольшее влияние на производительность?
Это GIL
Современные компьютеры поставляются с процессорами, которые имеют несколько ядер, а иногда и несколько процессоров. Чтобы использовать всю эту дополнительную вычислительную мощность, операционная система определяет низкоуровневую структуру, называемую потоком, где процесс (например, браузер Chrome) может порождать несколько потоков и иметь инструкции для системы внутри. Таким образом, если процессор особенно загружен одним процессом, эта нагрузка может быть распределена между ядрами, что эффективно ускоряет выполнение большинства приложений.
Имейте в виду, что структура и API многопоточности различаются между основанных на POSIX (например, Mac OS и Linux) и ОС Windows. Операционная система также обрабатывает планирование потоков.
Если вы раньше не занимались многопоточным программированием, вам нужно прежде всего познакомиться с блокировками. Вы узнаете, что в отличие от однопоточного процесса, при изменении переменных в памяти несколько потоков не пытаются получить доступ или изменить один и тот же адрес памяти одновременно.
Когда CPython создает переменные, он выделяет память и затем подсчитывает, сколько ссылок на эту переменную существует. Это понятие известно как подсчет ссылок. Если количество ссылок равно 0, это освобождает этот фрагмент памяти для системы. Вот почему создание «временной» переменной внутри, скажем, цикла for не увеличивает потребление памяти вашим приложением.
Затем проблема возникает, когда переменные совместно используются несколькими потоками. В этом случае CPython необходимо заблокировать счетчик ссылок. В Python существует «глобальная блокировка интерпретатора», которая тщательно контролирует выполнение потока. Интерпретатор может выполнять только одну операцию за раз, независимо от того, сколько у него потоков.
Каким образом это сказывается на производительности Python приложений?
Если у вас однопоточное, приложение с одним интерпретатором, это никак не скажется на скорости. Удаление GIL не повлияет на производительность вашего кода.
Если вы хотите реализовать параллельное выполнение внутри одного интерпретатора (процесс Python) с использованием нескольких потоков, и ваши потоки интенсивно принимают и отправляют информацию (например, сеть или дисковый накопитель), то вы увидите разницу от использования GIL.
Если у вас есть веб-приложение (например, Django), и вы используете WSGI, то каждый запрос к вашему веб-приложению является отдельным интерпретатором Python, поэтому для каждого запроса существует только 1 блокировка. Поскольку интерпретатор Python запускается медленно, в некоторых реализациях WSGI есть режим “Daemon Mode” , который поддерживает процесс в активном состоянии.
Как насчет других интерпретаторов Python
PyPy имеет GIL и он в большинстве случаев в 3 раза быстрее чем CPython. Jython не имеет GIL, потому что поток Python в Jython представлен потоком Java и использует систему управления памятью JVM.
Как это делает JavaScript?
Ну, во-первых, все движки Javascript используют сборку мусора mark-and-sweep (пометить и убирать). Как уже говорилось, основная потребность в GIL — создает алгоритм управления памятью CPython. JavaScript хоть и не имеет GIL, но он является однопоточным, поэтому GIL здесь не требуется. Вместо параллельной работы здесь используется асинхронное программирование. При этом используются цикл обработки событий JavaScrip и шаблон Promise/Callback. У Python есть нечто подобное с циклом обработки событий asyncio.
Это потому, что это интерпретируемый язык
Хотя об этом часто можно слышать, данная формулировка во многом является упрощением того, как на самом деле работает CPython. Если, например, в терминале вы написали python myscript.py, то CPython запустит длинную последовательность чтения, словарного и синтаксического анализа, компиляции, интерпретации и выполнения этого кода.
Если вам интересно, как в деталях работает этот процесс, посмотрите статью Modifying the Python language in 6 minutes
Важным моментом в этом процессе является создание файла с расширением .pyc на этапе компиляции. Последовательность байт-кода записывается в файл, находящийся в папке __pycache__ /. Это относится не только к вашему коду, но и ко всему, что было вами импортировано, включая сторонние модули.
Поэтому в большинстве случаев (если вы не пишете код, который запускаете только один раз), Python интерпретирует байт-код и выполняет его локально.
Java и C#
Давайте сравним теперь это с Java и C# .NET. Java компилируется в «промежуточный язык», а виртуальная машина Java считывает байт-код и в то же самое время компилирует его в машинный код. «Высокоуровневый ассемблер» (Common Intermediate Language, CLI) виртуальной машины .NET поступает точно также. Исполняющая среда .NET (Common-Language-Runtime, CLR) использует одновременную компиляцию для машинного кода.
Итак, почему Python намного медленнее, чем Java и C# в тестах, если они все используют виртуальную машину и определенный байт-код?
Во-первых, .NET и Java скомпилированы JIT. Компиляция JIT или Just-in-time требует промежуточного языка, чтобы код можно было разбить на куски (или кадры). AOT-компиляторы (Ahead of time) созданы таким образом, чтобы процессор мог распознавать до исполнения каждую строку.
Сам JIT не делает исполнение программы быстрее, потому что он все еще выполняет те же последовательности байт-кода. Однако JIT позволяет делать оптимизацию во время выполнения. Хороший JIT-оптимизатор увидит, какие части приложения выполняются более часто (назовем их «горячими точками»). Затем он выполнит оптимизацию этих фрагментов кода, заменив их более эффективными решениями.
Это означает, что когда ваше приложение делает одно и тоже несколько раз, оно может работать значительно быстрее. Также имейте в виду, что Java и C# являются языками со строгой типизацией, поэтому оптимизатор здесь может сделать гораздо больше предположений относительно оптимального кода.
PyPy имеет JIT и, как упоминалось в предыдущем разделе, значительно быстрее, чем CPython. Эта статья о производительности раскрывает больше деталей Which is the fastest version of Python?
Так почему же CPython не использует JIT?
У JIT есть свои недостатки, и одним из них является время запуска. Время запуска CPython уже относительно невысокое, а PyPy запускается еще в 2–3 раза медленнее. Виртуальная машина Java также, как известно, медленно загружается. CLR .NET справляется с этим, загружаясь при запуске системы, но в данном случае разработчики CLR также разрабатывают операционную систему, на которой работает CLR.
Если у вас продолжительное время работает один процесс с кодом Python, который содержит «горячие точки», то использование JIT для оптимизации будет оправдано. Но CPython при этом является более универсальной реализацией. Например, если вы разрабатываете приложения с командной строкой, ожидание запуска JIT при каждом вызове интерфейса будет ужасно долгим.
Главное назначение CPython заключается в том, чтобы покрывать как можно больше вариантов использования. И хоть и была возможность подключить JIT к CPython, но этот проект в основном застопорился.
Если вы хотите воспользоваться преимуществами JIT и имеете соответствующий объем работы, используйте интерпретатор PyPy.
Это потому, что это динамически типизированный язык
В языке со статической типизацией вы должны указать тип переменной при ее объявлении. К ним относятся C, C++, Java, C#, Go. В языке с динамической типизацией хоть все еще и существует понятие типов, но тип переменной определяется динамически.
a = 1 a = "foo"
В этом маленьком примере Python создает вторую переменную с тем же именем но типом str и освобождает память, созданную для первого экземпляра с именем a.
Cтатически типизированные языки устроены таким образом не для того, чтобы усложнить вашу жизнь. Их устройство полностью соответствует тому, как работает процессор. Если в конечном итоге все должно соответствовать простой двоичной операции, то это значит, что объекты и типы должны быть преобразованы в низкоуровневую структуру данных. Python делает это за вас, вы только этого никогда не видите, и вам никогда не нужно об этом заботиться.
Отсутствие необходимости объявлять тип — это не то, что делает Python медленным, устройство языка Python позволяет вам создавать практически все динамическим способом. Вы можете заменить методы на объектах прямо во время выполнения, вы также можете заменить значения атрибутов для системных вызовов низкого уровня на значения, объявленные в то самое время, когда они будут передаваться. Практически все возможно. И именно эти особенности невероятно затрудняют оптимизацию Python.
Чтобы проиллюстрировать это, давайте воспользуемся инструментом трассировки системного вызова, из Mac OS который называется Dtrace. Дистрибутивы CPython не поставляются со встроенной DTrace, поэтому нам придется перекомпилировать CPython. В нашем примере для демонстрации будет использована версия Python 3.6.6.
wget https://github.com/python/cpython/archive/v3.6.6.zip unzip v3.6.6.zip cd v3.6.6 ./configure --with-dtrace make
Теперь python.exe будет запускать код с использованием трассировщиков Dtrace.
Пол Росс написал интересную статью на эту тему Lightning Talk on Dtrace. Вы можете скачать начальные файлы DTrace для Python, чтобы измерять в миллисекундах вызовы функций, исполнение кода, работу процессора, системные вызовы и не только.
Запустим код с использованием Dtrace:
sudo dtrace -s toolkit / .d -c ‘../cpython/python.exe script.py’
Трассировщик py_callflow демонстрирует свойства всех вызовов функций в нашем приложении.
Делает ли динамическая типизация Python медленным?
- Сравнение и преобразование типов обходятся дорого. Каждый раз, когда переменная читается, записывается или ссылается на тип, тратится время на его проверку.
- Трудно оптимизировать язык, который настолько динамичен. Причина, по которой многие альтернативы Python намного быстрее, заключается в том, что они жертвуют гибкостью во имя производительности.
- Если обратить внимание на Cython, то он сочетает в себе C-статические типы с Python, который делает код более оптимальным. С помощью Cython можно повысить производительность в 84 раза.
Заключение
В первую очередь причиной медленной работы Python является его динамическая природа и универсальность. Его можно использовать в качестве инструмента для решения самых разнообразных задач, хотя при этом зачастую будут доступны более оптимальные и быстрые альтернативы.
Тем не менее, всегда есть способы оптимизировать ваши приложения на Python с помощью асинхронности, инструментов профилирования и использования нескольких интерпретаторов. А для приложений, где время запуска неважно будет полезен JIT-компилятор, и соответственно интерпретатор PyPy. Для тех же частей вашего кода, где производительность критична и у вас при этом имеется много статически типизированных переменных, в качестве наиболее оптимального варианта может стать использование Cython.