您的位置:首页 > 理论基础 > 计算机网络

什么是NIO

2017-04-06 00:46 134 查看

1. OSI模型及TCP

1.1 OSI模型

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



在用户进程层面的应用程序互相通信,可以直接调用应用层的通信协议,比如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实现客户端和服务器端连接的建立:



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可以使用下图进行描述:



同时需注意,在图的右边部分,内核中,其实分两个阶段,一个是无数据报准备好------ > 数据报准备好;另一个是复制数据报文 ----- > 复制完成。因为上面示例代码的客户端程序阻塞于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_set
int 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复用。见图:



2.3 非阻塞I/O

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



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



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



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

原本呢,我们只需要调用

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 */
else
err_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网络编程》
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息