本文主要为对UNP第五章部分内容的实验和总结。
UNP第五章对一个echo服务器和客户端在各种连接状态下的表现做了详细的分析,包括了:
- 正常启动和终止;
- accept返回前连接中止;
- 服务器进程终止;
- 客户进程忽略读错误继续写数据;
- 服务器主机崩溃;
- 服务器主机崩溃后重启;
- 服务器主机关机。
连接模型是最简单的TCP连接模型:
程序代码基本以UNP中提供代码为主。服务器采用图5-12、图5-11和图5-3中的代码;客户端采用图5-4和图5-5中的代码。为简化分析不采用UNP中客户端连续向服务器发起多个连接的模型。
#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;
}
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");
}
int
main(int argc, char **argv)
{
int listenfd, connfd;
pid_t childpid;
socklen_t clilen;
struct sockaddr_in cliaddr, servaddr;
void sig_chld(int);
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);
Signal(SIGCHLD, sig_chld); /* must call waitpid() */
for ( ; ; ) {
clilen = sizeof(cliaddr);
if ( (connfd = accept(listenfd, (SA *) &cliaddr, &clilen)) < 0) {
if (errno == EINTR)
continue; /* back to for() */
else
err_sys("accept error");
}
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 */
}
}
#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);
}
}
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);
}
本文主要以tcpdump(抓包写文件然后用wireshark查看)、netstat、ps等工具观察上述几种状态。程序运行在Ubuntu 14.04上,有需要时会采用另一台主机(Raspberry Pi)。服务器端口为9877。
一、正常启动和终止
正常TCP连接序列如下图所示:
服务器程序正常启动后,netstat观察到进程在9877端口监听。
随后在同一台主机上启动客户端进程发起连接,ps可以观察到服务端fork出一个子进程2665:
客户端发送数据0123456789,可以观察到服务端程序正常回射了该段数据。
此时netstat观察到连接的两端都已是established状态,客户端中内核分配了端口号50712。
最后客户端进程ctrl-d发送EOF终止连接,完成终止序列后,客户进程和服务端子进程退出,服务端父进程接到SIGCHILD信号,提供waitpid操作,回收了相关资源:
至此一次连接发起、数据传输、连接终止的过程结束。下面是tcpdump抓取的数据包:
对抓包结果的解释:
- 包1-3是TCP三次握手的序列,SYN消耗一个序列号;
- 包4-7是数据回射的过程,客户端向服务端PUSH数据,服务端ACK确认,然后服务端回射,向客户端PSH,客户端ACK确认。Ack的值是接受到的包的Seq+1,表示Seq及其以前的数据都已接收到,现在要求对方发送Seq+1号报文;
- 包8-10是TCP终止序列,可以看到第9个包中,服务端的FIN以及对客户端FIN的确认放在了一个包里。FIN也消耗一个序列号。
在连接终止后短时间内用netstat查询,可以观察到客户端处于time_wait状态。
二、accept返回前连接中止
此种情况相对复杂,暂时略过不表。
三、服务器进程终止
在客户端和服务器完成连接后,杀死服务器进程。这是在模拟服务器进程崩溃的情形。
建立连接和正常回射数据的过程和一中描述一致。
注意到现在服务器进程号为1893和1895,1895是1893的子进程,也是和客户端相连接的进程。
采用kill命令发送SIGINT到1895进程,即可杀死该进程。SIGCHILD信号被发送给父进程,并得到正确的处理。
现在我们来查看一下tcpdump的抓包结果:
服务端已经向客户端发送了FIN,客户端响应以一个ACK。但是此时在应用层上的客户端并没有任何响应,原因是它阻塞在了fgets上,正在等待从终端接受一行文本。
现在来观察一下netstat的输出:
服务器进程在发送FIN并接收ACK后进入了fin_wait2状态,而客户端进程则进入了close_wait状态。TCP终止序列的前半部分已经完成了。
这个时候再在客户端上键入文本,文本并没有正常回射,预设的信息被输出:
出现这个情况的原因是客户端进程在fgets接收到终端的输入后将数据通过writen写入socket,然后立即调用readline,而由于客户端已经收到FIN(在应用层表现为EOF),readline立即返回0,于是错误信息被输出。
而在服务端,接收到数据时,由于之前打开该套接字的进程已经终止,于是响应以一个RST。该RST不会被客户端感知到(UNP中还有对其时序的讨论,一般情况下都应该是先进入readline,而后RST才到达)。
下面是tcpdump的完整输出。
- 1-3是连接序列,4-7是正常回射;
- 8-9是服务器进程终止时发送的FIN以及客户端的确认;
- 10是客户端继续向套接字发送数据;
- 11是服务器的RST回应。
UNP中指出问题的原因出在客户端接收到FIN时正阻塞在fgets上,而此时客户端其实在应对终端和套接字两个输入,因此只阻塞在一个源上是错误的。这也是select、poll等多路复用函数的目标之一。
四、客户进程忽略读错误继续写数据
当一个进程向某个已经收到RST的套接字执行写操作时,内核向该进程发送一个SIGPIPE信号。因此,若客户端忽略readline返回的错误,继续写数据入套接字,因第一次写操作已经引发RST,那么第二次写则会触发SIGPIPE信号。
将str_cli函数改成如下所示:
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);
}
}
第一次writen将触发RST,第二次writen会触发SIGPIPE。
为了确定我们的确引发了SIGPIPE信号,我们在客户端中尝试捕获该信号,添加如下代码:
// inside echoclient
void sig_pipe(int signo){
printf("SIGPIPE captured!\n");
}
// and inside main
Signal(SIGPIPE, sig_pipe);
接下来就是启动服务器,启动客户端发起连接,发送数据验证连接,然后用kill将服务器进程杀死的过程,和前面所述的是基本一样的。
服务器进程被杀死后,在客户端上继续发送数据,此时会出现如下情况:
可以看到,SIGPIPE确实被触发了。
这里有一点需要说明的是,在我进行测试的系统上,如果不设置信号处理函数sig_pipe,那么客户端进程接收到SIGPIPE后的动作是直接退出,并不会输出”written error: Broken pipe”的字样,这是SIGPIPE的默认行为。在设置了处理函数后,该信息被输出,因为SIGPIPE已被处理,不再终止进程,此时终止进程的是writen触发的错误。
tcpdump的结果如下:
- 1-3仍然是连接建立;
- 4-10是数据传输,由于对一行数据分开了两次发送,因此出现了两次传输的过程。值得注意的是第9个包,该包是服务器进程将对发送过来的数据的确认以及回射的数据同时发送回了客户端。
- 客户端进程对回射回来的数据的表现并不是分两次显示的。也就是说,并不会先输出第一个字符,再输出剩下的字符,而是所有字符同时显示的。这应该与系统的缓冲机制有关,标准输出一般是行缓冲。
- 11-14是结束的过程,(二)中已经解释过了。
五、服务器主机崩溃
接下来我们恢复正常的代码,来实验服务器主机崩溃的状况。
主机崩溃的与关机不同,因为现代操作系统在关机往往会关闭进程打开的所有文件描述符,导致进程发送一个FIN;与进程崩溃也不同,因为仅进程崩溃而主机仍在正常运行时,客户端发来数据,虽然与客户端连接的进程已经崩溃,但主机仍会回应一个RST给客户端。
主机崩溃与网络连接中断类似,都是客户的数据已无法送至主机,且主机也无法给客户任何响应,因此可以用直接断开以太网连接,也即拔网线的方式模拟主机崩溃的状况。
进行这一实验以及下面两个实验都需要服务器程序和客户端程序运行在不同的主机上,因此除以上实验使用的Ubuntu主机外,加入另一台主机(Raspberry Pi),使用Raspberry Pi作为服务端主机,Ubuntu作为客户端主机。两台主机在同一局域网内,服务端主机IP为172.18.217.188,客户端主机IP为172.18.217.80。
此外,由于ARP会定时更新地址解析表,而网线的断开将导致服务器无法对ARP查询进行响应,因此我们提前将服务器IP和MAC地址的对应关系以静态的方式写入地址解析表中,否则客户端会由于无法确定服务器MAC地址而终止重传。
我们分别在两个主机中启动服务器程序和客户端程序,客户端向服务器发起连接,并发送数据验证连接正常建立。
接下来,拔掉网线断开服务器主机和以太网的连接。此时客户端程序对其没有任何感知。使客户端继续向服务器发送数据,并不会得到回应。在本次实验中,客户端在过了大约15分钟后才有所相应,客户端输出connection timed out,然后终止退出。
观察tcpdump的结果(去掉了一些不相关的包):
- 1-9是正常的连接建立和数据发送的过程,如前所述;
- 在第10个包发送前断开了连接,第10个包无法送达服务端;
- 从11-32可以看到在800多秒的时间内,客户端将同样的数据重传了16次,仔细观察重传的时间间隔,可以发现tcp执行了指数退避的拥塞控制算法。
六、服务器主机崩溃后重启
主机崩溃后重启,将丢失原有的TCP连接信息,那么当客户端再向服务器发送数据时,服务器会响应以一个RST,而此时客户端正阻塞在readline上,那么readline会返回错误,客户端程序退出。
我们采用先断开连接然后重启服务器的方式来模拟这种状况。客户端的输入如下:
tcpdump的结果如下(去掉不相关的包):
- 前7个包不再解释;
- 在服务器完成重启后,第36个包客户端发送数据至服务器,服务器由于以丢失连接信息,直接响应以RST。
七、服务器主机关机
Unix系统关机时,init进程通常先给所有进程发送SIGTERM信号(该信号可被捕获),等待一段固定的时间(往往在5~20秒),然后给所有仍在运行的进程发送SIGKILL信号(该信号不能被捕获)。因此接下来的结果和(三)中讨论的是基本一致的。区别在于因为服务器关机,不会再接到客户端发来的最后的数据,当然也不会响应以RST。
总结
在Unix系统中应用层的程序的表现和是直接与TCP/IP协议挂钩的,系统函数的调用会根据网络不同而产生不同的返回值或结果。了解在应用层怎样的操作将导致网络层中怎样的通信动作,以及可能产生的后果,对于网络编程来说是最基础的内容。
原文:https://www.cnblogs.com/caiminfeng/p/6501759.html
===============================================================================================
本文是UNP复习系列的第二篇,主要包括了以下几个内容
- UNIX系统下5种I/O模型
- 阻塞、非阻塞,同步、异步
- epoll函数用例
一、Unix下的五种可用I/O模型
-
阻塞式I/O模型
阻塞式I/O是最简单的I/O模型。也是系统默认的I/O模型。
图中采用了
recvfrom()
,使用TCP时候的read()
时也是类似的。read()
或者recvfrom()
被作用于阻塞的文件描述符时,直到数据报到达且被复制到应用进程的缓冲区种或者发生错误时才返回,最常见的错误时被信号中断。 -
非阻塞式I/O模型
非阻塞式I/O是在实际项目中最常用的I/O模型。对于很多进程或者线程而言,采用阻塞的I/O,有可能将该进(线)程阻塞在一个I/O操作上,这样的做法是不明智的。
On error, -1 is returned, and errno is set appropriately.
EAGAIN The file descriptor fd refers to a file other than a socket and has been marked nonblocking (O_NONBLOCK), and the read would block. See open(2) for further details on the O_NONBLOCK flag.
EAGAIN or EWOULDBLOCK The file descriptor fd refers to a socket and has been marked nonblocking (O_NONBLOCK), and the read would block.
以上内容引自
read()
的man page。使用非阻塞I/O时,若没有数据立即可读,那么调用会立即返回错误,并且errno被设为EAGAIN或EWOULDBLOCK。对一个非阻塞的描述符反复调用read()
或recvfrom()
被称为轮询(polling)。轮询往往和多路复用配合使用。 -
I/O复用模型
主要是指采用
select()
,poll()
,epoll()
(Linux),kqueue()
(BSD)等复用函数对多个描述符进行监听的方式。当采用这种方式时,进程阻塞在这些函数上,当注册于这些函数中的描述符允许读或写时函数返回。在实践中,
select()
、poll()
由于性能及易用性等问题,已较少采用,大多数多路复用的场景都被epoll()
函数取代,而带来的问题则是可移植性下降,因为epoll()
只存在于Linux平台上。 -
信号驱动式I/O模型
使用信号,让内核在描述符就绪式发送SIGIO通知进程读取的方式。
这种模型的优势在于等待数据报到达的器件进程不会被阻塞,主循环可以继续执行。
-
异步I/O模型
告知内核启动某个操作,并让内核在整个操作(包括将数据藏内核复制到我们自己的缓冲区)完成后通知我们。
这种模型的主要区别是不再需要调用
read()
或recvfrom()
进行读取操作,只需向异步I/O函数传入缓冲区指针和大小,内核将在负责把数据读入缓冲区,而后通知进程。这种模型应用很少,各种系统对其支持也不充分。这里补充一点内容。在较老的服务端模型中,采用阻塞I/O,每个连接被一个或多个线程管理是常见的方式。连接数量的增加就意味着线程数量的增加。这种模型对于几百上千的连接是可以胜任的,但对于更大量的连接,线程数量的增加导致线程间切换的开销也开始加大,耗费的内存等资源也开始增多,这种模型开始无法承受负担。
多路复用加上非阻塞I/O的方式则允许一个线程处理多个不同的连接,对多个描述符进行监听,并且只在描述符准备就绪时才对其进行处理。这种模型大大减少了非活跃线程的数量,也即减少了资源的开销。而由于一个线程中同时处理了多个描述符,那么使用阻塞I/O则可能使线程阻塞在其中一个描述符上,使得其它描述符无法得到处理。因此需要使用非阻塞的I/O。
二、阻塞和非阻塞,同步和异步
要注意阻塞/非阻塞和同步/异步的概念的区别。
POSIX把这两个属于定义如下:
同步I/O操作(synchronous I/O operation)导致请求进程阻塞,直到I/O操作完成;
异步I/O操作(asynchronous I/O operation)不导致请求进程阻塞。
前四种模型的主要区别在于第一阶段,其第二阶段是一样的:在数据从内核复制到调用者的缓冲区期间,进程阻塞于
recvfrom()
调用。相反,异步I/O模型在这两个阶段都要处理,从而不同于其它四种模型。
阻塞不等于同步,非阻塞不等于异步,如在第二种模型中,采用的是非阻塞的I/O,但仍属于同步模型。
同步异步区分的关键在于将数据从内核空间复制回应用进程空间的这一过程是否阻塞进程。不管是阻塞还是非阻塞的I/O,当有数据可供消费时,在调用recvfrom()
或read()
将数据从内核复制回应用进程空间的这个过程中,该进(线)程是阻塞的,是被挂起的,因此他们都是同步的。只不过非阻塞I/O在无数据时,会立即返回,而非阻塞I/O不会。而异步的I/O连将数据复制到进程空间的这个过程,都不阻塞进程,而直接由内核完成,之后再以别的形式通知进程。
讲清楚这点应该就较好理解UNP种所说的:
根据上述定义,我们的前4种模型都是同步I/O模型,因为其中真正的I/O操作(recvfrom)将阻塞进程。只有异步I/O模型与POSIX定义的异步I/O相匹配。
三、epoll()
用法总结
UNP中给出了select()
和poll()
的用法,这里不再重复。epoll()
从Linux 2.5.44开始引入,目的是为了替代select()
和poll()
,达到更高的性能,实现更高的并发。这里总结一下它的用法。
man pages:
典型用法:
epoll_create()
或epoll_create1()
创建一个epoll实例- 设置需要监听的epoll_event中的事件类型以及描述符,并将其用
epoll_ctl()
添加到epoll实例中 - 申请一个epoll_event数组,作为存放返回的活跃事件的容器
- 调用
epoll_wait()
,传入epoll_event数组,阻塞等待 - 当
epoll_wait()
返回时,返回值为epoll实例中活跃的事件个数,并且这些事件被写入到传入的epoll_event数组的前几个中。 - 处理活跃事件
- 返回第4步
epoll_event的定义如下:
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
电平触发和边缘触发
- 若用作电平触发(默认),那么
epoll
就只是一个速度更快的poll
,它们的语法基本兼容,epoll
可使用于任何poll
的使用场景。 - 若使用了EPOLLET标志,那么epoll被用作边缘触发。采用边缘触发方式的描述符应该使用非阻塞I/O,以避免监听多个描述符的进程阻塞在其中一个描述符的
read()
或write()
操作中。 - 边缘触发的建议使用方式是(I)采用非阻塞描述符(II)等待
read()
或write()
返回EAGAIN。
程序示例
#include <stdio.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <errno.h>
#define MAX_EVENTS 10
#define SERV_PORT 9877
#define BUFFER_SIZE 1024
int get_listen_fd();
void set_non_blocking(int fd);
void do_use_fd(int fd);
int main()
{
struct epoll_event ev, events[MAX_EVENTS];
int listen_sock, conn_sock, nfds, epollfd;
int n, addrlen;
struct sockaddr_in addr;
epollfd = epoll_create1(0);
listen_sock = get_listen_fd();
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = listen_sock;
epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev);
for(;;){
nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
for(n = 0; n <nfds; ++n){
if(events[n].data.fd == listen_sock){
for(;;){
conn_sock = accept(listen_sock, (struct sockaddr *) &addr, &addrlen);
if(conn_sock == -1){
if(errno != EAGAIN && errno != EWOULDBLOCK)
perror("accept");
break;
}
set_non_blocking(conn_sock);
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = conn_sock;
epoll_ctl(epollfd, EPOLL_CTL_ADD, conn_sock, &ev);
}
}
else{
do_use_fd(events[n].data.fd);
}
}
}
return 0;
}
int get_listen_fd(){
int listen_fd;
struct sockaddr_in serv_addr;
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_port = htons(SERV_PORT);
bind(listen_fd, (struct sockaddr*) &serv_addr, sizeof(serv_addr));
set_non_blocking(listen_fd);
listen(listen_fd, 10);
return listen_fd;
}
void set_non_blocking(int fd){
int s;
s = fcntl(fd, F_GETFL, 0);
s |= O_NONBLOCK;
fcntl(fd, F_SETFL, s);
}
void do_use_fd(int fd){
int done = 0;
for(;;){
int count;
char buf[BUFFER_SIZE];
count = read(fd, buf, sizeof(buf));
if(count == 0){
done = 1;
break;
}
else if(count == -1){
if(errno != EAGAIN){
perror("read");
done = 1;
}
break;
}
else{
write(STDOUT_FILENO, buf, count);
}
}
if(done){
printf("Closed connection on descriptor %d\n", fd);
close(fd);
}
}
示例说明
- listen socket 在本例中被设为边缘触发,且是非阻塞的,那么在返回
epoll_wait()
等待前必须将所有到达的新连接全部处理了,直到EAGAIN被触发; - 本例中的
do_use_fd()
也是类似的,边缘触发、非阻塞,返回等待前必须保证所有到达数据被处理; - 一个描述符被关闭,将导致epoll自动将该描述符对应的事件移除。不过要注意
dup2()
或fork()
等对描述符形成的复制。只有所有引用某个文件的描述符都被关闭了,epoll才会将相应的事件移除。(详见epoll的man page,Q6)
总结
几种I/O模型、阻塞/非阻塞常常是在服务器编程选型时候需要考虑的总点,本篇是相关基础知识的一个总结。熟悉相关的API和用法也是在编程时候提高效率,避免踩坑的必要条件。