echoサーバーを書いてみたときのメモ その3 マルチプロセス、ノンブロッキングI/O、I/O多重で複数クライアントを捌く
前回、前々回の続き。
echoサーバーを書いてみたときのメモ その1 ソケットAPIとTCP
echoサーバーを書いてみたときのメモ その2 なぜ複数クライアントを捌けないのか
複数クライアントを同時に捌くために以下の方法で対応してみる。
- ブロッキングI/Oのまま。forkを使って複数プロセスを立ち上げ、1プロセス1クライアントで対応する
- 非ブロッキングI/Oを使って対応する。1プロセスで複数クライアントに対応する
- I/O多重を使って対応する。1プロセスで複数クライアントに対応する
今回もUNIXネットワークプログラミングにお世話になります。
forkを使ったechoサーバー
前々回作ったechoサーバーをforkを使って、1クライアント毎に1プロセスを割り当てる様にする。 accept
後に fork
して、親プロセスではクライアントとの接続用ソケットである connect_d
を close
、子プロセスではListen用ソケットである listener_d
を close
する。
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <errno.h> #include <sys/socket.h> #include <arpa/inet.h> #include <unistd.h> #include <sys/wait.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"); } if (fork() == 0) { 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); exit(0); } close(connect_d); } return 0; }
forkを使っていないechoサーバーの場合、 connect_d
を close
したらFINが送信されクライアントとの接続が切れた。 fork
を使ったechoサーバーでは親プロセスが connect_d
を close
するわけだが大丈夫なのだろうか。UNIXネットワークプログラミングにはこう書いてある。
すべてのファイルやソケットが参照カウンタを持っていることを理解することが必要である
UNIXネットワークプログラミング P103
close
すると参照カウンタが1つ減るが、これが0にならないとFINは送信されない。親プロセスで close
しても参照カウンタが2から1に減るだけなので、FINは送信されずクライアントとの接続は切れない。
動かしてみる
echoサーバーを起動して確認。
$ netstat -an | grep 30000 tcp 0 0 0.0.0.0:30000 0.0.0.0:* LISTEN $ lsof -i:30000 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME fork_echo 11474 ubuntu 3u IPv4 246242056 0t0 TCP *:30000 (LISTEN) $ ll /proc/11474/fd total 0 dr-x------ 2 ubuntu ubuntu 0 Mar 12 07:09 ./ dr-xr-xr-x 9 ubuntu ubuntu 0 Mar 12 07:09 ../ lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:09 0 -> /dev/pts/1 lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:09 1 -> /dev/pts/1 lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:09 2 -> /dev/pts/1 lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:09 3 -> socket:[246242056]
クライアントから2つ接続してみる。
クライアント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 '^]'. Hello World!
fork
を使っていないechoサーバーの場合と異なり、両方に Hello World!
とサーバーから返事が来ていることが分かる。
サーバーで確認。
$ ps -xf PID TTY STAT TIME COMMAND 2767 ? S 0:00 sshd: ubuntu@pts/1 2768 pts/1 Ss 0:00 \_ -bash 11474 pts/1 S+ 0:00 \_ ./fork_echo_server 11479 pts/1 S+ 0:00 \_ ./fork_echo_server 11480 pts/1 S+ 0:00 \_ ./fork_echo_serve
親プロセスが1つ、子プロセスが2つできてる。さらに確認。
$ lsof -i:30000 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME fork_echo 11474 ubuntu 3u IPv4 246242056 0t0 TCP *:30000 (LISTEN) fork_echo 11479 ubuntu 4u IPv4 246242057 0t0 TCP 192.168.33.10:30000->192.168.33.1:59013 (ESTABLISHED) fork_echo 11480 ubuntu 4u IPv4 246242079 0t0 TCP 192.168.33.10:30000->192.168.33.1:59014 (ESTABLISHED) $ ll /proc/11474/fd total 0 dr-x------ 2 ubuntu ubuntu 0 Mar 12 07:09 ./ dr-xr-xr-x 9 ubuntu ubuntu 0 Mar 12 07:09 ../ lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:09 0 -> /dev/pts/1 lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:09 1 -> /dev/pts/1 lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:09 2 -> /dev/pts/1 lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:09 3 -> socket:[246242056] $ ll /proc/11479/fd total 0 dr-x------ 2 ubuntu ubuntu 0 Mar 12 07:10 ./ dr-xr-xr-x 9 ubuntu ubuntu 0 Mar 12 07:10 ../ lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:10 0 -> /dev/pts/1 lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:10 1 -> /dev/pts/1 lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:10 2 -> /dev/pts/1 lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:10 4 -> socket:[246242057] $ ll /proc/11480/fd total 0 dr-x------ 2 ubuntu ubuntu 0 Mar 12 07:10 ./ dr-xr-xr-x 9 ubuntu ubuntu 0 Mar 12 07:10 ../ lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:10 0 -> /dev/pts/1 lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:10 1 -> /dev/pts/1 lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:10 2 -> /dev/pts/1 lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:10 4 -> socket:[246242079]
3つのプロセスがそれぞれ1つずつソケットを使ってる。親プロセスはLISTENしてるソケット。子プロセスはクライアントと接続してるソケット。
クライアント2の方で適当な文字列を入力すると、文字列が返ってきてサーバーから切断される。
$ 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.
サーバーの状態を確認。接続済みソケットが1つになってることがわかる。子プロセスの close
が実行された時にソケットの参照カウンタが0になるため、サーバーはFINを送信して、最終的に接続が切れる。
$ lsof -i:30000 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME fork_echo 11474 ubuntu 3u IPv4 246242056 0t0 TCP *:30000 (LISTEN) fork_echo 11479 ubuntu 4u IPv4 246242057 0t0 TCP 192.168.33.10:30000->192.168.33.1:59013 (ESTABLISHED)
forkを使って、複数クライアントを同時に取り扱うことができた。
psでサーバー側のプロセスを確認してみる。
$ ps -xf PID TTY STAT TIME COMMAND 2767 ? S 0:00 sshd: ubuntu@pts/1 2768 pts/1 Ss 0:00 \_ -bash 11474 pts/1 S+ 0:00 \_ ./fork_echo_server 11479 pts/1 S+ 0:00 \_ ./fork_echo_server 11480 pts/1 Z+ 0:00 \_ [fork_echo_serve] <defunct>
接続が切れた子プロせすがゾンビ状態で残ってしまっている。この問題に対応するためにSIGCHLDシグナルを処理しましょう、といったこともUNIXネットワークプログラミング(5.8 Posixのシグナル処理、5.9 SIGCHLDシグナルの処理)に書いてある。
ノンブロッキングI/Oを使ったechoサーバー
ioctl
を使って listener_d
(Listen用ソケット)をノンブロッキングにする。ノンブロッキングなソケットに対して accept
すると、クライアントからの接続が来てない場合、ブロックせずにすぐに EWOULDBLOCK
を返す。
クライアントからの接続が来てる場合は accept
でこれまで通り接続済みソケットを返す。この接続済みソケットも ioctl
を使ってノンブロッキングにする。ノンブロッキングなソケットに対して read
すると、データが到達していない場合、ブロックせずにすぐに EAGAIN
を返す。
コードはこんな感じになった。
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <errno.h> #include <sys/socket.h> #include <arpa/inet.h> #include <unistd.h> #include <sys/ioctl.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); // Listenソケットをノンブロッキングにする int val = 1; if (ioctl(listener_d, FIONBIO, &val) == -1) { error("ioctl err"); } // 接続済みソケットを管理するための配列 int fds[255]; // 接続済みソケットの数 int n = 0; char buf[255]; while(1) { int connect_d = accept(listener_d, (struct sockaddr *)&client_addr, &address_size); if (connect_d < 0) { // クライアントの接続がない場合は EWOULDBLOCK if (errno != EWOULDBLOCK) { error("accept err"); } } else { // 接続済みソケットをノンブロッキングにする if (ioctl(connect_d, FIONBIO, &val) == -1) { error("ioctl err"); } fds[n] = connect_d; n++; char *msg = "Hello World!\r\n"; write(connect_d, msg, strlen(msg)); } int i = 0; while (i < n) { // 接続済みソケットを順番に処理 int conn = fds[i]; if (read_line(conn, buf, sizeof(buf)) < 0) { // データが届いてない場合は EAGAIN if (errno != EAGAIN) { error("read err"); } i++; } else { // データが届いてる場合はechoしてclose write(conn, buf, strlen(buf)); close(conn); n--; } } } return 0; }
ここで、UNIXネットワークプログラミングに書いてあった2つのステップを思い出す。
入力操作は次の2段階で構成されている。 - データの用意ができるまで待ち、 - そのデータをカーネルからプロセスにコピーする。
ソケットに関する入力では、最初のステップでは普通のネットワークからのデータの到着を待つ。パケットが到着すると、カーネル内のバッファにコピーされる。2つ目のステップでは、このデータをカーネルのバッファからアプリケーションのバッファにコピーすることになる。
Unixネットワーキングプログラミング P.140
accept
と read
がステップ1でブロックしてしまうのが原因で、ブロッキングI/Oを使ったechoサーバーでは複数クライアントを捌けなかった。ノンブロッキングI/Oではステップ1でデータが用意されていない場合、ブロックせずに何らかの返り値をすぐに返す。そのため、ブロックせずに複数クライアントを同時に捌くことができる。
動かしてみる
ノンブロッキングI/Oを使ったechoサーバーを起動して、確認してみる。
$ ps -xf PID TTY STAT TIME COMMAND 2767 ? S 0:00 sshd: ubuntu@pts/1 2768 pts/1 Ss 0:00 \_ -bash 11508 pts/1 R+ 0:02 \_ ./non_blocking_echo_server $ lsof -i:30000 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME non_block 11508 ubuntu 3u IPv4 246242707 0t0 TCP *:30000 (LISTEN) $ ll /proc/11508/fd total 0 dr-x------ 2 ubuntu ubuntu 0 Mar 12 07:49 ./ dr-xr-xr-x 9 ubuntu ubuntu 0 Mar 12 07:49 ../ lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:50 0 -> /dev/pts/1 lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:50 1 -> /dev/pts/1 lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:49 2 -> /dev/pts/1 lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:50 3 -> socket:[246242707]
クライアントから2つ接続してみる。
クライアント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 '^]'. Hello World!
forkを使ったechoサーバーと同様に、両方とも Hello World!
とサーバーから返事が来てる。
サーバー側を確認。
$ ps -xf PID TTY STAT TIME COMMAND 2767 ? S 0:00 sshd: ubuntu@pts/1 2768 pts/1 Ss 0:00 \_ -bash 11508 pts/1 R+ 3:14 \_ ./non_blocking_echo_server
forkを使ったechoサーバーと異なり、echoサーバーのプロセスは1つしかない。さらに確認してみる。
$ lsof -i:30000 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME non_block 11508 ubuntu 3u IPv4 246242707 0t0 TCP *:30000 (LISTEN) non_block 11508 ubuntu 4u IPv4 303901660 0t0 TCP 192.168.33.10:30000->192.168.33.1:59337 (ESTABLISHED) non_block 11508 ubuntu 5u IPv4 305166582 0t0 TCP 192.168.33.10:30000->192.168.33.1:59338 (ESTABLISHED) $ ll /proc/11508/fd total 0 dr-x------ 2 ubuntu ubuntu 0 Mar 12 07:49 ./ dr-xr-xr-x 9 ubuntu ubuntu 0 Mar 12 07:49 ../ lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:50 0 -> /dev/pts/1 lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:50 1 -> /dev/pts/1 lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:49 2 -> /dev/pts/1 lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:50 3 -> socket:[246242707] lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:53 4 -> socket:[303901660] lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:53 5 -> socket:[305166582]
echoサーバーのプロセスが3つのソケット(Listen用1つ、接続済み2つ)を使ってる。
クライアント2の方で適当な文字列を入力すると、文字列が返ってきてサーバーから切断される。
$ 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.
サーバーの状態を確認。接続済みソケットが1つになってることが分かる。
$ lsof -i:30000 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME non_block 11508 ubuntu 3u IPv4 246242707 0t0 TCP *:30000 (LISTEN) non_block 11508 ubuntu 4u IPv4 303901660 0t0 TCP 192.168.33.10:30000->192.168.33.1:59337 (ESTABLISHED) $ ll /proc/11508/fd total 0 dr-x------ 2 ubuntu ubuntu 0 Mar 12 07:49 ./ dr-xr-xr-x 9 ubuntu ubuntu 0 Mar 12 07:49 ../ lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:50 0 -> /dev/pts/1 lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:50 1 -> /dev/pts/1 lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:49 2 -> /dev/pts/1 lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:50 3 -> socket:[246242707] lrwx------ 1 ubuntu ubuntu 64 Mar 12 07:53 4 -> socket:[303901660]
複数クライアントを同時に捌くことができた。だけど、複数のソケットを管理するコードが微妙だし、 strace
すると分かるがクライアントからの接続がないとずっとループ処理が動いている。これは無駄。次のI/O多重を使うともっと良くなる。
I/Oの多重化を使ったechoサーバー
epoll
を使ってI/Oの多重化を使ったechoサーバーを書く。
read
は、2つのステップを行うが、I/Oの多重化では2つのステップのうちの1つ目、データの用意ができるまで待つ、という部分だけを切り出して行う。データが用意できるまでブロックし、データが用意できたらカーネルからプロセスに戻ってくる。この時、1つのディスクリプタだけでなく複数のディスクリプタに対してデータの用意を待つことができる。
I/Oの多重化のためのシステムコールはいくつかあるが、 epoll
を使う。 epoll
では、 epoll_create
epoll_ctl
epoll_wait
という3つのシステムコールを組み合わせる。
epoll_create
epoll_create
の定義。manを読むと size
の値は正でないといけないがなんでもいいらしい、現在は使われていない。
#include <sys/epoll.h> int epoll_create(int size);
以下のような感じでepollファイルディスクリプタをオープンする。
// epollファイルディスクリプタをオープン int epfd; if ((epfd = epoll_create(100)) < 0) { error("epoll_create err"); }
epoll_ctl
epoll_ctl
の定義。epollファイルディスクリプタと監視対象のディスクリプタとの関連を操作する。 op
の値として EPOLL_CTL_ADD
を指定すると監視対象として追加できる。
#include <sys/epoll.h> int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll_ctlを使って、echoサーバーの listener_d
をepollの監視対象にする。
// listener_dソケットをepollの監視対象とする struct epoll_event ev; memset(&ev, 0, sizeof ev); ev.events = EPOLLIN; ev.data.fd = listener_d; if ((epoll_ctl(epfd, EPOLL_CTL_ADD, listener_d, &ev)) < 0) { error("epoll_ctl error"); }
epoll_wait
epoll ファイルディスクリプタの I/O イベントを待つ。 timeout
に-1を指定すると準備ができたファイルディスクリプタができるまで待ち続ける。返り値は準備ができているファイルディスクリプタの数。
第2引数の events
には呼び出し可能なイベントが格納される。
#include <sys/epoll.h> int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
listener_d
を監視対象にしてから、 epoll_wait
を呼ぶ。 timeout
に -1
を指定しているので、 listener_d
のクライアントからの接続が来るまでブロックする。
struct epoll_event events[MAX_EVENTS]; while(1) { int fd_count = epoll_wait(epfd, events, MAX_EVENTS, -1); // 準備ができたディスクリプタを順番に処理 int i; for (i = 0; i < fd_count; i++) { if (events[i].data.fd == listener_d ){ // クライアントが接続してきた時の処理 } else { // 準備ができたディスクリプタがlisterner_dではない場合の処理 } } } }
クライアントが接続してきた時の処理は、単一クライアントにしか対応できないechoサーバーの時と同様に accept
を呼び出す。 epoll_wait
でソケットの準備ができるまで待ったので、この accept
ではブロックしない。この accept
で取得できる接続済みソケットのファイルディスクリプタをepollの監視対象とする。
struct epoll_event events[MAX_EVENTS]; while(1) { int fd_count = epoll_wait(epfd, events, MAX_EVENTS, -1); // 準備ができたディスクリプタを順番に処理 int i; for (i = 0; i < fd_count; i++) { if (events[i].data.fd == listener_d ){ // クライアントが接続してきた時の処理 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)); // connect_dソケットを監視対象とする memset(&ev, 0, sizeof ev); ev.events = EPOLLIN; ev.data.fd = connect_d; if ((epoll_ctl(epfd, EPOLL_CTL_ADD, connect_d, &ev)) < 0) { error("epoll_ctl error"); } } else { // 準備ができたディスクリプタがlisterner_dではない場合の処理 } } } }
準備ができたディスクリプタが listerner_d
ではない場合というのは、つまり準備ができたディスクリプタがクライアントとの接続用ソケットのディスクリプタである場合である。なので、クライアントから文字列が届きソケットから取得する準備ができたということである。この状態で read
を呼び出してもブロックされる時間は、step2のデータをカーネルからプロセスにコピーする部分だけなので、ほとんどない。
struct epoll_event events[MAX_EVENTS]; while(1) { int fd_count = epoll_wait(epfd, events, MAX_EVENTS, -1); // 準備ができたディスクリプタを順番に処理 int i; for (i = 0; i < fd_count; i++) { if (events[i].data.fd == listener_d ){ // クライアントが接続してきた時の処理 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)); // connect_dソケットを監視対象とする memset(&ev, 0, sizeof ev); ev.events = EPOLLIN; ev.data.fd = connect_d; if ((epoll_ctl(epfd, EPOLL_CTL_ADD, connect_d, &ev)) < 0) { error("epoll_ctl error"); } } else { // 準備ができたディスクリプタがlisterner_dではない場合の処理 int connect_d = events[i].data.fd; read_line(connect_d, buf, sizeof(buf)); write(connect_d, buf, strlen(buf)); close(connect_d); // closeしたソケットを監視対象から削除 epoll_ctl(epfd, EPOLL_CTL_DEL, connect_d, &ev); } } } }
epollを使ったechoサーバー
コードはこんな感じになった。
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <errno.h> #include <sys/socket.h> #include <arpa/inet.h> #include <unistd.h> #include <sys/epoll.h> const int MAX_EVENTS = 10; 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]; // epollファイルディスクリプタをオープン int epfd; if ((epfd = epoll_create(100)) < 0) { error("epoll_create err"); } // listener_dソケットをepollの監視対象とする struct epoll_event ev; memset(&ev, 0, sizeof ev); ev.events = EPOLLIN; ev.data.fd = listener_d; if ((epoll_ctl(epfd, EPOLL_CTL_ADD, listener_d, &ev)) < 0) { error("epoll_ctl error"); } struct epoll_event events[MAX_EVENTS]; while(1) { int fd_count = epoll_wait(epfd, events, MAX_EVENTS, -1); // 準備ができたディスクリプタを順番に処理 int i; for (i = 0; i < fd_count; i++) { if (events[i].data.fd == listener_d ){ // 準備ができたディスクリプタがlistener_dということは // 新しいクライアントが接続してきたということ 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)); // connect_dソケットを監視対象とする memset(&ev, 0, sizeof ev); ev.events = EPOLLIN; ev.data.fd = connect_d; if ((epoll_ctl(epfd, EPOLL_CTL_ADD, connect_d, &ev)) < 0) { error("epoll_ctl error"); } } else { // connect_dの準備ができたということは // クライアントからのデータが届いたということ int connect_d = events[i].data.fd; read_line(connect_d, buf, sizeof(buf)); write(connect_d, buf, strlen(buf)); close(connect_d); // closeしたソケットを監視対象から削除 epoll_ctl(epfd, EPOLL_CTL_DEL, connect_d, &ev); } } } return 0; }
動かしてみる
I/Oの多重化を使ったechoサーバーを起動して、確認してみる。
$ lsof -i:30000 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME epoll_ech 2836 ubuntu 3u IPv4 57737 0t0 TCP *:30000 (LISTEN) $ ll /proc/2836/fd total 0 dr-x------ 2 ubuntu ubuntu 0 Mar 5 12:50 ./ dr-xr-xr-x 9 ubuntu ubuntu 0 Mar 5 12:50 ../ lrwx------ 1 ubuntu ubuntu 64 Mar 5 12:50 0 -> /dev/pts/1 lrwx------ 1 ubuntu ubuntu 64 Mar 5 12:50 1 -> /dev/pts/1 lrwx------ 1 ubuntu ubuntu 64 Mar 5 12:50 2 -> /dev/pts/1 lrwx------ 1 ubuntu ubuntu 64 Mar 5 12:50 3 -> socket:[57737] lrwx------ 1 ubuntu ubuntu 64 Mar 5 12:50 4 -> anon_inode:[eventpoll]
ソケットだけではなく、epoll用のディスクリプタを使っていることが分かる。
クライアントから2つ接続してみる。
クライアント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 '^]'. Hello World!
forkを使ったechoサーバーと同様に、両方とも Hello World!
とサーバーから返事が来てる。
サーバー側を確認。
$ lsof -i:30000 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME epoll_ech 2836 ubuntu 3u IPv4 57737 0t0 TCP *:30000 (LISTEN) epoll_ech 2836 ubuntu 5u IPv4 60491 0t0 TCP 192.168.33.10:30000->192.168.33.1:49526 (ESTABLISHED) epoll_ech 2836 ubuntu 6u IPv4 60493 0t0 TCP 192.168.33.10:30000->192.168.33.1:49527 (ESTABLISHED) $ ll /proc/2836/fd total 0 dr-x------ 2 ubuntu ubuntu 0 Mar 5 12:50 ./ dr-xr-xr-x 9 ubuntu ubuntu 0 Mar 5 12:50 ../ lrwx------ 1 ubuntu ubuntu 64 Mar 5 12:50 0 -> /dev/pts/1 lrwx------ 1 ubuntu ubuntu 64 Mar 5 12:50 1 -> /dev/pts/1 lrwx------ 1 ubuntu ubuntu 64 Mar 5 12:50 2 -> /dev/pts/1 lrwx------ 1 ubuntu ubuntu 64 Mar 5 12:50 3 -> socket:[57737] lrwx------ 1 ubuntu ubuntu 64 Mar 5 12:50 4 -> anon_inode:[eventpoll] lrwx------ 1 ubuntu ubuntu 64 Mar 5 12:53 5 -> socket:[60491] lrwx------ 1 ubuntu ubuntu 64 Mar 5 12:53 6 -> socket:[60493] $ sudo strace -s 1024 -p 2836 strace: Process 2836 attached epoll_wait(4, $ ps x | grep epoll_echo_server 2836 pts/1 S+ 0:00 ./epoll_echo_server
2つの接続済みソケットが作成されている。また、 strace
の結果から read
ではなく epoll_wait
でブロッキングしていることが分かる。 ps
の結果からecho_serverのプロセスは1つだけであることも分かる。
epollを使ってI/O多重を行うことで、1つのプロセスでも複数クライアントを捌くことができた。
ブロックしてはいけない
I/O多重によってソケットのブロックをなくし、1プロセスで複数クライアントを捌けるようになったが、ブロックしてしまう可能性はまだ残っている。これは問題で、1プロセスで動いているのでブロックが発生してしまうとechoサーバー全体の処理が止まってしまう。
まず、epollでディスクリプタの準備ができたソケットから読み込むようにしたが、準備ができた かもしれない だけであって実施に読み込むとブロッキングしてしまう場合があるらしい。そのため、たとえepollを使ったとしてもソケットをノンブロッキングI/Oにしておくなどの対応が必要である。
他のI/Oでもブロックしてはいけない。例えば、ファイル読み込みや他のサーバーとの通信など。これらのI/Oでも、ノンブロッキングI/Oや非同期I/O(POSIX AIO インターフェース)、またはスレッドを使いI/O処理を別スレッドに任せるような非同期処理を行う必要がある。
また、ソケットからの読み込みのブロックはなくすことができたが、書き込みでブロックしてしまう場合がある。アプリケーションからカーネル内のバッファに書き込むが、この時バッファが一杯だとブロックしてしまう。ここでもやはりブロックしないようにノンブロッキングI/OやI/O多重、非同期I/O、スレッドなどで工夫する必要がある。
レベルトリガーとエッジトリガー
epollの通知方法としてレベルトリガーとエッジトリガーとがある。デフォルトはレベルトリガー。エッジトリガーの方が良い場面が良く分からなかったけど、どうやら書き込みのときに便利っぽい。
epoll, エッジトリガー, EPOLLRDHUP - 誰かの役に立てばいいブログ
libev
epollはLinuxで使えるが、BSDでは使えないらしい。代わりにkqueueというシステムコールがある。このようなプラットフォーム依存を隠蔽化したライブラリとしてlibevがある。libevを使ったechoサーバーも書いてみた。libevではepollの場合と異なり、ループ処理を自分で書く必要がなく、コールバックを登録するコードになる。
libevをインストール。
$ sudo apt-get install libev-dev
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <errno.h> #include <sys/socket.h> #include <arpa/inet.h> #include <unistd.h> #include <ev.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; } void connect_callback(EV_P_ ev_io *watcher, int revents) { char buf[255]; read_line(watcher->fd, buf, sizeof(buf)); write(watcher->fd, buf, strlen(buf)); ev_io_stop(EV_A_ watcher); close(watcher->fd); free(watcher); } void listener_callback(EV_P_ ev_io *watcher, int revents) { struct sockaddr_storage client_addr; unsigned int address_size = sizeof(client_addr); int connect_d = accept(watcher->fd, (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)); struct ev_loop *l; ev_io *connect_watcher; connect_watcher = malloc(sizeof(client_addr)); l = watcher->data; // connect_dを監視 ev_io_init(connect_watcher, connect_callback, connect_d, EV_READ); ev_io_start(l, connect_watcher); } 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 ev_loop *loop; ev_io watcher; loop = ev_default_loop(0); watcher.data = loop; // listener_dを監視 ev_io_init(&watcher, listener_callback, listener_d, EV_READ); ev_io_start(loop, &watcher); // イベントループ開始 ev_loop(loop, 0); close(listener_d); return 0; }
$ gcc libev_echo_server.c -l ev -o libev_echo_server
動かして
$ ./libev_echo_server
確認。 epoll_wait
でブロックしてることがわかる。
$ lsof -i:30000 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME libev_ech 13301 ubuntu 3u IPv4 566626035 0t0 TCP *:30000 (LISTEN) $ ll /proc/13301/fd total 0 dr-x------ 2 ubuntu ubuntu 0 Mar 18 13:03 ./ dr-xr-xr-x 9 ubuntu ubuntu 0 Mar 18 13:03 ../ lrwx------ 1 ubuntu ubuntu 64 Mar 18 13:03 0 -> /dev/pts/0 lrwx------ 1 ubuntu ubuntu 64 Mar 18 13:03 1 -> /dev/pts/0 lrwx------ 1 ubuntu ubuntu 64 Mar 18 13:03 2 -> /dev/pts/0 lrwx------ 1 ubuntu ubuntu 64 Mar 18 13:03 3 -> socket:[566626035] lrwx------ 1 ubuntu ubuntu 64 Mar 18 13:03 4 -> anon_inode:[eventpoll] lrwx------ 1 ubuntu ubuntu 64 Mar 18 13:03 5 -> anon_inode:[eventfd] $ sudo strace -s 1024 -p 13301 strace: Process 13301 attached epoll_wait(4,
おしまい
echoサーバーを書きながら、いろいろなI/Oモデルを試してみた。Node.jsは元々libevとlibeio(スレッドを使った非同期処理でI/Oを行うためのライブラリ)を使っていたが、現在は両方ともlibuvが使われているらしい。
UNIXネットワークプログラミングを読みながらコードを書くと、ネットワークの話とコードがつながっていく感じがして楽しかった。
コードはこちら。
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のtelnetに Hello 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段階で構成されている。
- データの用意ができるまで待ち、
- そのデータをカーネルからプロセスにコピーする。
ソケットに関する入力では、最初のステップでは普通のネットワークからのデータの到着を待つ。パケットが到着すると、カーネル内のバッファにコピーされる。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サーバーを以下の方法で書き換えてみようと思う。
echoサーバーを書いてみたときのメモ その1 ソケットAPIとTCP
echoサーバーを書いた日の日記。
Head First C の11章を参考に、クライアントから文字列を受け取り、受け取った文字列をそのままクライアントへ返すechoサーバーを作る。echo サーバーは30000ポートを使用することにする。途中疑問に思ったところは主にUNIXネットワークプログラミングで調べた。
UNIXネットワークプログラミング〈Vol.1〉ネットワークAPI:ソケットとXTI
- 作者: W.リチャードスティーヴンス,W.Richard Stevens,篠田陽一
- 出版社/メーカー: ピアソンエデュケーション
- 発売日: 1999/07
- メディア: 単行本
- 購入: 8人 クリック: 151回
- この商品を含むブログ (35件) を見る
- 作者: David Griffiths,Dawn Griffiths,中田秀基,木下哲也
- 出版社/メーカー: オライリージャパン
- 発売日: 2013/04/03
- メディア: 大型本
- この商品を含むブログ (5件) を見る
環境。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
全体の流れ
- ソケットを作成する
- ソケットをバインドする
- Listen状態になる
- コネクションを取り出す
- クライアントから文字列を受け取る
- クライアントへ文字列を返す
ソケットを作成する
まず、ソケットを作成する処理を追加。
@@ -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の状態遷移図を見ながらだとイメージしやすい。
引用元:第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; }