Не обязательно сониевские студии. Были попытки запускать на нем научные расчеты, у меня даже был доступ к какому-то исследовательском проекту на эту тему в середине 00, но фокус интересов быстро сменился и ничего путного тогда сделать не успели.
Вы каким-то удивительным образом разговариваете с голосами в своей голове, при этом цитируете мое сообщение.
как-то оформить вызывающему сообщение о возникшей неожиданной проблеме, и на этом прекратить обработку вызова
Для этого у нас, например, и используются исключения. И об этом я и написал.
Например, MDBX_PANIC - тут нам делать нечего и поправить мы не можем - бросаем исключение.
MDBX_DBS_FULL - тут мы можем что-то сделать, но в нашей реальности эти ошибка программиста и пользователь это никогда не увидит, впрочем как и обрабатывать на месте это не надо (но сообщить об этом надо и прекратить работу) - для этого тоже хорошо использовать исключения.
MDBX_NOTFOUND - тут мы можем что-то сделать в зависимости от нашей логики - заворачиваем в свой error codes и обрабатываем в моменте.
Простите, что приходится разжевывать, но судя по вашему сообщению надо.
У нас построена система на основе libmdbx, система на Python. Нам в Python надо было сигнализировать, что с kv произошли какие-то проблемы. Местами это было 7-8 уровней вложенности. Изначально все было написано на error codes (из libmdbx они заворачивались в свои), переход на исключения код заметно упростил.
Дополню: Те ошибки, которые мы можем исправить в моменте или которые являются частью логики, конечно оставлены кодами ошибки (перекодированными).
Вы задаете довольно очевидные вопросы и сами даете на них довольно очевидные ответы. Еще раз — все кто хоть раз серьезно сталкивался с многопоточным программированием об этом знают.
Процесс работы открыт — сформулирован спектр проблем, сформулированы возможные решения, код пишется и коммитится (в открытом репозитории) — вполне можно к этому источнику знаний припасть и помочь ценными замечаниями.
С другой стороны, перетирать труизмы в комментариях на хабре гораздо проще.
Спасибо большое за хороший вопрос. Я, к моему сожалению, не знаю как в Linux посмотреть, какой тип мьютекса используется. Я вижу, что вызовы уходят в pthread_mutex_trylock и pthread_mutex_lock, но какая там реализация — не знаю. Если вы подскажете, буду очень благодарен.
Я добавил результаты тестирования для Fedora 39, Ubuntu 20.04, 24.10 — если вам интересно, посмотрите в таблице. Также я переформулировал первый абзац.
Во-первых, я не говорил о списках на атомиках, я написал про объектные блокировки, это несколько иное. Во-вторых, lock-free структуру данных известны давно (даже здесь на хабре есть статье про них Lock-free структуры данных. Iterable list / Хабр), это к разговору о том, что есть структуры данных, безопасные для использования в многопоточном окружении, но которые не используют блокировки.
Есть различные задачи, под которые пишется код. Есть задачи IO-intensive, есть CPU-intensive. В первом случае, у вас потоки много ждут на операциях ввода-вывода и большую часть времени не занимают ресурсы процессора (но при этом все равно продолжают потреблять оперативную память). Как правильно ядро ОС будет реже на них переключаться, но это все равно будет занимать процессорное время. Во втором случае, потоки занимаются "число дробительными" задачами. Но т.к. у нас вытесняющая многозадачность, то это значит что ядро ОС все равно эти потоки будет прерывать и отдавать квант времени другим потокам (с учетом приоритетов и всего прочего). В этом случае нам не нужно использовать больше потоков, чем у нас есть ядер в процессоре. Тут можно так же упомянуть Закон Амдала — Википедия (простите за ссылку на википедию).
В случае 100к потоков, у нас шедулер ОС будет заниматься только тем что будет между потоками переключаться - т.е. весь ресурс процессора будет отдан только на переключение между потоками.
По второму вопросу - Py_BEGIN_ALLOW_THREADS это просто макрос, который сохраняет текущее состояние потока и вызывает PyEval_SaveThread, который освобождает GIL. Как правило GIL это обычный системный мьютекс. Но если внимательно читать статью, то я писал, что в любом случае мы этим мьютексом защищены - вызываем мы fastrlock или RLock. И преимущество мы достигаем, за счет самой реализации реентерабельной блокировки.
И возвращаясь к 100к потокам и RLock - никто в здравом уме не будет запускать 100к (даже если бы имел смысл) и ходить ими в защищаемый ресурс. На таких числах работают уже другие подходы, например, data parallelism.
Я вам ваше, в своем первом комментарии, написал, какие есть варианты написания многопоточного кода на Python, который мог бы заниматься осмысленной работой и при этом GIL ему бы не мешал. У меня складывается мнение, что вы не понимаете как работает GIL в Python и зачем он нужен.
Допустим у нас есть несколько больших массивов numpy, с которыми мы должны сделать какую то операцию и получить третий массив. Здесь код, который эту операцию выполняет вполне может быть запущен как в один поток, так и в несколько. Или другой пример, архивация данных - тоже может быть выполнена в несколько потоков, даже с применением OpenMP. При всем при этом вы можете отпустить GIL и другие потоки, например, главный поток приложения будет продолжать взаимодействовать с пользователем.
Если вы готовы это конструктивно обсуждать (в чем я не уверен), мы можем даже посмотреть какие-то примеры кода.
Возвращаясь к вашему первому примеру, вы там сделали правильную ремарку То есть на C с атомарными переменными - т.е. вы вполне понимаете, что даже на С++ код `x = x + 1` выполнится атомарно, только если x имеет тип, например, std::atomic<int32_t>. Тогда почему вы отказываете питону возможность реализовать ту же операцию с использованием таких же "атомиков" под капотом?
В том же питоне, GIL вам не запрещает работать с одним и тем же объектом из разных потоков (например, пополнять список). GIL дает вам гарантию, что внутреннее состояния списка не будет "разрушено" в ходе работы двух потоков. В том же PEP703 помимо всего прочего предлагается реализовать объектные блокировки.
В целом там много интересного, заставлять его прочитать вас я не могу, да и не хочу.
Вы очень категоричны в своих суждениях и, тем более, в развешивании ярлыков. Подход о котором вы пишите известен всем, кто с данной проблемой сталкивается.
Вам же я процитирую PEP703, который вы не читали, как и код, на который даны ссылки:
The Steering Council accepts PEP 703, but with clear proviso: that the rollout be gradual and break as little as possible, and that we can roll back any changes that turn out to be too disruptive – which includes potentially rolling back all of PEP 703 entirely if necessary (however unlikely or undesirable we expect that to be).
Тут наверное можно долго спорить для чего подходит Python.
На нем можно решать CPU-intensive задачи, да не самим питоном, но внешними библиотеками (например, numba, numpy, scipy, pandas, py-arrow и т.д.) либо разрабатывать свои собственные расширения (на C, C++, Rust) и такого рода задачи вполне можно пускать в отдельных потоках, управляя ими из приложения, написанного на Python.
К тому же, asyncio появился только в 3.4, а есть проекты, которые ведут свою историю раньше, и в которых такого рода задачи надо решать.
Этот код защищен GIL, поэтому исполняется только одним потоком в единицу времени. Пока мы не освободим GIL (через вызов Py_BEGIN_ALLOW_THREADS) другой поток в нашу функцию fastrlock_acquire попасть не может.
На 100_000 потоках тестировать это смысла нет (как впрочем и запускать такое количество потоков, только если они не занимаются ожиданием на IO).
Наверное, надо еще написать, где это полезно (на мой взгляд):
Первый вариант (ИМХО) допустим на вершине стека, когда дальше передавать исключение некуда.
Второй вариант - когда в данном месте вам нужна дополнительная обработка исключения (могу только придумать, насытить исключение дополнительным контекстом)
Третий вариант - представьте что у вас в 1М строк кода и в логах вы начинаете видеть During handling of the above exception, another exception occurred - для вас как для инженера это означает одно - произошла еще одна ошибка при обработке существующей и не известно, все ли пошло по плану - соответственно надо разбираться, что за ошибка.
Четвертый вариант - вы оборачиваете исключения либо в свои библиотечные, либо просто сокращаете количество исключений, которые могут от вас вылететь (снижая нагрузку на вызывающий код), но при этом сохраняете оригинальное исключение (например, чтобы оно попало в лог и потом кто-то его расследовал)
Пятый вариант как четвертый, но оригинальное сообщение вам не нужно, потому что в новом достаточно информации.
Находясь в обработчике исключений у вас есть 5 вариантов, что вы можете сделать:
Обработать исключение и подавить его
Обработать исключение и пробросить дальше - просто вызвать raise
Обработать исключение и выбросить новое исключение - вызвать raise Exception()
Обработать исключение и выбросить новое исключение и сохранить информацию об оригинальном - вызвать raise Exception() from e
Обработать исключение и выбросить новое исключение, но скрыть информацию об оригинальном исключении - вызвать raise Exception() from None
def func():
raise ConnectionError
# 1 вариант - обрабатывает и подавляет исключение
try:
func()
except ConnectionError as exc:
print('exception: ', type(exc), exc)
# получите сообщение:
# exception: <class 'ConnectionError'>
# 2 вариант - обрабатывает и пробрасывает оригинальное исключение дальше
try:
func()
except ConnectionError as exc:
print(exc)
raise
# получите следующее сообщение:
# exception: <class 'ConnectionError'>
# Traceback (most recent call last):
# File ".\z.py", line 6, in <module>
# func()
# File ".\z.py", line 2, in func
# raise ConnectionError
# ConnectionError
# 3 вариант - обработать и выбросить новое исключение из обработчика
# это будет воспринято, как произошло новое исключение, во время обработки оригинального
try:
func()
except ConnectionError as exc:
raise RuntimeError('Failed to open database')
# получите сообщение:
# Traceback (most recent call last):
# File ".\z.py", line 6, in <module>
# func()
# File ".\z.py", line 2, in func
# raise ConnectionError
# ConnectionError
# During handling of the above exception, another exception occurred:
# Traceback (most recent call last):
# File ".\z.py", line 8, in <module>
# raise RuntimeError('Failed to open database')# from None
# RuntimeError: Failed to open database
# 4 вариант - обработать и выбросить исключение (например, кастомное исключение вашей библиотеки)
# и сохранить информацию об оригинальном исключении
try:
func()
except ConnectionError as exc:
raise RuntimeError('Failed to open database') from exc
# получите следующее сообщение:
# Traceback (most recent call last):
# File ".\z.py", line 6, in <module>
# func()
# File ".\z.py", line 2, in func
# raise ConnectionError
# ConnectionError
# The above exception was the direct cause of the following exception:
# Traceback (most recent call last):
# File ".\z.py", line 8, in <module>
# raise RuntimeError('Failed to open database') from exc
# RuntimeError: Failed to open database
# Заметьте разницу в сообщении об оригинальном исключении:
# было During handling of the above exception, another exception occurred
# стало The above exception was the direct cause of the following exception
# разница я думаю понятна
# 5 вариант - обработать и выбросить исключение (например, кастомное исключение вашей библиотеки)
# и скрыть информацию об оригинальном сообщении:
try:
func()
except ConnectionError as exc:
raise RuntimeError('Failed to open database') from None
# получите следующее сообщение:
# Traceback (most recent call last):
# File ".\z.py", line 8, in <module>
# raise RuntimeError('Failed to open database') from None
# RuntimeError: Failed to open database
# как можете видеть - оригинальное исключение скрыто
В нашем случае десктопное приложение, пользователи не любят когда им делают аборт. Если нельзя работать с одним файлом, можно работать с другим.
Такое комментировать только портить.
Не обязательно сониевские студии. Были попытки запускать на нем научные расчеты, у меня даже был доступ к какому-то исследовательском проекту на эту тему в середине 00, но фокус интересов быстро сменился и ничего путного тогда сделать не успели.
Вы каким-то удивительным образом разговариваете с голосами в своей голове, при этом цитируете мое сообщение.
Для этого у нас, например, и используются исключения. И об этом я и написал.
Например, MDBX_PANIC - тут нам делать нечего и поправить мы не можем - бросаем исключение.
MDBX_DBS_FULL - тут мы можем что-то сделать, но в нашей реальности эти ошибка программиста и пользователь это никогда не увидит, впрочем как и обрабатывать на месте это не надо (но сообщить об этом надо и прекратить работу) - для этого тоже хорошо использовать исключения.
MDBX_NOTFOUND - тут мы можем что-то сделать в зависимости от нашей логики - заворачиваем в свой error codes и обрабатываем в моменте.
Простите, что приходится разжевывать, но судя по вашему сообщению надо.
У нас построена система на основе libmdbx, система на Python. Нам в Python надо было сигнализировать, что с kv произошли какие-то проблемы. Местами это было 7-8 уровней вложенности. Изначально все было написано на error codes (из libmdbx они заворачивались в свои), переход на исключения код заметно упростил.
Дополню: Те ошибки, которые мы можем исправить в моменте или которые являются частью логики, конечно оставлены кодами ошибки (перекодированными).
Какой ваш прогноз когда Python помрет и GIL убрать не смогут? 5 лет, 10 лет? 13 мая 2035 года?
Вы задаете довольно очевидные вопросы и сами даете на них довольно очевидные ответы. Еще раз — все кто хоть раз серьезно сталкивался с многопоточным программированием об этом знают.
Процесс работы открыт — сформулирован спектр проблем, сформулированы возможные решения, код пишется и коммитится (в открытом репозитории) — вполне можно к этому источнику знаний припасть и помочь ценными замечаниями.
С другой стороны, перетирать труизмы в комментариях на хабре гораздо проще.
Спасибо большое за хороший вопрос. Я, к моему сожалению, не знаю как в Linux посмотреть, какой тип мьютекса используется. Я вижу, что вызовы уходят в
pthread_mutex_trylock
иpthread_mutex_lock
, но какая там реализация — не знаю. Если вы подскажете, буду очень благодарен.Я добавил результаты тестирования для Fedora 39, Ubuntu 20.04, 24.10 — если вам интересно, посмотрите в таблице. Также я переформулировал первый абзац.
Во-первых, я не говорил о списках на атомиках, я написал про объектные блокировки, это несколько иное. Во-вторых, lock-free структуру данных известны давно (даже здесь на хабре есть статье про них Lock-free структуры данных. Iterable list / Хабр), это к разговору о том, что есть структуры данных, безопасные для использования в многопоточном окружении, но которые не используют блокировки.
Относительно переменных, вы правы, что просто так атомиками там не получится сделать. Но проблема там не в том, что переменная это структура данных, а в том, что Python поддерживает длинную арифметику - вот пример того как сделано сложение - cpython/Objects/longobject.c at eac41c5ddfadf52fbd84ee898ad56aedd5d90a41 · python/cpython. И есть перевод разбора, как эта арифметика работает - Как в Python реализованы очень длинные числа типа integer? / Хабр.
Думаю, что в рамках отказа от GIL тут тоже придется, что то придумывать.
Отвечу на оба два ваших комментария здесь.
Есть различные задачи, под которые пишется код. Есть задачи IO-intensive, есть CPU-intensive. В первом случае, у вас потоки много ждут на операциях ввода-вывода и большую часть времени не занимают ресурсы процессора (но при этом все равно продолжают потреблять оперативную память). Как правильно ядро ОС будет реже на них переключаться, но это все равно будет занимать процессорное время. Во втором случае, потоки занимаются "число дробительными" задачами. Но т.к. у нас вытесняющая многозадачность, то это значит что ядро ОС все равно эти потоки будет прерывать и отдавать квант времени другим потокам (с учетом приоритетов и всего прочего). В этом случае нам не нужно использовать больше потоков, чем у нас есть ядер в процессоре. Тут можно так же упомянуть Закон Амдала — Википедия (простите за ссылку на википедию).
В случае 100к потоков, у нас шедулер ОС будет заниматься только тем что будет между потоками переключаться - т.е. весь ресурс процессора будет отдан только на переключение между потоками.
По второму вопросу - Py_BEGIN_ALLOW_THREADS это просто макрос, который сохраняет текущее состояние потока и вызывает PyEval_SaveThread, который освобождает GIL. Как правило GIL это обычный системный мьютекс. Но если внимательно читать статью, то я писал, что в любом случае мы этим мьютексом защищены - вызываем мы
fastrlock
илиRLock
. И преимущество мы достигаем, за счет самой реализации реентерабельной блокировки.И возвращаясь к 100к потокам и RLock - никто в здравом уме не будет запускать 100к (даже если бы имел смысл) и ходить ими в защищаемый ресурс. На таких числах работают уже другие подходы, например, data parallelism.
Я вам ваше, в своем первом комментарии, написал, какие есть варианты написания многопоточного кода на Python, который мог бы заниматься осмысленной работой и при этом GIL ему бы не мешал. У меня складывается мнение, что вы не понимаете как работает GIL в Python и зачем он нужен.
Допустим у нас есть несколько больших массивов numpy, с которыми мы должны сделать какую то операцию и получить третий массив. Здесь код, который эту операцию выполняет вполне может быть запущен как в один поток, так и в несколько. Или другой пример, архивация данных - тоже может быть выполнена в несколько потоков, даже с применением OpenMP. При всем при этом вы можете отпустить GIL и другие потоки, например, главный поток приложения будет продолжать взаимодействовать с пользователем.
Если вы готовы это конструктивно обсуждать (в чем я не уверен), мы можем даже посмотреть какие-то примеры кода.
Возвращаясь к вашему первому примеру, вы там сделали правильную ремарку То есть на
C
с атомарными переменными - т.е. вы вполне понимаете, что даже на С++ код `x = x + 1` выполнится атомарно, только если x имеет тип, например, std::atomic<int32_t>. Тогда почему вы отказываете питону возможность реализовать ту же операцию с использованием таких же "атомиков" под капотом?В том же питоне, GIL вам не запрещает работать с одним и тем же объектом из разных потоков (например, пополнять список). GIL дает вам гарантию, что внутреннее состояния списка не будет "разрушено" в ходе работы двух потоков. В том же PEP703 помимо всего прочего предлагается реализовать объектные блокировки.
В целом там много интересного, заставлять его прочитать вас я не могу, да и не хочу.
Вы очень категоричны в своих суждениях и, тем более, в развешивании ярлыков. Подход о котором вы пишите известен всем, кто с данной проблемой сталкивается.
Вам же я процитирую PEP703, который вы не читали, как и код, на который даны ссылки:
Наверное надо было написать ‘для Python’, но тут уже моя проф. деформация сработала.
Полностью согласен.
Тут наверное можно долго спорить для чего подходит Python.
На нем можно решать CPU-intensive задачи, да не самим питоном, но внешними библиотеками (например, numba, numpy, scipy, pandas, py-arrow и т.д.) либо разрабатывать свои собственные расширения (на C, C++, Rust) и такого рода задачи вполне можно пускать в отдельных потоках, управляя ими из приложения, написанного на Python.
К тому же, asyncio появился только в 3.4, а есть проекты, которые ведут свою историю раньше, и в которых такого рода задачи надо решать.
Код для тестирования в репозитории у автора
fastrlock
- fastrlock/lockbench.py at master · scoder/fastrlock, я туда добавил только h5py (что не заслуживает какого-то отдельного выкладывания).Ссылку добавлю в статью, спасибо!
Этот код защищен GIL, поэтому исполняется только одним потоком в единицу времени. Пока мы не освободим GIL (через вызов Py_BEGIN_ALLOW_THREADS) другой поток в нашу функцию fastrlock_acquire попасть не может.
На 100_000 потоках тестировать это смысла нет (как впрочем и запускать такое количество потоков, только если они не занимаются ожиданием на IO).
В данном случае, если элемент отсутствует вы получите исключение. Но можно делать .pop(Key, None) например.
Наверное, надо еще написать, где это полезно (на мой взгляд):
Первый вариант (ИМХО) допустим на вершине стека, когда дальше передавать исключение некуда.
Второй вариант - когда в данном месте вам нужна дополнительная обработка исключения (могу только придумать, насытить исключение дополнительным контекстом)
Третий вариант - представьте что у вас в 1М строк кода и в логах вы начинаете видеть During handling of the above exception, another exception occurred - для вас как для инженера это означает одно - произошла еще одна ошибка при обработке существующей и не известно, все ли пошло по плану - соответственно надо разбираться, что за ошибка.
Четвертый вариант - вы оборачиваете исключения либо в свои библиотечные, либо просто сокращаете количество исключений, которые могут от вас вылететь (снижая нагрузку на вызывающий код), но при этом сохраняете оригинальное исключение (например, чтобы оно попало в лог и потом кто-то его расследовал)
Пятый вариант как четвертый, но оригинальное сообщение вам не нужно, потому что в новом достаточно информации.
Находясь в обработчике исключений у вас есть 5 вариантов, что вы можете сделать:
Обработать исключение и подавить его
Обработать исключение и пробросить дальше - просто вызвать raise
Обработать исключение и выбросить новое исключение - вызвать raise Exception()
Обработать исключение и выбросить новое исключение и сохранить информацию об оригинальном - вызвать raise Exception() from e
Обработать исключение и выбросить новое исключение, но скрыть информацию об оригинальном исключении - вызвать raise Exception() from None
PS. Простите за спам в почте - я победил редактор