[python-asyncio] libuv, pyuv, aiouv

windows python asyncio 에서 기본 내장된 ProactorEventLoop 보다 빠른 event loop 는 없을까 고민하다가 nodejs 기반으로 유명하다는 libuv가 생각났다. python에는 libuv를 기반으로하는 pyuv가 있다. pyuv를 asyncio 에서 사용하려면 aiouv가 필요하다. 이것을 설치해보기로 했다.

업데이트 참고. 요약하자면 소득은 없는데(run_in_executor 로 실행하는 쪽이 ProactorEventLoop보다 눈에 띄게 느리더라. 우리쪽 문제일수도 있다.) windows 에서 설치는 불편했다.

업데이트 2 참고. pip aiouv 설치는 pyuv 가 없다고 하면서 안된다. 뭐 여기까진 괜찮다. pyuv 설치하자.
pip 로 pyuv 설치도 안된다. cp949 UnicodeError와 libuv 설치를 하려고 했는데 링킹이 안되니 어쩌고…아몰랑.  수동 설치 고고!

python 2.7, windows sdk 7.1 설치가 되었다고 가정하고 진행함. (python2.7은 libuv 빌드에 필요함. libuv 설치 스크립트에 c:\python27 이 하드코딩되어 있으니 python 3를 기본으로 사용하면 웬만하면 python2.7은 c:\python27 에 설치할 것)

pyuv 빌드 & 설치
1. github 에서 pyuv 소스 받기 https://github.com/saghul/pyuv/archive/v1.x.zip (pip install -d . pyuv 해서 받은 소스는 빌드 시 링킹에러가 발생한다. 같은 버전으로 알고 있는데 왜…)
2. cmd 열기
3. call “C:\Program Files\Microsoft SDKs\Windows\v7.1\Bin\SetEnv.cmd” /release /x64
4. python setup.py build_ext –inplace –libuv-clean-compile
5. 원하는 venv 환경을 activate 하고 소스 폴더로 돌아온다.
6. python setup.py install 하면 venv 환경에 설치됨
7. 끝

aiouv 는 pyuv만 있으면 쉽다.
1. pip install https://github.com/saghul/aiouv/archive/master.zip (pip install aiouv 는 패키지가 없다면서 설치가 안됨 pypi 에서 검색이 되는데 패키지 명이 잘못됐나… 일단 이슈는 써봤는데… https://github.com/saghul/aiouv/issues/15)
2. 끝

업데이트 : 1000~ 2000 개의 redis set 하는 테스크로 시간 측정을 해봤을 때 aiouv.EventLoop가 윈도우즈에서 사용할 수 있는 ProactorEventLoop보다 40~60% 정도 빠르다. 테스트 중이다.

업데이트 2 : 언젠가부터 인지는 모르겠지만 windows python 3.4.3 x64 에서 간단히 설치된다. pip install pyuv 하면 된다

[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에 저장이라도 하면 헬게이트가 열리겠지.