😽

【Python入門】UNIXドメインソケットで学ぶプロセス間通信の実装例(SOCK_STREAM編)

に公開

はじめに

プロセス間通信には、パイプ・名前付きパイプ・ソケットなどがあります。
ソケットはソケットドメインとソケットタイプの組み合わせでどのようにプロセス間通信を行なうかを決めることができます。

この記事では、以下の学習サイトで紹介されているUNIXドメインソケットを使ったクライアントとサーバのプロセス間通信の簡単な実装例(一部改良)を交えながら、ソケットの基本と処理の流れについて解説していきます。

参考サイト
https://recursionist.io/dashboard/course/31/lesson/1095

ソケットとは

IBMさんの定義によると、ソケットとは以下のものを指します。

ソケットとは、 ネットワーク上で名前付けやアドレス指定が可能な通信接続ポイント (端点) のことです。...
ソケットを使用するプロセスは、同じシステムまたは異なるネットワークの異なるシステムに置くことができます。ソケットは、スタンドアロン・アプリケーションでもネットワーク・アプリケーションでも有効です。ソケットを使用すれば、同一マシン上の、またはネットワークを介した複数のプロセス間で、 情報を交換したり、最も効率のよいマシンに作業を配布することができ、 中央データに簡単にアクセスすることもできます。
https://www.ibm.com/docs/ja/i/7.4.0?topic=communications-socket-programming より引用

ソケットを使った通信では、パイプのような単方向通信ではなく、送信と受信を同時に行なうことができる双方向通信を実現します。

ソケットは、特定のソケットドメインソケットタイプに基づいて作成され、これによりソケットがどのように通信を行なうかを決定します。

ソケットドメイン

ソケットドメインは、ソケットが使用する通信の形式を定義します。
これにより、ソケットがどのようなネットワーク上で通信を行なうかが決定されます。
ソケットが使用する代表的なネットワークとしてUNIXドメインソケット・ネットワークソケットがあります。

UNIXドメインソケット

ネットワークソケット

トランスポート層のプロトコルの選択

代表的なソケットタイプとして、SOCK_STREAMSOCK_DGRAMがあります。
これらはトランスポート層の特定のプロトコルに対応しています。
そこで、それぞれが対応しているプロトコルについて簡単に整理します。

TCP

SOCK_STREAMは、TCPプロトコルに対応しています。
TCPでは、相手の存在を確認してから通信を行なうという特徴があります。

TCPは、データ通信を開始する前に、送信側と受信側が互いに通信できる状態にあることを確認し、コネクションを確立する、コネクション型プロトコルです。

データが欠落したり、順番が入れ替わったりしないよう、受信確認や再送制御の仕組みなどを持っており、信頼性の高さと順序の保証が実現します。
これは、特にメールの送受信やファイルの転送などのデータの正確性が不可欠なアプリケーションで使用されます。

接続の信頼性は確保できますが、接続の確立と維持には余分な手順が必要なため、リアルタイム性が求められる一部のアプリケーションには適していません。また、シーケンス番号や確認応答番号などの追加情報がパケットに含まれるため、全体のデータ量が増加します。

UDP

SOCK_DGRAMは、UDPプロトコルに対応しています。
UDPでは、相手の存在を確認せずデータ送信を行なうという特徴があります。
これは、UDPがコネクションレス型プロトコルで、データグラム(パケット)を送信先のIPアドレスとポート番号へ直接「投げっぱなし」で送ります。

そのため、接続の確立は不要で、送信側は、送信先のIPアドレスとポート番号を知っていれば、事前の接続手続きなしにデータの送信を開始できます。

UDPは相手がいようがいなかろうが、通信相手の確認をせず、問答無用でパケットを送信するプロトコルということです。

TCPと違い、通信相手の確認作業などがない分、通信は高速です。
ビデオストリーミングやオーディオストリーミングなどリアルタイムのアプリケーションに適しています。

ただし、TCPのように宛先の確認やパケットの順序の保証がないなど、信頼性が低い通信を提供している点には留意する必要があります。

代表的なソケットタイプ

ソケットタイプは、アプリケーションが通信する方法を決定します。これは、特に信頼性、メッセージの順序付け、重複の防止といった特性に影響を与えます。

SOCK_STREAM

SOCK_STREAM は、信頼性の高い、順序通りの、エラーのないバイトストリームの伝送を提供します。
これは通常、トランスポート層の TCP プロトコルを使用します。

SOCK_DGRAM

SOCK_DGRAM では、データは個々のパケット(データグラム)として送受信されます。
これは通常、トランスポート層の UDP プロトコルを使用します。

UDPはコネクションレスなので、送信先アドレスを毎回指定する必要があります。
ただし、connect() を呼ぶことで、送信先を固定して簡単に扱うことも可能です。

UNIX ドメインソケットを介してメッセージを送受信する簡易プログラムを実装してみる

実装する流れのイメージ

これから以下のようなイメージで実装していきます。
※ソケットAPIというライブラリの呼び出しとソケットなどを一緒に表記
※現時点の知識でまとめているため、間違いの可能性もあります。あらかじめご了承ください。

実装では、バックログキューに1つしか格納できないように設定しているため、1つのリクエストしか捌けない仕様にしています。
※クライアントからのリクエストが1つしか受付できないことは現実的ではありませんが、ここでは全体の流れを理解することを目的にしています。

listen()accept()といったメソッドは、TCPソケットと同じインターフェースを持つため使用されますが、その内部の動作は TCP/IP スタックとは異なります。
UNIXドメインソケットの場合、listen()は接続待ちのキューを作成する役割を果たし、accept()はキューに溜まった接続要求を受け入れる役割を果たします。

ディレクトリ構成

実際に使用したディレクトリ構成
unix-domain-socket/
├── tmp/
├── client.py
├── client2.py # ここでは使用しません
├── client3.py # ここでは使用しません
├── config.json
└── server.py

実装コード

設定ファイル
{
    "filepath" : "/tmp/socket_file/"
}

ソケットタイプはSOCK_STREAMを指定

server.py(サーバ)
import os
import json
import socket

# 1. ソケットオブジェクトの作成
unix_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)

config = json.load(open('config.json'))
server_address = config['filepath']

# ソケットを使えるようにアドレスを準備
# 古いサーバアドレスの削除
try:
    os.unlink(server_address)
except FileNotFoundError:
    pass

print(f"Starting up on {server_address}")

# 2. ソケットのバインド
# 作成済みのソケットを特定の場所に紐づける
unix_socket.bind(server_address)

# 3. ソケットが接続要求を待機
# 「同時に1つの接続要求をキューに入れておくことができる」という設定
unix_socket.listen(1)

# 4. クライアントからの接続待機
while True:
    # connection: 新しく生成された接続ソケットオブジェクト
    # client_address: クライアントのアドレス
    connection, client_address = unix_socket.accept()

    # 5. 接続ソケット確立後
    with connection:
        while True:
            # 1度の読み込みで64バイトの読み込みに設定
            data = connection.recv(64)
            # 受け取ったデータはバイナリ形式なので、それを文字列に変換
            data_str = data.decode('utf-8')
            print(f"Received from client: {data_str}")

            # クライアントからメッセージがきた場合の処理
            if data:
                response = f"Processing {data_str}"
                # 処理したメッセージをバイナリ形式にエンコードしてクライアントに送り返す
                connection.sendall(response.encode('utf-8'))
            else:
                print(f"No data from {client_address}")
                break
    # 6. 接続ソケット終了    
    print("Closing current connection")
client.py(クライアント)
import sys
import json
import socket

unix_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)

config = json.load(open('config.json'))
server_address = config['filepath']

print(f"Start to connect to {server_address}")

# サーバに接続
try:
    unix_socket.connect(server_address)
except OSError as err:
    print(err)
    sys.exit(1)

# サーバ接続成功後
try:
    # 送信メッセージ入力
    message_str = input("Sending a message to the server: ")
    # 文字列をバイト形式にエンコード
    message_bytes = message_str.encode('utf-8')

    with unix_socket:
        unix_socket.sendall(message_bytes)

        # タイムアウトの設定(サーバからの応答に最大5秒待つように設定)
        # 目的: プログラムがサーバからの応答がないまま永遠に待ち続けることを防ぐ
        unix_socket.settimeout(5)

        # サーバからの応答待ち
        try:
            while True:
                # データの受け取り・デコード
                data = unix_socket.recv(64).decode('utf-8')
                
                if data:
                    print(f"Server response: {data}")
                else:
                    break
        except TimeoutError:
            print("Socket timeout, ending listening for server messages")

except OSError as err:
    print(err)

finally:
    print("closing socket")

最初わからなかったコードの意味

try:
    os.unlink(server_address)
except FileNotFoundError:
    pass

このコードでは、パイプが存在しないときに備えています。

  • プログラムの初回実行時: まだ一度もパイプが作成されていない
  • 正常終了時: 前回の実行でパイプがすでに削除されている

このような場合、os.unlink(server_address) を実行すると、FileNotFoundError が発生し、プログラムが停止してしまいます。
これを防ぐために、try-except を使ってエラーを捕捉し、何もしない pass 文を実行することで、プログラムの実行を継続させています。

UNIXドメインソケットのバインドの仕組み

UNIXドメインソケットは、同じホスト(マシン)内のプロセス間通信に特化しています。
そのため、ネットワーク通信で使われるIPアドレスやポート番号は必要ありません。

unix_socket.bind(server_address)を実行すると、server_address で指定されたパスに特殊なファイル(ソケットファイル)が作成されます。このソケットファイルが、サーバープロセスとクライアントプロセスが通信するための「窓口」となります。

サーバー側は、このソケットファイルを作成し、クライアントからの接続を待ち受けます。
クライアント側は、このソケットファイルパスを指定してサーバーに接続します。

したがって、UNIXドメインソケットにとっての「バインド」は、IPアドレスとポート番号の組み合わせではなく、ファイルシステム上にアドレスとなるソケットファイルを作成することを意味します。
これにより、クライアントとサーバーは、共通のファイルパスを介して互いを認識し、通信を開始することができるのです。

connection, client_address = unix_socket.accept()が意味すること

accept()メソッドは、接続要求が来るまで処理をブロック(停止)させます。
接続が確立すると、サーバーは新しい接続用ソケット(connection)を使ってクライアントと通信し、同時に元のソケット(unix_socket)で次の接続要求を待ち受けることができるようになります。

  1. connection: 接続ソケットオブジェクト
    これは、新しく確立されたクライアントとの通信に使用される新しいソケットオブジェクトです。
    このソケットを使って、データの送受信(send()recv())を行ないます。
    元々のサーバーソケット(unix_socket)は、引き続き他の接続を待ち受けるために使われます。

  2. client_address: クライアントのアドレス
    これは、接続を要求してきたクライアントのアドレス情報です。
    UNIXドメインソケットの場合、このアドレスは多くの場合、空の文字列を返すようです。
    TCP/IPソケットの場合は、通常、(IPアドレス, ポート番号)のタプルになります。

※実際に空文字列が返却されました

実際の流れを確かめる

1. サーバを起動する

2. クライアントを起動する

3. クライアント側でメッセージを入力し送信(Enterキー)

4. サーバ側でデータ受け取り

5. クライアント側でサーバからのレスポンスを取得

6. 5秒後にクライアント終了

7. サーバの接続用ソケット終了

ここでは特定の1クライアントの通信が終了したのみで、引き続きクライアントからのリクエストを受け付けています。

まとめ

今回は、クライアントとサーバのリクエスト・レスポンスができる簡易プログラムを通じて、UNIXドメインソケットの基本についてまとめました。

今回のケースでは、データの流れを理解することに重きをおいていることから、クライアントは1リクエストで小さいサイズのデータを1度しか送れないような仕様で実装しています。

そのため、以下のような課題があります。

  • リクエストを複数受付できない
  • クライアントは連続して入力できない
  • 一度に読み取れるバイト数が少ない

これらの点は、今回の実装を土台にして改善していきます。

今回の記事が理解の助けになれば幸いです。
最後までお読みいただき、ありがとうございました。

参考URL

https://recursionist.io/dashboard/course/31/lesson/1095

https://man7.org/linux/man-pages/man7/address_families.7.html

https://ascii.jp/elem/000/001/415/1415088/

https://techgrowup.net/security-handshake/

http://qiita.com/toshihirock/items/b643ed0edd30e6fd1f14

https://docs.python.org/ja/3.13/library/socket.html

https://qiita.com/Michinosuke/items/0778a5344bdf81488114

https://www.ren510.dev/blog/network-socket

https://kazuhira-r.hatenablog.com/entry/2019/07/10/015733

Discussion