本博客参考自《Unix网络编程:卷1》
本博客使用的unp库的安装:https://blog.csdn.net/qq_37981695/article/details/106169972
简单TCP客户/服务器程序
1.程序介绍
此简单的程序的执行分为以下 几步
(1)客户从标准输入读入一行文本,并写给服务器。
(2)服务器从网络上输入读入这行文本,并回射给客户。
(2)客户从网络输入读入这行回射文本,并显示在标准输入上。
2.源码解析
2.1 服务器
主程序
#include "unp.h"
#define SERV_PORT 9877 //定义在unp.h文件中
int main(int argc, char **argv)
{
int listenfd, connfd;
pid_t childpid;
socklen_t clilen;
struct sockaddr_in cliaddr, servaddr;
listenfd = Socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);
Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));
Listen(listenfd, LISTENQ);
for ( ; ; ) {
clilen = sizeof(cliaddr);
connfd = Accept(listenfd, (SA *) &cliaddr, &clilen);
if ( (childpid = Fork()) == 0) { /* child process */
Close(listenfd); /* close listening socket */
str_echo(connfd); /* process the request */
exit(0);
}
Close(connfd); /* parent closes connected socket */
}
}
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");
}
服务器程序的流程看下面的流程图:
程序说明:
(1)监听套接字在没有客户连接到达时一直阻塞在Accept。其中Accept是包裹函数。可以参考本人前面的博客:https://blog.csdn.net/qq_37981695/article/details/106169972
(2)客户连接到达之后,fork函数为每个客户派生一个处理它们的子进程。fork函数可以参考这篇博客:https://blog.csdn.net/qq_37981695/article/details/106138213
(3)在父进程中关闭客户通信通信套接字,在子进程中关闭监听套接字。是为了保证每个套接字的引用计数为1。
(4)str_echo中的EINTR是信号中断错误,在信号中断结束之后继续执行即可,不需要终止程序。
2.2 客户端
主程序
#include "unp.h"
int main(int argc, char **argv)
{
int sockfd;
struct sockaddr_in servaddr;
if (argc != 2)
err_quit("usage: tcpcli <IPaddress>");
sockfd = Socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(SERV_PORT);
Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);
Connect(sockfd, (SA *) &servaddr, sizeof(servaddr));
str_cli(stdin, sockfd); /* do it all */
exit(0);
}
str_cli程序
#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);
}
}
客户端的流程可以看下图
程序说明:
(1)客户端相对服务器而言,少了Bind()和Listen()的步骤。客户端的IP地址和端口号是在Connect时自动关联的。
(2)Writen函数向套接字读取n个字节,Readline函数从套接字读取一行数据。可以参考博客:https://blog.csdn.net/qq_37981695/article/details/106169972
(3)当遇到文件结束符或错误时,Fgets()会返回空指针结束程序。
2.3 正常启动和终止
2.3.1 正常启动
首先,我们在Ubuntu系统上启动服务器
hujinrun@ubuntu:~/Documents/unpv13e/tcpcliserv$ ./tcpserv01 &
[1] 19720
(1)服务器启动后,调用socket、bind、listen和accept并阻塞与accept调用
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp 0 0 0.0.0.0:9877 0.0.0.0:* LISTEN
说明:Recv-Q和Send-Q是接收队列中的数据数量。Local Address-本地地址。这里的0.0.0.0是通配地址,9877本地端口号。Foreign Address是客户端的地址-这里的0.0.0.0:*表示任何IP地址、任何端口的客户端都可以发送数据给此服务器。可以通过限制Foreign Address的方式限制接收的数据。state是对应的TCP状态,这里的服务器处理listen状态。关于TCP的状态可以参考博客:https://blog.csdn.net/qq_37981695/article/details/104706673
接着,在同一台主机上启动客户端
(2)客户端调用socket和connect,后者引起三次握手。客户收到三次握手的第二个分节时connect返回,服务器收到三次握手的第三个分节时返回。服务器返回之后会fork子进程,并在子进程中调用str_echo处理客户请求,同时父进程再次阻塞于accept。
hujinrun@ubuntu:~/Documents/unpv13e/tcpcliserv$ ./tcpcli01 127.0.0.1
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp 0 0 0.0.0.0:9877 0.0.0.0:* LISTEN
tcp 0 0 localhost:9877 localhost:35696 ESTABLISHED
tcp 0 0 localhost:35696 localhost:9877 ESTABLISHED
上面输出了netstat的结果,其中一个服务器父进程、一个服务器子进程和一个客户端进程。
其中第一个LISTEN行对应服务器监听套接字。第二行对应服务器子进程的通信套接字,状态是ESTABLISHED。第三行对应客户端的通信套接字,状态同样是ESTABLISHED。
当我们在客户端输入数据时。第一行是客户端输入,第二行是服务器返回的数据。
hujinrun@ubuntu:~/Documents/unpv13e/tcpcliserv$ ./tcpcli01 127.0.0.1
hujunrun
hujunrun
(3)客户调用str_cli函数,该函数会阻塞于fgets调用,当我们输入文本时它会返回。并调用writen向服务器写数据。
(4)服务器的子进程阻塞于read,接收到客户的数据后立刻返回。调用writen将收到的数据发送给客户。
(5)客户发送完数据之后一直阻塞与readline,当接收到服务器发送的数据之后返回,并fputs将数据写到终端。并再次阻塞于fgets,等待下次输入。
2.3.2 正常终止
hujinrun@ubuntu:~/Documents/unpv13e/tcpcliserv$ ./tcpcli01 127.0.0.1
hujinrun
hujinrun
goodbye
goodbye
在输入两行文本之后,我们输入Ctrl+D终止。执行netstat命令我们可以看到下面的结果。
hujinrun@ubuntu:~/Documents/unpv13e/tcpcliserv$ netstat -a | grep 9877
tcp 0 0 0.0.0.0:9877 0.0.0.0:* LISTEN
tcp 0 0 localhost:34244 localhost:9877 TIME_WAIT
其中客户套接字进入TIME_WAIT状态。
总结正常终止客户和服务器的步骤:
(1)键入EOF(Ctrl+D)字符时,fgets返回一个空指针,于是str_cli函数返回。
(2)当str_cli返回到客户的main函数时,main通过调用exit终止。
(3)进程终止处理的部分工作是关闭所有打开的描述符,因此客户打开的套接字由内核关闭。这导致客户TCP发送一个FIN给服务器,服务器TCP以ACK响应,这就是TCP连接终止序列的前半部分。至此,服务器套接字处于CLOSE_WAIT状态,客户套接字则处于FIN_WAIT_2状态。TCP的状态转移可以参考博客:https://blog.csdn.net/qq_37981695/article/details/104706673
(4)当服务器TCP接收到FIN时,服务器子进程阻塞于readline调用,于是readline返回0。这导致str_echo函数返回服务器子进程的main函数。
(5)服务器子进程通过exit来终止。
(6)服务器子进程打开的所有描述符随之关闭。这会引发TCP连接终止序列的最后两个分节:一个从服务器到客户的FIN和一个从客户到服务器的ACK。至此,连接完全终止,客户套接字进入TIME_WAIT状态。
(7)在服务器进程终止时,给父进程发送一个SIGCHLD信号。在这里我们没有处理,导致子进程进入僵尸状态。
下面通过ps命令查看进程的状态
hujinrun@ubuntu:~/Documents/unpv13e/tcpcliserv$ ps -t
PID TTY STAT TIME COMMAND
2065 pts/0 Ss 0:00 bash
2078 pts/0 S 0:00 ./tcpserv01
2082 pts/0 Z 0:00 [tcpserv01] <defunct>
2086 pts/0 R+ 0:00 ps -t
子进程现在的状态是Z-表示僵尸。
2.4 POSIX(portable operation system interface unix)信号处理
2.4.1 基本概念-siganal函数
信号就是告知某个进程发生了某个事件的通知,有时也被称为软件中断。信号通常是异步发生的,也就是说进程无法预知信号的发生时刻。信号可以在进程之间相互发送,也可以由内核发送给进程。
信号的处理方式一共有三种。通过调用sigaction函数来对信号进行处理。
(1)提供一个信号处理函数(signal handler),只要特定信号发送,它就会被调用或者说信号被它捕获。但是有两个信号无法捕获:SIGKILL和SIGSTOP.信号处理函数的原型如下所示:
void handler(int signo);
(2)可以将信号的处理设置成SIG_IGN来忽略它。SIGKILL和SIGSTOP两个信号不可以被忽略。
(3)可以将信号的处理设置成SIG_DEL来启动它的默认处理。默认的处理通常有三种:终止进程、在当前工作目录产生一个进程的核心映像以及忽略信号。
下面是unp库中自定义的函数
/* include signal */
#include "unp.h"
Sigfunc *signal(int signo, Sigfunc *func)
{
struct sigaction act, oact;
act.sa_handler = func;
//设置sa_mask可以阻塞在信号处理函数执行期间发生的信号
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
if (signo == SIGALRM) {
#ifdef SA_INTERRUPT
//设置信号中断
act.sa_flags |= SA_INTERRUPT; /* SunOS 4.x */
#endif
} else {
#ifdef SA_RESTART
//设置对信号的自动重启
act.sa_flags |= SA_RESTART; /* SVR4, 44BSD */
#endif
}
if (sigaction(signo, &act, &oact) < 0)
return(SIG_ERR);
return(oact.sa_handler);
}
/* end signal */
Sigfunc *
Signal(int signo, Sigfunc *func) /* for our signal() function */
{
Sigfunc *sigfunc;
if ( (sigfunc = signal(signo, func)) == SIG_ERR)
err_sys("signal error");
return(sigfunc);
}
关于unp库可以参考博客:https://blog.csdn.net/qq_37981695/article/details/106169972
Signal是包裹函数。
该函数与POSIX中的signal函数重名,函数signal的正常定义如下:
void (*signal(int signo, void(*func)(int)))(int);
为了简化,在unp.h中定义了Sigfunc类型
typedef void Sigfunc(int);
unp库中的signal函数的第一个函数是信号,第二个函数是指向信号处理函数的指针。
如果一个信号在被阻塞期间产生一次或多次,那么信号被解阻塞之后通常只提交一次,一般信号是默认不排队的。
2.4.2 SIGCHLD信号
设置僵尸(zombie)状态的目的是维护子进程的信息,以便父进程在某个时候获取。这些信息包括子进程的进程ID、终止状态以及资源利用信息。如果一个进程终止,而该进程有子进程处于僵尸状态,那么它的所有僵尸子进程的父进程ID将被重置为1(init进程)。继承这些子进程的init进程将清理它们。Unix系统的ps命令输出的COMMAND栏以指明僵尸进程。
僵尸进程很容易就将进程的内存资源消耗殆尽,所以我们一般会对SIGCHLD信号进行处理
#include "unp.h"
void
sig_chld(int signo)
{
pid_t pid;
int stat;
pid = wait(&stat);
printf("child %d terminated\n", pid);
return;
}
服务器的程序中加上了以下两行(>)
...........
>void sig_chld(int);
listenfd = Socket(AF_INET, SOCK_STREAM, 0);
...................
Listen(listenfd, LISTENQ);
>Signal(SIGCHLD, sig_chld);
for ( ; ; ) {}
运行程序可以得到以下的结果
hujinrun@ubuntu:~/Documents/unpv13e/tcpcliserv$ ./tcpserv02 &
[1] 2607
hujinrun@ubuntu:~/Documents/unpv13e/tcpcliserv$ ./tcpcli01 127.0.0.1
hujinrun
hujinrun
goodbye
goodbye
child 2609 terminated
对具体的步骤进行总结:
(1)键入EOF字符来终止客户。客户发送FIN给服务器,服务器响应ACK。
(2)收到客户的FIN导致服务器递送一个EOF给子进程阻塞中的readline,从而子进程终止。
(3)当提交SIGCHLD信号时,父进程阻塞于accept调用。sig_chld信号处理函数执行,其wait调用取到子进程的PID和终止状态,随后调用printf调用,最后返回。
注意:有的系统可能会出现“accept error:Interrupted system call”的错误,这是因为有的系统不会重启慢系统调用导致accept出现EINTR错误。而linux系统中,系统的调用是会重启的,所以没有此错误。慢系统调用是指哪些可以永远无法返回的系统调用。例如此处的accept,如果没有客户连接则一直阻塞。为了移植性,我们可以对EINTR信号进行忽略,服务器的程序被修改如下:
for ( ; ; ) {
clilen = sizeof(cliaddr);
if ( (connfd = accept(listenfd, (SA *) &cliaddr, &clilen)) < 0) {
if (errno == EINTR)
continue; /* back to for() */
else
err_sys("accept error");
}
}
此处如果出现EINTR错误,则直接返回for循环开头。
2.4.3 wait和waitpid函数
//Ubuntu:/usr/include/x86_64-linux-gnu/sys/wait.h
#include <sys/wait.h>
pid_t wait(int *statloc);
pid_t waitpid(pid_t pid,int *statloc,int options);
//返回:若成功返回进程ID,若失败返回0或-1
两个函数均返回两个值:已终止子进程的进程ID号,以及通过statloc指针返回的子进程状态。可以通过宏来确认子进程是正常终止、由某信号杀死还是仅仅由作业控制停止而已。还有些宏可以获取子进程的退出状态、杀死子进程的信号值或停止子进程的作业控制信号值,其中WIFEXITED和WEXITSTATUS可以完成这些功能。
wait和waitpid的区别
(1)调用wait的子进程没有已终止的子进程,不过有一个或多个子进程仍在执行,那么wait将阻塞扫现有子进程第一个终止为止。
(2)waitpid函数就等待哪个进程以及是否阻塞给了我们更多的控制。pid可以指定想等待的进程ID,值-1表示等待第一个终止的进程。options参数运行我们指定附加选项,最常用的是WNOHANG,它告知内核在没有已终止子进程时不要阻塞。
调用wait可能出现的问题:
现在我们让客户和服务器建立多个套接字,然后一起关闭
//tcpcliserv/tcpcli04.c-unp源文件中的位置
#include "unp.h"
int main(int argc, char **argv)
{
int i, sockfd[5];
struct sockaddr_in servaddr;
if (argc != 2)
err_quit("usage: tcpcli <IPaddress>");
for (i = 0; i < 5; i++) {
sockfd[i] = Socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(SERV_PORT);
Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);
Connect(sockfd[i], (SA *) &servaddr, sizeof(servaddr));
}
str_cli(stdin, sockfd[0]); /* do it all */
exit(0);
}
客户连接服务器示意图:
客户端口与服务器的连接示意图:
运行客户与服务器得到如下结果:
hujinrun@ubuntu:~/Documents/unpv13e/tcpcliserv$ ./tcpserv03 &
[1] 1909
hujinrun@ubuntu:~/Documents/unpv13e/tcpcliserv$ ./tcpcli04 127.0.0.1
hujinrun
hujinrun
child 1911 terminated
child 1912 terminated
child 1913 terminated
可以看到只有三个子进程被终止发出了SIGCHLD信号。使用ps查下以下进程,结果如下
PID TTY TIME CMD
1888 pts/0 00:00:00 bash
1909 pts/0 00:00:00 tcpserv03
1914 pts/0 00:00:00 tcpserv03 <defunct>
1915 pts/0 00:00:00 tcpserv03 <defunct>
2036 pts/0 00:00:00 ps
可以看到有两个子进程处于僵尸状态。本问题的根本原因是调用wait时信号不排队,当信号处理函数执行时发生的信号很可能被忽略。而且本程序被终止的子进程的数目是未定的,要看实际运行的情况。如果是在不同主机运行,很大程度上依赖与FIN达到的速度。
解决这个问题的方法是采用waitpid代替wait,可以采用下面的形式:
#include "unp.h"
void
sig_chld(int signo)
{
pid_t pid;
int stat;
while ( (pid = waitpid(-1, &stat, WNOHANG)) > 0)
printf("child %d terminated\n", pid);
return;
}
此函数能解决信号被忽略的根本原因是:waitpid可以获取所有终止子进程的状态,且当没有终止进程时函数不阻塞,不影响父进程。采用waitpid程序运行的结果
hujinrun@ubuntu:~/Documents/unpv13e/tcpcliserv$ ./tcpserv04 &
[1] 2175
hujinrun@ubuntu:~/Documents/unpv13e/tcpcliserv$ ./tcpcli04 127.0.0.1
hujinrun
hujinrun
child 2177 terminated
child 2178 terminated
child 2179 terminated
child 2181 terminated
child 2180 terminated
网络编程使用wait(waitpid)的三种情况:
(1)当fork子进程时,必须捕获SIGCHLD信号。
(2)当捕获信号时,必须处理被中断的系统调用。
(3)SIGCHLD的信号处理函数需要使用waitpid函数以免留下僵尸进程。
3.TCP连接的各种错误的原理及应对方法
3.1 accept返回前连接中止
处理这种中止连接的方式是多种多样的。有些系统在内核处理,有些系统则返回指定的错误。而POSIX规定需要产生ECONNABORTED错误。产生此错误的原因某些流子系统发生致命的错误会产生EPROTO错误,此错误是非致命的,需要服务器忽略,所以产生一个不同的错误。
考虑到移植性的问题,可以通过select函数和正常阻塞模式下的监听套接字组合解决这个问题。
3.2 服务器进程终止
服务器的进程终止是指服务与客户的服务器子进程而不是服务器主进程。服务器子进程的终止导致与子进程有关的描述符被关闭。服务器会像客户发送一个FIN,而客户TCP则响应一个ACK。服务器的子进程会发送SIGCHLD信号给服务器父进程,子进程得以正确关闭。而此时客户的进程阻塞在fgets的调用,当客户输入文本之后,客户会向一个已经关闭的套接字上发送数据。这就是问题的所在。下面模拟这个过程。
hujinrun@ubuntu:~/Documents/unpv13e/tcpcliserv$ ./tcpserv01 &
[1] 3104
查看进程的PID和PPID
hujinrun@ubuntu:~/Documents/unpv13e/tcpcliserv$ ps -ef
UID PID PPID C STIME TTY TIME CMD
hujinrun 3092 3060 0 06:08 pts/1 00:00:00 bash
hujinrun 3104 3070 0 06:09 pts/0 00:00:00 ./tcpserv01
hujinrun 3106 3092 0 06:09 pts/1 00:00:00 ./tcpcli01 127.0.0.1
hujinrun 3107 3104 0 06:09 pts/0 00:00:00 ./tcpserv01
hujinrun 3110 3070 0 06:10 pts/0 00:00:00 ps -ef
可以看到PID为3107的进程的PPID为3104,所以进程3107为子进程。将子进程kill
hujinrun@ubuntu:~/Documents/unpv13e/tcpcliserv$ kill 3107
hujinrun@ubuntu:~/Documents/unpv13e/tcpcliserv$ ps
PID TTY TIME CMD
3070 pts/0 00:00:00 bash
3104 pts/0 00:00:00 tcpserv01
3107 pts/0 00:00:00 tcpserv01 <defunct>
4124 pts/0 00:00:00 ps
hujinrun@ubuntu:~/Documents/unpv13e/tcpcliserv$ netstat -a | grep 9877
tcp 0 0 0.0.0.0:9877 0.0.0.0:* LISTEN
tcp 0 0 localhost:9877 localhost:58792 FIN_WAIT2
tcp 1 0 localhost:58792 localhost:9877 CLOSE_WAIT
可以看到此时服务器的通信套接字处于FIN_WAIT2状态,而客户的套接字处于CLOSE_WAIT状态。
我们在客户上键入文本可以得到以下结果
hujinrun@ubuntu:~/Documents/unpv13e/tcpcliserv$ ./tcpcli01 127.0.0.1
hujinrun
str_cli: server terminated prematurely
使用tcpdump抓包,可以得到
hujinrun@ubuntu:~/Documents/unpv13e/tcpcliserv$ sudo tcpdump -i lo
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on lo, link-type EN10MB (Ethernet), capture size 262144 bytes
07:15:55.301748 IP localhost.58854 > localhost.9877: Flags [P.], seq 491423636:491423645, ack 3859145544, win 512, options [nop,nop,TS val 1065501965 ecr 1065457322], length 9
07:15:55.301761 IP localhost.9877 > localhost.58854: Flags [R], seq 3859145544, win 0, length 0
可以看到客户发送数据到服务器时,服务器回应RST。
下面将以下客户为何会出现错误:
当服务器TCP接收到来自客户的数据时,既然先前打开那个套接字的进程已经终止,于是响应以一个RST。然而客户进程看不到这个RST,因为它在调用writen后立即调用readline,并且由于前面接收的FIN,所调用的readline立即返回0(表示EOF)。我们的客户此时并未预期收到EOF,于是以出错信息“server terminated prematurely”(服务器过早终止)退出。
本节的根本问题是当FIN到达套接字时,客户阻塞在fgets调用上。
更进一步深入研究,如果在客户收到RST之后再次写入数据会发生什么呢?当一个进程向某个已收到RST的套接字执行写操作时,内核向该进程发送一个SIGPIPE信号。该信号会终止进程。
改写客户程序
#include "unp.h"
void
str_cli(FILE *fp, int sockfd)
{
char sendline[MAXLINE], recvline[MAXLINE];
while (Fgets(sendline, MAXLINE, fp) != NULL) {
Writen(sockfd, sendline, 1);
sleep(1);
Writen(sockfd, sendline+1, strlen(sendline)-1);
if (Readline(sockfd, recvline, MAXLINE) == 0)
err_quit("str_cli: server terminated prematurely");
Fputs(recvline, stdout);
}
}
运行结果
hujinrun@ubuntu:~/Documents/unpv13e/tcpcliserv$ ./tcpserv01 &
[1] 4215
hujinrun@ubuntu:~/Documents/unpv13e/tcpcliserv$ ./tcpcli11 127.0.0.1
hujinrun
hujinrun
hi
hi是在服务器子进程被kill之后发出的,这之间导致了客户进程的结束,并且没有任何提示。有的系统会在shell中提示"Broken pipe"。
3.3 服务器主机崩溃
客户发送的数据会被多次重传并且无法收到应答。如果客户发送的分节一直无法得到回应,则会返回ETIMEOUT错误。如果中间路由器判断主机不可达则会返回EHOSTUNREACH或ENETUNREACH错误。
等待的时间可能过长,我们可以给阻塞的readline设置超时或使用SO_KEEPALIVE套接字选项。
3.4 服务器主机崩溃重启
当服务器主机崩溃后重启,它将失去前面所有的连接信息。因此服务器以一个RST来响应客户数据。而此时客户正阻塞于readline,所以readline会返回ECONNRESET错误。
可以利用SO_KEEPALIVE套接字选择对服务器主机的崩溃进行检测。