本节围绕着基于 TCP 套接字编程实现的客户端和服务器进行分析,首先给出一个简单的客户端和服务器模式的基于 TCP 套接字的编程实现,然后针对实现过程中所出现的问题逐步解决。有关基于 TCP 套接字的编程过程可参考文章《基本 TCP 套接字编程》。该编程实现的功能如下:
(1)客户端从标准输入读取文本,并发送给服务器;
(2)服务器从网络输入读取该文本,并回射给客户端;
(3)客户端从网络读取由服务器回射的文本,并通过标准输出回显到终端;
简单实现流图如下:注:画图过程通信双方是单独的箭头,只是方便理解,实际上是全双工通信。
服务器与客户端
下面根据 TCP 套接字编程的流程具体实现客户端和服务器的程序。
TCP 服务器程序实现如下:
/* TCP 服务器程序 */ #include <stdio.h> #include <stdlib.h> #include <string.h> #include <errno.h> #include <sys/socket.h> /* 套接字操作函数头文件 */ #include <netinet/in.h> /* 套接字地址结构头文件 */ #include <unistd.h> #define SERV_PORT 9877 /* 通用端口号 */ #define QLEN 1024 /* 套接字最大队列数 */ extern int initserver(int, struct sockaddr*, socklen_t, int); extern void err_sys(const char *, ...); extern void str_echo(int); extern pid_t Fork(); int Accept(int fd, struct sockaddr *sa, socklen_t *salenptr); int main(int argc, char *argv[]) { int listenfd,connectfd; pid_t pid; socklen_t clilen; struct sockaddr_in cliaddr,servaddr; /* 初始化服务器地址信息:通信域(IPv4)、端口号、IP地址 */ bzero(&servaddr, sizeof(servaddr)); servaddr.sin_family = AF_INET; servaddr.sin_port = htons(SERV_PORT); servaddr.sin_addr.s_addr = htonl(INADDR_ANY); /* 服务器IP地址采用通配符,即任何地址都匹配 */ /* 初始化服务器 */ listenfd = initserver(SOCK_STREAM, (struct sockaddr *)&servaddr, sizeof(servaddr), QLEN); if(listenfd < 0) err_sys("initserver error"); for( ; ; ) { clilen = sizeof(cliaddr); connectfd = Accept(listenfd, (struct sockaddr *) &cliaddr, &clilen); if( (pid = Fork()) == 0) /* 子进程 */ { close(listenfd); /* 关闭监听套接字 */ str_echo(connectfd); /* 处理客户端请求 */ exit(0); } close(connectfd); /* 父进程关闭已连接套接字 */ } } int Accept(int fd, struct sockaddr *sa, socklen_t *salenptr) { int n; again: if ( (n = accept(fd, sa, salenptr)) < 0) { #ifdef EPROTO if (errno == EPROTO || errno == ECONNABORTED) #else if (errno == ECONNABORTED) #endif goto again; else err_sys("accept error"); } return(n); }
服务器初始化程序:
/* 服务器初始化套接字端点 */ #include <sys/socket.h> #include <unistd.h> #include <errno.h> /* 函数功能:初始化服务器套接字; * 返回值:若成功则返回监听套接字,若出错返回-1并设置errno值; */ /* type 套接字类型, qlen是监听队列的最大个数 */ int initserver(int type, struct sockaddr *servaddr, socklen_t len, int qlen) { int fd; int err = 0; /* 采用type类型默认的协议 */ if((fd = socket(servaddr->sa_family, type, 0)) < 0) return -1;/* 出错返回-1*/ int reuse = 1; /* 设置套接字选项 */ if(setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(int)) < 0) { err = errno; goto errout; } /* 将地址绑定到一个套接字 */ if(bind(fd, servaddr, len) < 0) { err =errno; goto errout;/* 跳转到出错输出语句 */ } /* 若套接字类型type是面向连接(SOCK_STREAM, SOCK_SEQPACKET)的,则执行以下语句 */ if(type == SOCK_STREAM || type == SOCK_SEQPACKET) { /* 监听套接字连接队列 */ if(listen(fd, qlen) < 0) { err = errno; goto errout; } } return (fd); errout: close(fd); errno = err; return -1; }
服务器的程序的基本实现过程:
(1)首先初始化地址结构,将地址结构中的地址填入通配地址(INADDR_ANY)和服务器的众所周知的端口(SERV_PORT,即为9877),捆绑通配地址的作用是告知系统:若系统是多宿主机,则将接受目的地址为任何本地接口的连接。端口号应该大于1023(不需要保留端口),比 5000 大(以免与许多源自 Berkeley的实现分配临时端口的范围冲突),比 49152 小(以免与临时端口号的”正确“范围冲突),而且不应该与任何已注册的端口冲突。然后调用 socket
函数创建一个基于 IPv4 的 TCP 套接字。接着调用 bind 函数把地址绑定到该 TCP 套接字上,调用 listen 函数把该套接字转换诚意个监听套接字,等待客户端的连接请求。
(2)接着服务器调用 accept 函数,使服务器进程处于阻塞状态,等待客户端连接的完成。
(3)接下来是关于并发服务器的内容,在当前进程调用 fork 函数创建一个新的子进程,在子进程中关闭监听套接字,父进程关闭已完成连接的套接字。子进程接着调用处理函数,处理客户端发来的信息。
TCP 客户端程序实现如下:
/* TCP 客户端程序 */ #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <errno.h> #include <arpa/inet.h> #define SERV_PORT 9877 extern void err_sys(const char *, ...); extern void str_cli(FILE*, int); extern void err_quit(const char *, ...); int main(int argc, char **argv) { int sockfd; int err; struct sockaddr_in servadrr; if(argc != 2) err_quit("usage: %s <IPaddress>", argv[0]); /* 初始化地址 */ bzero(&servadrr, sizeof(servadrr)); servadrr.sin_family = AF_INET; servadrr.sin_port = htons(SERV_PORT); /* 将文本字符串地址转换为网络字节序的二进制地址 */ inet_pton(AF_INET, argv[1], &servadrr.sin_addr); /* 创建客户端套接字 */ if( (sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0) err_sys("socket error"); /* 向服务器发出连接请求 */ if( (err = connect(sockfd, (struct sockaddr *)&servadrr, sizeof(servadrr))) < 0) err_sys("connect error"); /* 处理函数 */ str_cli(stdin, sockfd); exit(0); }
客户端程序的实现过程:
首先初始化地址结构信息,然后调用 socket 函数创建客户端套接字,接着调用 connect 函数建立与服务器的连接。连接建立完成之后,接着客户端发送并处理数据。
以下是服务器和客户端处理数据的函数:
客户端:从标准输入读取文本,写到服务器上,并读取从服务器回射的该文本,而且把回射的文本写到标准输出上。fgets 函数从标准输入读取一行文本,writen 把该行文本发送给服务器。readline 从服务器读入回射行文本,fputs 把它写到标准输出。当遇到文件结束符或错误时,fgets 将返回一个空指针,于是客户端处理循环终止,则终止进程。
#include "unp.h" void str_cli(FILE *fp, int sockfd) { char sendline[MAXLINE], recvline[MAXLINE]; while (Fgets(sendline, MAXLINE, fp) != NULL) { Writen(sockfd, sendline, strlen(sendline)); if (Readline(sockfd, recvline, MAXLINE) == 0) err_quit("str_cli: server terminated prematurely"); Fputs(recvline, stdout); } }
服务器:从客户端读取数据,并把它们回射给客户端。read 函数从套接字读入数据,writen 函数把其中的内容回射给客户端。如果客户端关闭连接,那么接收到客户端的 FIN 将导致服务器子进程的 read 函数返回0,这会导致 str_echo 函数的返回,从而终止子进程。
#include "unp.h" void str_echo(int sockfd) { ssize_t n; char buf[MAXLINE]; again: while ( (n = read(sockfd, buf, MAXLINE)) > 0) Writen(sockfd, buf, n); if (n < 0 && errno == EINTR) goto again; else if (n < 0) err_sys("str_echo: read error"); }
正常启动
首先我们在 Linux 主机上后台运行服务器执行程序,服务器启动后,它调用 socket、bind、listen、和 accept 函数,并阻塞与 accept 函数调用。接着我们运行 netstat 程序来检查服务器监听套接字的状态。注:有关 TCP 连接的建立与终止可参考文章《图解
TCP 连接建立与释放》
$ ps PID TTY TIME CMD 2540 pts/6 00:00:00 bash 3726 pts/6 00:00:00 ps /* 后台运行服务器 */ $./serv & [1] 3727 /* 检查服务器运行时状态 */ /* 从输出结果可以知道,服务器对应的本地端口号为9877的套接字处于监听状态,它有通配 "*" 的本地 IP 地址*/ $ netstat -a Active Internet connections (servers and established) Proto Recv-Q Send-Q Local Address Foreign Address State tcp 0 0 *:9877 *:* LISTEN $ ps PID TTY TIME CMD 2540 pts/6 00:00:00 bash 3727 pts/6 00:00:00 serv 3868 pts/6 00:00:00 ps
接下来运行客户端,并指定服务器的主机 IP 地址为 127.0.0.1。
客户端调用 socket 和 connect 函数,connect 函数的调用会引起 TCP 的建立连接的三次握手过程。当三次握手完成后,客户端的 connect 函数和服务器的 accept 函数均返回,表示连接成功建立。接着发生以下的步骤:
(1)客户端调用处理函数 str_cli 函数,该函数将阻塞于 fgets 函数调用,等待客户端输入文本。
(2)当服务器中的 accept 函数返回时,服务器调用 fork 函数,再由子进程调用 str_echo 处理函数。该函数调用 readline,readline 调用 read 函数,而read 函数等待客户端发送文本期间处于阻塞状态。
(3)另一方面,服务器父进程再次调用 accept 并阻塞,等待下一个客户端连接请求。
$./client 127.0.0.1 $ netstat -a Active Internet connections (servers and established) Proto Recv-Q Send-Q Local Address Foreign Address State tcp 0 0 *:9877 *:* LISTEN tcp 0 0 localhost:9877 localhost:54395 ESTABLISHED tcp 0 0 localhost:54395 localhost:9877 ESTABLISHED
第一个 ESTABLISHED 状态是服务器子进程的套接字状态。第二个 ESTABLISHED 状态是客户端进程套接字。
$ ps -t pts/6 -o pid,ppid,tty,stat,args,wchan PID PPID TT STAT COMMAND WCHAN 2540 31781 pts/6 Ss bash wait 3727 2540 pts/6 S ./serv inet_csk_wait_for_connect 3892 2540 pts/6 S+ ./client 127.0.0.1 n_tty_read 3893 3727 pts/6 S ./serv sk_wait_data
状态”S“表示进程在等待某些资源而处于睡眠状态,进程处于睡眠状态时 WCHAN 列出相应的条件。Linux 在进程阻塞于 accept 或 connect,输出
inet_csk_wait_for_connect;在进程阻塞于套接字的输入或输出时,输出 sk_wait_data;在进程阻塞于终端 I/O 时,输出 n_tty_read;
正常终止
上面已经正常启动客户端和服务器,均处于 ESTABLISHED 状态,此时,客户端等待从终端 I/O 输入文本。我们在终端输入文本,并键入终端 EOF 字符以终止客户端。并立即执行 netstat。
$ ./client 127.0.0.1 <strong>Unix Network Program /* 粗体表示从终端输入的文本 */</strong> Unix Network Program /* 从服务器回射的文本 */ <strong>Linux, Hello </strong> Linux, Hello ^D<span style="white-space:pre"> </span> /* 键入终端 EOF 字符 */ $ netstat -a | grep 9877 tcp 0 0 *:9877 *:* LISTEN tcp 0 0 localhost:54481 localhost:9877 TIME_WAIT
/* Z 表示进程处于僵死状态 */ $ ps -t pts/6 -o pid,ppid,tty,stat,args,wchan PID PPID TT STAT COMMAND WCHAN 2540 31781 pts/6 Ss+ bash n_tty_read 3727 2540 pts/6 S ./serv inet_csk_wait_for_connect 3893 3727 pts/6 Z [serv] <defunct> exit
当客户端请求终止连接时,从netstat 结果可以知道,客户端处于 TIME_WAIT 等待状态。而监听服务器套接字仍处于等待另一个客户端连接请求。正常终止的客户端和服务器的步骤:
(1)当我们键入 EOF 字符时,fgets 返回一个空指针,于是 str_cli 函数返回。
(2)当 str_cli 函数返回到客户端的主函数,主函数调用 exit 终止。
(3)进程终止处理部分工作是关闭所打开的描述符,因此,客户端打开的套接字描述符由内核关闭。导致客户端 TCP 发送一个 FIN 给服务器,服务器 TCP 则以 ACK 响应,这只是 TCP 连接终止的一部分。至此,服务器套接字处于 CLOSE_WAIT 状态,客户端套接字处于 FIN_WAIT_2 状态。
(4)当服务器 TCP 接收 FIN 时,服务器子进程阻塞于 readline 调用,于是 readline 返回0。导致 str_echo 函数返回服务器子进程的主函数。
(5)服务器子进程通过调用 exit 函数来终止。
(6)服务器子进程中打开的所有描述符随之关闭。由子进程来关闭已连接套接字会引起 TCP 连接终止序列的最后报文段:一个从服务器到客户端的 FIN 和 一个从客户端到服务器的 ACK。至此,连接完全终止,客户端套接字进入 TIME_WAIT 状态。
(7)进程终止处理的另一部分是:在服务器子进程终止时,给父进程发送一个 SIGCHLD 信号。由于我们没有信号捕捉处理,所以该信号的默认行为是被忽略。因此,子进程进入僵死状态。
信号处理
在上面介绍中,可以知道在服务器子进程终止时,给父进程发送一个 SIGCHLD 信号,由于没有对该信号进行处理导致子进程处于僵死状态。这里我们介绍对该信号的处理。有关信号的基本概念可以参考前面的《信号基本概述》等序列文章。
僵死状态的目的是维护子进程的信息,以便父进程在以后某个时刻获取。这些信息包括子进程的进程 ID 、终止状态以及资源利用信息(CPU 时间、内存使用量 等信息)。我们可以通过捕获信号对该信号进行处理,信号处理函数必须在 fork 第一个子进程之前完成,且只做一次。在服务器监听 listen 函数之后 accept 之前加入信号捕捉处理函数 signal。有关进程等待 wait 等函数的讲解可参考文章《进程等待》
signal(SIGCHLD, sig_chld); /* 其中处理函数定义如下 */ void sig_chld(int signo) { int stat; pid_t pid; pid = wait(&stat); return; }
加入信号捕捉处理函数之后运行服务器和客户端,在客户端键入 EOF 字符时,子进程不会处于僵死状态,可以通过 ps 查看,结果并不存在僵死进程。
$ ps -t pts/6 -o pid,ppid,tty,stat,args,wchan PID PPID TT STAT COMMAND WCHAN 2540 31781 pts/6 Ss+ bash n_tty_read 5168 2540 pts/6 S ./serv inet_csk_wait_for_connect
当 wait 函数处理多个客户端连接到服务器,即并发服务器时并不能正确处理僵死进程,例如当有 5 个客户端套接字连接到服务器时,wait 函数并不能处理全部僵死进程,此时应该使用 waitpid 函数;
客户端程序如下:
/* TCP 客户端程序 */ #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <errno.h> #include <arpa/inet.h> #define SERV_PORT 9877 extern void err_sys(const char *, ...); extern void str_cli(FILE*, int); extern void err_quit(const char *, ...); int main(int argc, char **argv) { int sockfd[5]; int err, i; struct sockaddr_in servadrr; if(argc != 2) err_quit("usage: %s <IPaddress>", argv[0]); for(i = 0; i< 5; i++) { /* 初始化地址 */ bzero(&servadrr, sizeof(servadrr)); servadrr.sin_family = AF_INET; servadrr.sin_port = htons(SERV_PORT); /* 将文本字符串地址转换为网络字节序的二进制地址 */ inet_pton(AF_INET, argv[1], &servadrr.sin_addr); /* 创建客户端套接字 */ if( (sockfd[i] = socket(AF_INET, SOCK_STREAM, 0)) < 0) err_sys("socket error"); /* 向服务器发出连接请求 */ if( (err = connect(sockfd[i], (struct sockaddr *)&servadrr, sizeof(servadrr))) < 0) err_sys("connect error"); } /* 处理函数 */ str_cli(stdin, sockfd[0]); exit(0); }
此时,依然出现僵死进程;
$ ps -t pts/6 -o pid,ppid,tty,stat,args,wchan PID PPID TT STAT COMMAND WCHAN 2540 31781 pts/6 Ss+ bash n_tty_read 5559 2540 pts/6 S ./serv inet_csk_wait_for_connect 5584 5559 pts/6 Z [serv] <defunct> exit 5585 5559 pts/6 Z [serv] <defunct> exit 5586 5559 pts/6 Z [serv] <defunct> exit
当使用 waitpid 函数处理僵死进程时,不会出现僵死进程:
void sig_chld(int signo) { int stat; pid_t pid; while( (pid = waitpid(-1, &stat, WNOHANG)) > 0); return; }
$ ps -t pts/6 -o pid,ppid,tty,stat,args,wchan PID PPID TT STAT COMMAND WCHAN 2540 31781 pts/6 Ss+ bash n_tty_read 5722 2540 pts/6 S ./serv inet_csk_wait_for_connect
最终的服务器程序如下:
/* TCP 服务器程序 */ #include <stdio.h> #include <stdlib.h> #include <string.h> #include <errno.h> #include <sys/socket.h> /* 套接字操作函数头文件 */ #include <netinet/in.h> /* 套接字地址结构头文件 */ #include <unistd.h> #include <sys/wait.h> #define SERV_PORT 9877 /* 通用端口号 */ #define QLEN 1024 /* 套接字最大队列数 */ extern int initserver(int, struct sockaddr*, socklen_t, int); extern void err_sys(const char *, ...); extern void str_echo(int); extern pid_t Fork(); int Accept(int fd, struct sockaddr *sa, socklen_t *salenptr); void sig_chld(int signo); int main(int argc, char *argv[]) { int listenfd,connectfd; pid_t pid; socklen_t clilen; struct sockaddr_in cliaddr,servaddr; /* 初始化服务器地址信息:通信域(IPv4)、端口号、IP地址 */ bzero(&servaddr, sizeof(servaddr)); servaddr.sin_family = AF_INET; servaddr.sin_port = htons(SERV_PORT); servaddr.sin_addr.s_addr = htonl(INADDR_ANY); /* 服务器IP地址采用通配符,即任何地址都匹配 */ listenfd = initserver(SOCK_STREAM, (struct sockaddr *)&servaddr, sizeof(servaddr), QLEN); if(listenfd < 0) err_sys("initserver error"); signal(SIGCHLD, sig_chld); for( ; ; ) { clilen = sizeof(cliaddr); connectfd = Accept(listenfd, (struct sockaddr *) &cliaddr, &clilen); if( (pid = Fork()) == 0) /* 子进程 */ { close(listenfd); /* 关闭监听套接字 */ str_echo(connectfd); /* 处理客户端请求 */ exit(0); } close(connectfd); /* 父进程关闭已连接套接字 */ } } void sig_chld(int signo) { int stat; pid_t pid; while( (pid = waitpid(-1, &stat, WNOHANG)) > 0); return; } int Accept(int fd, struct sockaddr *sa, socklen_t *salenptr) { int n; again: if ( (n = accept(fd, sa, salenptr)) < 0) { #ifdef EPROTO if (errno == EPROTO || errno == ECONNABORTED) #else if (errno == ECONNABORTED) #endif goto again; else err_sys("accept error"); } return(n); }
服务器进程终止
我们启动客户端和服务器之后,使用 kill 杀死服务器的子进程,模拟服务器进程崩溃的情形。具体步骤如下:
(1)首先在同一台主机上启动服务器和客户端,并在客户端上键入文本行,正常情况下该行文本由服务器子进程回射给客户端。
(2)接着使用 ps 查看服务器子进程的进程 ID ,并执行 kill 命名杀死服务器子进程。作为进程终止处理的部分工作,子进程中所有打开着的描述符都被关闭,导致服务器子进程向客户端发送一个 FIN ,而客户端 TCP 响应以一个 ACK。这就是 TCP 连接终止的前半部分工作。
(3)SIGCHLD 信号被发送给服务器父进程,并得到正确处理。
(4)客户端上没有发生任何特殊情况,客户端 TCP 接收来自服务器 TCP 的 FIN 并响应以一个 ACK,然而客户端进程阻塞于 fgets 调用上,等待从终端接收文本行。
(5)因此,出现以下情况:
$ ps -t pts/6 -o pid,ppid,tty,stat,args,wchan PID PPID TT STAT COMMAND WCHAN 2540 31781 pts/6 Ss bash wait 5722 2540 pts/6 S ./serv inet_csk_wait_for_connect 6357 2540 pts/6 S+ ./client 127.0.0.1 n_tty_read 6358 5722 pts/6 S ./serv sk_wait_data $ kill 6358 $ netstat -a | grep 9877 tcp 0 0 *:9877 *:* LISTEN tcp 0 0 localhost:9877 localhost:56195 FIN_WAIT2 tcp 1 0 localhost:56195 localhost:9877 CLOSE_WAIT ./client 127.0.0.1 Linux, Hellow Linux, Hellow new line when childProcess was killed str_cli: server terminated prematurely
(6)当键入 字符串 “new line when childProcess was killed”时,str_cli 调用 writen 函数,客户端 TCP 把数据发送给服务器,而此时客户端 TCP 已经接收到来自服务器子进程的 FIN 报文段,当服务器 TCP 接收到来自客户端的数据使,因为先前打开的套接字的进程已经通过 kill 函数终止,于是响应一个 RST 。然而客户端并没有收到 RST,因此 readline 返回 0(表示 EOF ),则客户端此时并未预期收到 EOF ,则以出错信息”server
terminated prematurely“退出。当客户端终止时,所有它打开的描述符都被关闭。
参考资料:
《Unix 网络编程》