В далеком 2003 году Intel выпустил новый процессор Pentium 4 “HT”. Этот процессор разгонялся до 3ГГц и поддерживал технологию гиперпоточности.
В последующие годы Intel и AMD боролись за достижение наибольшей производительности настольных компьютеров, увеличивая скорость шины, размер кэша L2 и уменьшая размер матрицы для минимизации задержки. В 2004 году на смену модели HT с частотой 3ГГц пришла 580 модель “Prescott” с разгоном до 4ГГц.
Казалось, чтобы идти вперед нужно было просто повышать тактовую частоту, однако новые процессоры страдали от высокого энергопотребления и тепловыделения.
Процессор вашего настольного ПК сегодня выдает 4ГГц? Маловероятно, поскольку путь к повышению производительности в конечном итоге лежал через повышение скорости шины и увеличение количества ядер. В 2006 году Intel Core 2 заменил Pentium 4 и имел гораздо более низкую тактовую частоту.
Помимо выпуска многоядерных процессоров для широкой пользовательской аудитории в 2006 году произошло кое-что еще. Python 2.5 наконец увидел свет! Он поставлялся уже с бета версией ключевого слова with, которое вы все знаете и любите.
У Python 2.5 имелось одно серьезное ограничение, когда речь заходила об использовании Intel Core 2 или AMD Athlon X2.
Это был GIL.
Что такое GIL?
GIL (Global Interpreter Lock – глобальная блокировка интерпретатора) – это булевое значение в интерпретаторе Python, защищенное мьютексом. Блокировка используется в основном цикле вычисления байткода CPython, чтобы установить, какой поток в данный момент времени выполняет инструкции.
CPython поддерживает использование нескольких потоков в одном интерпретаторе, но потоки должны запрашивать доступ к GIL, чтобы выполнять низкоуровневые операции. В свою очередь, это означает, что Python-разработчики могут использовать асинхронный код, многопоточность и больше не беспокоиться о блокировке каких-либо переменных или сбоях на уровне процессора при дедлоках.
GIL упрощает многопоточное программирование на Python.
GIL также говорит нам о том, что в то время, как CPython может быть многопоточным, только один поток в любой момент времени может выполняться. Это означает, что ваш четырехъядерный процессор делает примерно это (за исключением синего экрана, надеюсь).
Текущая версия GIL была написана в 2009 году для поддержки асинхронных функций и осталась нетронутой даже после множества попыток убрать ее в принципе или изменить требования к ней.
Любое предложение убрать GIL было обосновано тем, что глобальная блокировка интерпретатора не должна ухудшать производительность однопоточного кода. Тот, кто пробовал включать гиперпоточность в 2003 году, поймет, о чем я говорю.
Отказ от GIL в CPython
Если вы хотите действительно распараллелить код на CPython, вам придется использовать несколько процессов.
В CPython 2.6 модуль multiprocessing был добавлен в стандартную библиотеку. Мультипроцессная обработка (multiprocessing) маскировала собой порождение процессов в CPython (каждый процесс со своей собственной GIL).
from multiprocessing import Process
def f(name):
print 'hello', name
if __name__ == '__main__':
p = Process(target=f, args=('bob',))
p.start()
p.join()
Процессы создаются, в них отправляются команды с помощью скомпилированных модулей и функций Python, а затем они снова присоединяются к главному процессу.
Мультипроцессная обработка также поддерживает использование переменных через очередь или канал. У нее есть объект блокировки, который используется для блокировки объектов в главном процессе и записи из других процессов.
У мультипроцессной обработки есть один главный недостаток. Она несет значительную вычислительную нагрузку, которая отражается как на времени обработки, так и на использовании памяти. Время запуска CPython даже без no-site составляет 100-200 мс (загляните на https://hackernoon.com/which-is-the-fastest-version-of-python-2ae7c61a6b2b, чтобы узнать больше).
В итоге у вас может быть параллельный код на CPython, но вам все еще нужно тщательно распланировать работу длительных процессов, которые совместно используют несколько объектов.
Другой альтернативой может являться использование стороннего пакета, такого как Twisted.
PEP554 и смерть GIL?
Итак, напомню, что многопоточность в CPython – это просто, но в действительности это не является распараллеливанием, а вот мультипроцессная обработка параллельна, но влечет за собой значительные накладные расходы.
Что если есть путь лучше?
Ключ к обходу GIL кроется в имени, глобальная блокировка интерпретатора является частью глобального состояния интерпретатора. Процессы CPython могут иметь несколько интерпретаторов и, следовательно, несколько блокировок, однако эта функция используется редко, поскольку доступ к ней есть только через C-API.
Одной из особенностей CPython 3.8, является PEP554, реализация субинтерпретаторов и API с новым модулем
interpreters
в стандартной библиотеке.Это позволяет создавать несколько интерпретаторов из Python в рамках одного процесса. Еще одно нововведение Python 3.8 заключается в том, что все интерпретаторы будут иметь свои собственные GIL.
Поскольку состояние интерпретатора содержит область аллоцированную в памяти, коллекцию всех указателей на объекты Python (локальные и глобальные), субинтерпретаторы в PEP554 не могут получить доступ к глобальным переменным других интерпретаторов.
Подобно мультипроцессной обработке, совместное использование интерпретаторами объектов заключается в их сериализации и использовании формы IPC (сеть, диск или общая память). Существует много способов сериализации объектов в Python, например, модуль
marshal
, модуль pickle
или более стандартизированные методы, такие как json
или simplexml
. Каждый из них имеет свои плюсы и минусы, и все они дают вычислительную нагрузку. Лучше всего было бы иметь общее пространство в памяти, которое можно изменять и контролировать определенным процессом. Таким образом, объекты могут быть отправлены главным интерпретатором и получены другим интерпретатором. Это будет пространство управляемой памяти для поиска указателей PyObject, к которому может получить доступ каждый интерпретатор, при этом основной процесс будет управлять блокировками.
API для этого все еще разрабатывается, но оно, вероятно, будет выглядеть примерно так:
import _xxsubinterpreters as interpreters
import threading
import textwrap as tw
import marshal
# Create a sub-interpreter
interpid = interpreters.create()
# If you had a function that generated some data
arry = list(range(0,100))
# Create a channel
channel_id = interpreters.channel_create()
# Pre-populate the interpreter with a module
interpreters.run_string(interpid, "import marshal; import _xxsubinterpreters as interpreters")
# Define a
def run(interpid, channel_id):
interpreters.run_string(interpid,
tw.dedent("""
arry_raw = interpreters.channel_recv(channel_id)
arry = marshal.loads(arry_raw)
result = [1,2,3,4,5] # where you would do some calculating
result_raw = marshal.dumps(result)
interpreters.channel_send(channel_id, result_raw)
"""),
shared=dict(
channel_id=channel_id
),
)
inp = marshal.dumps(arry)
interpreters.channel_send(channel_id, inp)
# Run inside a thread
t = threading.Thread(target=run, args=(interpid, channel_id))
t.start()
# Sub interpreter will process. Feel free to do anything else now.
output = interpreters.channel_recv(channel_id)
interpreters.channel_release(channel_id)
output_arry = marshal.loads(output)
print(output_arry)
В этом примере используется NumPy. Массив numpy отправляется по каналу, он сериализуется с помощью модуля
marshal
, затем субинтерпретатор обрабатывает данные (на отдельном GIL), поэтому здесь может возникнуть проблема распараллеливания, связанная с ЦП, что идеально подойдет для субинтерпретаторов. Это выглядит неэффективно
Модуль
marshal
работает действительно быстро, однако не так быстро, как совместное использование объектов непосредственно из памяти. В PEP574 представлен новый протокол pickle (v5), который поддерживает возможность обработки буферов памяти отдельно от остальной части потока pickle. Что касается больших объектов данных, то сериализация их всех на одном дыхании и десериализация из субинтерпретатора добавит большое количество накладных расходов.
Новый API может быть реализован (чисто гипотетически) следующим образом —
import _xxsubinterpreters as interpreters
import threading
import textwrap as tw
import pickle
# Create a sub-interpreter
interpid = interpreters.create()
# If you had a function that generated a numpy array
arry = [5,4,3,2,1]
# Create a channel
channel_id = interpreters.channel_create()
# Pre-populate the interpreter with a module
interpreters.run_string(interpid, "import pickle; import _xxsubinterpreters as interpreters")
buffers=[]
# Define a
def run(interpid, channel_id):
interpreters.run_string(interpid,
tw.dedent("""
arry_raw = interpreters.channel_recv(channel_id)
arry = pickle.loads(arry_raw)
print(f"Got: {arry}")
result = arry[::-1]
result_raw = pickle.dumps(result, protocol=5)
interpreters.channel_send(channel_id, result_raw)
"""),
shared=dict(
channel_id=channel_id,
),
)
input = pickle.dumps(arry, protocol=5, buffer_callback=buffers.append)
interpreters.channel_send(channel_id, input)
# Run inside a thread
t = threading.Thread(target=run, args=(interpid, channel_id))
t.start()
# Sub interpreter will process. Feel free to do anything else now.
output = interpreters.channel_recv(channel_id)
interpreters.channel_release(channel_id)
output_arry = pickle.loads(output)
print(f"Got back: {output_arry}")
Это выглядит шаблонно
По сути, этот пример построен на использовании API низкоуровневых субинтерпретаторов. Если вы не использовали библиотеку
multiprocessing
, некоторые проблемы покажутся вам знакомыми. Это не так просто, как потоковая обработка, вы не можете просто, скажем, запустить эту функцию с таким списком входных данных в отдельных интерпретаторах (пока). Как только этот PEP объединится с другими, я думаю, мы увидим несколько новых API в PyPi.
Сколько накладных расходов имеет субинтерпретатор?
Короткий ответ: Больше, чем поток, меньше, чем процесс.
Длинный ответ: Интерпретатор имеет свое собственное состояние, потому ему нужно будет клонировать и инициализовать следующее, несмотря на то, что PEP554 упрощает создание субинтерпретаторов:
- Модули в пространстве имен
__main__
иimportlib
; - Содержимое словаря
sys
; - Встроенные функции (
print()
,assert
и т.д.); - Потоки;
- Конфигурацию ядра.
Конфигурацию ядра можно легко клонировать из памяти, но с импортированными модулями все не так просто. Импорт модулей в Python дело медленное, поэтому если создание субинтерпретатора означает импорт модулей в другое пространство имен каждый раз, преимущества уменьшаются.
Как насчет asyncio?
Существующая реализация цикла событий
asyncio
в стандартной библиотеке создает кадры стека для оценки, а также совместно использует состояние в главном интерпретаторе (и, следовательно, совместно использует GIL).После объединения PEP554, вероятно уже в Python 3.9, может быть использована альтернативная реализация цикла событий (хотя этого еще никто и не сделал), которая параллельно запускает асинхронные методы в субинтерпретаторах.
Звучит круто, заверните и мне!
Ну, не совсем.
Поскольку CPython так долго работал на одном интерпретаторе, многие части базы кода используют “Runtime State” вместо “Interpreter State”, поэтому если бы PEP554 был введен уже сейчас, проблем все равно было бы много.
Например, состояние сборщика мусора (в версиях 3.7<) принадлежит среде выполнения.
В изменениях во время спринтов PyCon, состояние сборщика мусора начало перемещаться в интерпретатор, так что каждый субинтерпретатор будет иметь свой собственный сборщик мусора (как и должно было быть).
Другая проблема заключается в том, что есть некоторые «глобальные» переменные, которые порядком задержались в базе кода CPython наряду с множеством расширений на С. Поэтому, когда люди внезапно начали правильно распараллеливать свой код, мы разглядели некоторые проблемы.
Еще одна проблема заключается в том, что дескрипторы файлов принадлежат процессу, поэтому если у вас есть файл, открытый для записи в одном интерпретаторе, субинтерпретатор не сможет получить доступ к этому файлу (без дальнейших изменений CPython).
Короче говоря, есть еще много проблем, которые надо решить.
Заключение: GIL правда уже не актуален?
GIL по-прежнему будет использоваться для однопоточных приложений. Поэтому даже при следовании PEP554 ваш однопоточный код внезапно не станет параллельным.
Если вы хотите писать параллельный код в Python 3.8, у вас будут проблемы распараллеливания, связанные с процессором, но это и билет в будущее!
Когда?
Pickle v5 и совместное использование памяти для мультипроцессной обработки скорее всего будут в Python 3.8 (Октябрь 2019 года), а субинтерпретаторы появятся между версиями 3.8 и 3.9.
Если у вас есть желания поиграться с представленными примерами, то я создал отдельную ветку со всем необходимым кодом: https://github.com/tonybaloney/cpython/tree/subinterpreters.
А что вы думаете по этому поводу? Пишите свои комментарии и до встречи на курсе.
Комментарии (13)
blueboar2
03.07.2019 18:01Я чего-то не понял, почему рисунок говорит про четыре ядра? Их же явно пять — с 0 по 4.
Sly_tom_cat
03.07.2019 18:20+2Выглядит костыльненько, но лучшем чем с процессами.
Однако, как по мне, так если нужно многопоточная обработка, то пожалуй лучше на Go написать.
amarao
03.07.2019 18:31+1Попытка принести shared state с concurrency обещает столько веселья, что только запасайся попкорном. Раньше GIL решал все проблемы — у вас просто не могло быть гонки условий. Выпиливание GIL потребует таких невероятных изменений в системе типов, что это перестанет быть питоном.
Решений с shared mutability ровно два: запретить mutability или запретить shared. Любое из этих решений сделает из питона инвалида, и даже самое изящное из существующих (ownership/borrow model в Rust), будучи принесённой в Python, сделает из него что угодно, кроме питона на котором легко писать.
menstenebris
03.07.2019 18:44Прочитав комментарий я как раз наоборот подумал. Скорее не borrow, а move из Rust отлично решит проблему. любые переменные либо должны быть immutable, либо реализовать mutex иначе будут перемещены при передаче в субинтерпретатор. Но передавать надо указатель на shared memory, а не передавать байты
amarao
03.07.2019 21:10+2borrow — это такой маленький синтаксический сахар для такого:
a = foo(a, ...)
Функция "взяла" объект и вернула. Вместо этого компилятор сам понимает "вернула" без дополнительных байтов на стеке, просто по лексическому контексту.
… А вот как только вы сделаете "передать указатель на shared memory", вот тут-то вас и ждут драконы.
Потому что вы не можете контролировать жизнь объекта по ссылке. Вы не можете запретить асинхронный доступ к объекту, вы не можете запретить использовать объекты после их удаления. Если всё это утыкать reference counter'ами (у питона они уже есть), то станет чуть лучше в смысле use after free, но мутекс на каждую переменную — это смерть однопоточной производительности. От слова "совсем", потому что на современных процессорах атомарные операции инвалидируют кеши соседей (других процессоров) межпроцессорным прерыванием. Каждый раз, когда вы трогаете переменную, ваш процессор бьёт в поддых каждого из соседей и говорит "а я изменил переменную". Разумеется, это не прокатит.
А если вы сделаете часть переменных локальными, а часть за мутексами, то всё станет плохо — Питон не может заставить вас использовать только мутексные переменные.
Дальше у вас будет yield из одного потока в другой, читающий из данных третьего потока. Неатомарно и без инвалидации кешей.
… Короче, плохо. И то, сколько людей пало пытаясь заменить GIL на что-то — явное тому доказательство.
Alex_ME
03.07.2019 22:31Простите, я не совсем в теме, но какая связь между многопоточностью и системой типов? Почему нельзя отдать все заботы по синхронизации на откуп программисту? Вы можете писать однопоточный код и совсем не задумываться о синхронизации, а если что-то с concurency — использовать блокировки и синхронизацию (мьютексы, критические секции, семафоры итп) или вообще lock-free, если можно. В общем, как это делают на других языках.
0xd34df00d
03.07.2019 23:15Потому что система типов — это такой инструмент, который позволяет гарантировать, что программист не понаделает некоторых видов ерунды.
В языке с контролем за эффектами (всякие хаскели и идрисы) я могу писать код в монаде STM и получить гарантии от компилятора, что software transactional memory будет работать как ожидается, например.
В таком же языке я могу просто прибить чистые вычисления в любой момент, что очень сильно меняет, как пишется код.
А языке с rank-2 polymorphism вы можете гарантировать, что вы не будете пользоваться ресурсом вне определенного скоупа типа транзакции, мьютекса и тому подобного. И, кстати, заодно можете гарантировать детерминированность параллельных вычислений.
Про локфри тоже можно, наверное, доказывать что-то, но я пока это особо не ковырял.
0xd34df00d
03.07.2019 23:10Растовский подход далеко не самый изящный. Полноценные аффинные типы, rank-2 polymorphism и гарантированная чистота таки поизящнее и помощнее.
homm
03.07.2019 21:17+1Время запуска CPython даже без no-site составляет 100-200 мс (загляните на https://hackernoon.com/which-is-the-fastest-version-of-python-2ae7c61a6b2b, чтобы узнать больше).
Но в стать время запуска в зависимости от версии от 18 до 35 мс. И это не «даже без no-site», а просто «без no-site», потому что с no-site еще значительно быстрее:
$ time python3.7 -S -c "" real 0m0.012s $ time python2.7 -S -c "" real 0m0.007s
И вообще не понятно, при чем тут время запуска интерпретатора, если мультипроцессинг запускается форком.
hardex
Slain — это не «мертв», а «побежден».
MaxRokatansky Автор
Если еще точнее, то «убитый», либо «умерщвленный». Но нам показалось, что «мертв» более точно передает смысл текста.