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 문을 사용하여 호출합니다)입니다. 하지만 둘 다 KeyboardInterrupt나 generator.throw()로 인해 발생하는 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 속성이 가리키는 프레임의 f_in_cleanup 속성 값을 반환합니다.

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()이 다음 호출 시 정리 훅에 전달될 객체와 동일한 객체를 반환해야 한다고 명시할 필요가 있을 수 있습니다.

대체 이름

원래 의도가 finally 절을 보호하는 것이었으므로, 최초 제안에는 f_in_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_in_cleanup이 0으로 재설정될 때 호출되는 f_cleanup_callback 멤버를 두게 할 수 있습니다. 이렇게 하면 코루틴마다 서로 다른 콜백을 등록하는 데 도움이 됩니다.

겉보기에는 훌륭해 보이지만, 이 해결책은 아무것도 더해 주지 않습니다. 두 가지 주요 사용 사례는 다음과 같기 때문입니다.

  • 시그널 핸들러에서 콜백을 설정하는 경우입니다. 이 경우 콜백은 본질적으로 하나뿐입니다.
  • 코루틴 사용 사례에서는 루프당 하나의 콜백을 사용합니다. 이 경우 거의 모든 상황에서 스레드당 루프는 하나뿐입니다.

정리 훅 없음

원래 제안에는 정리 훅 명세가 포함되어 있지 않았습니다. 현재 도구로 같은 결과를 얻을 수 있는 방법이 몇 가지 있기 때문입니다.

  • 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