浅谈Java网络编程——非阻塞I/O

柔情痞子 提交于 2020-02-26 11:46:46

文件描述符(descriptors)

Unix中I/O的基本组成元素是字节序列。大多数程序应用于字节流或I/O流。
进程通过描述符引用I/O流,也被称作文件描述符。管道、文件、POSIX IPC's(消息队列,信号量,共享内存),事件队列等都是通过文件描述符引用I/O流。

创建和释放描述符

描述符创建:

  • 通过系统命令调用(open,pipe,socket等)创建;
  • 继承自父进程。

描述符释放:

  • 进程退出
  • 系统调用close
  • 标记为close on exec的描述符在exec后释放

Close-on-exec

当进程forks时,所有描述符都会复制到子进程中。如果任意描述符被标记为close on exec,那么当子进程execs之前,父进程forks之后,这些描述符将关闭并且在子进程中不再可用。

使用描述符通过readwrite命令调用的数据转换

File Entry

每个描述符都指向内核中的File entry的数据结构。file entry为每个描述符维度了一个file offset。系统调用命令open创建file entry.

Fork/Dup and File Entries

fork创建的描述符被父子进程共享,在file entry中引用同一个offsetdup/dup2的系统调用与此类似。

#include <unistd.h>  
#include <sys/stat.h>  
#include <fcntl.h>  
#include <stdio.h>

int main(char \*argv\[\]) {  
    int fd = open("abc.txt", O\_WRONLY | O\_CREAT | O\_TRUNC, 0666);  
    fork();  
    write(fd, "xyz", 3);  
    printf("%ld\\n", lseek(fd, 0, SEEK\_CUR));  
    close(fd);  
    return 0;  
}

运行结果

3
6

Offset-per-descriptor

因为多个描述符可能引用同一个file entry, file entry为每个描述符维护了一个_file offset_。read和write操作从这个file offset开始,并且在数据转换之后file offset也将更新。offset决定了下次read write操作的位置。当进程终止时,内核将回收所有该进程所持有的描述符,如果此进程是引用file entry的最后一个进程,内核将回收整个file entry。

剖析File Entry

每个file entry包含:

  • 类型
  • 函数指针数组。这个函数指针数组将通用的对描述符的操作转换为具体文件类型的实现。

稍微解释下,所有的描述符都对外提供了一套通用的API操作,包含读、写、修改描述符模式、截断描述符、ioctl操作、polling等。
针对不同类型的文件,这些操作都有所不同,并且有不同的实现。对sockets的读操作与对pipes的读操作就有所不同,即使它们高层次的API是一样的。_open_命令并不在此列,因为不同类型的文件的open操作差异非常大。但是一旦file entry由open创建,剩下的操作都可以使用同一套通用的API

大多数的网络通讯使用sockets。sockets由描述符引用,作为传输的终点。两个进程可以创建两个sockets,通过连接这两个sockets建立可靠的字节流传输。一旦连接建立,描述符可以使用file offsets进行读写。内核可以将一个进程的输出重定向到另一台机器的另一个进程。对于字节流连接,统一使用_read write_命令读写,但对于不同类型的消息(比如网络数据包)使用不同的系统命令处理。

非阻塞描述符

默认情况下,在没有数据可用时,通过描述符read将阻塞。writesend也是如此。多数描述符的操作都是如此,但是磁盘文件除外,因为写磁盘并不是直接写,而是通过内核的buffer cache。只有当_open_磁盘文件时使用O_SYNC标识才会同步写磁盘。

任何描述符(pipes, FIFOs, sockets, terminals, pseudo-terminals等)都可以设置为非阻塞模式。当一个描述符设置为非阻塞模式时,对此描述符的I/O调用都将立即返回,即使此请求并不能马上完成(请求完成期间将使进程阻塞)。返回值分为下列情况:

  • an error: 操作完全不能完成
  • a partial count: 输入或输出可以部分完成
  • the entire result: I/O操作可以完全完成

通过设置非延迟标识O_NONBLOCK将描述符设置为非阻塞模式。这个标识也被叫做“open-file”状态标识。

描述符就绪

当进程通过描述符执行I/O操作时不被阻塞,称为描述符就绪。描述符就绪与操作是否会传输数据无关,而只与I/O操作是否可以无阻塞执行相关。

当有I/O事件发生时描述符进行就绪状态,例如新输入的到达、socket连接完成或者当TCP将列队中的数据传输后,socket的发送buffer出现可用容量时。

有两种方式可以判断一个描述符是否进入就绪状态——edge triggered和level triggered

Level Triggered

可以把level triggered看作是拉模式(pull或poll模式)。为了判断一个描述符是否就绪,进程尝试执行非阻塞的I/O操作。进程可以执行任意次这样的操作。这为随后的I/O操作提供了更多灵活性。比如,一个描述符进入就绪状态,进程可以读取所有可用数据,也可以不执行任何I/O操作,或者不读取buffer中的所有数据。
下面举例来看下

在t0时间,进程尝试使用非阻塞描述符进行I/O操作。如果I/O操作阻塞,系统调用返回error。

在t1时刻,进程再一次执行I/O,假设这次操作也阻塞并返回error。

在t2时刻,进程又执行了I/O,假设也阻塞或返回error。

假设到了t3时刻,进程拉取描述符的状态并且描述符就绪。进程可以执行整个I/O操作(例如读取socket上所有可用数据)

假设t4时刻,进程拉取描述符状态但描述符并没有就绪,这次调用将再次阻塞或返回error。

t5时刻,描述符就绪,进程只执行了部分I/O操作(例如只读取一半可用数据)

t6时刻,描述符就绪,进程什么I/O操作也没执行

Edge Triggered

当描述符就绪时,进程将收到一个通知(通常是描述符上有新事件发生)。可以把这种模式看作是push模式,这个描述符就绪的通知是被push给进程的。注意,push模式仅通知进程描述符已就绪,而不会通知其他信息,比如有多少数据已到达socket的buffer中。

因此,通过这种方式进程只能获取到不完整的数据,所以进程需要继续进行操作。当每次得到通知时,进程尝试进行最多的I/O操作,如果不这样做,进程不得不等到下一次得到通知时才能获取数据,即使在下一次通知到来前仍有部分数据可用。

下面举例说明

在t2时刻,进程得到描述符就绪的通知

可用的字节流存储在buffer中,假设有1024个字节可读。

假设进程只读取了其中的500个字节

这意味着在t3 t4 t5时刻,buffer中仍然有524个字节可使进程无阻塞地读取。但是因为只有在它得到下次通知时才会执行I/O操作,这524个字节的数据在这期间将一直留在buffer中。

假设进程在t6时刻接到下次通知,buffer中又有1024个字节可用。此时buffer中可用的数据为1548个字节——524字节是上次没读的,1024是新到达的。

假设进程这次读取了1024字节。

这意味着在这次I/O操作结束后仍有524字节的数据留在buffer中,直到一次通知到来进程才能读取到。

当一个描述符在通知来到时如果尝试执行所有I/O操作,可能造成其他描述符“饥饿”。即使使用level triggered,一次大量的writesend也可能导致阻塞。

多路复用I/O

上面我们只讨论了一个进程只处理一个描述符的情况。通常进程处理多个描述符。一个常见的场景是一个应用程序需要打印日志,同时接收socket连接并且和其他服务建立RPC连接。

有以下几种多路复用I/O方式:

  • 非阻塞I/O(描述符本身被标识为非阻塞,操作可能部分完成)
  • 信号驱动I/O(当I/O状态变化时通知拥有描述符的进程)
  • polling I/O(通过selectpoll系统调用,这两者都提供了level triggered方式的描述符就绪通知机制)
  • BSD 机制的内核事件polling(使用kevent系统调用)

非阻塞I/O的多路复用I/O

描述符

将所有描述符都设置为非阻塞模式

进程

进程尝试对描述符执行I/O操作,检查是否有任意I/O操作返回error。

内核

内核在描述符上执行I/O操作,返回error或部分输出或者是全部结果。

缺点

频繁检查:如果进程频繁尝试执行I/O操作,进程不得不持续地重复检查描述符是否就绪的操作。在tight循环中这样的busy-waiting可能会耗尽CPU周期。
不频繁检查:如果这样的操作执行不频繁,可能使进程对于有效的I/O事件长时间得不到响应。

何时使用

对于输出描述符(比如write)的操作并不总是阻塞的。在这种场景下,可以首先尝试执行I/O操作,如果返回error再回退到polling。当使用edge-triggered通知方式时也可以使用这种方式,此时描述符设置为非阻塞模式,进程一旦得到一个I/O事件的通知,进程可以重复执行I/O操作直到系统调用被阻塞(EAGAIN or EWOULDBLOCK)。

信号驱动I/O的多路复用I/O

描述符

当任意描述符上可执行I/O操作时,内核将发送通知给进程。

进程

进程等待任何描述符就绪的信号。

内核

跟踪描述符列表,当任意描述符就绪时给进程发送信号通知。

缺点

捕获信号的开销较大,当大量I/O操作时使用信号驱动I/O方式并不现实。

何时使用

通常在一些“特例条件”下使用,此时处理信号的开销低于不断使用_select/poll/epoll_或_kevent_的polling操作。一个“特例条件”的场景是socket上的带外(out-of-band)数据的到达。总之不常用。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!