ソケット通信で受信したデータが途切れてしまう問題の解決方法

受信データが途切れてしまう問題の解決方法の解説ページアイキャッチ

このページにはプロモーションが含まれています

このページでは、ソケット通信で受信したデータが途切れてしまう問題の解決方法について解説していきます。

基本的には Python の socket モジュールを利用した場合の解決方法について解説していきますが、実装は異なるものの、解決の仕方の考え方自体はどのプログラミング言語でも当てはまりますので、他のプログラミング言語を読んでいる方も是非読み進めていただければと思います。

解決しようとしている問題

タイトルを読んでも解決しようとしている問題がピンとこない方も多いかと思いますので、まず本ページで解決しようとしている問題について整理しておきます。問題の整理が不要という方は 解決方法 までスキップしていただければと思います。

問題の詳細

本ページで解決しようとしているのは、recv 実行時にバッファーのサイズを十分大きくしている&そのサイズ以下のデータを送信側が送信しているのにもかかわらず、受信側が送信されてきたデータを途切れた状態で受信してしまうという問題になります。要は、本来であれば “送信されてきたデータ全体が受信できるよう送信側も受信側も正く処理を行なっているはず” なのに、受信側が送信されてきたデータの一部しか受信できないという問題になります。

今回解決する問題点を説明する図

ポイントは、別にソケット通信のやり方やメソッド・関数の使い方が間違っているわけではないのに、毎回ではないが上記のような問題が発生することがあるという点になります。

例えば Python の socket モジュールで定義される recv メソッドでは、引数にバッファーのサイズを指定する必要があります。このサイズは、recv が一度に受信可能なデータサイズの最大値と捉えることもできます。

例えば下記のように recv を実行すれば、この recv の実行によって最大 65536 バイトのデータが一度に受信可能となります。

recvの実行
# sockはsocket.socketクラスのインスタンス
recv_data = sock.recv(65536)

で、このように recv を実行した場合、通信相手から 65536 バイトを超えるデータが送信されてくると、当然ですがデータが途切れて受信されることになります。なぜなら、この recv で受信可能な最大サイズは 65536 バイトだからです。これは納得できますよね。この場合、受信するデータが途切れてしまう原因は recv の引数に指定するバッファーのサイズが原因と考えて良いでしょう。

バッファーサイズを超えるサイズのデータが送信されてきた時も受信データが途切れてしまうことを説明する図

なんですが、実は通信相手から 65536 バイト以下のデータが送信されてきた場合もデータが途切れて受信されることがあります。つまり、”通信相手が送信するデータのサイズ” よりも大きなサイズを recv の引数に指定してバッファーサイズを十分大きく設定したとしても、受信できるデータが途切れる可能性があります。なので、recv の引数を単に大きくするだけでは上記の問題は解決できませんし、たとえ通信相手が小さなサイズのデータを送信してきたとしても同様の現象が発生することもあります。

バッファーサイズよりも小さなデータが送信された場合も受信が途切れる場合があることを説明する図

厄介なのは、この問題は毎回発生するというわけではないという点になります。なので、動作確認を行なって正常にデータの送受信が行えることを確認したとしても、いつかはこの問題が発生してしまう可能性があります。そのため、数回程度の動作確認では不十分ですし、確実に全てのデータを受信できるような対策をソケット通信プログラムに講じておく必要があります。

スポンサーリンク

TCP 通信の場合に発生する問題

また、この問題はソケット通信で TCP を利用する場合に発生します。UDP の場合、おそらくこの問題発生しないはずです。これを聞くと逆のように感じる人も多いはずです。確かに、下記ページでも解説しているように、TCP の場合は再送制御等によって接続を確立している相手からのデータは確実に受信できるようになっています。

Pythonでのソケット通信(ポート番号・プロトコル・サーバー / クライアント)

なので、厳密に言うと、受信側は全てデータを受信しているものの、recv によって受信バッファーからデータを取得する際にデータが中途半端に溜まった状態でデータが取得されることがあり、それによって結果的に取得されるデータが途切れた状態になってしまうことが解決すべき問題点となります。なので、厳密には、受信時にデータが途切れるのではなく、データを取得する際にデータが途切れていることになります。ただ、このページでは、あえて “受信データが途切れる” という表現で解説していくので、この点はご注意ください。

厳密にデータが途切れる現象を説明する図

問題が発生した時の動作

で、このような場合に、受信側が送信側の送信してきたデータ全てを受信したと考えて動作してしまうと2つのプログラム間での意思疎通ができなくなることになります。

例えば、送信側が「あなたのことが好きな人がいるので紹介しても良いですか?」というメッセージを送ったのにもかかわらず、受信側が「あなたのことが好き」までしか受信せずにメッセージを解釈した場合、送信側の伝えようとした意味合いと受信側が受け取った意味合いとが大きく異なることになり、上手く意思疎通ができません。

受信したデータが途切れることで送信側と受信側とで意思疎通がうまくできない様子

まぁ、このたとえが分かりやすいかどうかは置いておいて、とりあえず送信側が送信してきたデータ全体を受信側が取得できないと、送信側の送信してきたメッセージが異なるメッセージとして受信側に解釈されてしまったり、受信側でエラー・例外が発生したりすることになります。

そして、実は、ソケット通信で TCP を利用する場合、上記のような “送信側が送信してきたデータが途切れて受信されてしまう現象” は普通に発生します。この発生する原因は別途調べてみていただければ良いのですが、普通に起こりえる問題であるため、ソケット通信プログラムでは送信側が送信してきたデータを受信側のプログラムが確実に全て受信できるように対策を入れる必要があります。つまり、ソケット通信プログラム開発者が工夫して、この現象の解決方法を実装していく必要があります。

また、この問題は、基本的にはソケット通信ライブラリ・ソケット通信モジュール特有の問題であると考えて良いと思います。下記ページで解説している通り、ソケット通信ライブラリやソケット通信モジュールはトランスポート層以下のプロトコルに従って通信を行います。

Pythonでのソケット通信(ポート番号・プロトコル・サーバー / クライアント)

それに対し、アプリケーション層まで含めたプロトコルに従って通信を行うライブラリやモジュールも存在し、これらに関しては基本的に上記のような問題は発生しないと考えて良いと思います。なぜなら、アプリケーション層のプロトコルでは、上記のような問題が発生しないような仕組みが導入されているものが多いからです。そして、アプリケーション層のプロトコルに対応するライブラリやモジュールは、その対策込みのプロトコルに従って動作するように作られているため、基本的には上記のようにデータが途中で途切れるようなことは起こらないのではないかと思います。ちょっと全てを確認したわけではないので断言はできないですが、基本的にはソケット通信ライブラリ・ソケット通信モジュールを利用する場合に発生する問題と考えて良いと思います。以降の解説で、このあたりについても補足していきたいと思います。

問題が発生するスクリプト

実際に、ここまで説明してきた問題・現象が発生するスクリプトを紹介しておきます。

といっても、ここで紹介するのは、何の変哲もないソケット通信を行うプログラムのスクリプトであり、ソケット通信プログラムを開発したことのある方であれば同様のスクリプトを見たことのある方も多いと思います。ここでは、これらのスクリプトを実際に確認していただき、こういった何の変哲もないスクリプトでも上記のような問題が発生することを改めて認識していただければと思います。また、ここで紹介するスクリプトが、以降の解説の中で紹介する “解決方法を適用したスクリプト” のベースとなるスクリプトになりますので、ここで解決方法適用前のスクリプトがどんなものであるかを理解しておいていただくと、以降の解説や以降で紹介するスクリプトが理解しやすくなると思います。

クライアント

このページで紹介するスクリプトにおいては、データの送信側をクライアントサーバーモデルにおけるクライアントの位置づけで紹介していきます。逆にデータの受信側はサーバーの位置づけになります。

上記の問題が発生するクライアントプログラムのスクリプトは下記のようなものになります。

client.py
import socket

# 本文のデータ長を示すデータのサイズ
SIZE_DATA_LEN = 8

# 本文のデータのサイズ
BODY_DATA_LEN = 65536

class Client:
    def __init__(self):
        # ソケット作成
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        
    def __del__(self):
        # ソケットをクローズしてから終了
        self.sock.close()
               
    def transfer(self, data, addr):
        # 接続を確立
        self.sock.connect(addr)
        
        # データを送信
        self.sock.sendall(data)

        # サーバーからの応答を受信
        self.sock.recv(1024)

def main():

    # BODY_DATA_LENのサイズのデータを生成
    data = bytes([0] * BODY_DATA_LEN)

    # データの送信を10000回繰り返す
    for _ in range(10000):
        client = Client() 
        client.transfer(data, ('127.0.0.1', 40001))

if __name__ == '__main__':
    main()

Clientクラスは __init__ でソケットの生成を実行し、transfer メソッドで socket クラスの connect でサーバーへの接続要求の送信、sendall でデータの送信、さらに recv でサーバーからの応答の受信を実行するように作っています。そして、main 関数で Client のインスタンス生成と transfer メソッドを繰り返し実行してサーバーに対してデータの送信を繰り返し行うようにスクリプトを作っています。この処理の流れに関しては、TCP で通信を行うクライアントの処理の典型的な流れになります。また、データの送信と受信の両方を行なっていますが、問題となるのはデータの送信の方になります。

もし、この辺りのクライアントの処理の流れをご存知ない方は、下記ページを参照していただければと思います。下記ページを読んでいただければ、ソケット通信に関する基本的な基礎知識を身につけ、さらに Python でのソケット通信の使い方を理解していただけると思います。

Pythonでのソケット通信(ポート番号・プロトコル・サーバー / クライアント)

上記のスクリプトでは、Client のインスタンス生成と transfer メソッドは 10000 回繰り返し実行するようにしています。そして、クライアントからサーバーに送信するデータのサイズは 65536 バイトとしており、このクライアントからサーバーにデータを送信する際に、ここまで説明してきた問題が発生することがあります。

サーバー

上記の問題が発生するサーバープログラムのスクリプトは下記のようなものになります。

server.py
import socket

# 本文のデータ長を示すデータのサイズ
SIZE_DATA_LEN = 8

# クライアントが送信してくる本文のデータのサイズ
BODY_DATA_LEN = 65536

class Server:
    def __init__(self, addr):
        # ソケット作成からlistenまで実施
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.sock.bind(addr)
        self.sock.listen()

    def __del__(self):
        # ソケットをクローズしてから終了する
        self.sock.close()
    
    def transfer(self):
        # 相手からのconnect待ち
        e_sock, _ = self.sock.accept()

        # クライアントが送信してくる本文のサイズをバッファーサイズとして受信
        byte_data = e_sock.recv(BODY_DATA_LEN)

        # 受信が完了したことを示すメッセージを送信
        e_sock.sendall(b'OK')

        # データの送受信が終了したので接続確立済みのソケットをクローズ
        e_sock.close()

        return byte_data
    
def main():
    server = Server(('0.0.0.0', 40001))

    count = 0 # 受信データが途切れた回数
    while True:
        recv_data = server.transfer()
        
        if len(recv_data) < BODY_DATA_LEN:
            # 受信データが途切れた回数と受信データサイズを出力
            count += 1
            print(count, len(recv_data))

if __name__ == '__main__':
    main()

Server クラスは __init__ でソケットの生成および socket クラスの bindlisten を実行し、transfer メソッドで socket クラスの accept を実行して接続を確立し、接続確立後のソケットで socket クラスの recv を実行、さらに recv 実行後にデータの受信が完了するメッセージ b'OK'sendall で送信するように作っています。なので、Server クラスのインスタンスを生成し、その後に transfer メソッドを実行すれば、クライアントとの接続が確立されたのちにデータの送信及び受信が行われることになります。そして、Server クラスの transfer メソッドは無限ループの中で実行することで、常にクライアントからのデータの送信を待ち続けるようにしています。このあたりは典型的な TCP で通信を行うソケット通信の処理の流れになると思います。

で、今回問題となるのは transfer メソッドの中で実行している recv になります。クライアント でスクリプトを示したように、クラアインとプログラムは毎回 65536 バイトのデータを送信してきます。さらに、データ受信時に指定するバッファーサイズも 65536 バイトとしています。なので、毎回 65536 バイトのデータを受信できることが期待する結果となるのですが、前述の問題が発生して 65536 バイト未満のデータしか受信できないことがあります。そして、そのような場合は、transfer メソッドで問題が発生した回数と受信したデータのサイズを出力するようにしています。

なので、これらの情報が表示された場合は、このページで問題にしている受信データの途切れが発生しているということになります。

動作確認

では、先ほど示した2つのスクリプトを実行し、ここまで説明してきた問題が発生するかどうかを確認してみましょう。

まず、スクリプトの実行手順を示していきますが、この手順は 解決方法 の章で紹介するスクリプトの実行手順と同じになります。そのため、解決方法 の章ではスクリプトの実行手順の説明は省略させていただきますので、この点はご了承ください。

では、まずは、サーバー で示したスクリプトを server.pyクライアント で示したスクリプトを client.py というファイル名で適当なフォルダにファイル保存してください。続いて、コマンド実行が可能なアプリ(以降、CLI と呼びます)、Mac や Linux の場合はターミナル、Windows の場合は PowerShell (or コマンドプロンプト) 等を2つ起動します。そして、起動した2つの CLI で、先ほどファイルを保存したフォルダに cd コマンドで移動してください。

続いて、下記のようにコマンドを実行して pythonserver.py を起動します。これにより、server.py のソケットがクライアントからのデータの受信待ちを行うことになります。

python server.py

続いて、下記のようにコマンドを実行して pythonclient.py を起動します。これにより、client.py のソケットから server.py のソケットに 65536 バイトのデータが 10000 回送信されることになります。

python client.py

さらに、server.py を起動している方の CLI のウィンドウを確認してください。おそらくですが、下記のような出力が行われているのではないかと思います。

1 16332
2 16332
3 32664
4 65328
・
・
・
93 48996
94 65328
95 32664

サーバー で説明したように、server.py では recv メソッドで受信したデータのサイズが 65536 バイト未満である場合、すなわちクライアントが送信してきたデータが途切れて受信された場合に上記のような出力を行っており、スペース区切りで1つ目に、この現象が発生した回数を、2つ目に実際に受信したデータのサイズを出力しています。

なので、上記の結果の場合、95 回、クライアントが送信してきたデータが途切れて受信されてしまっていることが確認できます。

実行する環境によって、server.py で出力される問題の発生回数や受信されるデータのサイズは上記とは異なるものになると思いますが、おそらく、上記のようにスクリプトを実行すれば、ここまで説明してきた問題が発生することを皆さんも確認していただけるのではないかと思います。

ちなみに、server.py は無限ループするようになっているため、終了させたい場合は ctrl + c で強制終了させてください。

MEMO

特に Windows を利用されている場合は、データが 10000 回送信される前に client.pyConnectionResetError の例外が発生する可能性があります

これも、結局はデータが途切れて受信されることが原因でに発生している例外であり、次の 解決方法 を適用することで、データの途切れた状態での受信が解決され、それと共に例外が発生しなくなります

スポンサーリンク

解決方法

本ページで解決しようとしている問題を整理し終わったところで、次は本題の解決方法について解説していきます。

受信を繰り返し行うことで解決

解決方法の考え方は単純で、単に受信する処理を、送信側が送信したデータを全て受信できるまで繰り返し行うだけです。

前述でも説明しましたが、”データが途切れている” といっても、受信を行うメソッドや関数実行時にバッファーから取得されるデータが途切れてしまっているだけで、受信自体は成功しています。なので、そのバッファーからのデータの取得を繰り返し行うことでデータ全体の取得を行うことが可能となります。そして、このバッファーからのデータの取得は受信を行うメソッドや関数を実行することで行われることになりますので、例えば Python の場合は socket クラスの recv メソッドの実行をループさせれば良いということになります。

未受信のデータがある間recvを繰り返し実行する様子

ただ、これだと途切れ途切れにデータが受信されることになりますので、相手が送信したデータを復元するために受信したデータを結合する必要があります。なので、ループを組むだけでなく、データを結合したりするような処理が必要もあって処理が多少複雑になりますが、これによって相手が送信してきたデータを全て確実に受信することができるようになります。

ただし、この受信を何回繰り返せばよいのか?という点が、まだ課題として残ります。

結局、相手が送信してきたデータを全て受信できるまで繰り返し受信を行えば良いだけの話ではあるのですが、そのためには「相手が送信してきたデータを全て受信できたかどうか?」の判断が行えるようにする必要があります。この判断ができれば、あとはデータを全て受信するまで繰り返し受信を行えば良いだけなので問題は解決したと考えてよいでしょう。

で、この判断を行う方法として、このページでは下記の3つを紹介していきたいと思います。ここでいう “本文” とは、送信側が “本来送信したいデータ” となります。

  • 本文のサイズをデータの本文の前に付加する
  • 本文の終端を示す識別子を設ける
  • 本文の送信後に送信側のソケットをシャットダウンする

これらは3つとも、送信側と受信側とにルールを設け、そのルールに従って動作させることで、ここまで説明してきた問題を解決する方法となります。なので、問題が発生するスクリプト でサーバーとクライアントのプログラムのスクリプトを紹介しましたが、これらの両方を変更することで解決する必要があります。

方法①:送信するデータのサイズをデータの本文の前に付加する

最初に紹介する方法が「送信するデータのサイズをデータの本文の前に付加する」になります。

この方法は、送信側が、その本文の前に “本文のサイズを示すデータ” を付加するというものになります。このように送信側がデータを送信するようにすれば、受信側は受信したデータの先頭を読み込んで本文のサイズを知ることが可能となります。なので、受信済みの本文のデータのサイズから “本文の全てを受信できたかどうか” を判断することができるようになり、あとは本文を全て受信できるまで繰り返し受信を行うことで、確実に送信されてきた本文を全て受信できるようになります。

サイズを本文の前に付加することで、本文のサイズを受信側に知らせる様子

さらに、この “本文のサイズを示すデータ” のサイズを固定長にしておけば、受信したデータの先頭から何バイトのデータが本文のサイズを示しているのかが送信側と受信側の両方で明確になります。

サイズを示すデータの長さを固定長にすることで受信側がサイズを知るために読み込むべきデータの位置が分かる様子

こういった、本文のデータの前に付加されるデータをヘッダーと呼びます。そして、一般的なアプリケーション層のプロトコルでは、このヘッダーで本文のサイズを受信側に知らせることで、受信側が確実に送信されてきたデータの全体を受信できるようになっているものが多いです。

このように、本文のサイズを受信側に知らせることをプロトコルとしてルール化しているものが多いです。そして、アプリケーション層のプロトコルに対応している通信ライブラリは、それらのプロトコルに従ってデータを受信するようになっているため、アプリケーション層のプロトコルに対応している通信ライブラリでは受信したデータが途切れてしまう問題が解決されているものが多いです。

そのため、解決しようとしている問題 で説明したように、今回解決しようとしている問題は、基本的にはアプリケーション層よりも下位の層で起こりうる問題と考えて良く、ソケット通信はアプリケーション層よりも下位の層のプロトコルに対応する通信であるため、ソケット通信ライブラリ(モジュール)を利用して通信プログラムを開発する場合は、この問題の解決策を開発者自身が適用する必要があります。逆に、アプリケーション層に対応する通信ライブラリやモジュールに関しては、基本的にはアプリケーション層のプロトコルによって送信側が送信してきたデータの全体を取得できるようになっていると考えて良いと思います。

方法①の実装例:クライアント

続いて、実際に方法①を適用したスクリプトの紹介を行っていきます。

まずは、データの送信側となる client.py のスクリプトを紹介します。

client.py
import socket

# "本文のサイズを示すデータ"のサイズ
SIZE_DATA_LEN = 8

# 本文のサイズ
BODY_DATA_LEN = 65536

class Client:
    def __init__(self):
        # ソケット作成
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        
    def __del__(self):
        # ソケットをクローズしてから終了
        self.sock.close()
               
    def transfer(self, data, addr):
        # 接続を確立
        self.sock.connect(addr)

        # 本文のサイズを示すバイトデータを作成
        size_data_len = len(data)
        size_data = size_data_len.to_bytes(SIZE_DATA_LEN, 'little')

        # 作成したデータを本文と結合
        send_data = size_data + data
        
        # 結合後のデータを送信
        self.sock.sendall(send_data)

        # サーバーからの応答を受信
        self.sock.recv(1024)

def main():

    # BODY_DATA_LENのサイズのデータを生成
    data = bytes([0] * BODY_DATA_LEN)

    # データの送信を10000回繰り返す
    for _ in range(10000):
        client = Client() 
        client.transfer(data, ('127.0.0.1', 40001))

if __name__ == '__main__':
    main()

問題が発生するスクリプトクライアント で示したスクリプトとの違いは Client クラスの transfer メソッドでの sendall 実行部分になります。

この transfer メソッドでは、引数で受け取った本来送信すべき本文 data の前に、その本文 data のサイズを示すデータ size_data を結合したデータをサーバーに対して送信するようになっています。そして、この size_data は、本文 data のサイズである len(data)8 (SIZE_DATA_LEN) バイトのサイズのバイト型のデータに変換したものになります。

size_dataとdataを結合する様子

したがって、データの受信側となるサーバーは、このクライアントが送信してきたデータの先頭 8 バイトのみを読み込めば本文のサイズを確認することができることになります。そして、この本文のサイズを知っていれば、本文を全て受信できたかどうかを受信側で判断できるようになります。あとは、受信側は、本文を全て受信できるまで繰り返し受信を行えば、このクライアントが送信してきたデータを全て受信することが可能となります。

一点注意点を述べておくと、引数で受け取る data はバイト型のデータであるため、この data に結合する “本文のサイズを示すデータ” もバイト型である必要があります。つまり、size_data_len は、整数型であるため、そのままでは data に結合できません。そのため、size_data_len.to_bytes を実行してバイト型のデータに変換し、それを data に結合するようにしています。

to_bytes は整数型 (int型) のオブジェクトが利用可能なメソッドで、そのオブジェクトの整数を第1引数に指定したサイズのバイト型のデータに変換するメソッドになります(第2引数にはエンディアンを指定する)。逆に、int.from_bytes により、第1引数に指定したバイト型のデータを整数型のオブジェクトに変換可能です。ソケット通信ではバイト型のデータしか送受信できないため、こういった整数型とバイト型の間の変換も利用する機会が多いので、これらの to_bytesfrom_bytes に関しては覚えておくと良いと思います。 

方法①の実装例:サーバー

次に、先ほど示したクライアントのデータの受信側となる server.py のスクリプトを紹介します。

server.py
import socket

# "本文のサイズを示すデータ"のサイズ
SIZE_DATA_LEN = 8

# クライアントが送信してくる本文のサイズ
BODY_DATA_LEN = 65536

class Server:
    def __init__(self, addr):
        # ソケット作成からlistenまで実施
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.sock.bind(addr)
        self.sock.listen()

    def __del__(self):
        # ソケットをクローズしてから終了する
        self.sock.close()
    
    def transfer(self):
        # 相手からのconnect待ち
        e_sock, _ = self.sock.accept()

        # 未受信のデータサイズ(本文のサイズ)
        unreceived_len_size = SIZE_DATA_LEN

        byte_data_len = bytes()

        # 未受信のデータサイズが0になるまでループ
        while unreceived_len_size > 0:

            # データを受信して結合
            recv_data = e_sock.recv(unreceived_len_size)
            byte_data_len += recv_data

            # 未受信のデータサイズを更新
            unreceived_len_size -= len(recv_data)
        
        # バイトデータを整数に変換して本文のサイズを取得
        unreceived_body_size = int.from_bytes(byte_data_len, 'little')

        byte_data = bytes()

        # 未受信のデータサイズが0になるまでループ
        while unreceived_body_size > 0:

            # データを受信して結合
            recv_data = e_sock.recv(unreceived_body_size)
            byte_data += recv_data

            # 未受信のデータサイズを更新
            unreceived_body_size -= len(recv_data)

        # 受信が完了したことを示すメッセージを送信
        e_sock.sendall(b'OK')
        
        # データの送受信が終了したので接続確立済みのソケットをクローズ
        e_sock.close()

        return byte_data
    
def main():
    server = Server(('0.0.0.0', 40001))

    count = 0 # 受信データが途切れた回数
    while True:
        recv_data = server.transfer()
        
        if len(recv_data) < BODY_DATA_LEN:
            # 受信データが途切れた回数と受信データサイズを出力
            count += 1
            print(count, len(recv_data))

if __name__ == '__main__':
    main()

問題が発生するスクリプトサーバー で示したスクリプトと比較していただければ、問題の解決のためにどのような対策を行っているのかが分かりやすいと思います。

主に変更を加えているのが Server クラスの transfer のメソッドでの recv 実行部分で、サーバー で示したスクリプトではデータの受信を行うために socket クラスの recv メソッドを1度しか実行していませんでしたが、上記のスクリプトでは2段階的にデータの受信を行っており、さらに、それぞれのデータの受信では socket クラスの recv メソッドを繰り返し実行するようにしています。

1段階目のデータの受信では、クライアントが送信してきたデータの本文部分の “データサイズ” のみを受信するようにしています。そのため、まず 8 バイトのみを受信する処理を行っています。ここでもデータが途切れる可能性があるかもしれませんので、念のため受信したデータが 8 よりも小さい場合は繰り返しデータの受信を行うようにしています。また、複数回受信が行われるということは、送信されてきたデータが途切れて受信されているということになるため、受信するたびにデータの結合を行う必要があります。このあたりを実行しているのが下記部分になります。

データサイズの受信
# 未受信のデータサイズ(本文のデータ長)
unreceived_len_size = SIZE_DATA_LEN

byte_data_len = bytes()

# 未受信のデータサイズが0になるまでループ
while unreceived_len_size > 0:

    # データを受信して結合
    recv_data = e_sock.recv(unreceived_len_size)
    byte_data_len += recv_data

    # 未受信のデータサイズを更新
    unreceived_len_size -= len(recv_data)

1段階目のデータの受信で取得できるのはあくまでも “本文のサイズ” のみとなります。本文の受信を行うのが2段階目のデータの受信となります。そして、それを実行しているのが下記部分になります。

本文の受信
# バイトデータを整数に変換して本文のデータ長を取得
unreceived_body_size = int.from_bytes(byte_data_len, 'little')

byte_data = bytes()

# 未受信のデータサイズが0になるまでループ
while unreceived_body_size > 0:

    # データを受信して結合
    recv_data = e_sock.recv(unreceived_body_size)
    byte_data += recv_data

    # 未受信のデータサイズを更新
    unreceived_body_size -= len(recv_data)

基本的には、1段階目のデータの受信と同様の処理を行っているだけですが、受信すべきサイズは1段階目のデータの受信で取得した値となります。ただ、その値はバイト型のデータとして受信されるため、それを整数に変換する必要がある点に注意してください。このバイト型のデータの整数への変換には、先ほど説明したように int.from_bytes を利用します。

このように、受信すべきサイズを最初に取得できれば、受信側が相手の送信してきたデータサイズを知ることができるため、受信を繰り返す回数を受信側で判断することができるようになります。そして、これにより送信されてきたデータの全体を確実に取得することができるようになります。

実際に、問題が発生するスクリプト動作確認 で紹介した実行手順で動作確認を行ってみれば、クライアントがデータの送信を 10000 回繰り返しても、毎回 65536 バイトのデータが受信されることが確認でき、問題が解決できていることが確認できると思います。また、問題が発生するスクリプト を実行すると ConnectionResetError の例外が発生していた場合でも、上記のスクリプトであれば、その例外が発生しなくなっていることも確認できるはずです。

スポンサーリンク

方法②:本文の終端を示す識別子を設ける

2つ目は「本文の終端を示す識別子を設ける」です。

この方法は、送信側が本文の最後に “本文の最後であることを示す識別子” を付加するというルールを設けるものになります。このようなルールを設けることで、受信側は、その識別子を受信したときに “送信されてきたデータ全てを受信できた” と判断できるようになります。なので、受信側は、その識別子を受信するまで繰り返し受信を行うことで、送信されてきたデータ全てを確実に受信できるようになります。

識別子を送信することで、受信側がデータの終端を把握できるようにする様子

単純な方法ではあるのですが、この方法では「本文内に “本文の最後を示す識別子” を含められない」という制限があるので注意してください。例えば改行文字を識別子にすると本文内には改行文字を含めることができません。なぜなら、その改行文字が本文の最後を示す識別子となるため、その識別子までが本文としてみなされ、そこまでしかデータが受信されないことになるからです。

終端を示す識別子がデータの途中に存在すると、そこで受信が途切れてしまうことを示す図

ということで、本文の最後を示す識別子は慎重に選択する必要があります。

方法②の実装例:クライアント

続いて、実際に方法②を適用したスクリプトの紹介を行っていきます。

まずは、データの送信側となる client.py のスクリプトを紹介します。

client.py
import socket

# 本文のデータ長を示すデータのサイズ
SIZE_DATA_LEN = 8

# 本文のデータのサイズ
BODY_DATA_LEN = 65536

# 終端を表すデータ
TERMINATOR = b'\r\n'

class Client:
    def __init__(self):
        # ソケット作成
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        
    def __del__(self):
        # ソケットをクローズしてから終了
        self.sock.close()
               
    def transfer(self, data, addr):
        # 接続を確立
        self.sock.connect(addr)

        # 本文の最後に終端を表すデータを結合
        send_data = data + TERMINATOR
        
        # 結合後のデータを送信
        self.sock.sendall(send_data)

        # サーバーからの応答を受信
        self.sock.recv(1024)

def main():

    # BODY_DATA_LENのサイズのデータを生成
    data = bytes([0] * BODY_DATA_LEN)

    # データの送信を10000回繰り返す
    for _ in range(10000):
        client = Client() 
        client.transfer(data, ('127.0.0.1', 40001))

if __name__ == '__main__':
    main()

この方法でも 問題が発生するスクリプトクライアント で示したスクリプトから変更が必要になるのは主に Client クラスの transfer メソッドでの sendall 実行部分のみとなります。この transfer メソッドでは、引数で受け取った本来送信すべき本文 data の最後に本文の終わりを示す TERMINATOR (b'\r\n')を結合したデータを送信するようにしています。

したがって、データの受信側となるサーバーは、このクライアントが送信してきたデータに TERMINATOR が含まれているかどうかでデータを全て受信できたかどうかを判断することができるようになります。なので、受信側は受信したデータに TERMINATOR が含まれない間は繰り返しデータの受信を行うようにすれば、このクライアントが送信したデータの全てを確実に受信できることになります。 

方法②の実装例:サーバー

次に、先ほど示したクライアントのデータの受信側となる server.py のスクリプトを紹介します。

server.py
import socket

# 本文のデータ長を示すデータのサイズ
SIZE_DATA_LEN = 8

# クライアントが送信してくる本文のデータのサイズ
BODY_DATA_LEN = 65536

# 終端を表すデータ
TERMINATOR = b'\r\n'

class Server:
    def __init__(self, addr):
        # ソケット作成からlistenまで実施
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.sock.bind(addr)
        self.sock.listen()

    def __del__(self):
        # ソケットをクローズしてから終了する
        self.sock.close()
    
    def transfer(self):
        # 相手からのconnect待ち
        e_sock, _ = self.sock.accept()

        byte_data = bytes()

        data_len = BODY_DATA_LEN + len(TERMINATOR)

        while TERMINATOR not in byte_data:

            # データを受信して結合
            recv_data = e_sock.recv(data_len)
            byte_data += recv_data
        
        # 受信が完了したことを示すメッセージを送信
        e_sock.sendall(b'OK')

        # データの送受信が終了したので接続確立済みのソケットをクローズ
        e_sock.close()

        return byte_data[:-len(TERMINATOR)]
    
def main():
    server = Server(('0.0.0.0', 40001))

    count = 0 # 受信データが途切れた回数
    while True:
        recv_data = server.transfer()
        
        if len(recv_data) < BODY_DATA_LEN:
            # 受信データが途切れた回数と受信データサイズを出力
            print(count, len(recv_data))
            count += 1

if __name__ == '__main__':
    main()

このスクリプトのポイントは、下記のように Server クラスの transfer メソッドで受信済みのデータに TERMINATOR が含まれているかどうかを毎回確認し、含まれないは間繰り返し recv を実行している点になります。クライアントが送信するデータの最後には必ず TERMINATOR (b'\r\n') が付加されるようになっているため、このように繰り返し recv を実行すれば確実にクライアントが送信してきたデータの全体を受信することができます。

終端までの受信
while TERMINATOR not in byte_data:

    # データを受信して結合
    recv_data = e_sock.recv(data_len)
    byte_data += recv_data

ただし、受信したデータには本文以外の TERMINATOR が含まれているため、受信したデータから本文のみを取得する場合は TERMINATOR を除去する必要がある点に注意してください。この処理はスライスを使って byte_data[:-len(TERMINATOR)] によって実現できます。

こちらの方法においても、問題が発生するスクリプト動作確認 で紹介した実行手順で動作確認を行ってみれば、クライアントがデータの送信を 10000 回繰り返しても毎回 65536 バイトのデータが受信されることが確認でき、問題が解決できていることが確認できると思います。また、ConnectionResetError の例外も発生しなくなっていることが確認できるはずです。

方法③:本文の送信後に送信側のソケットをシャットダウンする

最後の3つ目の方法は「本文の送信後に送信側のソケットをシャットダウンする」になります。

socket クラスには shutdown メソッドが用意されており、このメソッドを実行することで確立していた接続を切断する(シャットダウンする)ことができます。さらに、shutdown メソッドに引数を指定することで、下記のように送信 or 受信、もしくは送信と受信の両方に対する接続を切断することができます。

  • socket.SHUT_WR:送信に対する接続切断(以降、そのソケットでの送信が不可になる)
  • sockt.SHUT_RD:受信に対する接続切断(以降、そのソケットでの受信が不可になる)
  • socket.SHUT_RDWR:送受信に対する接続切断(以降、そのソケットでの送受信が不可になる)

このシャットダウンを利用することで、送信側は「これ以降データは送信しないことを意思表示する」ことができ、それによって受信側は「これ以降データは送信されてこない」ということを知ることができます。

通常、接続確立後のソケットに対して recv メソッドで受信を行うと、相手がデータを送信してこないとずっと受信待ちの状態になります。なんですが、接続確立後のソケットにおいて、送信側がソケットの送信に対する接続を切断した場合、受信側がデータの受信を行うと、相手がデータを送信してきていなくてもサイズ 0 のデータが受信されることになります(なにもデータを受信せずに受信処理が終了する)。つまり、サイズ 0 のデータを受信した場合、接続を確立していた相手のソケットは既に送信に対して接続切断しており、これ以降データが送信されてこないということを判断することができます。

サイズ0のデータを受信することで相手が以降データを送信してこないことが判断できる様子

したがって、データの送信側でデータを全て送信した後にソケットの接続に対するシャットダウンを行うようにしてやれば、受信側は “サイズ 0 以外” のデータを受信している間は受信を繰り返し行い、サイズ 0 のデータを受信したときに、その繰り返しを終了してやることで、送信側が送信してきたデータ全てを確実に受信できるようになります。

送信に対するシャットダウンを行うことで、受信側がデータがこれ以上送信されてこないことを把握できるようにする様子

ただし、この方法では、送信側のソケットはデータの送信後に送信に対する接続が切断されるため、以降はデータの送信を行うことができないという制約が発生します。何回も通信相手とデータの送受信を繰り返すような場合は適用しにくい方法になるので注意してください。

方法③の実装例:クライアント

続いて、実際に方法③を適用したスクリプトの紹介を行っていきます。

まずは、データの送信側となる client.py のスクリプトを紹介します。

client.py
import socket

# 本文のデータ長を示すデータのサイズ
SIZE_DATA_LEN = 8

# 本文のデータのサイズ
BODY_DATA_LEN = 65536

class Client:
    def __init__(self):
        # ソケット作成
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        
    def __del__(self):
        # ソケットをクローズしてから終了
        self.sock.close()
               
    def transfer(self, data, addr):
        # 接続を確立
        self.sock.connect(addr)
        
        # データを送信
        self.sock.sendall(data)

        # 送信に対する接続を切断
        self.sock.shutdown(socket.SHUT_WR)

        # サーバーからの応答を受信
        self.sock.recv(1024)

def main():

    # BODY_DATA_LENのサイズのデータを生成
    data = bytes([0] * BODY_DATA_LEN)

    # データの送信を10000回繰り返す
    for _ in range(10000):
        client = Client() 
        client.transfer(data, ('127.0.0.1', 40001))

if __name__ == '__main__':
    main()

他の方法同様に、問題が発生するスクリプトクライアント で示したスクリプトから変更が必要になるのは主に Client クラスの transfer メソッドでの sendall 実行部分のみとなります。この transfer メソッドでは、データの送信後に self.sock.shutdown(socket.SHUT_WR) を実行して送信に対する接続を切断し、これ以降はデータを送信しないことを明示的に意思表示しています。

したがって、データの受信側となるサーバーは、このクライアントから受信したデータのサイズが 0 であった場合に、既にクライアントが送信してきたデータを全て受信済みであると判断することができます。

また、self.sock.shutdown(socket.SHUT_WR) では送信に対する接続が切断されますが、受信に対する接続は確立されたままとなるため、self.sock.shutdown(socket.SHUT_WR) 実行後もrecv メソッドを実行することは可能です。

方法③の実装例:サーバー

次に、先ほど示したクライアントのデータの受信側となる server.py のスクリプトを紹介します。

server.py
import socket

# 本文のデータ長を示すデータのサイズ
SIZE_DATA_LEN = 8

# クライアントが送信してくる本文のデータのサイズ
BODY_DATA_LEN = 65536

class Server:
    def __init__(self, addr):
        # ソケット作成からlistenまで実施
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.sock.bind(addr)
        self.sock.listen()

    def __del__(self):
        # ソケットをクローズしてから終了する
        self.sock.close()
    
    def transfer(self):
        # 相手からのconnect待ち
        e_sock, _ = self.sock.accept()

        byte_data = bytes()

        while True:

            # データを受信して結合
            recv_data = e_sock.recv(BODY_DATA_LEN)
            byte_data += recv_data

            # 何もデータを受信しなかった場合はデータを全て受信したと判断
            if len(recv_data) == 0:
                break

        # 受信が完了したことを示すメッセージを送信
        e_sock.sendall(b'OK')

        # データの送受信が終了したので接続確立済みのソケットをクローズ
        e_sock.close()

        return byte_data
    
def main():
    server = Server(('0.0.0.0', 40001))

    count = 0 # 受信データが途切れた回数
    while True:
        recv_data = server.transfer()
        
        if len(recv_data) < BODY_DATA_LEN:
            # 受信データが途切れた回数と受信データサイズを出力
            count += 1
            print(count, len(recv_data))

if __name__ == '__main__':
    main()

このスクリプトの  Server クラスの transfer メソッドでは、下記のように受信したデータのサイズが 0 になるまで recv メソッドでの受信を繰り返すようにしています。受信したデータのサイズが 0 であるということは、既にクライアント側が送信に対する接続を切断しているということなので、もうクライアントからはデータが送信されてこない、つまり、クライアントが送信してきたデータは全て受信済みということになります。

受信サイズが0になるまで繰り返し
while True:

    # データを受信して結合
    recv_data = e_sock.recv(BODY_DATA_LEN)
    byte_data += recv_data

    # 何もデータを受信しなかった場合はデータを全て受信したと判断
    if len(recv_data) == 0:
        break

こちらの方法においても、問題が発生するスクリプト動作確認 で紹介した実行手順で動作確認を行ってみれば、クライアントがデータの送信を 10000 回繰り返しても毎回 65536 バイトのデータが受信されることが確認でき、問題が解決できていることが確認できると思います。また、ConnectionResetError の例外も発生しなくなっていることが確認できるはずです。

MEMO

Windows の場合、shutdown メソッド実行後に close メソッドを実行してもソケットがすぐに閉じられず、ソケットが残り続けるようで、client.py の実行後にすぐに client.py を実行すると connect 時に例外が発生する現象が確認されました

client.py を2回以上実行する場合は、前回の client.py 実行から5分ほど時間を空けてから client.py を実行するようにしてください

まとめ

このページでは、ソケット通信で受信したデータが途切れてしまう問題の解決方法について解説しました!

TCP でソケット通信を行う場合、送信側が送信してきたデータが途切れて受信されてしまう場合があります。厳密に言うと、データは全て受信できるのですが、recv メソッド等でデータをバッファーから取得する際にデータが途切れて取得されてしまう場合があります。

この問題は、送信側が送信してきたデータを全て受信できるまで繰り返しデータの受信を行うことで解決することができます。そして、そのためには、”送信側が送信してきたデータを全て受信できたかどうか” を受信側で判断できるようにしてやる必要があり、これは下記のような方法で実現できます。

  • 本文のサイズをデータの本文の前に付加する
  • 本文の終端を示す識別子を設ける
  • 本文の送信後に送信側のソケットをシャットダウンする

データが途切れて受信できてしまうと、送信側が意図しないデータを受信側が受信してしまうことになり、送信側と受信側とでお互いに上手く意思疎通ができなくなってしまったり受信側でエラーが発生したりしてしまうので注意しましょう。

この問題は、ソケット通信ライブラリ・モジュールを利用していると発生する可能性があるため対策を入れておくことをオススメします。特に、ある程度大きなデータを送受信するような場合は必ず対策を入れておきましょう。

また、この問題はアプリケーション層のプロトコルに対応する通信ライブラリ・通信モジュールでは既に解決されていることが多いと思います。なぜなら、解決方法 で説明したような対策が行えるようにアプリケーション層のプロトコルが定義されているからです。なので、ソケット通信ライブラリを利用するのではなく、アプリケーション層のプロトコルに対応するライブラリを利用することでも問題が解決できる可能性は高いと思います。

ソケット通信プログラムを開発していると本ページで解説している問題が発生して頭を悩ます可能性もありますので、是非このページで解説した内容は頭の片隅にでも置いておいてください!

同じカテゴリのページ一覧を表示