[python-asyncio] ProactorEventLoop의 sock_accept는 특정한 상황에서 처리할 수 없는 예외가 발생한다.

윈도우즈에서 사용할만한 EventLoop는 ProactorEventLoop 이 유일하다. (SelectorEventLoop은 1024개 이상의 소켓을 만들 수 없다.) ProactorEventLoop은 내부적으로 윈도우즈의 IOCP 를 사용한다. sock_accept 메소드가 IOCP의 AcceptEx를 호출할 때 미처 처리하지 못한 사례가 있다. sock_accept 구현상, 그 사례에서 발생하는 예외는 호출자가 처리하지 못한다. 결국 loop의 exception_handler가 불린다.

미처 처리하지 못한 사례란 다음과 같다.

요약하자면, 서버의 AcceptEx 가 완료되기 전에 backlog 에만 연결되어있던 클라이언트가 강제종료가 되면 ERROR_NETNAME_DELETED(OSError 64)가 발생한다는 것이다. 뭐 OSError Exception이 발생한다면 except 해주면 되잖아? 대략 아래와 같은 식으로 하면 잡히겠지 했다.

@asyncio.coroutine
def accept(loop, s):
    while True:
        try:
            yield from loop.sock_accept(s)
        except asyncio.CancelledError:
            logging.info("on socket accept failed - cancelled error")
        except OSError:
            logging.info("on socket accept failed - os error ")
        else:
            logging.info("on socket accept")

해당 상황에서 on socket accept failed – cancelled error 메시지가 나오긴 한다. 그럼 잡힌것 아닌가? 그런데 아래와 같은 출력도 함께 발생한다.

ERROR:asyncio:Task exception was never retrieved
future:  exception=OSError(22, '지정된 네트워크 이름을 더 이상 사용할 수 없습니다', None, 64, None)>
Traceback (most recent call last):
  File "d:\python342\Lib\asyncio\tasks.py", line 236, in _step
    result = next(coro)
  File "d:\python342\Lib\asyncio\windows_events.py", line 363, in accept_coro
    yield from future
  File "d:\python342\Lib\asyncio\futures.py", line 390, in __iter__
    return self.result()  # May raise too.
  File "d:\python342\Lib\asyncio\futures.py", line 277, in result
    raise self._exception
  File "d:\python342\Lib\asyncio\windows_events.py", line 560, in _poll
    value = callback(transferred, key, ov)
  File "d:\python342\Lib\asyncio\windows_events.py", line 351, in finish_accept
    ov.getresult()
OSError: [WinError 64] 지정된 네트워크 이름을 더 이상 사용할 수 없습니다

그래서 ProactorEventLoop 소스를 까봤다.

# windows_events.py 
def accept(self, listener):
    self._register_with_iocp(listener)
    conn = self._get_accept_socket(listener.family)
    ov = _overlapped.Overlapped(NULL)
    ov.AcceptEx(listener.fileno(), conn.fileno())
    def finish_accept(trans, key, ov):
        ov.getresult()
        # Use SO_UPDATE_ACCEPT_CONTEXT so getsockname() etc work.
        buf = struct.pack('@P', listener.fileno())
        conn.setsockopt(socket.SOL_SOCKET,
                        _overlapped.SO_UPDATE_ACCEPT_CONTEXT, buf)
        conn.settimeout(listener.gettimeout())
        return conn, conn.getpeername()
    @coroutine
    def accept_coro(future, conn):
        # Coroutine closing the accept socket if the future is cancelled
        try:
            yield from future
        except futures.CancelledError:
            conn.close()
            raise
    future = self._register(ov, listener, finish_accept)
    coro = accept_coro(future, conn)
    tasks.async(coro, loop=self._loop)
    return future

1. 8번째 줄 finish_accept 함수에서 ERROR_NETNAME_DELETED(OSError 64) exception이 발생한다.
2. 이 exception은 이미 생성한 23번 째 줄 future 객체의 exception으로 등록된다.(windows_events.py의 560번째줄 참고)
3. accept_coro 라는 coroutine 에서도 그 future 객체를 yield from 하고 있으니 exception이 발생한다.
4. accept_coro 에서는 OSError 에 대한 except 처리를 안하고 있으니 25번째 줄 코드로 생성된 task(coro task라고 부르자)에 exception으로 등록된다.
5. coro task 가 삭제될 때 exception이 따로 처리 되지 않으면 eventloop은 exception_handler를 호출한다.

exception_handler가 호출되면 안되나? 나는 exception_handler 가 호출되면 서버가 종료되도록 설계했다.1 exception_handler를 최후의 수단이라고 생각하며, 이 한계 내에서 처리가 되어야 한다.

이 문제를 근본적으로 해결하려면 accept_co coroutine 을 수정해야 한다

#요렇게?
@coroutine
    def accept_coro(future, conn):
        # Coroutine closing the accept socket if the future is cancelled
        try:
            yield from future
        except futures.CancelledError:
            conn.close()
            raise
        except OSError as exc:
            if exc.winerror == _overlapped.ERROR_NETNAME_DELETED:
                pass

이외에도 tasks.async 의 리턴 값을 future와 같이 호출자에게 리턴해서 coro task 의 add_done_callback 이라도 심을 수 있게 하던지…(아 이건 아니야…)

python asyncio가 수정되기 전까지는, 처음 링크건 IOCP 관련 포스팅에서 언급된 것처럼 AcceptEx 요청 개수를 늘려서 backlog 쪽에서 클라이언트가 대기하지 않는 방식을 사용하자. 난 이미 이 방식을 사용하고 있었는데 AcceptEx 요청 개수가 내가 원하는 순간 접속 시도 회수보다 작았기 때문에 문제가 발생했던 것으로 보인다. 제일 위에 언급했던 accept 함수를 아래와 같이 수정하면 될 것이다.

@asyncio.coroutine
def accept(loop, s):
    for i in range(2000):
        loop.create_task(accept_impl(loop, s))

@asyncio.coroutine
def accept_impl(loop, s):
    while True:
        try:
            yield from loop.sock_accept(s)
        except OSError:
            logging.error("oserror")

참고로 이 포스팅을 하기 위해 재확인차 작성했던 소스를 첨부한다.

https://gist.github.com/kernel0/400d040dcf2734457123

약간 설명하자면 listen 에 backlog 값을 크게 지정한다. (서버 소스의 accept방식이면 500정도만 되어도 잘 발생함)
더미 클라이언트로 여러 개의 소켓 접속 시도를 한다. 접속 시도 중에 더미 클라이언트 파이썬 프로세스를 강제 종료하면 문제가 재현된다.

  1. exception_handler가 호출될 때 서버를 다운 시키지 않으면 안되나?
    나는 안된다고 생각한다. 명시적으로 처리하지 못하는 exception이 있으면 서버가 다운되는 게 옳다고 생각한다. 서버 개발자들마다 철학(?)이 다를텐데 내 생각에는 exception을 뭉개버리면 서버 상태는 개발자가 예측할 수 없는 값을 갖고 뒹구르다가 또 다른 exception을 만들 수도 있고 그렇다면 뭐가 문제인지 모를 수도 있다. 또한 잘못된 데이터라도 들고 있다가 엉뚱한 값을 DB에 저장이라도 하면 헬게이트가 열리겠지.

“[python-asyncio] ProactorEventLoop의 sock_accept는 특정한 상황에서 처리할 수 없는 예외가 발생한다.”에 한개의 의견

댓글 남기기

이메일은 공개되지 않습니다. 필수 입력창은 * 로 표시되어 있습니다