《网络编程》基于 TCP 套接字编程的分析

时间:2022-05-06 10:18:30

        本节围绕着基于 TCP 套接字编程实现的客户端和服务器进行分析,首先给出一个简单的客户端和服务器模式的基于 TCP 套接字的编程实现,然后针对实现过程中所出现的问题逐步解决。有关基于 TCP 套接字的编程过程可参考文章《基本 TCP 套接字编程》。该编程实现的功能如下:

(1)客户端从标准输入读取文本,并发送给服务器;

(2)服务器从网络输入读取该文本,并回射给客户端;

(3)客户端从网络读取由服务器回射的文本,并通过标准输出回显到终端;

简单实现流图如下:注:画图过程通信双方是单独的箭头,只是方便理解,实际上是全双工通信。

《网络编程》基于 TCP 套接字编程的分析


服务器与客户端

下面根据 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)
{
intn;

again:
if ( (n = accept(fd, sa, salenptr)) < 0) {
#ifdefEPROTO
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)
{
charsendline[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_tn;
charbuf[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)
{
intn;

again:
if ( (n = accept(fd, sa, salenptr)) < 0) {
#ifdefEPROTO
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 网络编程》