Статьи

Как заблокировать ваш питон с помощью getaddrinfo ()

Что произойдет, если вы запустите этот код ?:

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.

Ссылки:

Изображение: очковый кайман и американская труба змея