Статьи

PyPy, сборка мусора и тупик

Уроборос

Я исправил взаимоблокировку в PyMongo 3 и PyPy, что редко могло произойти и в PyMongo 2. Диагностика тупика была познавательной и учит нас правилу написания __del__методов — еще один совет о том, что ожидать, когда вы истекаете.

Пример игрушки

Это тупики в CPython:

import threading

lock = threading.Lock()

class C(object):
    def __del__(self):
        print('getting lock')
        with lock:
            print('releasing lock')
            pass

c = C()
with lock:
    del c

Оператор del cудаляет переменную cиз пространства имен. На cуказанный объект больше нет ссылок, поэтому CPython немедленно вызывает свой __del__метод, который пытается получить блокировку. Блокировка удерживается, поэтому процесс блокируется. Он печатает «получить замок» и висит навсегда.

Что если мы поменяем два последних утверждения ?:

del c
with lock:
    pass

Это хорошо. В __del__завершается и освобождает метод блокировки до следующего утверждения приобретает его.

Но рассмотрим PyPy. Он не использует подсчет ссылок: объекты, на которые нет ссылок, живут до тех пор, пока сборщик мусора не освободит их. Момент освобождения объектов непредсказуем. Если GC срабатывает, пока блокировка удерживается, он заходит в тупик. Мы можем форсировать эту ситуацию:

del c
with lock:
    gc.collect()

Как и в первом примере, здесь печатается «получение блокировки» и взаимоблокировки.

Ошибка PyMongo

Несколько недель назад я обнаружил такую ​​тупиковую ситуацию в своем коде для предстоящего выпуска PyMongo 3.0 . Оттуда я обнаружил гораздо более редкий тупик в текущем выпуске.

Я дам вам небольшой контекст, чтобы вы могли видеть, как возникла ошибка. С PyMongo вы транслируете результаты с сервера MongoDB как:

for document in collection.find():
    print(document)

findМетод фактически возвращает экземпляр Cursorкласса, так что вы могли бы написать это:

cursor = collection.find()
for document in cursor:
    print(document)

Когда вы выполняете итерацию курсора, он возвращает документы из своего клиентского буфера до тех пор, пока буфер не станет пустым, а затем извлекает еще один большой пакет документов с сервера. После того, как он возвращает окончательный документ окончательной партии, он поднимается StopIteration.

Но что, если ваш код выдает исключение раньше?

for document in cursor:
    1 / 0  # Oops.

Курсор на стороне клиента выходит из области видимости, но сервер сохраняет небольшое количество состояния курсора в памяти в течение 10 минут . PyMongo хочет быстро это исправить, приказав серверу закрыть курсор, как только клиенту это не нужно. Деструктор класса Cursor отвечает за сообщение серверу:

class Cursor(object):
    def __del__(self):
        if self.alive:
            self._mongo_client.close_cursor(self.cursor_id)

Чтобы отправить сообщение на сервер, PyMongo 3.0 должен проделать определенную работу: он получает блокировку внутреннего класса Topology, чтобы он мог получить пул соединений, затем он блокирует пул, чтобы он мог извлечь сокет. В PyPy мы выполняем эту работу в совершенно непредсказуемый момент: это происходит всякий раз, когда запускается сборка мусора. Если какой-либо поток удерживает любую блокировку в этот момент, процесс блокируется.

(Некоторые детали: по умолчанию объекты с __del__методом освобождаются сборщиком мусора PyPy только во время полного GC , который срабатывает, когда память выросла на 82% с момента последнего полного GC . Поэтому, если вы позволите открытому курсору выйти из области видимости, это не будет освобождено в течение некоторого времени.)

диагностика

Впервые я обнаружил этот тупик в неизданном коде для PyMongo 3.0. Наш тестовый набор иногда зависал под PyPy в Jenkins. Когда я дал контрольный тест Control-C, он напечатал:

Exception KeyboardInterrupt in method __del__
of <pymongo.cursor.Cursor object> ignored

The exception is «ignored» and printed to stderr, as all exceptions in __del__ are. Once it printed the error, the test suite resumed and completed. So I added two bits of debugging info. First, whenever a cursor was created it stored a stack trace so it could remember where it came from. And second, if it caught an exception in __del__, it printed the stored traceback and the current traceback:

class Cursor(object):
    def __init__(self):
        self.tb = ''.join(traceback.format_stack())

    def __del__(self):
        try:
            self._mongo_client.close_cursor(self.cursor_id)
        except:
            print('''
I came from:%s.
I caught:%s.
''' % (self.tb, ''.join(traceback.format_stack()))

The next time the test hung, I hit Control-C and it printed something like:

I came from:
Traceback (most recent call last):
  File "test/test_cursor.py", line 431, in test_limit_and_batch_size
    curs = db.test.find().limit(0).batch_size(10)
  File "pymongo/collection.py", line 828, in find
    return Cursor(self, *args, **kwargs)
  File "pymongo/cursor.py", line 93, in __init__
    self.tb = ''.join(traceback.format_stack())

I caught:
Traceback (most recent call last):
  File "pymongo/cursor.py", line 211, in __del__
    self._mongo_client.close_cursor(self.cursor_id)
  File "pymongo/mongo_client.py", line 908, in close_cursor
    self._topology.open()
  File "pymongo/topology.py", line 58, in open
    with self._lock:

Great, so a test had left a cursor open, and about 30 tests later that cursor’s destructor hung waiting for a lock. It only hung in PyPy, so I guessed it had something to do with the differences between CPython’s and PyPy’s garbage collection systems.

I was doing the dishes that night when my mind’s background processing completed a diagnosis. As soon as I thought of it I knew I had the answer, and I wrote a test that proved it the next morning.

The Fix

PyMongo 2’s concurrency design is unsophisticated and the fix was easy. I followed the code path that leads from the cursor’s destructor and saw two places it could take a lock. First, if it finds that the MongoClient was recently disconnected from the server, it briefly locks it to initiate a reconnect. I updated that code path to give up immediately if the client is disconnected—better to leave the cursor open on the server for 10 minutes than to risk a deadlock.

Second, if the client is not disconnected, the cursor destructor locks the connection pool to check out a socket. Here, there’s no easy way to avoid the lock, so I came at the problem from the other side: how do I prevent a GC while the pool is locked? If the pool is never locked at the beginning of a GC, then the cursor destructor can safely lock it. The fix is here, in Pool.reset:

class Pool:
    def reset(self):
        sockets = None
        with self.lock:
            sockets = self.sockets
            self.sockets = set()

        for s in sockets:
            s.close()

This is the one place we allocate data while the pool is locked. Allocating the new set while holding the lock could trigger a garbage collection, which could destroy a cursor, which could attempt to lock the pool again, and deadlock. So I moved the allocation outside the lock:

    def reset(self):
        sockets = None
        new_sockets = set()
        with self.lock:
            sockets = self.sockets
self.sockets = new_sockets

        for s in sockets:
            s.close()

Now, the two lines of reset that run while holding the lock can’t trigger a garbage collection, so the cursor destructor knows it isn’t called by a GC that interrupted this section of code.

And what about PyMongo 3? The new PyMongo’s concurrency design is much superior, but it spends much more time holding a lock than PyMongo 2 does. It locks its internal Topology class whenever it reads or updates information about your MongoDB servers. This makes the deadlock trickier to fix.

I borrowed a technique from the MongoDB Java Driver: I deferred the job of closing cursors to a background thread. Now, when an open cursor is garbage collected, it doesn’t immediately tell the server. Instead, it safely adds its ID to a list. Each MongoClient has a thread that runs once a second checking the list for new cursor IDs. If there are any, the thread safely takes the locks it needs to send the message to the server—unlike the garbage collector, the cursor-cleanup thread cooperates normally with your application’s threads when it needs a lock.

What To Expect When You’re Expiring

I already knew that a __del__ method:

Now, add a third rule:

  • It must not take a lock.

Weakref callbacks must follow these three rules, too.

The Moral Of The Story Is….

Don’t use __del__ if you can possibly avoid it. Don’t design APIs that rely on it. If you maintain a library like PyMongo that has already committed to such an API, you must follow the rules above impeccably.