博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
《网络编程》基于 TCP 套接字编程的分析
阅读量:2192 次
发布时间:2019-05-02

本文共 14449 字,大约阅读时间需要 48 分钟。

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

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

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

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

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

服务器与客户端

下面根据 TCP 套接字编程的流程具体实现客户端和服务器的程序。

TCP 服务器程序实现如下:

/* TCP 服务器程序 */#include 
#include
#include
#include
#include
/* 套接字操作函数头文件 */#include
/* 套接字地址结构头文件 */#include
#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 
#include
#include
/* 函数功能:初始化服务器套接字; * 返回值:若成功则返回监听套接字,若出错返回-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 
#include
#include
#include
#include
#include
#include
#include
#define SERV_PORT 9877extern 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
", 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"voidstr_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"voidstr_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 连接的建立与终止可参考文章《》

$ 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         ESTABLISHEDtcp        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.1Unix Network Program /* 粗体表示从终端输入的文本 */Unix Network Program /* 从服务器回射的文本 */Linux, Hello             Linux, Hello^D		     /* 键入终端 EOF 字符 */$ netstat -a | grep 9877tcp        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] 
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 
#include
#include
#include
#include
#include
#include
#include
#define SERV_PORT 9877extern 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
", 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] 
exit 5585 5559 pts/6 Z [serv]
exit 5586 5559 pts/6 Z [serv]
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 
#include
#include
#include
#include
/* 套接字操作函数头文件 */#include
/* 套接字地址结构头文件 */#include
#include
#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 9877tcp        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.1Linux, HellowLinux, Hellownew line when childProcess was killedstr_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 网络编程》

你可能感兴趣的文章
Set、WeakSet、Map以及WeakMap结构基本知识点
查看>>
【NLP学习笔记】(一)Gensim基本使用方法
查看>>
【NLP学习笔记】(二)gensim使用之Topics and Transformations
查看>>
【深度学习】LSTM的架构及公式
查看>>
【python】re模块常用方法
查看>>
剑指offer 19.二叉树的镜像
查看>>
剑指offer 20.顺时针打印矩阵
查看>>
剑指offer 21.包含min函数的栈
查看>>
剑指offer 23.从上往下打印二叉树
查看>>
剑指offer 25.二叉树中和为某一值的路径
查看>>
剑指offer 60. 不用加减乘除做加法
查看>>
Leetcode C++《热题 Hot 100-14》283.移动零
查看>>
Leetcode C++《热题 Hot 100-15》437.路径总和III
查看>>
Leetcode C++《热题 Hot 100-17》461.汉明距离
查看>>
Leetcode C++《热题 Hot 100-18》538.把二叉搜索树转换为累加树
查看>>
Leetcode C++《热题 Hot 100-21》581.最短无序连续子数组
查看>>
Leetcode C++《热题 Hot 100-22》2.两数相加
查看>>
Leetcode C++《热题 Hot 100-23》3.无重复字符的最长子串
查看>>
Leetcode C++《热题 Hot 100-24》5.最长回文子串
查看>>
Leetcode C++《热题 Hot 100-28》19.删除链表的倒数第N个节点
查看>>