CPythonの既知の不具合に遭遇した。処理系本体ではなく、標準ライブラリの問題のようだ。
界隈では有名な話かもしれないが、忘れないように個人的なメモを残しておく。
具体的には、標準ライブラリのsocketを使用して自前でUDPパケットを受信する、以下のようなスクリプトで遭遇した。
#!/usr/bin/env python3 import socket BIND_ADDR = '127.0.0.1' BIND_PORT = 41214 try: for ai in socket.getaddrinfo(BIND_ADDR, BIND_PORT, socket.AF_UNSPEC, socket.SOCK_DGRAM): family, socktype, protocol, _, sockaddr = ai with socket.socket(family, socktype, protocol) as sock: sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind(sockaddr) print(f'listening on {sockaddr[0]}:{sockaddr[1]}') while True: data, addr = sock.recvfrom(65535) print(f'receive from {addr[0]}:{addr[1]} : {repr(data)}') except KeyboardInterrupt: print('KeyboardInterrupt')
このスクリプトをコンソールで実行した時、macOS 11やUbuntu 20.04のPython 3.8と、Raspberry Pi OS bullseyeのPython 3.9では、Ctrl-cで終了した。しかしMicrosoft StoreからインストールしたPython 3.10 (amd64) では、Ctrl-cを入力しても即座には終了しなかった。
(1年前に、このスクリプトと同等の処理をCygwinのPython 3.6で実行した際には、正しくCtrl-cで終了した記憶がある。なので、純粋なWindows版ビルドで発生する現象だと思う)
実験した感じでは、socket.recvfrom()でブロッキングしている最中にCtrl-cが入力された場合、入力された時点では何も起きない。Ctrl-c入力後、パケットを受信してsocket.recvfrom()
によるブロッキングが解除された時点で、ようやくKeyboardInterruptが発生するようだ。
この挙動は既知の不具合のようで、GitHubのCPythonのリポジトリにSIGINT blocked by socket operations like recv on Windows #85609というIssueがあった。Winsockの仕様(実装?)に起因する制約のようだ。*1
Winsock絡みということなので、今回はMicrosoft Store版でしか挙動を確認していないが、おそらく公式サイトで配布されているWindows版バイナリでも同様の症状が見られるのではないかと思う。
この問題の回避策の1つは、タイムアウトモードないし非ブロッキングモードのソケットを使うことだ。常にブロッキングさせるのではなく、例えば100ミリ秒から500ミリ秒ぐらいの短い周期でブロッキングが解除されるようにしておけば、解除された時にCtrl-cの入力に起因するKeyboardInterrupt
が発生して、スクリプトが停止する。
今回、問題に遭遇したスクリプトでは、お手軽なのはsocket.settimeout()を使用してタイムアウトモードを使う方法だった。
#!/usr/bin/env python3 import socket BIND_ADDR = '127.0.0.1' BIND_PORT = 41214 try: for ai in socket.getaddrinfo(BIND_ADDR, BIND_PORT, socket.AF_UNSPEC, socket.SOCK_DGRAM): family, socktype, protocol, _, sockaddr = ai with socket.socket(family, socktype, protocol) as sock: sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind(sockaddr) # Workaround to handle keyboard interrupt on Windows. sock.settimeout(0.2) print(f'listening on {sockaddr[0]}:{sockaddr[1]}') while True: try: data, addr = sock.recvfrom(65535) except socket.timeout: continue print(f'receive from {addr[0]}:{addr[1]} : {repr(data)}') except KeyboardInterrupt: print('KeyboardInterrupt')
もう1つの回避策は、いきなりsocket.recvfrom()
(TCPの場合はsocket.accept())を呼び出すのではなく、selectやselectorsを使用して、ソケットが読み込み可能になるまで待ってから処理を行う、というものだ。
読み込み可能になるまで待つ周期を100ミリ秒から500ミリ秒ぐらいの短い時間にしておけば、タイムアウト後にCtrl-cに起因するKeyboardInterrupt
が発生して、スクリプトが停止する。
標準ライブラリのsocketserverではこの方法が採用されている。つまりserve_forever()の引数poll_interval
は、selectors
を使用してソケットが読み込み可能になるまで待つ周期だ。この引数に妙に大きな値を指定しなければ、Ctrl-c入力によるスクリプトの停止がいい感じに機能してくれる(引数poll_interval
のデフォルト値は0.5秒(500ミリ秒)だ)。
そういう意味でも、TCP/UDP通信のサーバ側を実装する際には、最初はsocket
ではなくsocketserver
を使うことを検討しろ、ということなのかもしれない。