Python socket モジュールの recvall とか recvexactly

雑なことを書くと誤解を招きかねないんで正直書くかどうか迷ったんだけれども。

久しぶりに剥き身の socket を使う機会に恵まれて(?)、そういえば、と。


本題に入る前に、「誤解を招きかねない」へのガードのための「そもそも論」から。「剥き身の socket を使う」ことの是非から検討すべき、てことは最初に言っておかないといけない。

何か「重たいコア処理」と「それに依存するタスク」のペアがある場合、考えることは、「その2つをどのように分離して連携するか」である。候補は常にこの2つ:

  1. スレッド(もしくはそれに類するもの)に分割して連携する(インプロセス)
  2. マルチプロセスに分割して連携する

前者はデータの授受は基本的に「インメモリ」で済むが、後者は fork でない限りは実際のデータ送受が発生する。マルチプロセスの連携では、Python では multiprocessing モジュールを利用出来るが、ここでのデータ連携はプラットフォーム依存だが「プラットフォームがサポートするプロセス間連携のインフラ(セマフォなど)」を使う。

multiprocessing モジュールが候補から外れるケースは、非常に当然のことだが、「重たいコア処理」と「それに依存するタスク」に明確な親子関係を持たない場合、持たせたくない場合であり、つまりは「重たいコア処理」を「サービス」として独立させたい場合である。

とした場合に次に問うべきは、

  • その「サービス」に対する要求のバリエーションは如何ほどか?
  • その「サービス」に対する応答のバリエーションは如何ほどか?

であり、これが「非常にバリエーションに富む」場合は、一般には「出来合いのプロトコル・インフラ」(HTTP など)を選択した方が得策であって、「剥き身の socket」はお呼びではない。

対して「要求も応答も発展しようがないほどシンプルである」場合には、かえって「この目的には HTTP などの高級なものは牛刀」と言える可能性があって、こうしてはじめて「剥き身の socket でもええやん」となる。

という大前提を踏まえた上で…。


見出しの通りの「recvall とか recvexactly」の話。ないよ、実在してない。常識かなとは思うけれど。

で、そのことで必要になるたびに「ちょっとは鬱陶しいなぁ」とは感じてはいたんだけれど、あまり深い考察は試みたことがなかったんで、ちょうどいい機会かな、と思って。

まず最初に。

「剥き身の socket でもええやん」と決断したとしたら、次にすべきことは、「プロトコルを考える」ことである。higher level における「プロトコル」とともに、「剥き身の socket」を使う場合は lower level のプロトコルも考えなければならない。無論「データ終端」の扱いのことを言っている。

「データ終端」への立ち向かい方には簡単には3種類:

  • 送受信サイズを予めやりとりする
  • zero-length データを終端と取り決める (伝統的な UDP では良くあるらしい)
  • 終端マーカバイトを取り決めておく

    • テキストベースのプロトコルなら一般的には改行コード
    • バイナリベースのプロトコルに「\0」を選ぶバカもいる (バカというか迷惑)

このうち「recvall とか recvexactly がない」ことが問題になるのは「送受信サイズを予めやりとりする」プロトコルを選んだ場合だけである。予め授受するサイズがわかっているからこそ、recv(121) が正確に完全に 121 バイトを「取れないなんて何事だ」、てことになる。そうでないなら終端発掘するまでループすることには何の抵抗もあるはずがない。

ここまでは OK? つまりすでに「recvall とか recvexactly を必要とする」状況自体が、かなり限られているということは理解出来た?

ようやく本題なんだけれど、思ったんだけどさ、「recvall とか recvexactly を必要とする」状況って、実際はさらに限定されない? これ、「そこそこ大きなデータをやりとりする」場合にしか問題にならないと思わん? 実際要求サイズよりも応答サイズが小さくなるのって、下層(物理層など)のバッファサイズの制約の影響をじかに受けるからなんだけど、当然小さなデータ授受を繰り返すだけであれば recv はいつだって期待通りに振舞う。

逆に言えば、「そんな大きなデータを扱ってるのだから、recvall とか recvexactly があっても実は嬉しくない」てこと:

ある目的で実際に書きかけのコードの一部
 1         def _get_img(self):
 2             imgbytessize = struct.unpack("!I", self.request.recv(4))[0]
 3             #
 4             bimg = BytesIO()
 5             chunk_size = 1024
 6             read = 0
 7             while read < imgbytessize:
 8                 b = self.request.recv(chunk_size)
 9                 read += len(b)
10                 bimg.write(b)
11             #
12             bimg.seek(0)
13             return Image.open(bimg)

PIL の Image モジュールの場合はいずれにしても「ファイル全体」を作ってから渡す必要があるとは思うけれど、いわゆる「ビルダパターン」を採用しているようなインフラであれば、細切れ recv のたびに例えば「update」だとか「feed」に逐次渡していくことは大変合理的である。つまりこんなケースじゃぁ、「recvall とか recvexactly は全然お呼びではない」のな。

当然 Python 標準モジュールのあり方としては、実際に提案・検討されたことがある。この issue へはこの質問から辿りついた。stackoverflow での議論や Python 本家 issue のどちらでも、「別にいらんのでは」ていうワタシとおんなじことを言ってる人もいるし、本家で話がストップしている理由の大きなものはやはり「期待通りのデータが得られぬまま」だった場合のエラーの扱いが繊細なこと、みたいだね。

issue1103213 で提案されたパッチは紆余曲折してて、当初は C で書かれ、「最新」のものは 2015 年に pure Python で書かれている:

 1     def recvall(self, size, flags=0):
 2         """
 3         Receive exactly bufsize bytes from the socket.
 4 
 5         The return value is a bytes object representing the data received.
 6         See the Unix manual page recv(2) for the meaning of the optional
 7         argument *flags*; it defaults to zero.
 8 
 9         Raise an IncompleteReadError exception if the end of the stream is
10         reached before size can be read, the IncompleteReadError.partial
11         attribute of the exception contains the partial read bytes.
12         """
13         buffer = bytearray(size)
14         view = memoryview(buffer)
15         pos = 0
16         while pos < size:
17             read = self.recv_into(view[pos:], size - pos, flags)
18             if not read:
19                 raise IncompleteReadError(bytes(view[:pos]), size)
20             pos += read
21         return bytes(buffer)

これが仮に適用されていたとすればまぁ「ありがたく使おう」という状況も「皆無ではない」とは思うけれど、ここまで考えてきた通り、実際はこうやって「バイトの塊の全部をバイト列で返してくれる」ことそのものが「大きなお世話となるケースが多い」のであろうなぁ、と。(ゆえに、yield だったり「ビジターパターン」的に、利用者側に割り込める余地があるものならありがたい可能性が高い。)

実際問題としては、「recvall とか recvexactly がない」ことの唯一の実害は、「使いたいときにそれがない」ことではなくて、「いずれにせよループして全部を食べ尽くすコードを書く必要があるが、毎度忘却してて最大5分くらいは頭を掻き毟る、かもしれない」てことでしかないわけね。いずれにせよ「recvall とか recvexactly そのもの」ではなく「recvall とか recvexactly に似せた自前処理」を書くわけだから、その「元ネタの有無」でしかないのだろうなぁと。

これがまぁ「オレ式な結論」。実際世間的にはどう思われてるんだろうねぇ? 別にあって困るもんではないと思うわけで、つまりは「あるので非」てことは絶対にない。Ruby とかってどうなの? 全然知らんのだけど。


16:40追記:
すまん、書くつもりで忘れてた。リンク先にも言及があるんで、マジメに読む人ならわかることだけれど、「テキストベース」なら、socket の「makefile」を使うのが吉:

1 allread = sock.makefile('r').readlines()

そう、ますます「recvall とか recvexactly」がなくても困らない。