From error-handling to structured concurrency

Nelson Elhage

오류 처리에서 구조적 동시성까지

원문은 Nelson Elhage님이 에 게재했습니다. 이 블로그 구독하기

동시성 프로그램에서 오류 처리를 어떻게 바라봐야 할까?

단일 스레드 프로그램에서는 구현과 세부 패턴은 다양하지만 대체로 표준적인 패턴에 수렴해 왔다. 오류가 발생하면 이를 처리할 준비가 된 스택 프레임을 찾을 때까지 스택을 타고 위로 전파된다. 그 과정에서 스택 프레임을 차례로 되감으면서 각 프레임이 적절히 자원을 정리하거나 해제할 기회를 갖는다.

이 패턴은 많은 현대 언어(C++, Python, Java)의 명시적 예외 처리 메커니즘을 잘 설명한다. 이들 모두는 프레임이 되감길 때 자원을 정리하는 메커니즘(RAII, finally 블록, 파이썬 컨텍스트 매니저)도 갖추고 있다. 하지만 이는 Rust의 표준 패턴(Result 반환, ? 연산자, 되감기 시 drop 호출)은 물론, Go의 전형적인 if err != nil { return err } 패턴과 정리를 위한 defer, 심지어 goto error 패턴을 통한 대부분의 현대적인 C 코드(리눅스 커널의 예시 참조)까지도 설명한다.

오늘날의 관점에서 보면 이 설명은 너무 일반적이어서 아무 내용도 없는 것처럼 보일 수 있지만, 항상 그랬던 것은 아니다. 다른 (대부분 버려진) 오류 처리 접근법으로는 Lisp의 “restarts” 메커니즘, C의 longjmp1, 유닉스 시그널 같은 “트랩” 메커니즘, 그리고 악명 높은 Visual Basic의 on error 구문 등이 있다. “되감기” 역시 구조화된 호출 스택이 존재한다는 것을 전제로 하는데, 이 개념 역시 발명되고 보급되어야 했다.

오늘 묻고 싶은 질문은 이것이다: 단일 스택이 존재하지 않는 동시성 프로그램에서는 이 패턴을 어떻게 업데이트해야 할까? 여러 동시 태스크2가 존재하는 상황에서 오류 조건을 처리하도록 코드를 어떻게 구성해야 할까?

처리되지 않은 오류

아마도 오류 처리에 있어 가장 단순한 경우는 오류가 발생했는데 이를 명시적으로 처리하는 코드가 없는 경우일 것이다. 단일 스레드 프로그램에서는 오류가 진입점까지 “거슬러 올라가” 프로그램을 종료하고, 가급적 유용한 오류 메시지나 스택 트레이스를 남길 것으로 기대한다.

여러 태스크가 있는 동시성 프로그램에서는 어떤 일이 일어나야 할지 덜 명확하다. 오류를 위로 버블링해 오류를 발생시킨 태스크를 종료시킬 수는 있지만, 그 다음은 어떻게 해야 할까? 구체적인 예로, 다음과 같은 토이 프로그램3을 생각해 보자:

import threading
import time

def background_thread():
  # This was supposed to be running some background work, but it
  # encountered an error!
  raise ValueError("oops")

def main():
    threading.Thread(target=background_thread).start()

    # do the main work
    time.sleep(5)
    print("All done, exiting!")

main()

이 프로그램은 두 개의 동시 스레드를 실행하려 시도한다. 하나는 “메인” 작업을 수행하고, 다른 하나는 “백그라운드”에서 어떤 작업을 수행한다. 하지만 백그라운드 스레드는 즉시 예외를 발생시킨다. 어떻게 되어야 할까?

다양한 언어 구현에서 이 프로그램의 변형을 실행해 보면, 대체로 두 가지 공통된 접근법이 있음을 알 수 있는데, 이는 거의 자명한 두 가지 선택지라고 할 수 있다:

  • 오류를 출력하고 해당 스레드를 종료한 뒤, 모든 스레드(혹은 변형으로 메인 스레드만, 혹은 모든 “비-데몬” 스레드)가 종료될 때까지 계속 실행한다. (Java, Python)
  • 오류를 출력하고 즉시 전체 프로그램을 종료한다. (Go, Rust, C++)

파이썬에서는 프로그램이 즉시 예외를 로그로 남기지만 계속 실행된다. time.sleep이 끝날 때까지 기다린 뒤, 심지어 성공적인 종료 코드와 함께 종료된다!

$ time python exception_thread.py
Exception in thread Thread-1 (background_thread):
Traceback (most recent call last):
  File "/Users/nelhage/.pyenv/versions/3.11.0/lib/python3.11/threading.py", line 1038, in _bootstrap_inner
    self.run()
  File "/Users/nelhage/.pyenv/versions/3.11.0/lib/python3.11/threading.py", line 975, in run
    self._target(*self._args, **self._kwargs)
  File "/Users/nelhage/Sync/code/structured-concurrency/exception_thread.py", line 7, in background_thread
    raise ValueError("oops")
ValueError: oops
All done, exiting!
python exception_thread.py  0.03s user 0.02s system 0% cpu 5.124 total

$ echo $?
0

어느 쪽도 그다지 만족스럽지 않다.

전체 프로그램을 죽이는 것은 너무 무거운 망치다. 나는 백그라운드 모니터링 고루틴에서 처리되지 않은 패닉으로 인해 중요한 데몬이 크래시되어 심각한 장애 대응을 도운 적이 있다. 그 특정 상황에서는 “진짜” 작업이 방해받지 않고 계속되도록 두는 편이 훨씬 나았을 것이다.

반면, 프로그램이 계속 실행되도록 두면 거의 확실히 테스트되거나 예상된 적 없는 상태에 놓이게 되고, 다른 태스크가 죽은 태스크가 진행하거나 특정 동작을 수행할 것으로 기대한다면 교착 상태나 더 나쁜 상황에 빠질 위험이 크다.

프로그램을 계속 실행되도록 두는 것은 개발 과정에서도 고통을 준다. 개발 중에는 많은 오류가 “사소한” 개발자 실수다 — 오타, 단순한 논리 오류, 그 밖의 사소하고 국소적인 실수들이다. 그런 오류를 만나면 보통 가능한 한 빨리 고치고 프로그램을 재시작하고 싶다. 프로그램이 계속 실행 중이라면 수동으로 재시작해야 하며, 다른 태스크가 출력을 생성하고 있다면 예외가 스크롤백에 묻혀 버릴 수도 있다.

오류를 어디로 전달해야 할까?

어떤 의미에서 우리가 원하는 것은 오류를 “전달”할 더 나은 장소를 갖는 것이다. 단일 스레드 프로그램에서 그 장소는 “호출자”다. 동시성이 있는 경우 태스크는 결국 반환할 호출자가 없으므로, 대신 무엇을 해야 할까?

파이썬의 asyncio 프레임워크에서 약간의 영감을 얻어 보자. asyncio는 위의 두 가지와는 다른 “세 번째 길”을 택한다. asyncio에서 태스크는 Task 객체로 표현되며, 이는 이벤트나 락, 소켓 등과 마찬가지로 대기할 수 있는 객체다. Task를 기다리면 완료될 때까지 블록되며, 태스크가 예외를 발생시키면 그 예외는 Task를 기다리고 있던 누군가에게 다시 전달된다.

이 접근법은 독단적이지 않다. 태스크를 벗어난 예외를 누가 처리해야 하는지에 대해 의견을 강요하지 않으면서도, 프로그래머가 스스로 그 결정을 내릴 수 있는 도구를 제공한다.

하지만 여기에는 가파른 단점이 있다. 아무도 Task를 기다리지 않는데 그 태스크가 예외를 발생시키면, 그 예외는 프로그램 종료 시점까지 사실상 삼켜진 뒤에야 경고와 함께 출력된다. 만약 누군가 그 태스크가 예컨대 큐를 통해 출력을 생성해 주기를 기다리고 있었다면, 프로그램은 그저 영원히 멈춘 채 조용하고, 불가해하고, 신비로운 상태로 행(hang)될 것이다.

실제로 상황이 너무나 나빠서 나는 때때로 이를 “기본적으로 asyncio는 메인 태스크 밖의 예외를 완전히 삼켜 버린다”고 요약하곤 한다. 문자 그대로 사실은 아니지만, 내 경험상 시작점으로 삼기에 괜찮은 멘탈 모델이며 많은 개발자들의 asyncio 경험을 유용하게 설명한다.

만약 항상 태스크를 기다린다면 어떨까?

asyncio 접근법에는 모든 Task를 기다리는 것을 잊지 않는 한 추천할 만한 점이 많다. 어떻게 하면 이 불변식을 규칙으로 강제할 수 있을까? 가장 단순한 규칙은 아마 이럴 것이다: 태스크를 생성했다면, 그것을 기다릴 책임도 진다. 이 패턴을 asyncio.create_task(coro)를 비동기 컨텍스트 매니저로 만들어, 블록을 빠져나가기 전에 태스크를 기다리도록 함으로써 인코딩할 수 있을 것이다.

# (n.b. this is not real API in any version of Python)
async with asyncio.create_task(background_task()) as task:
  # …
  # `task` will be waited for on exit from this block, and any exception raised

물론 우리는 매우 자주 많은 태스크 — 혹은 동적으로 정해지는 개수의 태스크 — 를 생성하고 싶으므로, contextlib.ExitStack의 관용구를 빌려와 임의의 수의 태스크를 생성할 수 있는 하나의 컨텍스트 매니저 객체를 만들 수 있을 것이다. 예를 들면 다음과 같다:

async with TaskLauncher() as tasks:
  tasks.create_task(background_task())

  # do the main work via a second task
  tasks.create_task(asyncio.sleep(5))

  # All tasks will be waited for on exit from the region

새로운 태스크를 TaskLauncher를 통해서만 생성한다면, 이제 모든 태스크가 명확한 부모에 “속하고” 부모가 자식에서 벗어난 예외를 기다릴 책임을 지는 명확한 부모-자식 관계를 갖게 된다. 어떤 태스크든 처리되지 않은 예외를 발생시키면 이 계층 구조를 따라 위로 버블링될 것이고, 아무도 잡지 않으면 결국 루트 태스크와 asyncio.run 호출에서 끝나게 된다. 우리는 동시성 예외 처리 문제를 친숙한 단일 스레드 버전으로 변환하는 데 큰 진전을 이룬 셈이다!

두 가지 문제

불행히도 아직 멀었다. 위의 스케치에는 사소하게 고칠 수 없는 두 가지 심각하고 연관된 과제가 있다.

교착 상태

첫째, 교착 상태다. 위에서 태스크의 부모가 “결국” 그것을 기다릴 것이라고 말했다. 그건 부모가 실제로 컨텍스트 매니저를 빠져나올 때만 사실이다. 하지만 부모가 자식 태스크 중 하나가 오류를 만나 처리했어야 할 작업을 영원히 완료하지 못하기 때문에 절대 끝나지 않을 작업을 기다리고 있다면 어떨까?

다음은 이 문제의 한 양상을 보여주는 짧은 예제다:

from task_launcher import TaskLauncher
import asyncio

async def do_work(job_id, done_event):
  if job_id == 1:
    raise ValueError("Oops, job 1 failed!")

  done_event.set()

async def main():
  async with TaskLauncher() as tasks:
    events = []
    for i in range(4):
      done = asyncio.Event()
      tasks.create_task(do_work(i, done))
      events.append(done)

    for ev in events:
      await ev.wait()
    print("All done!")

if __name__ == '__main__':
  asyncio.run(main())

이 예제는 흔한 패턴을 양식화한 것이다. 많은 “팬아웃” 혹은 “팬아웃/팬인” 동시성 패턴은 기본적으로 “몇몇 태스크를 시작한다; 그 태스크들이 작업을 수행한다; 부모는 작업이 끝나기를 기다린다”는 형태를 갖는다.

이 특정 버그에 대한 간단한 해결책은 많이 있다4. 하지만 우리는 이걸 “자동으로” 혹은 좀 더 일반적인 방식으로 고치는 패턴을 찾고 싶고, 최소한 저렇게 쉽게 발을 걸어 넘어지게 하는 함정이 그냥 도사리고 있지 않도록 하고 싶다. 내 경험상 이런 종류의 교착 상태에 빠지는 것은 특히 동시성 시스템을 처음 개발하는 동안 엄청나게 쉽다.

자원 누수

단일 스레드 프로그램에서 오류 복구의 과제는 단순히 제어 흐름을 되감는 것만이 아니라, 실패한 연산과 어쨌든 연관되어 진행 중이던 작업을 “되돌리거나” “정리”하는 것을 보장하는 것이다. 메모리를 해제하거나, 파일 핸들을 닫거나, 자료 구조를 일관된 상태로 복원해야 할 수도 있다.

동시성 프로그램에서는 실패한 연산과 연관된 자원이 여러 개의 서로 다른 실행 태스크를 포함할 수도 있다! TaskLauncher와 연관된 태스크 중 하나가 실패하면, 생성된 모든 태스크가 어떻게든 중지되거나 최소한 버려진 상태를 정리할 기회를 갖도록 보장해야 한다.

어떤 의미에서 가장 단순한 수정은 TaskLauncher가 종료되기 전에 생성된 모든 태스크를 기다리도록 하는 것이겠다. 하지만 실제로 그 변경만으로는 우리의 교착 상태 문제가 극적으로 더 악화된다.

취소하고 싶진 않지만…

만약 우리가 동시에 다음 두 가지를 모두 원한다면:

  • 모든 자식 태스크가 종료될 때까지 기다려 추가 오류가 있는지 알고, 관련 자원을 정리하도록 보장하고 싶고, 동시에
  • 잠재적으로 무한한 시간을 기다리지 않고 자식 태스크의 오류에 신속히 대응하고 싶다면,

그렇다면 임의의 태스크가 다른 태스크에서 발생한 이벤트에 반응해 일찍, 그리고 신속하게 종료되도록 요청할 방법이 기본적으로 필요하다고 생각한다. 다시 말해, 취소 메커니즘이 필요하다.

우리는 여기서 전개 중인 특정 패러다임에 비추어 이 결론에 도달했지만, 이는 훨씬 더 광범위하며 곰곰이 생각해 보면 꽤 직관적이라고 생각한다. 어떤 동시성 패러다임에서든 “협력하는 여러 동시 태스크”라는 개념이 있을 것이고, 이는 “그중 하나가 예기치 않게 죽으면 어떻게 되는가”에 대한 답이 필요하다는 뜻이다. 그리고 결국 “다른 태스크들에게 취소를 요청해 일찍 종료하도록 한다” 외에 완전히 일반적인 답을 상상하기는 어렵다.

물론 임시방편적인 메커니즘들의 조합과 신중한 추론 및 구성을 통해 범용 취소 메커니즘 없이 특정 동시성 프로그램이나 패턴을 구현하는 것은 충분히 가능하다. 하지만 나는 범용적이고 조합 가능한 동시성 패러다임이 그런 메커니즘 없이 존재하는 것을 상상하기 어렵다.

나는 이 결론이 달갑지 않다고 생각한다. 취소를 구현하고 지원하는 것은 어렵기 때문이다. 사실상 모든 코드 조각에 원래부터 비동기적이고 추론하거나 테스트하기 어려운, 추가적인 오류 경로를 도입한다. C의 pthread_cancel, Java의 Thread.stop, Ruby의 Thread.terminate 같은 역사적인 취소 메커니즘 시도는 기껏해야 믿을 수 없을 정도로 미묘하고 오류가 발생하기 쉬우며, 최악의 경우 근본적으로 사용할 수 없을 정도다.

적어도 동시성이라는 맥락에서는 몇 가지 이점이 있다. 이미 동시성 코드를 작성하고 있다면, 취소 메커니즘은 “비동기적으로 일어날 수 있는 일”을 하나 더 추가하지만, 어차피 우리는 이미 그 문제를 어느 정도 안고 있다. asyncio 같은 협력적 동시성 시스템에서는 취소를 await 지점에서만 발생하도록 제한할 수 있어 잠재적 혼란의 범위를 줄일 수 있다. 한편 Go는 취소를 Context 객체에 인코딩하고 코드가 취소를 명시적으로 확인하도록 요구하며, 다른 종류의 트레이드오프를 갖는다.

더 일반적으로, 우리는 지난 수십 년간 이 분야로서 많은 것을 배웠고, 이러한 새로운 시스템 중 일부는 실제로 어느 정도 쓸 만한 범용 취소 메커니즘을 갖춘 것으로 보인다. 이 글은 취소 메커니즘의 과제와 설계 공간에 대한 깊이 있는 탐구를 의도한 것은 아니므로, 지금은 어떤 종류의 취소 API5가 있다고 가정하고 넘어가겠다.

취소를 동반한 태스크 트리

태스크를 취소할 수 있다면, 이를 “태스크 트리” 아이디어와 함께 사용해 동시성 오류 처리에 대한 꽤 일반적인 해결책을 만들 수 있다:

  • TaskLauncher가 실행한 태스크 중 하나라도 예외를 발생시키면(컨텍스트 매니저를 실행하는 부모 태스크 자체를 포함해), 다른 모든 태스크(자식 태스크와 부모 태스크 자체 모두)를 취소한다.
  • 자식 태스크가 취소를 감지하고 자체 자원을 정리할 수 있는 메커니즘을 제공한다. 일반적으로 이는 일반적인 오류 처리 메커니즘을 어떤 형태로든 재사용한다는 것을 의미한다. 예를 들어 취소 시 CancelledError 예외를 발생시킬 수 있으며, 태스크는 이를 잡아 다시 발생시키거나 finally 블록이나 컨텍스트 매니저를 사용할 수 있다.
  • TaskLauncher 컨텍스트를 빠져나갈 때, 모든 자식 태스크가 성공적으로, 처리되지 않은 예외와 함께, 혹은 취소에 반응해 종료될 때까지 기다린다.
  • 그런 다음 자식 태스크 중 하나라도 오류를 발생시켰다면, 그 오류를 부모 태스크로 다시 발생시킨다.

이 메커니즘을 사용하면 동시성 오류는 이제 단일 스레드 오류와 상당히 유사하게 동작한다. 처리되지 않으면 잡혀 위로 전파될 것이다. 일반적인 오류 처리 메커니즘을 사용해 잡아 처리할 수 있다(자식 태스크에서 비롯된 오류 포함). 일반적인 단일 스레드 방식으로 오류 뒤를 정리하는 코드를 작성한다면, 여러 태스크가 존재하는 상황에서도 (적어도 대부분) 적절한 정리가 이루어질 것이다.

그 대가로 우리는 동시성 코드에 추가적인 구조를 요구한다. 태스크를 부모/자식 계층으로 중첩하고, 그 생명주기가 적절히 중첩되도록 해야 한다.

구조적 동시성

이제 이 모든 것이 새로운 아이디어가 아니며, 내 발명도 아니라는 점을 고백해야 할 부분이다(비록 이 형태로 접근한 다른 글을 본 적은 없지만). 중첩된 생명주기를 가진 태스크 트리, 그리고 트리를 따라 위아래로 자동 취소되는 이 패러다임은 최근 몇 년간 “구조적 동시성”이라는 이름으로 서서히 그러나 꾸준히 인기와 채택을 얻어 왔다.

많은 아이디어와 구성 요소는 오랜 역사를 가지고 있지만, 이 아이디어는 내가 아는 한 2016년에 처음 이름이 붙여졌고, 아마도 trio 프레임워크와 함께, trio의 창시자이자 메인테이너인 njs가 쓴 심층 에세이를 통해 가장 잘 알려졌다.

파이썬 3.11 기준으로 파이썬의 asyncio에는 위에서 스케치한 TaskLauncher의 (프로덕션 준비가 된) 버전과 본질적으로 동일한 TaskGroup 클래스가 포함되어 있다. trio에서는 이를 “nursery”라고 부른다. Go에서는 errgroup 패키지가 Go의 취소를 지원하는 context 패키지를 기반으로 본질적으로 동일한 의미를 제공한다.

구조적 동시성은 많은 장점을 가지고 있으며, 나와 많은 다른 사람들은 이 스타일로 프로그램을 작성하면 정확하고 안전한 동시성 코드를 작성하는 것이 훨씬 쉬워진다는 것을 알게 되었다(물론 다른 과제들이 여전히 남아 있지만!). 더 철저한 패러다임 탐구와 그 장점에 대해서는 앞서 링크한 njs의 고전 에세이를 강력히 추천한다.

코다: 왜 오류 처리인가?

오류 처리에 대한 성찰로 글을 마무리하고 싶다. 그리고 애초에 왜 이 관점과 이 글을 생각하게 되었는지에 대해서도.

프로그래머가 오류 처리를 생각할 때, 종종 이를 “견고성” 문제나 “프로덕션” 혹은 “진지한 소프트웨어”를 위한 문제로 분류하는 것 같다 — “규모”가 커졌을 때, 혹은 무언가가 “신뢰성”을 갖춰야 할 때, 혹은 다양한 환경에서 실행되고 네트워크로부터 예상치 못한 입력을 처리해야 할 때 신경 써야 하는 주제라는 식이다.

그리고 그건 모두 사실이며, 그런 종류의 시스템에서는 무엇이 잘못될 수 있고 어떻게 잘못될 수 있는지, 그리고 이를 어떻게 신중하게 처리할지를 주의 깊게 생각하는 것이 확실히 중요하다.

그렇긴 하지만, 나는 “성숙하고 견고한 프로그램”이라는 방향에서가 아니라 완전히 반대쪽 끝, 즉 처음부터 새로운 코드를 작성하는 개발 경험이라는 방향에서 이 사고를 시작했다. 앞서 간략히 언급했듯이, 새로운 프로그램 — 동시성 여부와 관계없이 — 을 작성할 때는 매우 흔히 “멍청한 버그”가 많은 초기 단계가 있고, 그저 가능한 한 빨리 그것들을 처리해 나가야 한다.

구조적 동시성 프레임워크 밖에서 동시성 프로그램을 작성한 내 경험상, “프로그램 실행, 멍청한 버그 확인, 멍청한 버그 수정”이라는 기본적인 개발 루프를 돌리는 것조차 매우 답답할 정도로 어려워지곤 한다. 단일 스레드 프로그램이라면 멋진 스택 트레이스를 출력하고 종료될 멍청한 버그가 교착 상태로 변하거나, 삼켜지거나, 더 기괴한 형태로 변하는 나쁜 습성을 가지고 있기 때문이다. 그리고 임시방편으로 오류 처리를 추가하려는 시도가 때로는 상황을 더 악화시킨다는 것도 알게 되었다! 예를 들어, 때로는 큰 동시성 연산이 끝난 뒤 한곳에 모아 로그를 남길 수 있도록 어떤 파이프라인을 통해 오류를 “전달”하는 것이 “자연스러운” 접근법이라고 느끼곤 했다. 그 접근법은 동작할 수 있지만, 때로는 전체 프로그램이 완료될 때까지 어떤 오류에 대해서도 알 수 없다는 것을 의미하기도 하며, 이는 개발 중에 정말 답답하다!

따라서 나는 구조적 동시성 접근법을 채택하거나, 적어도 환경에 “진정한” 구조적 동시성 라이브러리가 없더라도 기본적인 사고방식이자 패러다임으로 삼는 것이, 실제로 처음부터 동시성 프로그램을 작성하고 디버깅하는 것을 훨씬 쉽게 만든다는 것을 알게 되었다 — 결국 혹은 프로덕션에서만이 아니라, 거의 즉시, 심지어 일회성 프로토타입에서도 배당금을 안겨준다.


  1. longjmp는 예외와 되감기를 구현하는 데 도움이 되는 프리미티브로 사용될 수 있다. 하지만 그 자체로는 훨씬 더 저수준의 프리미티브이며 매우 다양한 대안 패턴을 허용한다.

  2. 나는 하드웨어 병렬성보다는 논리적 동시성에 더 관심이 있으므로, “태스크”라는 단어를 다른 여러 시퀀스와 시간적으로 인터리빙될 수 있는 별개의 선형 실행 시퀀스를 가리키는 데 사용하겠다.

  3. 이 논의의 대부분은 다양한 언어와 동시성 프레임워크에 걸쳐 광범위하게 적용되도록 의도되었지만, 구체성을 위해 파이썬 예제를 사용하겠다. 다른 이점 외에도 파이썬은 스레딩과 협력적 비동기를 모두 지원하므로 여러 패러다임을 탐색할 수 있다.

  4. 아마도 가장 단순한 수정은 asyncio.Event를 완전히 제거하고 대신 Task 객체들을 직접 asyncio.gather하는 것이다. 여기서는 쉽지만, “작업 단위”와 “자식 태스크” 사이에 1:1 매핑이 없을 때는 항상 그렇게 간단하지 않다.

  5. 실제로 파이썬의 asyncio에는 광범위한 취소 메커니즘이 존재한다는 점을 언급해 둔다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글