socket通信の例外ってどういう状況でどんなのが出る?(Python, socket)

 前回までsocket通信で色々遊んできましたが、見て見ぬふりをしてきたんです…例外を(-_-;。でもsocket通信を使うなら例外は絶対に対処しなければなりません。

 そこで今回はsocket通信のプロセスをつぶさにステップしながら見て行きつつ、あれこれ色々無茶をやってみて何が起こるか実験して検証していこうと思います。

例外ログ出力

 実験用にsocket通信で出る例外をログ出力する関数を先に作っておきます:

def exceptLog( e ):
    print( "errno: ", e.errno )
    print( "filename: ", e.filename )
    print( "filename2: ", e.filename2 )
    print( "strerror: ", e.strerror )
    print( "args:", e.args )

 例外が発生した場合socket.error型として例外情報が飛んできます。それをexcept節でキャッチしこの関数に投げます。
 socket.error型にはエラー番号(errno)やエラー文言(strerror)などがあり、それで起きた例外の種類を細かく判別できます。実際対処する場合にこれらを使う事になるでしょう。

エラー番号がOSで違うぅ(T-T)

 試して分かった事ですが、例外発生時のエラー番号がOSによって違いました。試したOSはWindowsとRaspberry Pi OSです。これつまり例外を捕まえた後に、

if e.errno == 11001:
    # 対処ほにゃらら

こういう番号を直接ハードコーディングする書き方だとOS依存になってしまうという事です。プラットフォームを跨ぐ事が前提の場合は、各OS用のエラーハンドラを用意した方が賢明かもしれません。何か良い方法があるのかな…(-_-;

クライアント:connect関数

 connect関数には接続したいサーバーのIPアドレスとポート番号を指定します。この辺りは色々ダメな引数を渡せますのでやってみます。

IPアドレスがそもそも滅茶苦茶

import socket

client = socket.socket( socket.AF_INET )

try:
    client.connect( ( "Dummy", 12345 ) )
except socket.error as e:
    errorLog( e )

 IPアドレスには"192.168.11.16"のような文字列を渡さなければなりません。上のように適当な文字列を渡すと、

IPアドレスがおかしいという例外が出ました。サーバーへの接続に失敗しているのでその旨を返すようにして、再接続を試みるなど対処しましょう。

IPアドレス先にサーバーが無い

client = socket.socket( socket.AF_INET )

try:
    client.connect( ( "192.168.11.123", 12345 ) )
except socket.error as e:
    exceptLog( e )

 IPアドレスのフォーマットは正しいですが、指定のアドレスに対応する機器(サーバー)が無い状態。ブロッキングしている場合、しばらくここで待ちぼうけが発生します。そして、

例外に飛びました。タイムアウトが発生したようです。これもサーバー接続が失敗なので、IPアドレスを正しくするように促すとか、ネットワーク非接続なモードに移行するなど対処して下さい。

IPアドレスの書式が正しい場合、socketはちゃんとそのIPからの返答を待っているんですね。裏を返せばこの段階でもう通信が発生しているという事です。

IPアドレス先に機器があるけどサーバーが受け付けていない

client = socket.socket( socket.AF_INET )

try:
    client.connect( ( "192.168.11.16", 12345 ) )
except socket.error as e:
    exceptLog( e )

 上のアドレス先には僕の環境では電源が入ってOSが立ち上がっているRaspberry Piが繋がっています。つまり機器はあります。でもRaspberry Pi側でサーバープログラムを動かしていません。この場合は、

機器の存在は認知したようですが、拒否されたとあります。これまでと同様にサーバーとの接続エラーなので再接続等の対応をしましょう。

 この例外は先程と違いタイムアウトは起こさず「コンピュータによって拒否」と理由が説明されています。つまりクライアント側からのconnect要請に誰かがちゃんと即答したって事です。このテストではサーバープログラムは動かしていないので、返答したのはRaspberry Pi OS自身に他なりません。OSは自身のポートを総管理していて、指定のポートを監視している人がいないとちゃんとconnect要請を拒否するんですね。

ポート番号がサーバー側の想定と違う

client = socket.socket( socket.AF_INET )

try:
    client.connect( ( "192.168.11.16", 12345 ) )  # サーバは15769を要求しているのに…
except socket.error as e:
    exceptLog( e )

 IPアドレスは正しいですが、サーバー側はポート番号15769番を監視しているのに対して、クライアント側が12345番と異なるポート番号に接続しに行こうとした場合、

先と同じ例外エラーが出ました。サーバーが動いていたとしても指定のポートを監視していなければ接続エラーになってしまうという事です。これもOSがIPアドレスと監視ポートをセットで総監視してくれているお陰ですね。

connect済みオブジェクトで再度connect

client = socket.socket( socket.AF_INET )

try:
    client.connect( ( "192.168.11.16", 15769 ) )
    time.sleep( 1 )
    client.connect( ( "192.168.11.16", 15769 ) )
except socket.error as e:
    exceptLog( e )

 サーバープログラムを動作させて、ポート番号15769で待ち受けした状態でconnectを試みています。1回目は接続に成功します。しかし2回目のconnectで、

多重接続要請に対して例外が飛びました。すでにconnectされているsocketオブジェクトはその接続専用であるため、2回目のconnectは出来ないんです。この例外はプログラムの組み方で排除できるものなので、こうならないようにコードを組んで下さい。

サーバーがbindしているけどlistenしていない状態でconnect

 サーバー側の不備でbind関数で監視体制に入っているもののlistenしていない(受信開始にしていない)場合にconnectするとどうなるか?

明確に拒否られました。つまりサーバーがlistenしない限りはサーバーがいないと同じ扱いになるという事です。ちゃんとlistenするようサーバー管理者にぐーパンしてあげましょうww

connectビジータイムアウト

 サーバーがちゃんとlistenしてくれているのですが、listen関数の引数に渡すバックログ数(待ち受け可能数)が1とか超少ない場合、クライアント側で同時に複数のconnectを要求すると、要求が受理されるまで待ちぼうけされてしまいます。socket.settimeout関数でタイムアウト時間を指定している場合はタイムアウトを起こしてしまう事があります:

try:
    for i in range( 10 ):
        client = socket.socket( socket.AF_INET )
        client.settimeout( 0.5 )
        client.connect( ( "192.168.11.16", 15769 ) )
        client.send( "Hello".encode("utf-8") )

except socket.error as e:
    exceptLog( e )

タイムアウトは例外扱いではないのでエラー番号は振られていませんが、タイムアウトが起きたという事は接続に失敗したわけで、sendしたいはずのデータがサーバーに送られなかった事になります。もしlistenのバックログ数が1とかに設定されていたら、サーバー管理者をぐーパンですw。listenのバックログ数はPython 3.5以降はオプション設定となりました。指定しなければライブラリが宜しくやってくれるようなので、理由が無ければ指定しないで良いのかなと思います。

サーバー:bind関数

 サーバー側の最初の一歩はbind関数でOSに対して「IPアドレス○○のポート番号△△を使うのでよろしく」と告げる所から始まります。これもダメ引数を色々入れて実験してみましょう。

 尚、サーバー側はRaspberry PiのPythonで組んでいます。エラー番号がWindowsと異なるので注意です。

IPアドレスが滅茶苦茶

sv = socket.socket()

try:
    sv.bind( ( "Dummy", 15769 ) )
except socket.error as e:
    exceptLog( e )

 IPアドレスのフォーマットがそもそも間違っている場合、

そんな名前は知らんと例外を吐かれます。そりゃそうだって話なので、正しいIPアドレスフォーマットのを入れるように対処しましょう。

バインドするIPアドレスが間違っている

sv = socket.socket()

try:
    sv.bind( ( "192.168.11.123", 15769 ) )
except socket.error as e:
    exceptLog( e )

 IPアドレスのフォーマットは正しいのですが、Raspberry Piに割り当てられているIPアドレスと異なる指定がされています。この場合は、

要請したIPアドレスにアサインできなかったという例外が出ました。監視対象が存在しなくてサーバーが機能しない事になりますので、正しいIPアドレスを指定するように促すか、明確なエラーログを出力してサーバー管理者へ通知する等対処が必要です。

 サーバーのIPアドレスが固定のグローバルIPアドレスになるような事は今はもう無いんじゃないかなと思います(IPv4だと枯渇するので)。なので大抵はローカルIPアドレス(192.168.*,*)です。これはルーター等のDHCPサーバーが割り振ります。サーバーが1台ならともかく、複数のサーバーで分散処理する場合などは、プログラム内にIPアドレスを直書きする事がそもそも出来なくなります。サーバー起動時にアプリケーションの引数として与え得るか、サーバープログラムが起動したらIPアドレスを自動で取得してbind関数に渡す仕組みをちゃんと作る必要があります。

bind済みオブジェクトで再bind

sv = socket.socket()

try:
    sv.bind( ( "192.168.11.16", 15769 ) )
    time.sleep( 1 )
    sv.bind( ( "192.168.11.16", 15769 ) )
except socket.error as e:
    exceptLog( e )

サーバー自身のIPアドレスを指定し、ポート番号も有効な状態です。1回目のbindは成功し192.168.11.16の15769の監視を担当する事になりました。この段階で同じアドレスを再度bindをしようとすると、

引数が不正であるという例外が出ました。bind済みサーバーsocketはその監視専用になるため、鞍替えは出来ないんですね。この例外はプログラムの組み方で排除できるので、こうならないように構成して下さい。

bind中のIPアドレスとポートを別のsocketでbind

try:
    sv = socket.socket()
    sv.bind( ( "192.168.11.16", 15769 ) )
    time.sleep( 1 )

    sv2 = socket.socket()
    sv2.bind( ( "192.168.11.16", 15769 ) )
except socket.error as e:
    exceptLog( e )

 先程はbind済みオブジェクトを再度使おうとしましたが、今度は新しいサーバーsocketを作り、同じIPアドレスとポートを監視させようとしています。この場合は、

そのアドレスはすでに使用中だよという例外が飛びました。ここから「特定のIPアドレスとポート番号の組み合わせを監視できるのは一人だけ」という事がわかります。この例外もプログラムの組み方で排除できますので、こうならないように注意ですね。

 ちなみに、同じIPアドレスで別のポート番号を監視する分には問題ありません。一つのポートに凄まじい量の接続要請が飛ぶと処理がスタックしてしまう可能性があります。そういう場合は複数のポート番号を利用して、サーバーsocketも複数で監視するようにし、処理を分散させてあげましょう。

クライアント:send関数

 connectに成功したらクライアントはsend関数で任意データを送る事ができます。ここで起こりそうな事というとサーバー側に何らかのトラブルがあって受信不能になる状態です。その辺りをエミュレートしながら何が起こるか実験してみます。

send関数呼ぶ前にサーバーが落ちた(例外なし)

 connectを通った後にブレークで一端プログラムを止めて、サーバー側のプログラムをシャットダウンし、その後send してみました:

この状態でサーバーを止めます

すると、僕の環境では例外が飛びませんでした。もちろんsendしたデータはサーバー側には伝わっていないので、どこか闇に葬られたわけです…。そういうものなのか、テストの仕方が悪いのかは分かりません。詳しい方教えて下さい。

サーバー:accept関数

 bindとlistenで監視を開始した後、サーバーはaccept関数でデータが飛び込んでくるのを待つ事になります。acceptをブロッキングする場合、すなわちsocket.setblocking関数にTrueを指定するか、socket.settimeoutにNoneを指定した場合、ここで延々と待ちぼうけです。その場合、例外を発生させるケースを思い付けませんでした(^-^;。「こういう場合があるよ」という情報がありましたらコメントで教えて下さい。

タイムアウトした

 accept関数はブロッキングしている場合にsettimeout関数でタイムアウト時間を設定できます:

try:
    sv = socket.socket()
    sv.bind( ( "192.168.11.16", 15769 ) )
    sv.settimeout( 1.0 )
    sv.listen()
    client, addr = sv.accept()
    
except socket.timeout as e:
    exceptLog( e )
except socket.error as e:
    exceptLog( e )

上の例では1秒タイムアウトを設けています。この場合accept関数を呼び出してから1秒以内に受信が無いと、

上のようなタイムアウトの例外が飛びます。これは例外ですがエラーというくくりでは無いのでerrnoが割り振られていないですね。2つあるexcept節の内上のsocket.timeout側にちゃんと飛んできていますので、通常のsocket.errorと別の処理を走らせられます。

 タイムアウトを設ける理由はブロッキングを防ぎたいというのがあるでしょう。メインスレッドでacceptで無限待ちされるとアプリケーションが反応しなくなってしまいますからね。またノンブロッキングにしてwhileループとかするといわゆる「ビジーループ」というCPUに高負荷をかけっぱなしの状態にもなります。タイムアウトループならCPUにより優しいです。

ノンブロッキングで受信無し

 socket.setblocking関数にFalseを指定しacceptをノンブロッキングにした場合、accept関は即返って来てくれますが、その時に受信データが存在しないと例外が飛びます:

sv = socket.socket()
sv.bind( ( "192.168.11.16", 15769 ) )
sv.setblocking( False )
sv.listen()

while True:
    try:
        time.sleep( 1 )
        client, addr = sv.accept()
        print( client.recv(1024).decode("utf-8"))
        client.close()

    except socket.timeout as e:
        exceptLog( e )
    except socket.error as e:
        exceptLog( e )

意識的にノンブロッキングにするという事は、この例外が頻発する事、そしてこの例外を受けて対処する事を織り込み済みという事です。受信データが常にある訳では無いですからね。

 上のコードのようにノンブロッキングにして1秒ごとにループを回すみたいな事をする場合はブロッキングにしてsettimeout関数でタイムアウトを設けた方がスマートです。ノンブロッキングにして且つsleepしないとビジーループになってしまうので避けましょう。「じゃあ、ノンブロッキングってどこで使うん?」となりますが、selectorというsocketプログラミングをかなり楽にしてくれる仕組みで活躍します。

終わりに

 今回はsocket通信をする時にどういう例外がどこで出るのかを色々検証してみました。もちろん他にもまだ色々あるとは思います。「こんなケースでも出るよ」という情報をお持ちの方は是非コメントで教えて下さい。都度それらもここに追加していこうと思います。

ではまた(^-^)/

この記事が気に入ったらサポートをしてみませんか?