操作系统上的网络IO基础

网络IO过程包含许多层面的内容,如计算机组成、网络通信、操作系统、应用程序API等。

本次只讨论操作系统层面以上的网络IO基础。

从操作系统层面看网络IO

socket

socket,主要包含五个要素, 通信协议、客户端ip、客户端port、服务端 ip、服务端port;可以理解为应用层到传输层的一层抽象,操作系统就为应用程序提供了许多socket相关的系统调用,以方便应用程序进行网络通信。

可以通过netstat命令查看网络连接

netstat -natp # 查看网络连接
复制代码

如果你进行过网络通信,如之前调用了curl www.baidu.com,再查看网络连接,将看到socket的五个要素,如下图:

image-20201019170605440.png

文件描述符

操作系统将socket连接映射成为 -> 文件描述符(file descriptor,简称fd),针对socket的读写转换为对fd的读写,进程的输入输出。说起来抽象,可以通过在linux下创建一个socket连接并通过fd读写更形象的理解:

# 与百度建立socket连接,并将其读写交给文件描述符8
exec 8<> /dev/tcp/www.baidu.com/80 # 8是文件描述符,<>代表输入输出流,由内核建立socket连接
复制代码
# 输出一段文本到上面的socket文件描述符,即发送tcp数据,在应用层向传输层发送了数据,socket是应用层到传输层的一个抽象
echo -e "GET / HTTP/1.1\n" 1>& 8 
复制代码
# 输入从文件描述符8处来
cat 0<& 8 
复制代码

执行完第三条命令后将得到百度首页的相应内容

image-20201023162844174.png

服务端与客户端

如下图C1,C2,C3为三个客户端,Server为一个服务端,它们建立连接并进行数据读写的过程如下:

  1. Server启动,创建了一个socket,绑定地址,得到一个S-fd服务端文件描述符
  2. 客户端通过Server的socket地址,进行TCP三次握手连接,成功后,客户端生成一个代表socket连接的文件描述符(图中客户端的c1-fd、c2-fd、c3-fd)
  3. 服务端S-fd读写进行三次握手,连接成功后,在服务端生成一个代表客户端socket连接的文件描述符(图中Server的c1-fd、c2-fd、c3-fd)
  4. 客户端、服务端通过socket连接(即c1-fd、c2-fd、c3-fd的读写)进行发送接收数据

image-20201023171411969.png

网络IO的阶段

image-20201023170853186.png

从CPU工作的角度来看,网络IO的读取过程大概分为两个阶段

  1. 数据从网卡读取到内核缓冲区;(需要发起IO请求后等待数据就绪
  2. 从内核缓冲区拷贝数据到用户空间

类似的,写入数据过程也可能因为图中标红的buffer memory,内核socket缓冲区被占满而需要等待就绪。

综上所述,网络IO与本地文件IO的不同在于,其读写过程中可能需要一个等待的过程,这有助于理解后面编写网络IO程序的阻塞概念。

从Java网络IO程序到系统调用

操作系统为应用程序提供了一系列系统调用用于实现建立socket连接、读写数据等操作。具体的包括这几类:

socket # 创建socket
bind # 服务端绑定地址
listen # 监听
accept # 服务端接收客户端连接
recvfrom / read  # 读取数据
复制代码

下面从几个Java应用程序,结合它们运行时所进行的系统调用理解网络IO的过程。

BIO

BIO,Blocking IO的简称,指的是阻塞IO,下面通过一个java程序的运行分析操作系统层面是如何实现一个BIO的Server的。

程序

// BIOServer.java
ServerSocket serverSocket = new ServerSocket(8081);
while (true){
  // 接受连接,阻塞至有连接
  final Socket socket = serverSocket.accept();
  // new Thread(()->{
    try {
      InputStream inputStream = socket.getInputStream();
      while (true){
        byte[] bytes = new byte[1024];
        // 从socket连接中读取数据,阻塞至有数据可读
        if (inputStream.read(bytes) > 0){
          System.out.println(String.format("got message: %s", new String(bytes)));
        }
      }
    } catch (IOException e) {
      e.printStackTrace();
    }
  // }).start();
}
复制代码

启动

上面是一个简单的Java BIO程序,我们可以在执行它时同时使用strace命令查看程序运行时所进行的系统调用(Linux下)

strace -ff -o out java BIOServer # 查看进程的线程对内核进行了那些调用
复制代码

执行上面命令后,我们会得到几个out为前缀的文件,这些文件代表程序运行过程中不同的线程产生的系统调用,在主线程所打印的系统调用中可以找到我们上面提到过的几个关键的系统调用(其实会打印大量的系统调用,这里只列出关键的几个):

socket(AF_INET, SOCK_STREAM, IPPROTO_IP) = 7 # 创建一个socket,返回值7即代表这个server-socket在进程中的文件描述符
bind(7, {sa_family=AF_INET, sin_port=htons(8081), sin_addr=inet_addr("0.0.0.0")}, 16) = 0 # 绑定地址,可以看到传入了socket系统调用返回的文件描述符,同样可以看到我们程序中的8081端口,地址0.0.0.0代表这是一个server,允许其它客户端进行连接
listen(7, 50)                           = 0 # listen命令,同样使用到了文件描述符7,第二个参数50,实际代表了允许等待TCP三次握手的客户端队列长度
poll([{fd=7, events=POLLIN|POLLERR}], 1, -1 # 对应我们程序中accept方法的调用,这里会阻塞,因为我们的程序刚刚启动,没有客户端进行连接
复制代码

以上几个关键的系统调用便是Linux操作系统为应用程序在系统层面上提供的,以方便应用程序创建一个Server。上面的注释中已经详细介绍了每个系统调用的含义,其实可以去 Linux man pages查看每个系统调用的详细文档,比如listen系统调用的第二个参数的含义在官方文档中的描述

int listen(int sockfd, int backlog);

The backlog argument defines the maximum length to which the queue of
pending connections for sockfd may grow.  If a connection request
arrives when the queue is full, the client may receive an error with
an indication of ECONNREFUSED or, if the underlying protocol supports
retransmission, the request may be ignored so that a later reattempt
at connection succeeds.
复制代码

连接

如上所述,当前程序阻塞在了accept方法处,我们使用telnet尝试连接:

telnet localhost 8081 
复制代码

然后去主线程的系统调用打印中查看,下面可以看到本来阻塞在poll系统调用的程序接着执行了,这里仍然列出几个关键的系统调用进行分析:

poll([{fd=7, events=POLLIN|POLLERR}], 1, -1) = 1 ([{fd=7, revents=POLLIN}]) # 阻塞调用返回 1
accept(7, {sa_family=AF_INET, sin_port=htons(58972), sin_addr=inet_addr("127.0.0.1")}, [16]) = 8 # TCP三次握手成功,客户端连接创建成功,返回的8,是一个新的文件描述符,代表着服务端的客户端连接
recvfrom(8, # 从文件描述符8(即客户端连接)中读取数据,阻塞
复制代码

因为我们只是连接成功,并没有发送数据,所以java程序主线程阻塞在了read方法处,对应系统调用recvfrom阻塞,系统调用打印停止。

读写

接下来在客户端连接处发送数据aaaa,再查看系统调用:

recvfrom(8, "aaaa\r\n", 1024, 0, NULL, NULL) = 6
复制代码

可以看到recvfrom阻塞结束,成功读取到了我们在客户端发送的数据,返回值为读取到的字节数,这一步结束返回后,操作系统已经将读取到的数据拷贝到了java应用程序的内存区域,即数据到了java中的字节数组对象中。

问题

BIO程序编写比较简单,简单的实现了服务端接受连接、读取数据等功能。但通过上面的分析,可以看到无论是应用程序层面,还是操作系统层面,程序存在线程阻塞的问题,且分别有接受连接、读取数据两处阻塞。如果我们的程序只有一个主线程,可以发现只能处理一个客户端连接,因为服务端不知道客户端何时发送数据,只能在没数据的时候也阻塞在read方法(系统层面的recvfrom系统调用)处。

为了可以使Server接受并处理多个客户端连接,一种解决方案是为每一个客户端连接创建一个线程,这样就解决了阻塞造成的问题。但是,现在的服务端应用程序一般对于客户端并发量要求较高,如果为每一个客户端连接创建一个线程,必然需要创建很多的线程,而线程是非常宝贵的资源,这样的程序设计浪费线程资源,且并发不能达到要求。

可以发现,问题出现在了阻塞上,如果解决了阻塞的问题,我们便可以有少量线程处理大量并发的解决方案。

无论是操作系统层面,还是java的api层面,其实都已经为我们开发者提供了非阻塞IO的支持。下面就对一个NIO程序进行分析。

NIO

NIO,在java中表示New IO,指新的IO操作方式(基于管道和缓冲区);在操作系统层面理解为Non-blocking IO,指的是非阻塞IO。java中的新IO同样提供了非阻塞IO的编程方式。下面的程序按照上面同样的流程分析一个NIO的Server程序是如何实现的:

程序

这是一个简单的非阻塞的NIO Server程序,但编程难度相对BIO Server程序有所增加,可以将其与之前分析的BIO Server程序进行对比,必要的几步,比如开启服务端,绑定地址,接受连接,读取数据,其实仍然一一对应存在,变化不过是API层面的变化。

但该程序与BIO Server程序不同的地方在于,服务端开启连接和接受到客户端连接时,均调用了configureBlocking(false)方法设置为非阻塞,在接受连接的acceptread后,均判断了是否接受到连接或是否读取到数据,这也正是非阻塞与阻塞的最大区别。

// NIOSimpleServer.java
// 保存客户端连接
List<SocketChannel> socketChannelList = new ArrayList<>();
// 开启服务端连接
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.bind(new InetSocketAddress("localhost", 8080));
// 设置为非阻塞
serverSocketChannel.configureBlocking(false);

// 死循环,先尝试接受连接,再保存连接,并每次循环后对已有的连接进行处理
while (true){
  TimeUnit.MILLISECONDS.sleep(1000);
  // 因为非阻塞,无论有没有客户端进行连接,这一步将立即返回,所以下一步需要判断是否返回为null
  SocketChannel socketChannel = serverSocketChannel.accept();
  if (socketChannel != null) {
    System.out.println("connect success");
    // 设置为非阻塞
    socketChannel.configureBlocking(false);
    socketChannelList.add(socketChannel);
  }
  for (SocketChannel socketChannel1 : socketChannelList) {
    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
    // 从客户端连接中读取数据,因为设置了非阻塞,无论有没有数据可读,这一步将立即返回,所以下一步需要判断是否读取到了数据
    int read = socketChannel1.read(byteBuffer);
    if (read > 0) {
      byteBuffer.flip();
      System.out.println(String.format("got message: %s", new String(byteBuffer.array())));
    }
  }
}
复制代码

启动

同样,我们使用strace命令启动NIOSimpleServer程序在执行它时同时,查看程序运行时所进行的系统调用

strace -ff -o out java NIOSimpleServer
复制代码

同样,在主线程所打印的系统调用中可以找到我们上面提到过的几个关键的系统调用:

socket(AF_INET6, SOCK_STREAM, IPPROTO_IP) = 4 # 创建一个socket,返回值4即代表这个server-socket在进程中的文件描述符
bind(4, {sa_family=AF_INET6, sin6_port=htons(8080), sin6_flowinfo=htonl(0), inet_pton(AF_INET6, "::ffff:127.0.0.1", &sin6_addr), sin6_scope_id=0}, 28) = 0 # 绑定地址,可以看到传入了socket系统调用返回的文件描述符,同样可以看到我们程序中的8080端口
listen(4, 50)                           = 0 # listen命令,同样使用到了文件描述符7,第二个参数50,实际代表了允许等待TCP三次握手的客户端队列长度

fcntl(4, F_SETFL, O_RDWR|O_NONBLOCK)    = 0 # 设置socket连接非阻塞

accept(4, 0x7f56140f5420, [28])         = -1 EAGAIN (资源暂时不可用) # 对应我们程序中accept方法的调用,这里不会阻塞,看到立马返回了-1,代表无连接,程序接着向下执行
accept(4, 0x7f56140f5420, [28])         = -1 EAGAIN (资源暂时不可用)
accept(4, 0x7f56140f5420, [28])         = -1 EAGAIN (资源暂时不可用)
... # 无客户端连接时,会一致循环的accept下去,每次都返回-1,代表无连接
复制代码

可以看到其实非阻塞IO程序的启动过程涉及到的系统调用与阻塞IO程序基本相同,只是不再调用阻塞的poll,而是直接调用接受连接的accept,如果没有连接会返回-1,java程序中使用了死循环,所以没有客户端连接的情况下,一致循环进行accept系统调用。

连接

同样,使用telnet尝试连接:

telnet localhost 8080
复制代码

然后去主线程的系统调用打印中查看,下面可以看到本来一直在进程循环accept并返回-1程序的一次accept返回类不一样的值,这里仍然列出几个关键的系统调用进行分析:

accept(4, {sa_family=AF_INET6, sin6_port=htons(40896), sin6_flowinfo=htonl(0), inet_pton(AF_INET6, "::ffff:127.0.0.1", &sin6_addr), sin6_scope_id=0}, [28]) = 5 # TCP三次握手成功,客户端连接创建成功,返回的8,是一个新的文件描述符,代表着服务端的客户端连接 

fcntl(5, F_SETFL, O_RDWR|O_NONBLOCK)    = 0 # 设置通道非阻塞

accept(4, 0x7f56140df710, [28])         = -1 EAGAIN (资源暂时不可用)
read(5, 0x7f56140f6440, 1024)           = -1 EAGAIN (资源暂时不可用)
accept(4, 0x7f56140df710, [28])         = -1 EAGAIN (资源暂时不可用)
read(5, 0x7f56140f6440, 1024)           = -1 EAGAIN (资源暂时不可用)
...
复制代码

因为我们只是连接成功,并没有发送数据,所以程序每次read都会返回-1,没有数据可读,但是因为设置了非阻塞,所以没有数据可读并不会导致线程阻塞停下,而是不停的循环accept(接收新连接)和read(从已有连接中读取数据)。

读写

接下来在客户端连接处发送数据aaa,再查看系统调用:

read(5, "aaa\r\n", 1024)                = 5

accept(4, 0x7f5614129d80, [28])         = -1 EAGAIN (资源暂时不可用)
read(5, 0x7f56140f6440, 1024)           = -1 EAGAIN (资源暂时不可用)
accept(4, 0x7f5614129d80, [28])         = -1 EAGAIN (资源暂时不可用)
read(5, 0x7f56140f6440, 1024)           = -1 EAGAIN (资源暂时不可用)
...
复制代码

可以看到其中一次read读取到了发送的数据aaa,返回值为读取到的字节数。同时,在读取数据完成后,按照程序的死循环写法,立马又开始循环的进行acceptread系统调用,尝试接受连接和读取从客户端连接中读取数据,也证明了这个程序是非阻塞的。

这里会发现,无论是阻塞IO还是非阻塞IO,操作系统底层为我们提供的支持,即系统调用函数,基本上是一样的。不同的点就在于,在创建socket连接,产生文件描述符那一步,是否通过fcntl系统调用设置了socket连接非阻塞。如果设置了非阻塞,操作系统在应用程序调用acceptread时,便不会阻塞,会直接返回,如果没数据返回的是-1,表示资源暂时不可用,如果有数据便会返回一个非负数,表示文件描述符(accept)或读取到的字节数(read)。向socket写入数据这里没有演示,但是可以类比,write系统调用向socket写入数据并返回已经写入的字节数。

问题

通过这样一套操作,虽然略微增加了编码的复杂度,但是可以看到,我们已经解决了BIO Server程序的一些问题,我们现在仅仅使用一个线程,不仅可以不停的接收连接,还可以处理多个客户端连接的读写数据。

但是,这个程序也存在这一些问题。第一问题,我们使用了非阻塞,导致线程基本上需要不停的使用CPU,无论有没有新连接,有没有数据需要处理,每次循环都会把所有的socket连接轮询一遍。这就导致,我们虽然没有浪费更多的线程资源,但是会浪费许多的CPU资源。

第二个问题,我们将已经连接的客户端保存在了集合中,随着客户端连接的增加,这个集合会越来越大,每次循环我们都需要遍历这个结合,调用socketChannelread尝试读取,而这个read操作涉及到系统调用read,一旦有系统调用,就涉及到了CPU在用户态和内核态之间的切换,这个切换是有一定性能代价的。

为了解决这两个问题,可以使用IO多路复用技术。

IO多路复用

IO多路复用可以实现一次系统调用,由操作系统内核检查多个socket连接的状态,并且可以设置线程阻塞至关心的事件在socket连接上发生为止,也就解决了前面NIO的程序的两个问题。

操作系统提供了三个常用的系统调用selectpollepoll来实现IO多路复用,下面分别说明。

image-20201026124622277.png

select

之前非阻塞IO的java程序将客户端连接保存在集合中,在应用程序层面进行遍历尝试读取数据,循环进行系统调用。select系统调用可以理解为将遍历查询是否有可读/可写等事件这一步放在操作系统内核完成,且可以阻塞timeout事件至有感兴趣的事件发生后返回,提高了性能。

// select函数的定义,nfds为fd最大 + 1,readfds、writefds、exceptfds分别为所关心的读事件、写事件、异常事件的文件描述符,timeout为阻塞时间;返回值为整数代表有事件的文件描述符个数;具体那个有事件在对应fd_set中获得
int select(int nfds, fd_set *readfds, fd_set *writefds,
                  fd_set *exceptfds, struct timeval *timeout); 
复制代码

select使用IO多路复用的方式一定程度上解决了非阻塞IO程序的两个问题,但是其也存在者一些问题:

  • 调用时直接传入fd_set监听描述符集合,FD_SETSIZE限制,linux系统下默认1024;
  • 调用后阻塞,遍历描述符集合,找到就绪的,随着监听的文件描述符多时,效率会降低;

poll

poll系统调用的作用与select基本一致,同样需要传入相关的文件描述符和事件,内核进行轮询,返回是否有事件发生并将事件设置在传入的参数中。

poll解决了select的监听文件描述符数量受限的问题,因为不再使用fd_set,而是使用结构体数组。

但像上面的图中描述,poll仍然需要每次调用传入所有关心的文件描述符,由内核进行遍历检查事件。

// poll函数的定义,fds为一个结构体数组
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

struct pollfd {
    int fd; /* file descriptor 要监听的文件描述符*/
    short events; /* requested events to watch ,感兴趣的事件,如果为负,将不检测*/
    short revents; /* returned events witnessed,所发生的事件 */
};
复制代码

epoll

epoll 同样是操作系统提供的IO多路复用的函数,解决与selectpoll相同的问题,但相对后两者要更加强大。结合着上面的图和epoll的几个函数的定义介绍一下epoll使用的过程:

int epoll_create(int size); // 创建一个管理约size个需要监听的fd的fd;
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // 监听一个fd,放在红黑树中,一旦就绪,内核会采用类似callback的回调机制,激活这个描述符,再调用epoll_wait时便得到通知
int epoll_wait(int epfd, struct epoll_event *events,
                      int maxevents, int timeout); // 查询一波事件
复制代码

epoll具体包含epoll_createepoll_ctlepoll_wait三个函数

  1. 首先调用epoll_create创建一个epoll实例,返回一个文件描述符epfd,这个文件描述符将用来管理需要监听事件的文件描述符
  2. 使用epoll_ctlepfd注册要监听的文件描述符(即socket连接),epoll_event中包含要监听的文件描述符、要监听的事件、一些配置项等;这一步之后,epoll将要监听的文件描述符加入到一个红黑树中,当事件就绪,将这个文件描述符放入到另一个就绪集合中
  3. 应用程序调用epoll_wait,直接返回2中的就绪集合

可以发现epoll有下面几个特点:

  • 监听的fd数量基本不受限制,上限为打开文件的个数
  • IO效率不会随监听的fd数量增长而下降,不需要遍历,采用每个fd回调通知方式
  • epoll_wait查询是否有就绪事件时不需要复制fd,因为在之前的epoll_ctl中已经注册

另外,epoll支持对于事件就绪的一些配置,比如支持边缘触发(Edge-triggered),关于事件就绪后的通知,epoll有两种处理方式

  • LT(Level-triggered)模式:就绪后如果不操作,会继续通知;(selectpoll即java NIO均是这种模式)

  • ET(Edge-triggered)模式:就绪后仅通知一次;这种模式仅epoll支持,这种模式下,比如有2M数据就绪,而应用程序只读取了1M,下次再调用epoll_wait,这个文件描述符不会出现在就绪集合中,剩下的数据只能应用程序自行循环读取(直到读到特殊的标记表示结束),nginx使用到

多路复用程序

Java NIO提供了多路复用的支持,主要使用了Selector类结合非阻塞IO程序中的几个类进行编码,下面是一个多路复用程序的主要代码:

// NIOServer.java
Selector selector = Selector.open();
// 省略非阻塞IO程序中重复代码
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
// 查询事件
selector.select();
Set<SelectionKey> selectionKeys = selector.selectedKeys();
// 处理客户端连接事件
if (selectionKey.isAcceptable()) {
    SocketChannel channel = ((ServerSocketChannel) selectionKey.channel()).accept();
    channel.configureBlocking(false);
    SelectionKey readKey = channel.register(selector, SelectionKey.OP_READ);
} else if (selectionKey.isReadable()) {
    // 处理读取数据事件
    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
    SocketChannel channel = (SocketChannel) selectionKey.channel();
    channel.read(byteBuffer);
    byteBuffer.flip();
    System.out.println(String.format("got message: %s", new String(byteBuffer.array())));
}
复制代码

类似前面的分析,我们使用strace启动这个程序,并连接,可以看到关键的系统调用如下:

epoll_create(256)                       = 6 # 创建
socket(AF_INET6, SOCK_STREAM, IPPROTO_IP) = 7
bind(7, {sa_family=AF_INET6, sin6_port=htons(8080), sin6_flowinfo=htonl(0), inet_pton(AF_INET6, "::ffff:127.0.0.1", &sin6_addr), sin6_scope_id=0}, 28) = 0
listen(7, 50)                           = 0 # 监听server socket
epoll_ctl(6, EPOLL_CTL_ADD, 7, {EPOLLIN, {u32=7, u64=362164703394267143}}) = 0
epoll_wait(6, [{EPOLLIN, {u32=7, u64=362164703394267143}}], 8192, -1) = 1 # 查询事件阻塞
accept(7, {sa_family=AF_INET6, sin6_port=htons(37078), sin6_flowinfo=htonl(0), inet_pton(AF_INET6, "::ffff:127.0.0.1", &sin6_addr), sin6_scope_id=0}, [28]) = 8 # 接收客户端连接
epoll_ctl(6, EPOLL_CTL_ADD, 8, {EPOLLIN, {u32=8, u64=352448473758433288}}) = 0 # 注册监听客户端连接上事件
read(8, "aaaa\r\n", 1024)               = 6 # 读到数据
epoll_wait(6, # 下一次循环阻塞
复制代码

可以看到java中的Selector即使用了操作系统提供的epoll IO多路复用模式。

总结

  • 首先介绍了网络IO在操作系统层面上的表现,通过一个实例理解socket连接和文件描述符等关系;并根据网络IO的过程介绍了网络IO存在等待就绪的特点;
  • 然后根据Java的网络IO程序分别分析了阻塞IO(BIO)操作系统上的主要运行过程,根据BIO存在的问题然后同样分析了非阻塞IO(NIO)的运行过程;
  • 最后介绍了操作系统层面提供的IO多路复用技术,并分析java多路复用程序是如何使用epoll

猜你喜欢

转载自juejin.im/post/7052474296911790087