Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 개선 제안 한국어 번역

PEP 419 – 중단으로부터 정리 문 보호하기

Author:
Paul Colomiets <paul at colomiets.name>
Status:
Deferred
Type:
Standards Track
Created:
06-Apr-2012
Python-Version:
3.3

Table of Contents

번역·라이선스 안내

이 비공식 한국어 번역은 원문 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_WITHWITH_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, isframeincleanupgetcleanupframe은 각각 set_cleanup_hook, is_frame_in_cleanupget_cleanup_frame으로 명확하게 풀어 쓸 수 있지만, 각각의 모듈에 해당하는 명명 규칙을 따릅니다.

대체 제안

‘f_in_cleanup’ 플래그 자동 전파

이렇게 하면 getcleanupframe()이 필요 없어질 수 있습니다. 그러나 yield 기반 코루틴에서는 이를 직접 전파해야 합니다. 이를 쓰기 가능하게 만들면 setcleanuphook()의 동작이 다소 예측 불가능해집니다.

‘INCR_CLEANUP’, ‘DECR_CLEANUP’ 바이트코드 추가

이러한 바이트코드는 with 문 내부의 표현식을 보호하는 데 사용할 수 있으며, 카운터 증가를 더 명시적이고 디버깅하기 쉽게 만들 수 있습니다(디스어셈블리에서 볼 수 있습니다). END_FINALLYSETUP_WITH가 카운터를 암묵적으로 감소시키도록 하는 방식처럼, 중간 지점을 선택할 수도 있습니다(END_FINALLY는 모든 with 스위트의 끝에 존재합니다).

그러나 새로운 바이트코드를 추가하는 일은 매우 신중하게 고려해야 합니다.

‘f_in_cleanup’을 카운터로 노출

원래 의도는 필요한 기능을 최소한으로 노출하는 것이었습니다. 그러나 프레임 플래그 f_in_cleanup을 구현 세부 사항으로 간주하므로, 이를 카운터로 노출할 수도 있습니다.

마찬가지로 카운터를 사용한다면 카운터가 감소할 때마다 정리 훅을 호출해야 할 수도 있습니다. 중첩된 finally 절은 흔하지 않은 경우이므로 성능에 큰 영향을 미칠 가능성은 낮습니다.

코드 객체 플래그 ‘CO_CLEANUP’ 추가

SETUP_WITHWITH_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