地方エンジニアの学習日記

興味ある技術の雑なメモだったりを書いてくブログ。たまに日記とガジェット紹介。

【Python】デストラクタ(__del__)でソケットがクローズされていた話

C言語でこんなコードを書くとどうなるでしょうか?

#define NUM_THREADS 5
#define TARGET_IP "127.0.0.1"
#define TARGET_PORT 8080

void *thread_func(void *arg) {
    int sockfd;
    struct sockaddr_in serv_addr;

    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(TARGET_PORT);
    serv_addr.sin_addr.s_addr = inet_addr(TARGET_IP);

    if (connect(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
        perror("connect");
        // close(sockfd);  ← 通常なら閉じるが、今回はあえて漏らす
        pthread_exit(NULL);
    }

    printf("Thread %ld connected on socket %d\n", (long)arg, sockfd);
    pthread_exit(NULL);
}

int main() {
    pthread_t threads[NUM_THREADS];
    pid_t pid = getpid();

    printf("PID = %d\n", pid);

    for (long i = 0; i < NUM_THREADS; i++) {
        if (pthread_create(&threads[i], NULL, thread_func, (void *)i) != 0) {
            perror("pthread_create"); exit(1);
        }
    }

    for (int i = 0; i < NUM_THREADS; i++) {
        pthread_join(threads[i], NULL);
    }

    printf("Sleeping for 60 seconds... check with 'lsof -p %d'\n", pid);
    sleep(60);
}

スレッドが終了してもプロセスの終了まではコネクションは残り続けます。これはCでもGoでも似たようなことがおきます。(Goだと少し違いますがclose忘れでリークするという意味で)Pythonだとどうなるでしょうか?以下のコード実行のsleep中ですでにコネクションが閉じられます!なってこった!

import asyncio
import socket
import os

NUM_CONNECTIONS = 1
TARGET_HOST = '127.0.0.1'
TARGET_PORT = 8080

def open_connection(i):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

    try:
        sock.connect((TARGET_HOST, TARGET_PORT))
        print(f"[Thread] Connection {i} opened on fd {sock.fileno()}")
    except Exception as e:
        print(f"[Thread] Connection {i} failed: {e}")

async def main():
    pid = os.getpid()
    print(f"PID = {pid}")

    tasks = [asyncio.to_thread(open_connection, i) for i in range(NUM_CONNECTIONS)]
    await asyncio.gather(*tasks)

    print(f"All connections attempted. Sleeping 60 sec... check with 'lsof -p {pid}'")
    await asyncio.sleep(60)  # この間に lsof で確認

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

デストラク

オブジェクトが消えるとき(メモリ解放されるとき)に呼ばれる処理 のこと。今回はこれが動くことでソケットがアプリケーションコードでcloseしなくても閉じられるという感じのようです。

class MyClass:
    def __del__(self):
        print("destructor called")

obj = MyClass()
del obj  # ← ここで __del__() が呼ばれる

docs.python.org

socketクラス

じゃあさっきのサンプルコードをみる感じsocketクラスでデストラクタは定義されているだろうと見に行くとデストラクタは定義されていなかった。

github.com

    # こんなのがいると思った
    def __del__(self):
        self.close()

Python の socket.socket クラスは C拡張の _socket.socket クラス を継承しているようでした。なのでPython 側の socket.socket クラスには del() は定義されていないのです。

class socket(_socket.socket):

    """A subclass of _socket.socket adding the makefile() method."""

    __slots__ = ["__weakref__", "_io_refs", "_closed"]

    def __init__(self, family=-1, type=-1, proto=-1, fileno=None):
        # For user code address family and type values are IntEnum members, but
        # for the underlying _socket.socket they're just integers. The
        # constructor of _socket.socket converts the given argument to an
        # integer automatically.
        if fileno is None:
            if family == -1:
                family = AF_INET
            if type == -1:
                type = SOCK_STREAM
            if proto == -1:
                proto = 0
        _socket.socket.__init__(self, family, type, proto, fileno)
        self._io_refs = 0
        self._closed = False

Py_tp_finalize

Py_tp_finalize は、Python C API における PyTypeObject のスロットのひとつで、オブジェクトのファイナライザを定義するために使用されます。これは Python 3.4 で導入された PEP 442 の内容に基づいて追加されたものであり、古い tp_del に代わる安全なファイナライザとして設計されているらしいです。オブジェクトの解放じに呼ばれる処理というのを書ける仕組みになっています。先ほど継承しているクラスを見に行くと以下のように定義されていました。

github.com

じゃあcloseは書かなくて良いのか

docs.python.org

ソケットはガベージコレクション時に自動的にクローズされます。しかし、明示的に close() するか、 with 文の中でソケットを使うことを推奨します。

推奨はcloseするかwithで囲っておきましょうでした。なるほど。

なんとかしてリークさせたい!

リークしないなんてつまらない!リークさせたい!ってことで_socketを書き換えようと思ったのですが書き換えた後にPythonをビルドする必要がでてくるのでそれは面倒なので継承させて__del__をオーバーライドさせてpassに書き換えることでうまく行きました。リーク!

class LeakingSocket(socket.socket):
    def __init__(self, i):
        super().__init__(socket.AF_INET, socket.SOCK_STREAM)
        self.i = i

    def connect_and_log(self):
        try:
            self.connect((TARGET_HOST, TARGET_PORT))
            print(f"[Thread] Connection {self.i} opened on fd {self.fileno()}")
        except Exception as e:
            print(f"[Thread] Connection {self.i} failed: {e}")

    # デストラクタを無効化して close() を呼ばせない
    def __del__(self):
        pass