Что произойдет, если вы запустите этот код ?:
import os
import socket
import threading
def lookup():
socket.getaddrinfo('python.org', 80)
t = threading.Thread(target=lookup)
t.start()
if os.fork():
# Parent waits for child.
os.wait()
else:
# Child hangs here.
socket.getaddrinfo('mongodb.org', 80)
В Linux это завершается за миллисекунды. На Mac обычно виснет. Почему?
Путешествие в центр переводчика
Анна Херлихи и я решили этот вопрос несколько месяцев назад. Это не выглядело как пример кода выше — не поначалу. Мы натолкнулись на статью Энтони Фейеса, в которой сообщалось, что новый PyMongo 3.0 не работал с его программным обеспечением, которое сочетало многопоточность и многопроцессорность. Часто он создавал MongoClient, затем форк, и в дочернем процессе MongoClient не мог подключиться ни к одному серверу:
import os
from pymongo import MongoClient
client = MongoClient()
if os.fork():
# Parent waits for child.
os.wait()
else:
# After 30 sec, "ServerSelectionTimeoutError: No servers found".
client.admin.command('ping')
В PyMongo 3 MongoClient начинает подключаться к вашему серверу с фоновым потоком. Это позволяет распараллеливать соединения, если есть несколько серверов, и предотвращает блокировку вашего кода, даже если некоторые соединения медленные. Это работало нормально, за исключением сценария Энтони Фейеса: когда за конструктором MongoClient сразу же следовал a fork
, MongoClient был прерван в дочернем процессе.
Анна расследовала. Она могла бы воспроизвести тайм-аут на своем Mac, но не на коробке с Linux.
Она спустилась по слоям PyMongo, используя отладчик PyCharm и операторы print, и обнаружила, что дочерний процесс завис, когда он пытался открыть свое первое соединение с MongoDB. Он достиг этой линии и остановился:
infos = socket.getaddrinfo(host, port)
Это напомнило мне getaddrinfo
причуду, о которой я узнал во время поездки в сторону, когда я отлаживал совершенно не связанный getaddrinfo
тупик в прошлом году . Суть заключается в следующем: на некоторых платформах Python блокирует getaddrinfo
вызовы, позволяя обрабатывать имя только одному потоку за раз. В стандартном socketmodule.c Python:
/* On systems on which getaddrinfo() is believed to not be thread-safe,
(this includes the getaddrinfo emulation) protect access with a lock. */
#if defined(WITH_THREAD) && (defined(__APPLE__) || \
(defined(__FreeBSD__) && __FreeBSD_version+0 < 503000) || \
defined(__OpenBSD__) || defined(__NetBSD__) || \
defined(__VMS) || !defined(HAVE_GETADDRINFO))
#define USE_GETADDRINFO_LOCK
#endif
Поэтому Анна добавила несколько printfs в socketmodule.c, пересобрала свою копию CPython на Mac и спустилась еще глубже в слои. Конечно же, интерпретатор блокируется здесь в дочернем процессе:
static PyObject *
socket_getaddrinfo(PyObject *self, PyObject *args)
{
/* ... */
Py_BEGIN_ALLOW_THREADS
printf("getting gai lock...\n");
ACQUIRE_GETADDRINFO_LOCK
printf("got gai lock\n");
error = getaddrinfo(hptr, pptr, &hints, &res0);
Py_END_ALLOW_THREADS
RELEASE_GETADDRINFO_LOCK
Макрос Py_BEGIN_ALLOW_THREADS
сбрасывает глобальную блокировку интерпретатора, так что другие потоки Python могут работать, пока этот ожидает getaddrinfo
. Затем, в зависимости от платформы, ACQUIRE_GETADDRINFO_LOCK
ничего не делает (Linux) или захватывает блокировку (Mac). После getaddrinfo
возврата этот код сначала запрашивает глобальную блокировку интерпретатора, а затем снимает getaddrinfo
блокировку (если она есть).
Итак, в Linux эти строки допускают одновременный поиск имени хоста. На Mac только один поток может ожидать getaddrinfo
одновременно. Но почему разветвление вызывает полный тупик?
диагностика
Рассмотрим наш оригинальный пример:
def lookup():
socket.getaddrinfo('python.org', 80)
t = threading.Thread(target=lookup)
t.start()
if os.fork():
# Parent waits for child.
os.wait()
else:
# Child hangs here.
socket.getaddrinfo('mongodb.org', 80)
Поток lookup
запускается, сбрасывает глобальную блокировку интерпретатора, захватывает getaddrinfo
блокировку и ожидает getaddrinfo
. Поскольку GIL доступен, основной поток берет его и возобновляет. Следующим вызовом основного потока является fork
.
Когда процесс разветвляется, только fork
дочерний поток копируется в дочерний процесс. Таким образом, в дочернем процессе основной поток продолжается, а lookup
поток исчезает. Но это была нить, удерживающая getaddrinfo
замок! В дочернем процессе getaddrinfo
блокировка никогда не будет снята — поток, задачей которого было освободить его, является капутом.
В этом урезанном примере следующим событием является дочерний процесс, вызывающий getaddrinfo
основной поток. getaddrinfo
Замок никогда не освобождается, поэтому этот процесс просто ТУПИКОВ. В реальном сценарии PyMongo основной поток не блокируется, но всякий раз, когда он пытается использовать сервер MongoDB, он истекает. Анна объяснила: «В дочернем процессе getaddrinfo
блокировка никогда не будет разблокирована — поток, который заблокировал ее, не был скопирован на дочерний процесс, — поэтому фоновый поток никогда не сможет разрешить имя хоста сервера и подключиться. В этом случае основной поток дочернего процесса истечет время ожидания. «
(Отступление: если бы это была программа на C, она бы непредсказуемо переключала потоки и не всегда находилась бы в тупиковой ситуации. Иногда lookup
поток заканчивал работу getaddrinfo
до того, как основной поток разветвился, иногда нет. Но в Python переключение потоков происходит нечасто и предсказуемо. разрешено переключать каждые 1000 байт-кодов в Python 2 или каждые 15 мс в Python 3. Если несколько потоков ожидают GIL, они будут склонны переключаться каждый раз, когда отбрасывают GIL Py_BEGIN_ALLOW_THREADS
и ждут, например, вызова C. Как и getaddrinfo
в Python тупик практически детерминированный.)
верификация
У нас с Анной была гипотеза. Но можем ли мы доказать это?
Один тест состоял в том, что, если мы подождем, пока фоновый поток, вероятно, не снимет getaddrinfo
блокировку, прежде чем мы разветвимся, мы не должны видеть тупик. Действительно, мы избежали тупика, добавив крошечный сон перед форком:
client = MongoClient()
time.sleep(0.1)
if os.fork():
# ... and so on ...
Мы ifdef
снова прочитали файл sockmodule.c и изобрели другой способ проверить нашу гипотезу: мы должны зайти в тупик на Mac и OpenBSD, но не на Linux или FreeBSD. Мы создали несколько видов виртуальных машин и вуаля, они заблокированы или нет, как и ожидалось.
(Windows тоже может зайти в тупик, за исключением того, что Python на Windows не может разветвляться.)
Почему сейчас?
Почему эта ошибка сообщалась в PyMongo 3, а не в нашей предыдущей версии драйвера PyMongo 2?
PyMongo 2 имеет более простой и менее параллельный дизайн: если вы создаете один MongoClient, он не создает фоновых потоков, поэтому вы можете fork
безопасно выйти из основного потока. Старый MongoReplicaSetClient использовал фоновый поток, но его конструктор блокировался, пока фоновый поток не завершил свои соединения. Этот код был медленным, но довольно безопасным:
# Blocks until initial connections are done.
client = MongoReplicaSetClient(hosts, replicaSet="rs")
if os.fork():
os.wait()
else:
client.admin.command('ping')
Однако в PyMongo 3 MongoReplicaSetClient пропал. MongoClient теперь обрабатывает подключения к отдельным серверам или наборам реплик. Конструктор нового клиента порождает один или несколько потоков, чтобы начать соединение, и он сразу возвращается, а не блокируется. Таким образом, фоновый поток обычно удерживает getaddrinfo
блокировку, в то время как основной поток выполняет следующие несколько своих операторов.
Тогда не делай этого
К сожалению, нет реального решения этой ошибки. Мы не будем возвращаться к старому однопоточному, блокирующему MongoClient — преимущества нового кода слишком велики. Кроме того, даже медленный старый код не делал его полностью безопасным для форка. Вы были менее склонны к форку, когда поток удерживал getaddrinfo
блокировку, но если вы использовали MongoReplicaSetClient, риск взаимной блокировки всегда был.
Анна и я решили, что сценарий использования для разветвления сразу после создания MongoClient, в любом случае, не является распространенным или необходимым. Вам лучше сначала разветвляться:
if os.fork():
os.wait()
else:
# Safe to create the client now.
client = MongoClient()
client.admin.command('ping')
Форкинг в первую очередь является хорошей идеей, будь то с PyMongo или любой другой сетевой библиотекой — очень сложно сделать библиотеки защищенными от форка, лучше не рисковать.
Если вам нужно сначала создать клиент, вы можете сказать ему не запускать фоновые потоки до тех пор, пока он не понадобится, например:
client = MongoClient(connect=False)
if os.fork():
os.wait()
else:
# Threads start on demand and connect to server.
client.admin.command('ping')
Предупреждение, впереди тупик!
У нас были удобные обходные пути. Но как мы можем помешать следующему пользователю, как Энтони, потратить дни на отладку этого?
Анна нашла способ определить, использовался ли MongoClient с опасностью, и напечатать предупреждение от дочернего процесса:
UserWarning: MongoClient opened before fork. Create MongoClient
with connect=False, or create client after forking. See PyMongo's
documentation for details:
http://bit.ly/pymongo-faq#using-pymongo-with-multiprocessing
Мы отправили это исправление с PyMongo 3.1 в ноябре.
В следующий раз: getaddrinfo
замок снова срабатывает, и я провожу неделю, исправляя asyncio в стандартной библиотеке Python.
Ссылки: