この記事について
この記事は私がC言語で簡易的なチャットサーバーを写経し、そのクライアントを書く最中で、まとめておきたいことや疑問点などを残すための記事です。
また、この記事が似たようなコードを書く際の手助けになれば幸いです。
なお、ソースコードはここにあります。
それと、何かありましたら是非ともご指摘ください。
後述するようにこのサーバープログラムはほぼ借り物で、私自身知識がないため、このコードが適切なのか判断しかねています。
また現状のクライアントプログラムはとりあえず動くようには書きましたが、かなり間違っていると思われます。
チャットサーバーについて
今回は主にチャットサーバーについて、その動作を見ていこうと思います。
実際私が今書いているのはクライアント側で、サーバーはYorick de Wid氏のchat_server.cをほぼそのままお借りしてきました。元々このサーバーに対するクライアントは、telnetクライアントであれば何でも良いとのことでしたが、個人的には専用のチャットクライアントを用意することで、メッセージをターミナル内で整形(右端によらせたり)したいと考えています。
(アイデアが間違っているかもしれませんが)
では見ていきましょう。
ソケットとファイルディスクリプタ
そもそもこのプログラムでは、前提としてソケットやファイルディスクリプタといった概念が登場します。これらは一体何なのでしょうか。
まずはじめに、Linux(UNIX?)にはすべてのものがファイルであるといった思想が存在するようです。これは通常思い浮かべるようなテキストファイルなどに加えて、キーボードやディスプレイへの入出力、ソケットやパイプ、ブロックデバイスやキャラクタデバイスなどが「ファイル」という概念に抽象化され、統一的な処理が行えるようになっています。
そして、これらファイルを区別するための単なる整数がファイルディスクリプタであるようです。その番号は0からはじまり、stdin, stdout, stderr
の3つ(0~2番)までが予約されています。
次にソケットとは何でしょう。
インターネットはTCP/IPと呼ぶ通信プロトコルを利用しますが、そのTCP/IPを プログラムから利用するには、プログラムの世界とTCP/IPの世界を結ぶ特別な 出入り口が必要となります。その出入り口となるのがソケット (Socket)であり、TCP/IPのプログラミング上の大きな特徴となっています。 このため、TCP/IP通信をソケット通信と呼ぶこともあります。
引用元 : https://research.nii.ac.jp/~ichiro/syspro98/socket.html
とのことで、ソケットはプロセスがネットワークにカーネルを介してアクセスするためのインタフェースであるようです。また前述したようにソケットも「ファイル」に抽象化されており、その操作にもwrite()
システムコールなどが利用できます。
C言語でソケットを生成するためにはsocket()
関数を利用することで生成し、関数はソケットを指し示すファイルディスクリプタを返します。そしてソケット通信においては、このソケットを指し示すFDに対して入出力を行うことで通信が実現します。
基本的な処理の流れ
チャットサーバーの処理の流れは以下のようになります。
1. ソケットの生成とハンドルの返却
前述したように、ネットワーク通信のためにはソケットを生成し、そのFDを取得する必要があります。
これはsocket()
関数によって実現します。今回のサーバーでは
listenfd = socket(AF_INET, SOCK_STREAM, 0);
がこれに相当し、man 2 socket
によれば、
socket()は通信のためのエンドポイントを生成し、それを参照するファイルディスクリプタを返却する。
とあります。更にsocket()
はdomain, type, protocol
に3つの引数を受け付け、それぞれプロトコルファミリの指定、通信方法の指定、プロトコルファミリごとの固有のプロトコルを意味します。具体的にはAF_INET
がIpv4を指定し、SOCK_STREAM
がTCPを指定し、AF_INET
は一つのプロトコルのみをサポートするため0
を指定しています。
(正直良くわからなかったですけどね)
2. ソケットへアドレスとポートの割付け
socket()
関数ではソケットを生成しますが、次には通信に使用するアドレスやポートの割付けが必要です。これをbind()
関数で行います。
ですが、bind()
関数には前準備が必要で、通信に利用するプロトコルファミリやアドレス、ポートの情報を持つ構造体を作成する必要があります。IPv4の場合にはsockaddr_in
構造体を生成して情報をもたせます。
具体的には以下の通りです。
// 構造体の生成
struct sockaddr_in serv_addr;
...
// 構造体へ情報の追加
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(5000);
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
特に、man pages にはsin_port
とsin_addr
の2つがネットワークバイトオーダーでなければならないとあります。そのためhtons(), htonl()
といった処理が必要なようです。これはホストマシンのバイトオーダーをネットワークのバイトオーダー(ビッグエンディアン)に変換します。
情報を持つ構造体が作成できたら、bind()
関数によってソケットと情報を結びつけます。
bind(listenfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr))
この際、構造体はsockaddr*
にキャストする必要があるようです。
3. 接続待ちソケットにする
次は、sockfd
が参照するソケットを接続待ちソケットとする必要があります。これによってクライアントからの接続要求を指定したソケットが受け付けるようになります。これはlisten()
によって実現されます。
listen(listenfd, 10)
listen()
はsockfd
とbacklog
を引数として引き受けます。backlog
はsockfd
に対して到着した、保留中の接続数の最大値を指定します。今回は10件までとしており、後述するaccept()
関数が接続を引き受けてくれるまでキューは溜まり続けるようです。
4. ソケットへの接続を受ける
listen()
しているソケットに到着した接続をキューから取り出し、「接続済みのソケット」を新たに生成して、新たな「接続済みのソケット」を参照するFDを返却します。
これは主に、接続指向のソケット型(SOCK_STREAM
など)に使用されるシステムコールのようです。
struct sockaddr_in cli_addr;
...
socklen_t clilen = sizeof(cli_addr);
connfd = accept(listenfd, (struct sockaddr *)&cli_addr, &clilen);
accept()
関数は3つの引数を受取り、それぞれsocket(),bind(),listen()
済みのFD、接続相手の情報を持つsockaddr
構造体(実際にはsockaddr_in
でcli_addr
を生成し、キャストしている)、構造体の大きさです。
よく分からなかったのですが、accept()
でcli_addr
に情報が格納されるんですかね?
5. 確立したコネクションを子プロセスへ、ブン投げる
接続が確立したら、子プロセスを作成して接続を処理させます。こうすることで親プロセス(main)は接続要求の処理を再度受け付けつつ、確立した接続を子プロセスで並行して処理することができます。
C言語で子プロセスというかスレッドを扱うにはpthread
というAPI関数群を利用します。
// スレッドの生成
pthread_create(&tid, NULL, &handle_client, (void *)cli);
スレッドを生成するこの関数は引数を4つ取り、それぞれ左からスレッドIDを格納するバッファ、スレッドの属性指定、スレッドの内容である関数へのポインタ、関数への引数(void* のみ)となっています。
6. クライアントとの通信を処理するスレッド
上記まではソケット通信を行うサーバーなどにある程度共通した処理でした。
このあとはクライアントとの通信をハンドルする処理で、そんなに難しいこともないので、概要のみ記述します。
このサーバープログラムの基本的な流れとしては、
- ウェルカムメッセージの送信
- クライアントからのメッセージ受信待ちループ
- メッセージバッファをナル終端
- 空バッファを無視
- メッセージをトークナイズ
- トークナイズによって得られたコマンドを判定
- それぞれのコマンドに合わせたメッセージの送信
- トークナイズによって得られたコマンドを判定
- ループを抜けたら接続しているクライアントとの接続を切断する。
となっています。
個人的に勉強になったのはmutex
の使い方です。いわゆる排他制御を実現するためのコードがいくつか存在していました。主に接続しているクライアントの情報をまとめている構造体とそれを指し示すポインタを要素として持つ配列があるのですが、クライアント対してメッセージの送信を行う場合、クライアントの情報が増えたり減ったりしてほしくないので、排他制御を行っていました。
具体的な使用法は以下です。
// ロックの生成と初期化
pthread_mutex_t client_mutex = PTHREAD_MUTEX_INITIALIZER;
...
pthread_mutex_lock(&clients_mutex);
/* アトミックに行いたい操作 */
pthread_mutex_unlock(&clients_mutex);
大学の講義としてこの知識を学んだ覚えはあるのですが、実際に使ってみることができると感慨深いものがありました。
また、送信するメッセージは必ず\r\n
で終端されていました。通信プロトコルではCRLFが使われる傾向にあるという話も見かけたのですが、もう少し詳しく調べたいところです。
これから追加したい機能
- サーバーを終了するには、現状SIGINTで割り込んで強引に止めているのですが、これだときちんとソケットをクローズしていないため、終了してからすぐにサーバーを再起動させることができません。なのでサーバーを終了する機能を追加したいところです。
分かってないこと
- プロトコルファミリ、スレッドセーフ、CRLFとか
最後に
まあ、詳細に分かっているわけではないんですが、とりあえず基本的なソケット通信と接続処理の方法が分かっただけ儲けもんかなって...
次回はクライアントプログラムについて書きます。
(いま絶賛書いている途中で、多分記事にするときにも雑で中途半端だと思います)