Обработка KeyboardInterrupt в методе контекстного менеджера __enter__

Я пишу код, который хочу использовать в интерактивной оболочке, такой как IPython, это означает, что код должен иметь возможность обрабатывать полностью асинхронное и неожиданное исключение, такое как KeyboardInterrupt, и по-прежнему функционировать после него. Думаю, мне следует использовать контекстные менеджеры для обработки блокировки и разблокировки.

Проблема в том, что если во время выполнения метода __enter__ или __exit__ возникает исключение, и мне нужно получить несколько блокировок в методе __enter__ и снять несколько блокировок в __exit__?

Насколько я понимаю, вызовы кода C являются атомарными, а все инструкции байт-кода являются атомарными. Таким образом, вызов thread.Lock.acquire() не будет прерван, как и присвоение результата этого вызова переменной. Но у нас нет гарантии, что исключение не будет вызвано между возвратом из acquire() и присвоением возвращаемого значения переменной (поскольку это обрабатывается двумя разными операциями на уровне байт-кода).

Другими словами, такой код:

import threading
lock = threading.Lock()
while True:
    locked = False
    try:
        locked = lock.acquire()
    except KeyboardInterrupt:
        if locked:
            lock.release()
            locked = False
        raise
    finally:
        if locked:
            lock.release()    

не всегда отпускает lock на Ctrl+C.

аналогично, следующий скрипт иногда не проходит последнюю проверку assert:

import sys
import threading

class ComplexLock():
    def __init__(self):
        self.lock_a = threading.Lock()
        self.lock_b = threading.Lock()

    def __enter__(self):
        self.lock_a.acquire()
        self.lock_b.acquire()

    def __exit__(self, exc_type, exc_value, traceback):
        self.lock_b.release()
        self.lock_a.release()

    def state_consistent(self):
        return not self.lock_a.locked() and not self.lock_b.locked()


a = ComplexLock()
assert a.state_consistent()

try:
    while True:
        with a:
            pass
except KeyboardInterrupt:
    sys.exit(0)
finally:
    assert a.state_consistent()

(примечание: я не могу использовать lock.locked() вместо locked, так как я не знаю, удерживает ли блокировку текущий поток; lock является глобальным и общим, locked является локальным для потока)

Итак, вопрос в том, как обращаться с KeyboardInterrupt таким образом, чтобы не повредить глобальные объекты?

Практический пример использования: Учтите, что вы хотите вычислить e**x, но ради этого аргумента время вычисления велико и зависит от значения x. Как поставщик библиотеки, я предоставляю e в качестве экспорта, чтобы пользователь мог

from example_library import e
e**12

Теперь, поскольку вычисления занимают много времени, я хочу внутренне кэшировать некоторые предварительно вычисленные переменные (не результаты, я знаю о lru_cache, и это не сработает для моего реального случая использования). Так что, хотя e не меняется внешне, оно меняется внутренне. Но это детали реализации, неизвестные пользователю. Поэтому, когда пользователь делает ^C, потому что e**10000 занимает слишком много времени, пользователь может ожидать, что выполнение e**12 после этого ^C будет работать нормально.

Но это не сработает, если блокировка, используемая для синхронизации кеша предварительно вычисляемых переменных, была заблокирована, но не освобождена во время предыдущего вычисления.

Если вы намеренно не подавите исключение (через signal), у вас не будет возможности полностью его обработать; поскольку он асинхронный, он может быть вызван снова при обработке, и теоретически это может произойти вплоть до произвольной рекурсии. Можете ли вы уточнить, что вы считаете правильным обращением с KeyboardInterrupt? Поскольку смысл KeyboardInterrupt состоит в том, чтобы сигнализировать о завершении работы, кажется, что правильным ответом является завершение приложения; это, естественно, избавит от необходимости снимать внутрипроцессные блокировки.

MisterMiyagi 12.12.2020 22:20

@MisterMiyagi, как я уже сказал, это для случая использования IPython, когда у вас есть глобальные объекты с блокировками, вы хотите, чтобы пользователь мог прервать долгий расчет с помощью Ctrl + C и при этом иметь согласованную среду. Итак, нет, KeyboardInterrupt не означает завершение процесса.

Hubert Kario 12.12.2020 22:28

Теперь, когда мы выяснили, что прекращение — это не то, что вы ожидаете, можете ли вы уточнить, что вы ожидаете на самом деле? Каково ожидаемое поведение? Пока я вижу только то, чего вы не ожидаете. Что вы ожидаете, если блокировка удерживается дочерним потоком, который естественным образом защищен от KeyboardInterrupt? Это должно работать только для ipython или обычных программ?

MisterMiyagi 13.12.2020 06:56

@MisterMiyagi ситуация такова, что пользователь может начать длительный расчет с небольшим критическим разделом, в котором используются глобальные объекты. После Ctrl+C пользователем я не хочу, чтобы глобальные объекты не работали, потому что их состояние блокировки непоследовательно (заблокировано, несмотря на то, что код не запущен). Он должен работать как для интерактивного режима интерпретатора Python, так и для IPython.

Hubert Kario 13.12.2020 12:25

Является ли вычисление на самом деле дорогостоящим, т.е. вы хотите, чтобы ^C просто возвращал приглашение или он также должен убить вычисление?

MisterMiyagi 13.12.2020 12:51

на самом деле, я думаю, что это должно работать и в обычном питоне: я могу представить ситуацию, когда приложение позволяет пользователю выполнять Ctrl + C, чтобы пропустить некоторые вычисления, отбросить эти результаты и продолжить другие, несвязанные вычисления.

Hubert Kario 13.12.2020 12:51

@MisterMiyagi расчет стоит дорого, я хочу прервать его в произвольном месте

Hubert Kario 13.12.2020 12:53

Означает ли это, что вы также должны иметь возможность прервать его, если он не работает в основном потоке? Использование блокировки для защиты общего состояния между потоками, по-видимому, подразумевает это.

MisterMiyagi 13.12.2020 12:55

@MisterMiyagi блокировка есть, потому что я не знаю, будет ли код использоваться в многопоточном приложении или нет. Обычно ^C происходит только в однопоточном коде, но я не могу этого предположить. Если пользователь запускает поток, который выполняет вызов моей библиотеки, который занимает много времени, он не сможет прервать его немедленно, но это верно для всего кода Python, так что это неудивительно. Неспособность выполнять вычисления с объектами, которые кажутся неизменяемыми (но поскольку детали реализации внутри не являются таковыми) после ^C, является неожиданностью и, возможно, ошибкой.

Hubert Kario 13.12.2020 13:05

Хорошо, это, вероятно, то, с чем можно поработать (все же было бы неплохо иметь требования в реальных вопросах). Однако имейте в виду, что, поскольку многопоточность кажется не частью вашего предполагаемого варианта использования, обычным способом было бы документировать ваши вычисления как не потокобезопасные. Это означает, что пользователи соглашаются на защиту потоков, подходящую для их варианта использования, вместо того, чтобы вам приходилось гадать, что это за варианты использования и как их защитить.

MisterMiyagi 13.12.2020 13:34

Давайте продолжим обсуждение в чате.

Hubert Kario 13.12.2020 15:28
Почему в Python есть оператор "pass"?
Почему в Python есть оператор "pass"?
Оператор pass в Python - это простая концепция, которую могут быстро освоить даже новички без опыта программирования.
Некоторые методы, о которых вы не знали, что они существуют в Python
Некоторые методы, о которых вы не знали, что они существуют в Python
Python - самый известный и самый простой в изучении язык в наши дни. Имея широкий спектр применения в области машинного обучения, Data Science,...
Основы Python Часть I
Основы Python Часть I
Вы когда-нибудь задумывались, почему в программах на Python вы видите приведенный ниже код?
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
Алиса и Боб имеют неориентированный граф из n узлов и трех типов ребер:
Оптимизация кода с помощью тернарного оператора Python
Оптимизация кода с помощью тернарного оператора Python
И последнее, что мы хотели бы показать вам, прежде чем двигаться дальше, это
Советы по эффективной веб-разработке с помощью Python
Советы по эффективной веб-разработке с помощью Python
Как веб-разработчик, Python может стать мощным инструментом для создания эффективных и масштабируемых веб-приложений.
1
11
1 350
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Я не думаю, что есть хороший способ справиться с этим с помощью KeyboardInterrupt, но вы можете справиться с этим с помощью сигналов.

В Python KeyboardInterrupt вызывается обработчиком по умолчанию для SIGNINT (см. здесь). Вы можете определить свой собственный обработчик сигналов, чтобы делать что-то еще, и обрабатывать его, когда хотите.

import os
import signal
import threading

interrupted = False


def signal_handler(signal_number, frame):
    global interrupted
    interrupted = True


original_signal_handler = signal.signal(signal.SIGINT, signal_handler)

lock = threading.Lock()
while not interrupted:
    locked = False
    try:
        locked = lock.acquire()
        # Do something while holding the lock
    finally:
        if locked:
            lock.release()
signal.signal(signal.SIGINT, original_signal_handler)
if os.name == "nt":
    # Windows needs a special case because kill() doesn't send SIGINT.
    os.kill(os.getpid(), signal.CTRL_C_EVENT)
else:
    os.kill(os.getpid(), signal.SIGINT)

Я не тестировал версию для Windows, но она должна работать.

Вы должны позаботиться о том, чтобы ваш код быстро завершал работу после установки interrupted.

проблема в том, что я делаю это в библиотеке, а не в приложении, поэтому я не могу предположить, что могу использовать обработчик SIGINT

Hubert Kario 12.12.2020 22:12

Вы предполагаете, что KeyboardInterrupt будет повышено, а это означает, что вы предполагаете, что приложение, использующее вашу библиотеку, не будет устанавливать свой собственный обработчик сигналов. В таком случае, не могли бы вы установить свой собственный обработчик, сделать свое дело, а затем восстановить обработчик по умолчанию и снова отправить сигнал?

Nadav Zingerman 12.12.2020 22:17

Как мне сделать так, чтобы код, который я оборачиваю в контекстный менеджер, все еще мог быть прерван с помощью Ctrl + C?

Hubert Kario 12.12.2020 23:09

У вас все еще есть пользовательский обработчик между lock.acquire() и lock.release(), поэтому «# Сделайте что-нибудь, удерживая блокировку» не будет прервано

Hubert Kario 13.12.2020 12:09

Вы имеете в виду, что вы перезваниваете коду пользователя, удерживая блокировку?

Nadav Zingerman 13.12.2020 13:23

Давайте продолжим обсуждение в чате.

Nadav Zingerman 13.12.2020 13:25
Ответ принят как подходящий

На самом деле, похоже, что это невозможно в общем смысле, это ошибка в питоне: https://bugs.python.org/issue29988 а также это известный недостаток, см. PEP 419 (к сожалению, отложено ). И конкретнее для этого случая: https://bugs.python.org/issue31388

Суть в том, что даже если __enter__ и __exit__ являются функциями C, не гарантируется, что __exit__ будет вызвана, даже если __enter__ будет выполнена успешно, мы можем просто исправить это на уровне Python, чтобы сделать это менее вероятным, а не невозможным

реализуя наш собственный обработчик сигналов и откладывая доставку сигнала, мы можем сделать его достаточно надежным, но только в том случае, если ожидается, что критический участок будет коротким, поэтому отсрочка не повлияет на пользователя.

чтобы продолжить пример из вопроса, похоже, что-то вроде этого работает, как и ожидалось, даже если мы введем потоки в микс:

import sys
import signal
import threading

class ComplexLock():
    def __init__(self):
        self.lock_a = threading.Lock()
        self.lock_b = threading.Lock()

    def __enter__(self):
        if threading.current_thread().__class__.__name__ == '_MainThread':
            # only MainThread can handle signals
            self.signal_received = False
            self.old_handler = signal.signal(signal.SIGINT, self._handler)

        self.lock_a.acquire()
        self.lock_b.acquire()

    def __exit__(self, exc_type, exc_value, traceback):
        self.lock_b.release()
        self.lock_a.release()

        if threading.current_thread().__class__.__name__ == '_MainThread':
            signal.signal(signal.SIGINT, self.old_handler)
            if self.signal_received:
                self.old_handler(*self.signal_received)

    def _handler(self, sig, frame):
        self.signal_received = (sig, frame)

    def state_consistent(self):
        return not self.lock_a.locked() and not self.lock_b.locked()

lock = ComplexLock()
assert lock.state_consistent()
import time
def countdown(x):
    while x[0]:
        with x[1]:
            pass

param = [True, lock]

t = threading.Thread(target=countdown, args=(param, ))
t.start()

try:
    while True:
        with lock:
            pass
except KeyboardInterrupt:
    param[0] = False
    t.join()
    sys.exit(0)
finally:
    assert lock.state_consistent()

(очевидно, есть гонки, когда доставляется несколько сигналов, и некоторые из них могут быть сброшены, но это меньшая проблема, чем нарушенная среда после Ctrl + C)

Другие вопросы по теме