python

socket.makefile()で作成したファイルっぽいオブジェクトからread()を呼び出すと無限待ちになるよ

Pythonでのソケット通信でsocket.makefile()の機能を使うと、まるでローカルファイルに対して読み込み・書き出しをしているかのような感じでプログラムの記述ができて便利です。

socket.send()socket.recv()などを使った古臭い方法は極力使わずに、makefile()を使ってプログラムを組んでいくのがオススメです。なのですが…

実際のファイルとソケット通信ではファイルの終端を表すEOF(End Of File)の扱いが違うってことを知らないままプログラム組んでしまって、無限待ちに悩まされた…ということがありました。

ということでファイルアクセスでのEOFと、ソケット通信でのEOFについての知識、それを踏まえたソケット通信のコツ的な部分をこの記事で共有しておきます。

ファイルアクセスでのEOFはファイルの末尾を意味する

まずはファイルアクセスでのEOFについて説明します。ソケット通信の話をする上での前提情報的な部分になるので、知ってる方も多いとおもますがここでいったん明確にしておきます。ちなみにここでいうファイルというのはハードディスク上に保存されているデータのこととして話を進めます。

画像であればバイナリデータ、テキストであればテキストデータがその内容として保存されているわけですが、これらのファイルを開いて読み込んでいくとバイナリであれテキストであれ、EOFというデータが読み込まれます。

そしてEOFが読み込まれると、「ここがファイルの終端なんだな」という判断になり、読み込み処理をそこで終了するための目印になります。

ちなみにこのEOFというのは、実際にディスク上に保存されたファイルの末尾にEOFというデータが書き込まれているわけではありません。

Pythonのファイル読み込み機能は、実際にはOSのファイル読み込みAPIを利用しています。そのAPIが、「ファイルの終端に達したらEOFを返す」という仕様になっているからです。

Pythonでのファイル読み込みで、オープンしたファイルオブジェクトに対してread()メソッドを呼び出すと、ファイルの内容を最後まで、つまりEOFが登場するまで読み込み、その内容(EOFは含まれない)を返してくれます。

with open('sample_text.txt', 'r') as f:
    data = f.read()
    print(data) # ファイルの内容が全て出力される

socket.makefile()の使い方

Pythonでソケット通信の機能を提供するsocketモジュールは、C言語で実装されたソケット通信のAPIを模しています。そこにPython向けの改善が加えられているとはいえ、データをいったんバイナリ形式に変換したうえで送受信する、といったテクニカルな部分の管理をする必要があります。

それをまるでファイルにアクセスするかのごとく通信できるようにしてくれるのがsocket.makefile()です。

これを使うと、クライアントからサーバーにテキスト情報を送るプログラムは次のように書くことができます。(画像情報などを送受信したい場合はバイナリモードで作成する必要があるのでmakefile('rwb')のようにmodeにバイナリの'b'を指定する)

# just_send.py
import socket

conn = socket.create_connection(('localhost', 11111))
f = conn.makefile('w', encoding='utf-8')

f.write('クライアントからのデータ')

f.close()
conn.close()

# just_receive.py
from socketserver import TCPServer, StreamRequestHandler

class Handler(StreamRequestHandler):
    def handle(self):
        f = self.request.makefile('r', encoding='utf-8')
        data = f.read()
        print(data) # 出力:クライアントからのデータ
        f.close()

TCPServer(('', 11111), Handler).serve_forever()

このプログラムは何も問題なくバッチ動きます。クライアント側で送信されたデータがサーバー側で受信され、その内容が画面に出力されます。

問題になるのはこの先で、この記事の題材である「気をつけないと無限待ちになるよ」という部分の話に突入していきます。

ソケット通信でのEOFは接続のクローズを意味する

今度はクライアントがサーバーにデータを送信したら、レスポンスを返してもらう、というプログラムを考えてみます。ここで、ソケット通信でのEOFは、通信のクローズを意味するということを意識しないでプログラムを書くと、みごとにハマります。

(みごとにハマってしまった、ケンヂまるです。😇)

ということで、次のプログラムが、見事にハマってしまっているプログラムです。

# send_and_receive.py
import socket

conn = socket.create_connection(('localhost', 11111))
f = conn.makefile('rw', encoding='utf-8')

f.write('クライアントからのデータ')
f.flush() # ただちに送信開始するためにフラッシュ
data = f.read()
print(f'受信データ:{data}')

f.close()
conn.close()
# receive_and_send.py
from socketserver import TCPServer, StreamRequestHandler

class Handler(StreamRequestHandler):
    def handle(self):
        f = self.request.makefile('rw', encoding='utf-8')

       # ここで無限待ち発生
        data = f.read()
        print(f'受信データ:{data}')
        f.write('サーバーからの返信データ')

        f.close()

TCPServer(('', 11111), Handler).serve_forever()

send_and_receive.pyは、メッセージを送って、レスポンスを受け取ろうとしています。

receive_and_send.pyは、メッセージを受け取って、レスポンスを返そうとしています。

しかし思惑むなしく、このプログラムはサーバー側のread()呼び出しが無限待ちになってしまいます。

read()に引数を指定しないで呼び出した場合、EOFが登場するまで継続して読み込もうとする。けれど実際にはいつまで待ってもEOFが送られてきません。そしてEOFを待ち続けるので、無限待ち…といった結果になってしまうわけです。

なぜEOFが送られてこないのでしょうか?じつはソケット通信において、EOFとは送信データの終わりではなく、コネクションがクローズされたことを意味します。

send_and_receive.pyはデータを送った後にサーバーからのレスポンスデータを読み込もうとコネクションを開いたまま待っています。つまりコネクションがクローズされていないので、通信の終了を意味するEOFが送信されなかった、というわけです。

read()の無限待ちを回避する方法

read()の無限待ちを回避する方法はいくつかあります。

1つは先ほど示したように、クライアントからデータを送ったら、すぐにコネクションをクローズしてしまう方法です。これならクローズした時にEOFが送信されるので、read()はEOFを受け取ることができます。

でも、サーバーからのレスポンスを受け取りたい場合などは、コネクションをクローズするわけにはいかないですよね。ということで、現実的な解決策を2つ紹介します。

read()ではなくreadline()を使う

read()がEOFまで読み込むのに対して、readline()は改行文字まで読み込みます。なのでreadline()を使えば、送信データの最後に改行文字を送ることで、データの終端を伝えることができます。

# send_and_receive.py
import socket

conn = socket.create_connection(('localhost', 11111))
f = conn.makefile('wr', encoding='utf-8')

f.write('クライアントからのデータ\n')
f.flush()
data = f.read()
print(f'受信データ:{data}') # 受信データ:サーバーからの返信データ

f.close()
conn.close()
# receive_and_send.py
from socketserver import TCPServer, StreamRequestHandler

class Handler(StreamRequestHandler):
    def handle(self):
        f = self.request.makefile('rw', encoding='utf-8')

        data = f.readline()
        print(f'受信データ:{data}') # 受信データ:クライアントからのデータ
        f.write('サーバーからの返信データ\n')

        f.close()

TCPServer(('', 11111), Handler).serve_forever()

read()に読み込み文字数を指定する

次に、受信データの文字数をread()の引数に与える方法です。

送信データの先頭に文字数情報を含めておき、受信側でその情報だけ固定長で読み込んでから、read()関数に渡します。すると文字数だけを読み込むことができ、EOF読み込み待ちを回避できます。

ただ、文字数情報はバイナリで読み込む必要があるので、その部分だけコネクションに対してのrecv()send()を呼び出す必要があります。

# len_manage.py
def send_len(c, d):
    char_count = len(d)
    print(f'送信文字数:{char_count}')
    c.send(char_count.to_bytes(4, byteorder='big'))


def recv_len(c):
    char_count = int.from_bytes(c.recv(4), byteorder='big')
    print(f'受信文字数:{char_count}')
    return char_count
# send_and_receive_with_data_len.py
import socket
from len_manage import send_len, recv_len

conn = socket.create_connection(('localhost', 11111))
f = conn.makefile('wr', encoding='utf-8')

request_data = 'クライアントからの送信データ'
send_len(conn, request_data) # 送信文字数:14
f.write(request_data)
f.flush()

data = f.read(recv_len(conn)) # 受信文字数:12
print(f'受信データ:{data}') # 受信データ:サーバーからの返信データ

f.close()
conn.close()

# receive_and_send_with_data_len.py
from socketserver import TCPServer, StreamRequestHandler
from len_manage import send_len, recv_len

class Handler(StreamRequestHandler):
    def handle(self):
        conn = self.request

        f = self.request.makefile('rw', encoding='utf-8')
        data = f.read(recv_len(conn)) # 受信文字数:14
        print(f'受信:{data}') # 受信:クライアントからの送信データ

        response_data = 'サーバーからの返信データ'
        send_len(conn, response_data) # 送信文字数:12
        f.write(response_data)

        f.close()

TCPServer(('', 11111), Handler).serve_forever()

まとめ

  • ソケット通信はsocket.makefile()を活用すると便利
  • でもread()としちゃうと無限待ちが発生しちゃうよ
  • 代わりにreadline()を使おう
  • それか送受信サイズを先頭固定長で知らせよう

-python

© 2024 ヂまるBlog