为什么80%的码农都做不了架构师?>>>   什么是NIO-编程知识网

1. OSI模型及TCP

1.1 OSI模型

I/O是计算机获取、交换信息的主要渠道,它涵盖的含义实际上很广,包含文件IO、网络IO等。在网络中,客户端和服务器通信、应用程序之间的互相通信等都离不开网络间消息报文的传输交换。消息报文在网络间交换的时候,一般需要按照某个网络协议进行通信。实际上,网络通信会涉及到多个网络协议层:应用层、表示层、会话层、传输层、网络层、数据链路层和物理层,被国际标准化组织称之为OSI模型。
在OSI模型中,物理层和数据链路层一般是系统提供的设备驱动程序和网络硬件,网络层则由IPv4或者IPv6协议来处理,这3层加上传输层,是属于操作系统内核层面的。而顶上三层可以统一合并为应用层,可以理解为属于用户进程层面的协议。

什么是NIO-编程知识网

在用户进程层面的应用程序互相通信,可以直接调用应用层的通信协议,比如http协议等进行通信,应用程序不需要了解内核层面如何操作;应用程序也可以通过内核提供的套接字API通信。套接字API在操作系统层面实现了对TCP协议的封装。所以,我们想要搞清楚阻塞和非阻塞I/O,就要调用套接字API进行网络I/O通信。那么,就必须先了解TCP协议以及它如何进行3次握手协议进行连接的建立。

1.2 TCP及TCP连接的建立

传输层的通信协议,主要包括TCP、UDP、SCTP等,但是绝大多数的网络应用都使用TCP或UDP。其中,TCP被称为传输控制协议,它提供客户端和服务端之间建立可靠连接功能。不仅如此,TCP还可以保证报文的顺序到达、重复报文丢弃、丢失报文重新发送、报文拥堵控制等。
我们都知道,TCP是通过三次握手协议建立客户端和服务器端的连接,另外操作系统又为我们封装了基于TCP的套接字API。所以,应用程序可以通过如下步骤,利用套接字API实现客户端和服务器端连接的建立:

什么是NIO-编程知识网

1、服务端通过调用socket、bind、listen做好接受连接的准备。

2、客户端通过调用connect发起主动连接。客户端在建立连接的时候,会发送一个同步的数据报文,该报文只包括IP数据报等基础信息。

3、服务器必须发送ACK报文确认客户端的SYN,同时也发送一个SYN报文

4、客户端确认服务器端的SYN。

如上图的socket、bind、listen、connect等函数,就是由操作系统内核提供的套接字API。通过以上步骤,我们就通过了3次握手建立了连接。关于TCP连接,还有很多的内容值得探讨,不过我们本篇文章主要是为了说明NIO,所以点到为止,先介绍到这里,后面有时间专门开篇文章探讨关于TCP的其他内容。

2. 网络I/O模型

在网络上,我们经常会看到关于网络I/O模型的讲解,并且给出一些图片,试图用图片来说明I/O模型。但是毕竟这些概念比较抽象,仅凭一张图,是无法彻底表达清楚的。下面将根据我对这些概念的理解,结合一些简单的代码,配合模型图,来描述各个网络I/O模型。在进行各个网络模型探讨之前,我们需要先了解一段客户端发送消息,服务端接收到消息,并把该消息再返回给客户端的代码,后面的内容我们将根据这段代码进行讨论。

服务端代码如下:

#include    "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);  // 设置监听地址和端口的sockaddr_in结构体bzero(&servaddr, sizeof(servaddr));  servaddr.sin_family      = AF_INET;  servaddr.sin_addr.s_addr = htonl(INADDR_ANY);  servaddr.sin_port        = htons(SERV_PORT);  // 绑定监听文件描述符listenfd和上面初始化好的sockaddr_in结构体Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));  // 启动监听Listen(listenfd, LISTENQ);  // 服务端一直运行,接受客户端的连接for ( ; ; ) {  clilen = sizeof(cliaddr);  // 接受客户端连接并获取连接文件描述符if ( (connfd = accept(listenfd, (SA *) &cliaddr, &clilen)) < 0) {  if (errno == EINTR)  continue; else  err_sys("accept error");  }  // 创建子进程if ( (childpid = Fork()) == 0) {Close(listenfd); // 在子进程中进行回传接收到的客户端消息str_echo(connfd); exit(0);  }// 关闭连接Close(connfd); }  
}  str_echo(int sockfd)  
{  ssize_t  n;  char  buf[MAXLINE];again:  // 从连接文件描述符sockfd中读取客户端发送的消息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                 sockfd;  struct sockaddr_in  servaddr;   // 创建套接字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);exit(0);  
}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);  }  
}

2.1 阻塞I/O

经过上面的讨论,我们已经知道,在应用程序层面执行通信的时候,是需要调用操作系统内核的。如果我们的应用程序发送系统调用命令之后,就一直阻塞等待,那么这种情况就是阻塞I/O。结合前面的实例代码while (Fgets(sendline, MAXLINE, fp) != NULL),客户端将阻塞于Fgets函数的调用,套接字上即便发生什么事件,客户端也不能做及时的处理。所以,上面的客户端代码就是典型的阻塞I/O。阻塞I/O可以使用下图进行描述:

什么是NIO-编程知识网

同时需注意,在图的右边部分,内核中,其实分两个阶段,一个是无数据报准备好—— > 数据报准备好;另一个是复制数据报文 —– > 复制完成。因为上面示例代码的客户端程序阻塞于Fgets调用,所以,在内核中的这两个阶段,客户端应用程序是无感知的,客户端程序对这两个事件并不能做出及时处理。

同样的,对于上面的示例代码,因为在while循环中执行了Fgets,造成了从套接字连接中读取服务端的数据,阻塞于从控制台读取输入字符串的Fgets。而实际上,它们是可以独立开来,互相不受影响的。下面,我们就使用select函数修改示例代码,让其不再阻塞于Fgets。

2.2 阻塞I/O之select阻塞

上面我们知道str_cli函数阻塞于Fgets,那么,如何进行改造呢?首先,我们需要认识一个函数:select。该函数函数允许进程将其关心的描述符注册给操作系统内核,如果这些描述符有什么事件发生,则由操作系统将其标出。用户进程通过轮询FD_ISSET函数,判断是否有关注的事件发生。

#include    "unp.h"  void str_cli1(FILE *fp, int sockfd)  
{  // 定义相关变量并初始化fd_setint maxfdp1;fd_set rset;char sendline[MAXLINE], recvline[MAXLINE];  FD_ZERO(&rset);for ( ; ; ) {FD_SET(fileno(fp), &rset);FD_SET(sockfd, &rset);maxfdp1 = max(fileno(fp), sockfd) + 1;// 将关注的套接字连接描述符sockfd 和 输入文件描述符fileno(fp)注册给系统内核Select(maxfdp1, &rset, NULL, NULL, NULL);// 判断套接字连接描述符sockfd是否有读事件发生if (FD_ISSET(sockfd, &rset)) {if (Readline(sockfd, recvline, MAXLINE) == 0) err_quit("str_cli");// 套接字有读事件发生,则输出到控制台Fputs(recvline, stdout);}// 判断输入文件描述符fileno(fp)是否有读事件发生if (FD_ISSET(fileno(fp), &rset)) {if (Fgets(sendline, MAXLINE, fp) == null) return;// 能从控制台读到数据,则发送给服务器Writen(sockfd, sendline, strlen(sendline));}}
}  

经过以上代码的改造,我们已经将str_cli程序修改为阻塞于select调用,或是等待标准输入可读,或是等待套接字可读。但是,上面的代码仍然是阻塞的。举例来说,如果在标准输入有一行文本可读,我们调用Fgets读入,再调用Writen写入socket发送到服务器。但是如果套接字发送缓冲区已经满了,Writen函数将会阻塞。类似,如果从套接字有一行文本可读,一旦标准输出比网络还慢,进程一样会阻塞于Fputs调用。
解决这个问题的办法,就是使用非阻塞IO,我们下小节探讨,现在我们先注意以下select函数。需要知道的是,select函数也可以用在服务器端。在服务器端将已经连接的套接字连接描述符集合注册给服务器内核,由内核标志出来这些套接字连接符的事件,然后服务器端代码进行轮询,即可实现IO复用。见图:

什么是NIO-编程知识网

2.3 非阻塞I/O

上面几节,我们通过示例代码分别讨论了阻塞I/O,非阻塞I/O,I/O多路复用,那么怎么样才能做到非阻塞I/O呢?要想搞清楚这个问题,首先我们得明白,我们前面的阻塞IO,到对阻塞到了哪里。上面两个例子,一个是阻塞于从控制台读取数据的Fgets函数,一个是阻塞于select函数。那么,怎么样让他们不阻塞呢?先看一张非阻塞IO的图示:

什么是NIO-编程知识网

图片来源于《Unix网络编程》,由图片可知,所谓的非阻塞IO,其实就是用户进程在和内核进行交互的时候,实时得到内核的反馈消息,而不是一直等待内核返回最终的结果。因此,我们可以把系统调用的各个阶段,划分为更细的调用过程,在每个更细分的调用过程中,同步得到系统的反馈。举例来说,我们在读取标准输入字符串,并发送给服务器这两个过程,就可以不断细分,划分为如下过程

什么是NIO-编程知识网

以上是表示从标准输入发送到套接字的数据的缓冲区。其中tooptr指针指向将要写入到套接字的下一个字节,toiptr表示从标准输入读入的数据可以存放的下一个字节。

什么是NIO-编程知识网

以上表示从套接字到标准输出的数据的缓冲区

原本呢,我们只需要调用

Fputs(recvline, stdout);

Writen(sockfd, sendline, strlen(sendline));

即可完成的操作,现在我们通过更细的划分,不仅精确到读写缓冲区,同时利用select函数,将STDIN_FILENO和STDOUT_FILENO注册到内核,轮询其事件的发生。

如果toiptr < &to[MAXLINE],说明是从stdin读取数据,开启内核对STDIN_FILENO读写事件的检测;

如果friptr < &fr[MAXLINE],说明是从socket读取数据,开启内核对sockfd读写事件的检测;

如果tooptr != toiptr,说明在缓冲区还存在写入套接字的数据,开启内核对sockfd读写事件的检测;

如果froptr != friptr,说明缓冲区还存在输出到标准输出的数据,开启内核对STDOUT_FILENO读写事件的检测;

通过以上轮询内核中STDIN_FILENO、STDOUT_FILENO、sockfd的读写事件,再通过代码来判断,就可以避免select阻塞中遇到的问题。

void str_cli(FILE *fp, int sockfd)
{int			maxfdp1, val, stdineof;ssize_t		n, nwritten;fd_set		rset, wset;char		to[MAXLINE], fr[MAXLINE];char		*toiptr, *tooptr, *friptr, *froptr;val = Fcntl(sockfd, F_GETFL, 0);Fcntl(sockfd, F_SETFL, val | O_NONBLOCK);val = Fcntl(STDIN_FILENO, F_GETFL, 0);Fcntl(STDIN_FILENO, F_SETFL, val | O_NONBLOCK);val = Fcntl(STDOUT_FILENO, F_GETFL, 0);Fcntl(STDOUT_FILENO, F_SETFL, val | O_NONBLOCK);toiptr = tooptr = to;	/* initialize buffer pointers */friptr = froptr = fr;stdineof = 0;maxfdp1 = max(max(STDIN_FILENO, STDOUT_FILENO), sockfd) + 1;for ( ; ; ) {FD_ZERO(&rset);FD_ZERO(&wset);if (stdineof == 0 && toiptr < &to[MAXLINE])FD_SET(STDIN_FILENO, &rset);	/* read from stdin */if (friptr < &fr[MAXLINE])FD_SET(sockfd, &rset);			/* read from socket */if (tooptr != toiptr)FD_SET(sockfd, &wset);			/* data to write to socket */if (froptr != friptr)FD_SET(STDOUT_FILENO, &wset);	/* data to write to stdout */Select(maxfdp1, &rset, &wset, NULL, NULL);if (FD_ISSET(STDIN_FILENO, &rset)) {if ( (n = read(STDIN_FILENO, toiptr, &to[MAXLINE] - toiptr)) < 0) {if (errno != EWOULDBLOCK)err_sys("read error on stdin");} else if (n == 0) {stdineof = 1;			/* all done with stdin */if (tooptr == toiptr)Shutdown(sockfd, SHUT_WR);/* send FIN */} else {toiptr += n;			/* # just read */FD_SET(sockfd, &wset);	/* try and write to socket below */}}if (FD_ISSET(sockfd, &rset)) {if ( (n = read(sockfd, friptr, &fr[MAXLINE] - friptr)) < 0) {if (errno != EWOULDBLOCK)err_sys("read error on socket");} else if (n == 0) {if (stdineof)return;		/* normal termination */elseerr_quit("str_cli: server terminated prematurely");} else {friptr += n;		/* # just read */FD_SET(STDOUT_FILENO, &wset);	/* try and write below */}}if (FD_ISSET(STDOUT_FILENO, &wset) && ( (n = friptr - froptr) > 0)) {if ( (nwritten = write(STDOUT_FILENO, froptr, n)) < 0) {if (errno != EWOULDBLOCK)err_sys("write error to stdout");} else {froptr += nwritten;		/* # just written */if (froptr == friptr)froptr = friptr = fr;	/* back to beginning of buffer */}}if (FD_ISSET(sockfd, &wset) && ( (n = toiptr - tooptr) > 0)) {if ( (nwritten = write(sockfd, tooptr, n)) < 0) {if (errno != EWOULDBLOCK)err_sys("write error to socket");} else {tooptr += nwritten;	/* # just written */if (tooptr == toiptr) {toiptr = tooptr = to;	/* back to beginning of buffer */if (stdineof)Shutdown(sockfd, SHUT_WR);	/* send FIN */}}}}
}

 

3. 总结

以上仅仅是标准输入发送给服务器、接收服务器消息输出到标准输出的例子,其实服务器端到connect、accept函数等都有非阻塞实现,关于套接字和TCP的网络编程还涉及到非常多非常多的内容,想要完全讨论清楚,需要大量的时间,所以还是后面有机会再针对其中的某个点,做详细的探讨,这次先到这里。

 

注:文中代码和图片均来自《Unix网络编程》

转载于:https://my.oschina.net/kalo/blog/873775