PEP 419 – 중단으로부터 정리 문 보호하기
- Author:
- Paul Colomiets <paul at colomiets.name>
- Status:
- Deferred
- Type:
- Standards Track
- Created:
- 06-Apr-2012
- Python-Version:
- 3.3
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
이 PEP는 finally 절 내부 또는 컨텍스트 관리자 정리 중에 Python 코드가 중단되지 않도록 보호하는 방법을 제안합니다.
PEP 연기
이 PEP에서 다루는 개념에 대한 추가 검토는 PEP의 목표를 추진하고 피드백을 수집 및 반영하는 데 관심이 있는 현재의 주도자가 없고, 이를 효과적으로 수행할 충분한 시간도 부족하여 연기되었습니다.
근거
Python에는 정리 작업을 수행하는 두 가지 좋은 방법이 있습니다. 하나는 finally 문이고, 다른 하나는 컨텍스트 관리자입니다(일반적으로 with 문을 사용하여 호출합니다). 그러나 어느 쪽도 generator.throw()로 인해 발생하는 KeyboardInterrupt 또는 GeneratorExit에 의한 중단으로부터 보호되지 않습니다. 예를 들어 다음과 같습니다.:
lock.acquire()
try:
print('starting')
do_something()
finally:
print('finished')
lock.release()
두 번째 print() 호출 직후에 KeyboardInterrupt가 발생하면 잠금이 해제되지 않습니다. 마찬가지로 with 문을 사용하는 다음 코드도 영향을 받습니다.:
from threading import Lock
class MyLock:
def __init__(self):
self._lock_impl = Lock()
def __enter__(self):
self._lock_impl.acquire()
print("LOCKED")
def __exit__(self):
print("UNLOCKING")
self._lock_impl.release()
lock = MyLock()
with lock:
do_something
어떤 print() 호출 근처에서든 KeyboardInterrupt가 발생하면 잠금은 절대 해제되지 않습니다.
코루틴 사용 사례
코루틴에서도 비슷한 경우가 발생합니다. 일반적으로 코루틴 라이브러리는 시간 초과를 사용하여 코루틴을 중단하려고 합니다. generator.throw() 메서드는 이 사용 사례에서 작동하지만, 코루틴이 현재 finally 절 내부에서 일시 중단된 상태인지 알 방법이 없습니다.
다음은 yield 기반 코루틴을 사용하는 예입니다. 널리 사용되는 코루틴 라이브러리인 Monocle [1], Bluelet [2], 또는 Twisted [3]를 사용해도 코드는 비슷하게 보입니다.
def run_locked():
yield connection.sendall('LOCK')
try:
yield do_something()
yield do_something_else()
finally:
yield connection.sendall('UNLOCK')
with timeout(5):
yield run_locked()
위 예제에서 yield something은 현재 코루틴의 실행을 일시 중지하고, 코루틴 something을 실행이 끝날 때까지 실행한다는 의미입니다. 따라서 코루틴 라이브러리 자체가 제너레이터 스택을 유지해야 합니다. connection.sendall() 호출은 소켓을 쓰기 가능한 상태가 될 때까지 기다리며 socket.sendall()이 수행하는 것과 비슷한 작업을 수행합니다.
with 문은 모든 코드가 5초의 시간 초과 내에 실행되도록 보장합니다. 시간 초과가 발생하면 메인 루프에 콜백을 등록하고, 이 콜백이 코루틴 스택의 최상위 프레임에서 generator.throw()를 호출하도록 하여 이를 수행합니다.
greenlets 확장 기능도 비슷한 방식으로 작동하지만, 새 스택 프레임에 진입하는 데 yield가 필요하지 않습니다. 그 외의 고려 사항은 비슷합니다.
사양
프레임 플래그 ‘f_in_cleanup’
프레임 객체에 새로운 플래그를 추가할 것을 제안합니다. 이 프레임이 현재 finally 절을 실행 중이면 True로 설정됩니다. 내부적으로 이 플래그는 현재 실행 중인 중첩된 finally 문의 개수를 세는 카운터로 구현해야 합니다.
내부 카운터는 SETUP_WITH 및 WITH_CLEANUP 바이트코드가 실행되는 동안에도 증가해야 하며, 이러한 바이트코드의 실행이 완료되면 감소해야 합니다. 이를 통해 __enter__() 및 __exit__() 메서드도 보호할 수 있습니다.
함수 ‘sys.setcleanuphook’
sys 모듈에 새로운 함수를 추가할 것을 제안합니다. 이 함수는 f_in_cleanup이 거짓이 될 때마다 실행되는 콜백을 설정합니다. 콜백에는 프레임 객체가 유일한 인자로 전달되므로, 콜백이 어디에서 호출되었는지 알아낼 수 있습니다.
이 설정은 스레드 로컬이며 PyThreadState 구조체에 저장해야 합니다.
Inspect 모듈 개선 사항
inspect 모듈에 두 개의 새로운 함수인 isframeincleanup() 및 getcleanupframe() 을 추가할 것을 제안합니다.
유일한 인자로 프레임 또는 제너레이터 객체를 받는 isframeincleanup()은 프레임 자체의 f_in_cleanup 속성 또는 제너레이터의 gi_frame 속성 값을 반환합니다.
유일한 인자로 프레임 객체를 받는 getcleanupframe()은 f_in_cleanup이 참인 가장 안쪽 프레임을 반환하며, 스택의 어떤 프레임에도 해당 속성의 0이 아닌 값이 없으면 None을 반환합니다. 이 함수는 지정된 프레임부터 검사를 시작하고 getouterframes()와 마찬가지로 f_back 포인터를 사용하여 바깥쪽 프레임으로 이동합니다.
예제
안전하게 인터럽트하는 SIGINT 핸들러의 구현 예제는 다음과 같을 수 있습니다.:
import inspect, sys, functools
def sigint_handler(sig, frame):
if inspect.getcleanupframe(frame) is None:
raise KeyboardInterrupt()
sys.setcleanuphook(functools.partial(sigint_handler, 0))
코루틴 라이브러리가 사용하는 트램펄린(또는 메인 루프)에 구현이 크게 의존하므로 코루틴 예제는 이 문서의 범위에 포함되지 않습니다.
미해결 문제
With 문 표현식 내부에서의 인터럽트
다음 문이 주어졌을 때
with open(filename):
do_something()
open()이 호출된 후 SETUP_WITH 바이트코드가 실행되기 전에 Python이 인터럽트될 수 있습니다. 가능한 결정은 두 가지입니다.
with표현식을 보호합니다. 현재는with표현식의 시작을 식별할 방법이 없으므로 또 다른 바이트코드가 필요합니다.- 사용 사례에서 중요하다고 판단하면 사용자가 래퍼를 작성하도록 합니다. 안전한 래퍼는 다음과 같을 수 있습니다.:
class FileWrapper(object): def __init__(self, filename, mode): self.filename = filename self.mode = mode def __enter__(self): self.file = open(self.filename, self.mode) def __exit__(self): self.file.close()
또는
contextmanager()데코레이터를 사용하여 작성할 수 있습니다.:@contextmanager def open_wrapper(filename, mode): file = open(filename, mode) try: yield file finally: file.close()
제너레이터의 첫 번째 부분(yield 이전)이 호출자의
SETUP_WITH바이트코드 내부에서 실행되므로 이 코드는 안전합니다.
예외 전파
때때로 finally 절 또는 __enter__()/__exit__() 메서드에서 예외가 발생할 수 있습니다. 일반적으로 이는 문제가 되지 않습니다. KeyboardInterrupt 또는 SystemExit과 같은 더 중요한 예외가 대신 발생해야 하기 때문입니다. 그러나 원래 예외를 __context__속성 내부에 보존할 수 있으면 유용할 수 있습니다. 따라서 정리 훅의 시그니처에 예외 인자를 추가할 수도 있습니다.:
def sigint_handler(sig, frame)
if inspect.getcleanupframe(frame) is None:
raise KeyboardInterrupt()
sys.setcleanuphook(retry_sigint)
def retry_sigint(frame, exception=None):
if inspect.getcleanupframe(frame) is None:
raise KeyboardInterrupt() from exception
Note
Python 3의 예외에는 __traceback__속성이 있으므로 __exit__ 메서드처럼 인자를 세 개 사용할 필요는 없습니다.
그러나 이렇게 하면 예외에 __cause__가 설정되며, 이는 정확히 의도한 동작은 아닙니다. 따라서 일부 숨겨진 인터프리터 로직을 사용하여 정리 훅에서 발생한 모든 예외에 __context__속성을 설정할 수 있습니다.
리소스 획득과 Try 블록 사이의 인터럽트
첫 번째 절의 예제는 완전히 안전하지 않습니다. 좀 더 자세히 살펴봅시다.:
lock.acquire()
try:
do_something()
finally:
lock.release()
lock.acquire()가 실행된 직후부터 try 블록에 진입하기 전 사이에 코드가 인터럽트되면 문제가 발생할 수 있습니다.
코드를 수정하지 않고 고칠 방법은 없습니다. 실제 수정 방법은 사용 사례에 크게 좌우됩니다. 일반적으로 with 문을 사용하면 코드를 수정할 수 있습니다.:
with lock:
do_something()
그러나 코루틴에서는 획득 및 해제 작업 모두에 대해 yield해야 하므로 일반적으로 with 문을 사용할 수 없습니다. 따라서 코드를 다음과 같이 다시 작성할 수 있습니다.:
try:
yield lock.acquire()
do_something()
finally:
yield lock.release()
실제 잠금 코드는 이 사용 사례를 지원하기 위해 더 많은 코드가 필요할 수 있지만, 구현은 일반적으로 다음과 같이 간단합니다. 잠금이 획득되었는지 확인하고 잠금을 해제합니다.
Finally 내부에서 EINTR 처리
시그널 핸들러가 f_in_cleanup플래그를 확인할 준비가 되어 있더라도, 해당 시스템 호출이 EINTR오류를 반환하기 때문에 정리 핸들러에서 InterruptedError가 발생할 수 있습니다. 주요 사용 사례는 이를 처리할 준비가 되어 있습니다:
- Posix 뮤텍스는
EINTR를 절대 반환하지 않습니다. - 네트워킹 라이브러리는 항상
EINTR를 처리할 준비가 되어 있습니다. - 코루틴 라이브러리는 일반적으로 시그널이 아니라
throw()메서드로 인터럽트됩니다.
플랫폼별 함수 siginterrupt()를 사용하면 EINTR를 처리할 필요를 없앨 수 있습니다. 그러나 이는 거의 예측할 수 없는 결과를 초래할 수 있습니다. 예를 들어 주 스레드가 IO 루틴 내부에서 멈춰 있으면 SIGINT핸들러가 호출되지 않습니다.
더 나은 방법은 일반적으로 정리 핸들러에서 사용되는 코드가 InterruptedError를 명시적으로 처리할 준비가 되어 있도록 하는 것입니다. 이러한 코드의 한 예로 파일 기반 잠금 구현을 들 수 있습니다.
signal.pthread_sigmask를 사용하면 EINTR로 인터럽트될 수 있는 정리 핸들러 내부에서 시그널을 차단할 수 있습니다.
Finally 자체에서 인터럽트 컨텍스트 설정
일부 코루틴 라이브러리는 finally 절 자체에 대한 타임아웃을 설정해야 할 수 있습니다. 예를 들어:
try:
do_something()
finally:
with timeout(0.5):
try:
yield do_slow_cleanup()
finally:
yield do_fast_cleanup()
현재 의미론에서는 각 라이브러리의 구현에 따라 타임아웃이 전체 with 블록을 보호하거나 전혀 보호하지 않게 됩니다. 작성자가 의도한 것은 do_slow_cleanup을 일반 코드로, do_fast_cleanup을 정리 코드(인터럽트할 수 없는 코드)로 취급하는 것입니다.
그린렛 또는 태스크릿을 사용할 때에도 유사한 사례가 발생할 수 있습니다.
이 사례는 f_in_cleanup을 카운터로 노출하고 감소할 때마다 정리 훅을 호출하도록 하면 해결할 수 있습니다. 그러면 코루틴 라이브러리는 타임아웃 시작 시 값을 기억한 다음, 각 훅 실행 시 그 값과 비교할 수 있습니다.
그러나 실제로는 이 예제가 고려하기에는 너무 난해하다고 여겨집니다.
KeyboardInterrupt 수정
기본 SIGINT 핸들러를 설명된 메커니즘을 사용하도록 수정할지 결정해야 합니다. 초기 제안은 이전 동작을 유지하는 것이며, 그 이유는 다음 두 가지입니다.
- 대부분의 애플리케이션은 종료 시 정리를 신경 쓰지 않습니다(외부 상태가 없거나, 크래시 안전한 방식으로 상태를 수정하기 때문입니다).
- 정리에 너무 많은 시간이 걸려 사용자가 애플리케이션을 중단할 기회를 얻지 못할 수 있습니다.
SIGINT 핸들러가 두 번 호출될 경우 안전하지 않은 중단을 허용하면 후자의 경우를 해결할 수 있지만, 복잡성을 감수할 가치가 없어 보입니다.
대체 Python 구현 지원
f_in_cleanup을 구현 세부 사항으로 간주합니다. 실제 구현에서는 시그널 핸들러와 정리 훅에 전달되고 getcleanupframe()에서 반환되는 가짜 프레임과 유사한 객체를 사용할 수 있습니다. 유일한 요구 사항은 inspect 모듈 함수가 이러한 객체에서 예상대로 작동하는 것입니다. 이러한 이유로 isframeincleanup() 함수에 제너레이터 객체를 전달하는 것도 허용하며, 이를 통해 gi_frame 속성을 사용할 필요가 없어집니다.
getcleanupframe()이 다음 호출 시 정리 훅에 전달될 동일한 객체를 반환해야 한다고 명시할 필요가 있을 수 있습니다.
대체 이름
원래 제안에는 f_in_finally 프레임 속성이 있었으며, 원래 의도는 finally 절을 보호하는 것이었습니다. 그러나 __enter__ 및 __exit__ 메서드까지 보호하도록 범위가 확장되면서 f_in_cleanup이라는 이름이 더 적절해 보입니다. __enter__ 메서드는 정리 루틴이 아니지만, 적어도 컨텍스트 관리자가 수행하는 정리와 관련이 있습니다.
setcleanuphook, isframeincleanup 및 getcleanupframe은 각각 set_cleanup_hook, is_frame_in_cleanup 및 get_cleanup_frame으로 명확하게 풀어 쓸 수 있지만, 각각의 모듈에 해당하는 명명 규칙을 따릅니다.
대체 제안
‘f_in_cleanup’ 플래그 자동 전파
이렇게 하면 getcleanupframe()이 필요 없어질 수 있습니다. 그러나 yield 기반 코루틴에서는 이를 직접 전파해야 합니다. 이를 쓰기 가능하게 만들면 setcleanuphook()의 동작이 다소 예측 불가능해집니다.
‘INCR_CLEANUP’, ‘DECR_CLEANUP’ 바이트코드 추가
이러한 바이트코드는 with 문 내부의 표현식을 보호하는 데 사용할 수 있으며, 카운터 증가를 더 명시적이고 디버깅하기 쉽게 만들 수 있습니다(디스어셈블리에서 볼 수 있습니다). END_FINALLY와 SETUP_WITH가 카운터를 암묵적으로 감소시키도록 하는 방식처럼, 중간 지점을 선택할 수도 있습니다(END_FINALLY는 모든 with 스위트의 끝에 존재합니다).
그러나 새로운 바이트코드를 추가하는 일은 매우 신중하게 고려해야 합니다.
‘f_in_cleanup’을 카운터로 노출
원래 의도는 필요한 기능을 최소한으로 노출하는 것이었습니다. 그러나 프레임 플래그 f_in_cleanup을 구현 세부 사항으로 간주하므로, 이를 카운터로 노출할 수도 있습니다.
마찬가지로 카운터를 사용한다면 카운터가 감소할 때마다 정리 훅을 호출해야 할 수도 있습니다. 중첩된 finally 절은 흔하지 않은 경우이므로 성능에 큰 영향을 미칠 가능성은 낮습니다.
코드 객체 플래그 ‘CO_CLEANUP’ 추가
SETUP_WITH 및 WITH_CLEANUP 바이트코드 내부에 플래그를 설정하는 대신, CO_CLEANUP 플래그를 도입할 수 있습니다. 인터프리터가 CO_CLEANUP이 설정된 코드를 실행하기 시작하면, 전체 함수 본문에 대해 f_in_cleanup을 설정합니다. 이 플래그는 __enter__ 및 __exit__ 특수 메서드의 코드 객체에 설정됩니다. 기술적으로는 __enter__ 및 __exit__이라고 불리는 함수에 설정할 수도 있습니다.
이는 명확성이 떨어지는 해결책으로 보입니다. 또한 __enter__ 및 __exit__이 수동으로 호출되는 경우도 다룹니다. 이는 기능으로 받아들여질 수도 있고, 불필요한 부작용으로 받아들여질 수도 있으며(가능성은 낮지만 버그로 받아들여질 수도 있습니다).
또한 __enter__ 또는 __exit__ 함수가 C로 구현된 경우에는 문제가 발생할 수 있습니다. f_in_cleanup 플래그를 확인할 코드 객체가 없기 때문입니다.
프레임 객체 자체에 정리 콜백 두기
프레임 객체를 확장하여 f_cleanup_callback 멤버를 갖게 할 수 있으며, 이 멤버는 f_in_cleanup이 0으로 재설정될 때 호출됩니다. 이렇게 하면 서로 다른 코루틴에 서로 다른 콜백을 등록할 수 있습니다.
겉보기에는 아름답지만, 이 해결책은 아무것도 추가하지 않습니다. 주요 사용 사례 두 가지가 다음과 같기 때문입니다.
- 시그널 핸들러에서 콜백을 설정합니다. 이 경우 콜백은 본질적으로 하나뿐입니다.
- 코루틴 사용 사례에서는 루프마다 하나의 콜백을 사용합니다. 여기서는 거의 모든 경우에 스레드마다 루프가 하나뿐입니다.
정리 훅 없음
원래 제안에는 정리 훅 사양이 포함되지 않았습니다. 현재 도구를 사용하여 동일한 결과를 얻을 수 있는 방법이 몇 가지 있기 때문입니다.
sys.settrace()및f_trace콜백을 사용합니다. 이는 디버깅에 몇 가지 문제를 일으킬 수 있으며, 성능에 큰 영향을 줍니다(인터럽트가 매우 자주 발생하지는 않지만 그렇습니다).- 조금 더 기다렸다가 다시 시도합니다. 코루틴 라이브러리에서는 이것이 쉽습니다. 시그널의 경우
signal.alert를 사용하여 이를 구현할 수 있습니다.
두 방법 모두 너무 비실용적인 것으로 여겨지며, finally 절에서 빠져나가는 것을 포착하는 방법이 제안됩니다.
참고 자료
[4] 원 논의 https://mail.python.org/pipermail/python-ideas/2012-April/014705.html
[5] PEP 419 구현 https://github.com/python/cpython/issues/58935
Copyright
This document has been placed in the public domain.