echoサーバーを書いてみたときのメモ その2 なぜ複数クライアントを捌けないのか

前回、echoサーバーを書いてみた。その続き。

echoサーバーを書いてみたときのメモ その1 ソケットAPIとTCP

このechoサーバーだと同時に複数のクライアントを捌けない。どうしてか?実際に試して見る。クライアントから2回telnetでつないでみる。

クライアント1

$ telnet 192.168.33.10 30000
Trying 192.168.33.10...
Connected to 192.168.33.10.
Escape character is '^]'.
Hello World!

クライアント2

$ telnet 192.168.33.10 30000
Trying 192.168.33.10...
Connected to 192.168.33.10.
Escape character is '^]'.

2つ目の接続では、 Hello World! が表示されていない。

サーバー側で接続を確認してみる。

netstatでみると複数の接続ができてる。

$ netstat -an | grep 30000
tcp        0      0 0.0.0.0:30000           0.0.0.0:*               LISTEN
tcp        0      0 192.168.33.10:30000     192.168.33.1:64085      ESTABLISHED
tcp        0      0 192.168.33.10:30000     192.168.33.1:64084      ESTABLISHED

しかし、ソケットに対するディスクリプタを確認すると、1つしかない(64084ポートを使ってる方)。

$ lsof -i:30000
COMMAND  PID   USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
echo_serv 2316 ubuntu    3u  IPv4  52901      0t0  TCP *:30000 (LISTEN)
echo_serv 2316 ubuntu    4u  IPv4  52902      0t0  TCP 192.168.33.10:30000->192.168.33.1:64084 (ESTABLISHED)

$ ll /proc/2316/fd
total 0
dr-x------ 2 ubuntu ubuntu  0 Mar  5 03:12 ./
dr-xr-xr-x 9 ubuntu ubuntu  0 Mar  5 03:12 ../
lrwx------ 1 ubuntu ubuntu 64 Mar  5 03:14 0 -> /dev/pts/0
lrwx------ 1 ubuntu ubuntu 64 Mar  5 03:14 1 -> /dev/pts/0
lrwx------ 1 ubuntu ubuntu 64 Mar  5 03:12 2 -> /dev/pts/0
lrwx------ 1 ubuntu ubuntu 64 Mar  5 03:14 3 -> socket:[52901]
lrwx------ 1 ubuntu ubuntu 64 Mar  5 03:14 4 -> socket:[52902]
$ ps x | grep echo_server
 2316 pts/0    S+     0:00 ./echo_server

psのSTATが S+ なので割り込み可能なスリープ状態でフォアグラウンドのプロセスグループに入っているという状態。

straceで見てみると、readでスリープしている。

$ sudo strace -p 2316
strace: Process 2316 attached
read(4,

クライアント1で文字列を入力

$ telnet 192.168.33.10 30000
Trying 192.168.33.10...
Connected to 192.168.33.10.
Escape character is '^]'.
Hello World!
hoge
hoge
Connection closed by foreign host.

接続がサーバー側から切られて、クライアント2のtelnetHello World! が表示される。

$ telnet 192.168.33.10 30000
Trying 192.168.33.10...
Connected to 192.168.33.10.
Escape character is '^]'.
Hello World!

サーバー側で確認してみると新しい接続に変わってることがわかる。

$ lsof -i:30000
COMMAND  PID   USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
echo_serv 2316 ubuntu    3u  IPv4  52901      0t0  TCP *:30000 (LISTEN)
echo_serv 2316 ubuntu    4u  IPv4  56445      0t0  TCP 192.168.33.10:30000->192.168.33.1:64085 (ESTABLISHED)

 ll /proc/2316/fd
total 0
dr-x------ 2 ubuntu ubuntu  0 Mar  5 03:12 ./
dr-xr-xr-x 9 ubuntu ubuntu  0 Mar  5 03:12 ../
lrwx------ 1 ubuntu ubuntu 64 Mar  5 03:14 0 -> /dev/pts/0
lrwx------ 1 ubuntu ubuntu 64 Mar  5 03:14 1 -> /dev/pts/0
lrwx------ 1 ubuntu ubuntu 64 Mar  5 03:12 2 -> /dev/pts/0
lrwx------ 1 ubuntu ubuntu 64 Mar  5 03:14 3 -> socket:[52901]
lrwx------ 1 ubuntu ubuntu 64 Mar  5 03:14 4 -> socket:[56445]

まとめると、 read から処理が進まず、クライアント2の接続に対するacceptが呼ばれない。そのためサーバーはクライアントを1つずつしか処理できない。

I/Oモデル

前回に引き続きUNIXネットワークプログラミングを参考にする。

UNIXネットワークプログラミングを読むと5種類のI/Oがあるって書いてある。

Unixネットワーキングプログラミング P.140

さらに、入力操作は2つのステップから成ると書いてある。

入力操作は次の2段階で構成されている。

  1. データの用意ができるまで待ち、
  2. そのデータをカーネルからプロセスにコピーする。

ソケットに関する入力では、最初のステップでは普通のネットワークからのデータの到着を待つ。パケットが到着すると、カーネル内のバッファにコピーされる。2つ目のステップでは、このデータをカーネルのバッファからアプリケーションのバッファにコピーすることになる。

Unixネットワーキングプログラミング P.140

ブロッキングI/O、ノンブロッキングI/O、I/Oの多重化、シグナル駆動I/Oの4つはステップ1をどう扱うかが異なる。ステップ2については同じ。非同期I/Oだけが他と異なる方法でステップ1,2を扱う。

非同期I/Oは、Linuxだとaioとして提供されているが、ググって色々読んでみた感じだとあまり使われてないっぽい?

同期I/O操作と非同期I/O操作

I/Oモデルとは別の観点で同期I/O操作と非同期I/O操作というのがある。

Posix.1 ではこれら2つの語を以下の様に定義している。

  • 同期I/O操作では、これを要求したプロセスは要求したI/O操作が完了するまでブロックする
  • 非同期I/O操作では、これを要求したプロセスはブロックしない

この定義によれば、最初の4つのI/Oモデル、すなわちブロッキング、非ブロッキング、多重化I/OおよびシグナルI/O操作のすべてにおいてプロセスはブロックする。非同期I/Oモデルのみが非同期I/Oの定義に一致している。

UNIXネットワーキングプログラミング P.144

書いてみたechoサーバーのI/Oモデル

書いてみたechoサーバーで使ってる readブロッキングI/Oである。ブロッキングI/Oではステップ1と2が終わるまで元のプロセスに処理が戻らない。 read で処理が止まっていたのはステップ1で、クライアントからのデータの到達を待っているからである。クライアントからのデータが届くまでechoサーバーのプロセスはブロックされる。

複数クライアントを同時に捌くために、echoサーバーを以下の方法で書き換えてみようと思う。

  • ブロッキングI/Oのまま。forkを使って複数プロセスを立ち上げ、1プロセス1クライアントで対応する
  • ブロッキングI/Oを使って対応する。1プロセスで複数クライアントに対応する
  • I/O多重を使って対応する。1プロセスで複数クライアントに対応する

echoサーバーを書いてみたときのメモ その1 ソケットAPIとTCP

echoサーバーを書いた日の日記。

Head First C の11章を参考に、クライアントから文字列を受け取り、受け取った文字列をそのままクライアントへ返すechoサーバーを作る。echo サーバーは30000ポートを使用することにする。途中疑問に思ったところは主にUNIXネットワークプログラミングで調べた。

UNIXネットワークプログラミング〈Vol.1〉ネットワークAPI:ソケットとXTI

UNIXネットワークプログラミング〈Vol.1〉ネットワークAPI:ソケットとXTI

  • 作者: W.リチャードスティーヴンス,W.Richard Stevens,篠田陽一
  • 出版社/メーカー: ピアソンエデュケーション
  • 発売日: 1999/07
  • メディア: 単行本
  • 購入: 8人 クリック: 151回
  • この商品を含むブログ (35件) を見る

Head First C ―頭とからだで覚えるCの基本

Head First C ―頭とからだで覚えるCの基本

環境。VagrantとUbuntuで。

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=16.04
DISTRIB_CODENAME=xenial
DISTRIB_DESCRIPTION="Ubuntu 16.04.1 LTS"
$ uname -a
Linux ubuntu-xenial 4.4.0-38-generic #57-Ubuntu SMP Tue Sep 6 15:42:33 UTC 2016 x86_64 x86_64 x86_64 GNU/Linux
$ gcc -v
Using built-in specs.
COLLECT_GCC=gcc
COLLECT_LTO_WRAPPER=/usr/lib/gcc/x86_64-linux-gnu/5/lto-wrapper
Target: x86_64-linux-gnu
Configured with: ../src/configure -v --with-pkgversion='Ubuntu 5.4.0-6ubuntu1~16.04.2' --with-bugurl=file:///usr/share/doc/gcc-5/README.Bugs --enable-languages=c,ada,c++,java,go,d,fortran,objc,obj-c++ --prefix=/usr --program-suffix=-5 --enable-shared --enable-linker-build-id --libexecdir=/usr/lib --without-included-gettext --enable-threads=posix --libdir=/usr/lib --enable-nls --with-sysroot=/ --enable-clocale=gnu --enable-libstdcxx-debug --enable-libstdcxx-time=yes --with-default-libstdcxx-abi=new --enable-gnu-unique-object --disable-vtable-verify --enable-libmpx --enable-plugin --with-system-zlib --disable-browser-plugin --enable-java-awt=gtk --enable-gtk-cairo --with-java-home=/usr/lib/jvm/java-1.5.0-gcj-5-amd64/jre --enable-java-home --with-jvm-root-dir=/usr/lib/jvm/java-1.5.0-gcj-5-amd64 --with-jvm-jar-dir=/usr/lib/jvm-exports/java-1.5.0-gcj-5-amd64 --with-arch-directory=amd64 --with-ecj-jar=/usr/share/java/eclipse-ecj.jar --enable-objc-gc --enable-multiarch --disable-werror --with-arch-32=i686 --with-abi=m64 --with-multilib-list=m32,m64,mx32 --enable-multilib --with-tune=generic --enable-checking=release --build=x86_64-linux-gnu --host=x86_64-linux-gnu --target=x86_64-linux-gnu
Thread model: posix
gcc version 5.4.0 20160609 (Ubuntu 5.4.0-6ubuntu1~16.04.2)

以下のコードから始める。 while の無限ループ内でクライアントからデータを受け取ってレスポンスを返すような処理を書いていく。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>

void error(char *msg)
{
  fprintf(stderr, "%s:%s\n", msg, strerror(errno));
  exit(1);
}

int main(int argc, char *argv[])
{
  puts("wait...");
  while(1) {
  }
  return 0;
}
$ gcc echo_server.c -o echo_server
$ ./echo_server

30000ポートでは何も動いていないことを確認。

$ lsof -i:30000

echo_serverが開いているファイルディスクリプタを見ておく。

$ ps -x | grep echo_server
 9932 pts/0    R+     0:46 ./echo_server
 9942 pts/1    S+     0:00 grep --color=auto echo_server
$ ll /proc/9932/fd
total 0
dr-x------ 2 ubuntu ubuntu  0 Oct  9 07:56 ./
dr-xr-xr-x 9 ubuntu ubuntu  0 Oct  9 07:56 ../
lrwx------ 1 ubuntu ubuntu 64 Oct  9 07:56 0 -> /dev/pts/0
lrwx------ 1 ubuntu ubuntu 64 Oct  9 07:56 1 -> /dev/pts/0
lrwx------ 1 ubuntu ubuntu 64 Oct  9 07:56 2 -> /dev/pts/0

全体の流れ

  1. ソケットを作成する
  2. ソケットをバインドする
  3. Listen状態になる
  4. コネクションを取り出す
  5. クライアントから文字列を受け取る
  6. クライアントへ文字列を返す

ソケットを作成する

まず、ソケットを作成する処理を追加。

@@ -2,6 +2,7 @@
 #include <stdlib.h>
 #include <string.h>
 #include <errno.h>
+#include <sys/socket.h>
 
 void error(char *msg)
 {
@@ -11,6 +12,10 @@ void error(char *msg)
 
 int main(int argc, char *argv[])
 {
+  int listener_d = socket(PF_INET, SOCK_STREAM, 0);
+  if (listener_d == -1) {
+    error("socket err");
+  }
   puts("wait...");
   while(1) {
   }

ソケットとは、UNIXネットワークプログラミングに以下のように書いてある。

TCPコネクションのソケットペア(socket pair)は、コネクションの両方のエンドポイントを定義する、ローカルIPアドレス、ローカルTCPポート、リモートIPアドレス、およびリモートTCPポートの4つ組である。あるソケットペアは、インターネット中の特定のコネクションを一意に識別する。

 各エンドポイントを識別する2つの値、すなわちIPアドレスとポート番号は、多くの場合ソケット(socket)と呼ばれる。

UNIXネットワークプログラミング 第2版 Vol.1

socket関数

socket関数の定義。カーネル内にソケットを作成し、ソケットに対応したディスクリプタを返す。

#include <sys/socket.h>

int socket(int domain, int type, int protocol)

PF_INET とは? man socket を見ると AF_INET になってる。

/usr/include/x86_64-linux-gnu/sys/socket.h を確認してみると以下の記述が。

/* This operating system-specific header file defines the SOCK_*, PF_*,
   AF_*, MSG_*, SOL_*, and SO_* constants, and the `struct sockaddr',
   `struct msghdr', and `struct linger' types.  */
#include <bits/socket.h>

/usr/include/x86_64-linux-gnu/bits/socket.h を確認すると。

/* Protocol families.  */
#define PF_UNSPEC       0       /* Unspecified.  */
#define PF_LOCAL        1       /* Local to host (pipes and file-domain).  */
#define PF_UNIX         PF_LOCAL /* POSIX name for PF_LOCAL.  */
#define PF_FILE         PF_LOCAL /* Another non-standard name for PF_LOCAL.  */
#define PF_INET         2       /* IP protocol family.  */
・
・
・
/* Address families.  */
#define AF_UNSPEC       PF_UNSPEC
#define AF_LOCAL        PF_LOCAL
#define AF_UNIX         PF_UNIX
#define AF_FILE         PF_FILE
#define AF_INET         PF_INET
・
・
・

AF_INET でも PF_INET でも同じ 2 という値で、IP protocol family と書いてある。

UNIXネットワークプログラミングを読むと以下のように書いてあった。

AFプリフィックスは"アドレスファミリ"の意であり、PFプリフィックスは"プロトコルファミリ"の意である。もともとは単一のプロトコルファイミリが複数のアドレスファミリをサポートすることが意図されており、ソケットの作成にはPF値を、ソケットアドレス構造体にはAF値が用いられてきた。実際には複数のアドレスファミリをサポートするプロトコルファミリがサポートされたことはなく、<sys/socket.h>ヘッダでは、あるプロトコルのPF値はそのプロトコルのAF値と同じ値を持つように定義されている。

UNIXネットワークプログラミング 第2版 Vol.1

だそうです。

AF_INETの場合、以下の3種類のソケットを作成できる。

  • tcp_socket = socket(AF_INET, SOCK_STREAM, 0);
  • udp_socket = socket(AF_INET, SOCK_DGRAM, 0);
  • raw_socket = socket(AF_INET, SOCK_RAW, protocol);

この記述からも、PF_* という定数からも、 socket 関数からいろいろなプロトコルに対応したソケットが作れる雰囲気が出てる。

ソケットのディスクリプタ

int listener_d = socket(AF_INET, SOCK_STREAM, 0) により作成されたTCPソケットのソケットのディスクリプタを取得できる。ファイルディスクリプタがファイル入出力のストリームを識別するように、ソケットのディスクリプタはソケットを識別できる数値である。

このディスクリプタ( listener_d )に対応したTCPソケットがカーネルで管理されているということか。

動かしてみる

この段階でも30000ポートは使われてない。

$ lsof -i:30000

しかし、ファイルディスクリプタを確認してみると、ソケットが作成されていることが分かる。

$ ll /proc/9950/fd
total 0
dr-x------ 2 ubuntu ubuntu  0 Oct  9 07:58 ./
dr-xr-xr-x 9 ubuntu ubuntu  0 Oct  9 07:58 ../
lrwx------ 1 ubuntu ubuntu 64 Oct  9 07:58 0 -> /dev/pts/0
lrwx------ 1 ubuntu ubuntu 64 Oct  9 07:58 1 -> /dev/pts/0
lrwx------ 1 ubuntu ubuntu 64 Oct  9 07:58 2 -> /dev/pts/0
lrwx------ 1 ubuntu ubuntu 64 Oct  9 07:58 3 -> socket:[29062]

ポートをバインドする

ポートをバインドする処理を追加。

@@ -3,6 +3,7 @@
 #include <string.h>
 #include <errno.h>
 #include <sys/socket.h>
+#include <arpa/inet.h>
 
 void error(char *msg)
 {
@@ -16,6 +17,13 @@ int main(int argc, char *argv[])
   if (listener_d == -1) {
     error("socket err");
   }
+
+  struct sockaddr_in name;
+  name.sin_family = AF_INET;
+  name.sin_port = (in_port_t)htons(30000);
+  name.sin_addr.s_addr = htonl(INADDR_ANY);
+  bind(listener_d, (struct sockaddr *) &name, sizeof(name));
+
   puts("wait...");
   while(1) {
   }

bind関数

bind関数は、ソケットにプロトコル特有のアドレスを割り当てる。

#include <sys/socket.h>

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

ソケットアドレス構造体

bind関数の第2引数にプロトコル特有のアドレスのポインタを指定する。プロトコル特有のアドレスを表す構造体をソケットアドレス構造体と呼ぶ。

ソケットAPIにおいて、ソケットアドレス構造体は、プロセスからカーネルへ、カーネルからプロセスへの両方向で受け渡しされる。ソケットアドレス構造体の名前は sockaddr_ で始まり、 IPv4のソケットアドレス構造体は sockaddr_in

TCPソケットのアドレスは、IPアドレスとポート番号の組である。

htons 関数はshort型の値をネットワークバイトオーダー(ビッグエンディアン)のバイト列にする。 htonl 関数はlong型の値をネットワークバイトオーダーのバイト列にする。 (参考: http://www-cms.phys.s.u-tokyo.ac.jp/~naoki/CIPINTRO/NETWORK/endian.html

これらの関数を使って、ネットワークバイトオーダーで、ポート番号とIPアドレスを指定する。INADDR_ANYは0を差しワイルドカードであることを示す。

bindのようなソケットAPIは、いろいろなプロトコルのソケットに対応する必要があるので、IPv4用の sockaddr_in を引数に取るわけにはいかない。そこで、 sockaddr という構造体に一旦キャストしてから引数として渡す。

動かしてみる

特に変化なし。

$ lsof -i:30000
$ ll /proc/9971/fd
total 0
dr-x------ 2 ubuntu ubuntu  0 Oct  9 08:10 ./
dr-xr-xr-x 9 ubuntu ubuntu  0 Oct  9 08:10 ../
lrwx------ 1 ubuntu ubuntu 64 Oct  9 08:10 0 -> /dev/pts/0
lrwx------ 1 ubuntu ubuntu 64 Oct  9 08:10 1 -> /dev/pts/0
lrwx------ 1 ubuntu ubuntu 64 Oct  9 08:10 2 -> /dev/pts/0
lrwx------ 1 ubuntu ubuntu 64 Oct  9 08:10 3 -> socket:[29203]

LISTEN状態になる

@@ -26,6 +26,10 @@ int main(int argc, char *argv[])
     error("bind err");
   }
 
+  if (listen(listener_d, 1) == -1) {
+    error("listen err");
+  }
+
   puts("wait...");
   while(1) {
   }

listen関数

listen関数により、TCPの状態が初期状態であるCLOSEからLISTENになる。

#include <sys/socket.h>

int listen(int sockfd, int backlog);

コネクションキュー

第2引数は、第1引数で指定したソケットに対するコネクションキューのサイズ。このコネクションキューは、ESTABLISH状態のコネクションが格納される。

このキューの大きさは、カーネルパラメータであるnet.core.somaxconnより大きな値が指定した場合はnet.core.somaxconnの大きさになる。

SYN_RCVD状態のコネクションが格納されるキューも別にある。こちらはカーネルパラメータnet.ipv4.tcp_max_syn_backlog 最大値が決められる。

http://wiki.bit-hive.com/linuxkernelmemo/pg/listen%20backlog%20%A1%DA3.6%A1%DB

キューに入れなかったリクエストはどうなるか。UNIXネットワークプログラミングを読むと

クライアントからのSYNが到着した際にキューが満杯だった場合、TCPはRSTを送信せず、単に到達したSYNを無視する。これは、この状態が過度的な状態であると考えられるためで、クライアントはSYNを再送することにより、運がよければキューの空きを見つけることが可能である。

UNIXネットワークプログラミング 第2版 Vol.1

キューの大きさを1にして、同時接続を行うと段々と処理が行われることが分かる。

動かしてみる

listen 関数によって30000ポートがTCPのLISTEN状態になった。

$ lsof -i:30000
COMMAND    PID   USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
echo_serv 9992 ubuntu    3u  IPv4  29544      0t0  TCP *:30000 (LISTEN)

netstat コマンドでも確認できる。

$ netstat -an | grep 30000
tcp        0      0 0.0.0.0:30000           0.0.0.0:*               LISTEN

では、 telnet でつないでみる。Vagrantで動かしてるUbuntu上動いているecho サーバーに、ホストOSであるMacからつないでみる。

$ telnet 192.168.33.10 30000
Trying 192.168.33.10...
Connected to 192.168.33.10.
Escape character is '^]'.

サーバー上で確認してみると、ESTABLISHED状態の接続ができていることがわかる。

$ netstat -an | grep 30000
tcp        0      0 0.0.0.0:30000           0.0.0.0:*               LISTEN
tcp        0      0 192.168.33.10:30000     192.168.33.1:64304      ESTABLISHED

TCPの状態遷移図を見ながらだとイメージしやすい。

http://image.itmedia.co.jp/ait/articles/0402/13/wi-fig06.gif

引用元:第16回 信頼性のある通信を実現するTCPプロトコル(3) (3/4):基礎から学ぶWindowsネットワーク - @IT

ESTABLISHEDになってるということは3ウェイハンドシェークが完了したってこと。tcpdumpでパケットを覗いてみる。tcpdumpを実行してから、telnetで再度つなぐ。

$ tcpdump -i vboxnet0

telnetでつなぐと以下のように出力された。

13:14:16.795321 IP 192.168.33.1.65088 > 192.168.33.10.30000: Flags [S], seq 2760695165, win 65535, options [mss 1460,nop,wscale 5,nop,nop,TS val 654960331 ecr 0,sackOK,eol], length 0
13:14:16.795364 IP 192.168.33.10.30000 > 192.168.33.1.65088: Flags [S.], seq 3180502780, ack 2760695166, win 28960, options [mss 1460,sackOK,TS val 1405176 ecr 654960331,nop,wscale 7], length 0
13:14:16.796340 IP 192.168.33.1.65088 > 192.168.33.10.30000: Flags [.], ack 1, win 4117, options [nop,nop,TS val 654960334 ecr 1405176], length 0

3ウェイハンドシェークされてる。

tcpdumpを実行したまま、telnetを切断すると、クライアントからサーバーへのFIN,ACKが送られるが、サーバーからのACKとFINが送られないことが分かる。クライアントは何度かFIN,ACKを送るが最終的には、RST,ACKを送り強制的に接続を切った。

キューからESTABLISH状態のコネクションを取り出す

コネクションキューの先頭からESTABLISH状態のコネクションを1つ取り出す。キューが空の場合、プロセスはブロックされスリープ状態となる。

@@ -32,6 +32,12 @@ int main(int argc, char *argv[])
 
   puts("wait...");
+ 
+  struct sockaddr_storage client_addr;
+  unsigned int address_size = sizeof(client_addr);
   while(1) {
+    int connect_d = accept(listener_d, (struct sockaddr *)&client_addr, &address_size);
+    if (connect_d == -1) {
+      error("accept err");
+    }
   }
   return 0;
 }

accept関数

#include <sys/socket.h>

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

accept関数は、クライアントとのTCPコネクションを参照しているソケットディスクリプタを返す。socket関数で取得したソケットディスクリプタとは別のものを差している。

第2引数で指定したソケットアドレス構造体にクライアントのアドレスが格納される。

動かしてみる

$ netstat -an | grep 30000
tcp        0      0 0.0.0.0:30000           0.0.0.0:*               LISTEN     
tcp        0      0 192.168.33.10:30000     192.168.33.1:49697      ESTABLISHED
$ lsof -i:30000
COMMAND     PID   USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
echo_serv 10264 ubuntu    3u  IPv4  31554      0t0  TCP *:30000 (LISTEN)
echo_serv 10264 ubuntu    4u  IPv4  31555      0t0  TCP 192.168.33.10:30000->192.168.33.1:49697 (ESTABLISHED)
$ ll /proc/10264/fd
total 0
dr-x------ 2 ubuntu ubuntu  0 Oct  9 13:44 ./
dr-xr-xr-x 9 ubuntu ubuntu  0 Oct  9 13:44 ../
lrwx------ 1 ubuntu ubuntu 64 Oct  9 13:44 0 -> /dev/pts/0
lrwx------ 1 ubuntu ubuntu 64 Oct  9 13:44 1 -> /dev/pts/0
lrwx------ 1 ubuntu ubuntu 64 Oct  9 13:44 2 -> /dev/pts/0
lrwx------ 1 ubuntu ubuntu 64 Oct  9 13:44 3 -> socket:[31554]
lrwx------ 1 ubuntu ubuntu 64 Oct  9 13:44 4 -> socket:[31555]

telnetで接続を切ってみる。今度は、クライアントからのFIN,ACKに対し、サーバーからACKが返ってきた。

サーバー側のソケットは、CLOSE_WAIT状態のまま残っている。

$ ll /proc/10264/fd
total 0
dr-x------ 2 ubuntu ubuntu  0 Oct  9 13:44 ./
dr-xr-xr-x 9 ubuntu ubuntu  0 Oct  9 13:44 ../
lrwx------ 1 ubuntu ubuntu 64 Oct  9 13:44 0 -> /dev/pts/0
lrwx------ 1 ubuntu ubuntu 64 Oct  9 13:44 1 -> /dev/pts/0
lrwx------ 1 ubuntu ubuntu 64 Oct  9 13:44 2 -> /dev/pts/0
lrwx------ 1 ubuntu ubuntu 64 Oct  9 13:44 3 -> socket:[31554]
lrwx------ 1 ubuntu ubuntu 64 Oct  9 13:44 4 -> socket:[31555]

$ netstat -an | grep 30000
tcp        0      0 0.0.0.0:30000           0.0.0.0:*               LISTEN     
tcp        1      0 192.168.33.10:30000     192.168.33.1:49697      CLOSE_WAIT 

$ lsof -i:30000
COMMAND     PID   USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
echo_serv 10264 ubuntu    3u  IPv4  31554      0t0  TCP *:30000 (LISTEN)
echo_serv 10264 ubuntu    4u  IPv4  31555      0t0  TCP 192.168.33.10:30000->192.168.33.1:49697 (CLOSE_WAIT)

CLOSE_WAIT状態は、FINに対してACKを送って、アプリケーションからのクローズ待ち。クローズ処理漏れがあるとこの状態のTCPソケットが残ったままになってしまう。

ソケットを閉じる

@@ -4,6 +4,7 @@
 #include <errno.h>
 #include <sys/socket.h>
 #include <arpa/inet.h>
+#include <unistd.h>
 
 void error(char *msg)
 {
@@ -38,6 +39,8 @@ int main(int argc, char *argv[])
     if (connect_d == -1) {
       error("accept err");
     }
+
+    close(connect_d);
   }
   return 0;
 }

close関数

ファイルでも利用するclose関数で、ソケットをクローズする。

#include <unistd.h>

int close(int sockfd);

ソケットに対してclose関数を実行すると、ソケットディスクリプタが使用できなくなる。カーネルでは、TCPソケットの送信バッファ内のデータを通信先へ送ってから、TCPコネクションの終了シーケンス(FINとACKを相互に送り合う)が実行される。

動かしてみる

telnetでつなぐとすぐにサーバー側から切断される。今度はサーバー側からclose関数で接続を切っている。接続が切られたのでTIME_WAIT状態になっている。

$ netstat -an | grep 30000
tcp        0      0 0.0.0.0:30000           0.0.0.0:*               LISTEN
tcp        0      0 192.168.33.10:30000     192.168.33.1:50509      TIME_WAIT
$ ll /proc/10314/fd
total 0
dr-x------ 2 ubuntu ubuntu  0 Oct 10 02:18 ./
dr-xr-xr-x 9 ubuntu ubuntu  0 Oct 10 02:18 ../
lrwx------ 1 ubuntu ubuntu 64 Oct 10 02:18 0 -> /dev/pts/0
lrwx------ 1 ubuntu ubuntu 64 Oct 10 02:18 1 -> /dev/pts/0
lrwx------ 1 ubuntu ubuntu 64 Oct 10 02:18 2 -> /dev/pts/0
lrwx------ 1 ubuntu ubuntu 64 Oct 10 02:18 3 -> socket:[31911]

tcpdumpを見ると、3ウェイハンドシェークの後、FINとACKを相互に送りあって接続を閉じている。

02:17:52.031983 IP 192.168.33.1.50509 > 192.168.33.10.30000: Flags [S], seq 537180097, win 65535, options [mss 1460,nop,wscale 5,nop,nop,TS val 658332935 ecr 0,sackOK,eol], length 0
02:17:52.032002 IP 192.168.33.10.30000 > 192.168.33.1.50509: Flags [S.], seq 169597332, ack 537180098, win 28960, options [mss 1460,sackOK,TS val 2223648 ecr 658332935,nop,wscale 7], length 0
02:17:52.032175 IP 192.168.33.1.50509 > 192.168.33.10.30000: Flags [.], ack 1, win 4117, options [nop,nop,TS val 658332935 ecr 2223648], length 0
02:17:52.032372 IP 192.168.33.10.30000 > 192.168.33.1.50509: Flags [F.], seq 1, ack 1, win 227, options [nop,nop,TS val 2223648 ecr 658332935], length 0
02:17:52.032627 IP 192.168.33.1.50509 > 192.168.33.10.30000: Flags [.], ack 2, win 4117, options [nop,nop,TS val 658332935 ecr 2223648], length 0
02:17:52.032643 IP 192.168.33.1.50509 > 192.168.33.10.30000: Flags [F.], seq 1, ack 2, win 4117, options [nop,nop,TS val 658332935 ecr 2223648], length 0
02:17:52.032654 IP 192.168.33.10.30000 > 192.168.33.1.50509: Flags [.], ack 2, win 227, options [nop,nop,TS val 2223649 ecr 658332935], length 0

ソケットに書き込む

クライアントから文字列を読み込む前に、 Hello World! を返すようにしてみる。

@@ -40,6 +40,9 @@ int main(int argc, char *argv[])
       error("accept err");
     }
 
+    char *msg = "Hello World!\r\n";
+    write(connect_d, msg, strlen(msg));
+
     close(connect_d);
   }
   return 0;

write関数

ファイルに書き込む時にも使うwrite関数でソケットにデータを書き込む。

#include <unistd.h>

ssize_t write(int fd, const void *buf, size_t count);

writeによりデータが、カーネル内のソケットの送信バッファにコピーされる。ソケットバッファに空きがない場合、ブロックされプロセスはスリープ状態になる。writeの成功はバッファへのコピーが完了しただけであり、通信相手にデータが届いたわけではない。

ソケットの送信バッファ内のデータは、カーネルによってTCPのMSSやIPのMTUのサイズに分割されて、通信先へ送信される(調べた感じだと通常はIPのMTUのサイズの方が大きいみたい)。

動かしてみる

3ウェイハンドシェークの内容から、クライアントがサーバーへ伝えているmssが1460バイトであることが分かる。

12:10:03.903979 IP 192.168.33.1.52745 > 192.168.33.10.30000: Flags [S], seq 732193526, win 65535, options [mss 1460,nop,wscale 5,nop,nop,TS val 661451291 ecr 0,sackOK,eol], length 0
12:10:03.904111 IP 192.168.33.10.30000 > 192.168.33.1.52745: Flags [S.], seq 389803173, ack 732193527, win 28960, options [mss 1460,sackOK,TS val 3007057 ecr 661451291,nop,wscale 7], length 0
12:10:03.904157 IP 192.168.33.1.52745 > 192.168.33.10.30000: Flags [.], ack 1, win 4117, options [nop,nop,TS val 661451291 ecr 3007057], length 0

"Hello World!"という文字列のバイト数は、mssより小さいので、1回だけデータが送られている。

12:10:03.904402 IP 192.168.33.10.30000 > 192.168.33.1.52745: Flags [P.], seq 1:15, ack 1, win 227, options [nop,nop,TS val 3007057 ecr 661451291], length 14
12:10:03.904436 IP 192.168.33.1.52745 > 192.168.33.10.30000: Flags [.], ack 15, win 4117, options [nop,nop,TS val 661451291 ecr 3007057], length 0

mss(1460バイト)より大きなバイト数にしてみる。

char *msg = "123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901\r\n";

1つのTCPセグメントだけでは送信仕切れず、2つのTCPセグメントに分割して送っていることがわかる。

12:11:56.140718 IP 192.168.33.10.30000 > 192.168.33.1.52797: Flags [.], seq 1:1449, ack 1, win 227, options [nop,nop,TS val 3035101 ecr 661563206], length 1448
12:11:56.140725 IP 192.168.33.10.30000 > 192.168.33.1.52797: Flags [P.], seq 1449:1464, ack 1, win 227, options [nop,nop,TS val 3035101 ecr 661563206], length 15
12:11:56.140867 IP 192.168.33.1.52797 > 192.168.33.10.30000: Flags [.], ack 1464, win 4072, options [nop,nop,TS val 661563207 ecr 3035101], length 0

クライアントからデータを受け取る

read関数で読む。read_line関数というラッパー関数を作り1行ずつ読むようにする。

@@ -12,6 +12,22 @@ void error(char *msg)
   exit(1);
 }
 
+int read_line(int socket, char *buf, int len)
+{
+  char *s = buf;
+  int slen = len;
+  int c = read(socket, s, slen);
+  while ((c > 0) && (s[c - 1] != '\n')) {
+    s += c;
+    slen = -c;
+    c = read(socket, s, slen);
+  }
+  if (c < 0) {
+    return c;
+  }
+  return len - slen;
+}
+
 int main(int argc, char *argv[])
 {
   int listener_d = socket(PF_INET, SOCK_STREAM, 0);
@@ -32,6 +48,8 @@ int main(int argc, char *argv[])
   }
 
   puts("wait...");

   struct sockaddr_storage client_addr;
   unsigned int address_size = sizeof(client_addr);
+
+  char buf[255];
   while(1) {
@@ -40,8 +58,9 @@ int main(int argc, char *argv[])
       error("accept err");
     }
 
+    read_line(connect_d, buf, sizeof(buf));
+
+    write(connect_d, buf, strlen(buf));
 
     close(connect_d);
   }

read関数

ファイルから読む時にも使用するread関数を使う。

#include <unistd.h>

ssize_t read(int fd, void *buf, size_t count);

ファイルディスクリプタとは異なり、ソケットのディスクリプタに対するreadは、要求したバイト数より少ない入出力を行うことがある。これはカーネル内のソケットのバッファが限界に達するからである。そのため、ループで文字列の終端(ソケットの場合は \0 ではなく \r\n )に達するまで何回もreadを呼ぶ必要がある。

おしまい

コードはこんな感じ。次は複数クライアントの同時接続に対応しようと思う。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>

void error(char *msg)
{
  fprintf(stderr, "%s:%s\n", msg, strerror(errno));
  exit(1);
}

int read_line(int socket, char *buf, int len)
{
  char *s = buf;
  int slen = len;
  int c = read(socket, s, slen);
  while ((c > 0) && (s[c - 1] != '\n')) {
    s += c;
    slen = -c;
    c = read(socket, s, slen);
  }
  if (c < 0) {
    return c;
  }
  return len - slen;
}

int main(int argc, char *argv[])
{
  int listener_d = socket(PF_INET, SOCK_STREAM, 0);
  if (listener_d == -1) {
    error("socket err");
  }

  struct sockaddr_in name;
  name.sin_family = AF_INET;
  name.sin_port = (in_port_t)htons(30000);
  name.sin_addr.s_addr = htonl(INADDR_ANY);
  if (bind(listener_d, (struct sockaddr *) &name, sizeof(name)) == -1) {
    error("bind err");
  }

  if (listen(listener_d, 1) == -1) {
    error("listen err");
  }

  puts("wait...");

  struct sockaddr_storage client_addr;
  unsigned int address_size = sizeof(client_addr);

  char buf[255];
  while(1) {
    int connect_d = accept(listener_d, (struct sockaddr *)&client_addr, &address_size);
    if (connect_d == -1) {
      error("accept err");
    }

    char *msg = "Hello World!\r\n";
    write(connect_d, msg, strlen(msg));

    read_line(connect_d, buf, sizeof(buf));

    write(connect_d, buf, strlen(buf));

    close(connect_d);
  }
  return 0;
}

プログラミング言語作成ハンズオンに参加してきた #lang_impl

プログラミング言語作成ハンズオンに参加してきました。楽しかったです!

プログラミング言語を作成したことがないので、こういう勉強会で教えてもらえるのはすごくありがたいです。

connpass.com

午前中はざっとプログラミング言語処理系についての説明。その後、今回のハンズオン用に作られたnubという言語を拡張していきます。

github.com

やったこと

  • 午前中の説明に出てきた内容と用意されたソースコードの対応を考える
  • ANTLRというものを使っておりこれが何かを考える。これはパーサジェネレータで、g4ファイルを元にパーサを生成してくれる。最初はANTLRというものがパーサそのもの?などと何も分かっていない勘違いをしていたのだが、NubParserクラスが自動生成されているようだったので、勘違いに気がついた
  • 用意されたコードの役割をざっと把握できたので、最初に累乗演算子 ** を導入。これは +* を参考にして、とくに問題なくできた
  • 次に、文字列リテラルを導入。test-inputディレクトリに用意されていた string.nub を実行できるようにした
  • その次は、Stringに対して指定した位置にある文字を取得するindexOf関数をnub言語で実装できるようにすることに
    • Stringを文字の配列っぽい感じで扱いたい
    • そもそも配列がないので作らないと
    • よくわからなかったので、ペアで実装することに
    • 文法はLispを真似て cons car cdr を作る
    • まずは cons 。文法ファイルをいじる。なんかどの辺に書けばいいのだろうか悩む。専用の抽象構文木を作ったけどBinaryOperator使えばよかったのかも。インタプリタ( Evaluatorクラス )では、長さが2の Object[] で実装
    • 次は car cdr 。これも文法ファイルのどの辺に書けばいいのか悩む。。処理の優先順位をどうしたいかで決めればいいのかなぁ。単項演算子のための抽象構文木を作って対応
    • ペアができた。 println(car(cons(1, cons(2, 3)))) こんな風に書ける
    • Stringリテラルの実装を作ったペアで表現するようにした。 "Hello"(H(e(l(l(o))))) といった風に
    • indexOf関数を定義できるようになった
  • 最後は文字列の長さを返すlength関数をnubで作れるようにすることに
    • 空のペアかどうかを調べる isNil という文法を導入
    • length関数を定義できるようになった

というわけで実装したものです。

GitHub - bati11/nub at study

全体的に

nubという言語を拡張するということだったが、上に書いた通り自分は cons car cdr でペアを作れるようにした。だけど、拡張というのはこういうことでいいのか、自分がやってることは正しいのか?というのが不安でした。実装に夢中になってしまったけど、チューターの方にもっと聞けばよかったかもしれない。

あとは、今回やってみて思ったのは、プログラミング言語の作成というのはインタプリタの実装も含んでいるのだなぁということでした。なぜかは分からないけど、仕様が決まっているインタプリタが読めるような何かを出力するイメージを持っていました。でも、インタプリタも一緒に作るんだなぁ。文法・抽象構文木、それからインタプリタを実装するときに、今どれを実装しているのか、という頭の切り替えをしっかりやらないとごちゃごちゃしてくるなぁと感じました。これは水島さんがブログ記事で書いてるメタとベタの話と近いことを言ってるのかもしれないです。

プログラミング言語作成ハンズオンを開催しました - kmizuの日記

おわりに

もうちょっと色々いじってみたいなぁ、とりあえず以下のことはやってみたいです。

この勉強会のおかげでもっと踏み込んでみたいなぁという気持ちになりました、ありがとうございました!