什么是NIO
博客专区 > kalo 的博客 > 博客详情
什么是NIO
kalo 发表于10个月前
什么是NIO
  • 发表于 10个月前
  • 阅读 33
  • 收藏 2
  • 点赞 0
  • 评论 0

新睿云服务器60天免费使用,快来体验!>>>   

摘要: 现在很多网络框架都是基于NIO实现的,于是有很多文章对框架的使用和源码进行了分析,但是对IO、NIO的概念都是一笔带过,没有做深入的讲解。最近正好也看到群里有朋友在聊NIO,看到大家讨论的不亦乐乎,激起了我的兴趣,索性专门写篇文章,用代码一步步对阻塞IO,非阻塞IO等网络I/O模型进行解读,希望对大家有所帮助。

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网络编程》

  • 打赏
  • 点赞
  • 收藏
  • 分享
共有 人打赏支持
粉丝 12
博文 5
码字总数 11716
×
kalo
如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
* 金额(元)
¥1 ¥5 ¥10 ¥20 其他金额
打赏人
留言
* 支付类型
微信扫码支付
打赏金额:
已支付成功
打赏金额: