Linux/IO学习笔记

2020-10-28  本文已影响0人  奔向学霸的路上

相关知识

  1. Linux中一切类型都被抽象成文件,故Linux都是文件描述符
  2. 内存被划分为:内核态和用户态,数据在内核态和用户态之间拷贝,内核态可以访问用户态数据,反之不可以
  3. 只要内核可以操作硬件资源(网卡、磁盘等),内核提供syscall函数,故用户空间的程序,通过调用系统函数来访问操作系统软硬件资源

文件描述符

  1. 文件描述符是内核创建的方便管理已打开文件的索引,指代被打开的文件。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。


    image.png
  2. 所有执行I/O操作的系统调用都通过文件描述符,故提供网络IO能力的不是Java/Python高级语言而是Linux Kernel


    image.png

Unix共5种I/O模型

  1. 阻塞I/O
  2. 非阻塞I/O
  3. I/O多路复用

这两个本节先不做考虑

  1. 信号驱动
  2. 异步I/O

几种模型对比图:


image.png

阻塞IO

阻塞IO

非阻塞IO

非阻塞IO

I/O多路复用

I/O多路复用是指把非阻塞IO中需要用户线程在每个IO通路上,不断轮询IO状态,来判断是否有可处理的数据-->改为提出一个单独的线程来对其进行管理。


I/O多路复用

多路复用在内核中提供了select,poll,epoll三种方式:

select

poll

epoll

  1. epoll推出是为了替换select
  2. 目录/proc/sys/fs/epoll/max_user_watches表示用户能注册到epoll实例中的最大文件描述符的数量限制。
    image.png

epoll 关键函数:

epoll_create创建epoll

#include <sys/epoll.h>

int epoll_create(int size);
int epoll_create1(int flags);

如果不需要使用这个描述符,需要close关闭,否则会耗尽内存。

  1. epoll_create(int size)

size的作用是告诉内核需要使用多少个文件描述符。内核会使用size的大小去申请对应的内存,在linux内核版本大于2.6.8后,size参数就被弃用了,但是传入的值必须大于0,内核会动态的申请需要的内存。

  1. epoll_create1(int flags)
    flags=0,等价于poll_create(0)
    EPOLL_CLOEXEC:这是这个参数唯一的有效值,如果这个参数设置为这个。那么当进程替换映像的时候会关闭这个文件描述符,这样新的映像中就无法对这个文件描述符操作,适用于多进程编程+映像替换的环境里

epoll_ctl设置epoll事件

#include <sys/epoll.h>

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

调用epoll_ctl函数能够控制给定的文件描述符epfd指向的epoll实例,op是添加事件的类型,fd是目标文件描述符。

event这个参数是用于关联制定的fd文件描述符的。它的定义如下:

typedef union epoll_data {
    void        *ptr;
    int          fd;
    uint32_t     u32;
    uint64_t     u64;
} epoll_data_t;

struct epoll_event {
    uint32_t     events;      /* Epoll events */
    epoll_data_t data;        /* User data variable */
};

events这个参数是一个字节的掩码构成的。下面是可以用的事件:

epoll_wait等待epoll事件

#include <sys/epoll.h>

int epoll_wait(int epfd, struct epoll_event *events,
                      int maxevents, int timeout);
                      
int epoll_pwait(int epfd, struct epoll_event *events,
                      int maxevents, int timeout,
                      const sigset_t *sigmask);

epoll_wait是用来等待epfd中的事件,events指向调用者可以使用的事件的内存区域。maxevents告知内核有多少个events,必须大于0.
timeout阻塞毫秒数,timeout=-1会无限期阻塞下去,timeout=0就算没有任何事件,也会立刻返回。

epoll

官方demo

#define MAX_EVENTS 10
struct epoll_event  ev, events[MAX_EVENTS];
int         listen_sock, conn_sock, nfds, epollfd;


/* Code to set up listening socket, 'listen_sock',
 * (socket(), bind(), listen()) omitted */

epollfd = epoll_create1( 0 );
if ( epollfd == -1 )
{
    perror( "epoll_create1" );
    exit( EXIT_FAILURE );
}

ev.events   = EPOLLIN;
ev.data.fd  = listen_sock;
if ( epoll_ctl( epollfd, EPOLL_CTL_ADD, listen_sock, &ev ) == -1 )
{
    perror( "epoll_ctl: listen_sock" );
    exit( EXIT_FAILURE );
}

for (;; )
{
    nfds = epoll_wait( epollfd, events, MAX_EVENTS, -1 );
    if ( nfds == -1 )
    {
        perror( "epoll_wait" );
        exit( EXIT_FAILURE );
    }

    for ( n = 0; n < nfds; ++n )
    {
        if ( events[n].data.fd == listen_sock )
        {
            conn_sock = accept( listen_sock,
                        (struct sockaddr *) &local, &addrlen );
            if ( conn_sock == -1 )
            {
                perror( "accept" );
                exit( EXIT_FAILURE );
            }
            setnonblocking( conn_sock );
            ev.events   = EPOLLIN | EPOLLET;
            ev.data.fd  = conn_sock;
            if ( epoll_ctl( epollfd, EPOLL_CTL_ADD, conn_sock,
                    &ev ) == -1 )
            {
                perror( "epoll_ctl: conn_sock" );
                exit( EXIT_FAILURE );
            }
        } else {
            do_use_fd( events[n].data.fd );
        }
    }
}

BIO

在不同的java版本中存在差异
Java5:
accept会阻塞直到数据到达

image.png

Java6/Java7/Java8:
poll会一直阻塞,直到有一个事件event到达,再去调用accept去接受新的连接

image.png

以socket.read()为例,传统的BIO里面socket.read(),如果TCP RecvBuffer里没有数据,函数会一直阻塞,直到收到数据后返回。
对于NIO,如果TCP RecvBuffer里没有数据,函数会返回0,如果有数据,就把数据从网卡读到内存,并且返回给用户。
最新的AIO,不但等待数据是非阻塞的,就连数据从网卡到内存的过程也是异步的。
通俗来讲就是,BIO里用户关心“我要读”,NIO里用户关心“我能读”,AIO里用户关心“读完了”。

NIO

NIO(Non-blocking I/O),是一种同步非阻塞的I/O模型,也是I/O多路复用的基础,NIO中socket的读、写、注册和接收函数,在等待就绪阶段都是非阻塞的,只有真正的I/O操作是同步阻塞的。

NIO由三部分组成:

传统的IO是基于字节流和字符流的,而NIO基于Channel和Buffer进行操作,数据总是从通道读到缓冲区,或者从缓冲区写入通道中。选择器用于监听多个通道的事件(如:连接打开,数据到达),因此可以监听多个数据通道。

NIO

Java NIO Buffer用于和NIO Channel交互,我们从Channel中读取数据到Buffer里,从Buffer把数据写入到Channel。

当写入数据到Buffer中时,Buffer会记录已经写入的数据大小。当需要读数据时,通过flip()方法把Buffer从写模式调整为读模式;

Buffer的实现

java.nio.Buffer 中定义了4个成员变量:

读写模式,通过调用flip切换读写,实际上是调整position,limit的值


读写模式切换

Buffer对象
实际上,会存在一块内存区,用来写入数据,稍后读取处理
以字节缓冲区为例,ByteBuffer是一个抽象类,不能直接通过new语句来创建,只能通过一个static方法allocate来创建:

ByteBuffer byteBuffer = ByteBuffer.allocate(10);

在JVM中创建对象是放入堆中,JVM垃圾回收时,会把堆中的对象,在不同的分区中来回拷贝,内存地址会频繁发生变化,本身Buffer会频繁读写,这样会导致内存整理繁琐 ,Direct Buffer脱离JVM对象管理而存在,直接来看,allocate()创建了一个HeapByteBuffer,调用allocateDirect()创建的是DirectByteBuffer,看名字,一个是堆内存,一个是直接内存。

ByteBuffer buf = ByteBuffer.allocateDirect(1024);

allocateDirect使用了 unsafe.allocateMemory 来分配内存,而 allocateMemory 是一个 native 方法,会调用 malloc 方法在 JVM 外分配一块内存空间。

结合事件模型使用NIO同步非阻塞特性

回忆BIO,能不能进行读写,只能“傻等”,即使可以估算,也没办法在read或write函数中返回,这两个函数无法进行有效的终端,所以除了多开线程,没有好的办法利用CPU。
NIO的读写函数可以立刻返回,这给我们不开线程利用CPU提供了好的机会:如果一个连接不能读写(socket.read()返回0或者socket.write()返回0),我们可以把这件事记下来,记录的方式通常是在Selector上注册标记位,然后切换到其他就绪的(Channel)继续进行读写。

NIO的主要事件有几个:读就绪、写就绪、有新连接到来。

我们首先需要注册当这几个事件到来的时候所对应的处理器。然后在合适的时机告诉事件选择器:我对这个事件感兴趣。对于写操作,就是写不出去的时候对写事件感兴趣;对于读操作,就是完成连接和系统没有办法承载新读入的数据的时;对于accept,一般是服务器刚启动的时候;而对于connect,一般是connect失败需要重连或者直接异步调用connect的时候。

其次,用一个死循环选择就绪的事件,会执行系统调用(Linux 2.6之前是select、poll,2.6之后是epoll,Windows是IOCP),还会阻塞的等待新事件的到来。新事件到来的时候,会在selector上注册标记位,标示可读、可写或者有连接到来。

其次,用一个死循环选择就绪的事件,会执行系统调用(Linux 2.6之前是select、poll,2.6之后是epoll,Windows是IOCP),还会阻塞的等待新事件的到来。新事件到来的时候,会在selector上注册标记位,标示可读、可写或者有连接到来。

注意,select是阻塞的,无论是通过操作系统的通知(epoll)还是不停的轮询(select,poll),这个函数是阻塞的。所以你可以放心大胆地在一个while(true)里面调用这个函数而不用担心CPU空转。

NIO总结

伪代码:

interface ChannelHandler{
      void channelReadable(Channel channel);
      void channelWritable(Channel channel);
   }
   class Channel{
     Socket socket;
     Event event;//读,写或者连接
   }

   //IO线程主循环:
   class IoThread extends Thread{
   public void run(){
   Channel channel;
   while(channel=Selector.select()){//选择就绪的事件和对应的连接
      if(channel.event==accept){
         registerNewChannelHandler(channel);//如果是新连接,则注册一个新的读写处理器
      }
      if(channel.event==write){
         getChannelHandler(channel).channelWritable(channel);//如果可以写,则执行写事件
      }
      if(channel.event==read){
          getChannelHandler(channel).channelReadable(channel);//如果可以读,则执行读事件
      }
    }
   }
   Map<Channel,ChannelHandler> handlerMap;//所有channel的对应事件处理器
  }

这也是最简单的Reactor模式,注册所有感兴趣的事件处理器,单线程轮询选择就绪事件,执行事件处理器。

Reactor

一些概念性问题

阻塞与非阻塞:指的是当不能进行读写(网卡满时的写/网卡空时的读)的时候,I/O操作是立即返回还是阻塞;
同步与异步:描述的是用户线程与内核的交互方式;

所以阻塞I/O,非阻塞I/O,I/O多路复用,都属于同步调用。只有实现了特殊API的AIO才是异步调用。

相关原文链接:
https://xie.infoq.cn/article/0e36ad9712c8d9ad8f7a7c570
http://gee.cs.oswego.edu/dl/cpjslides/nio.pdf
https://zhuanlan.zhihu.com/p/93609693
https://www.jianshu.com/p/ee381d365a29
https://zhuanlan.zhihu.com/p/23488863

上一篇 下一篇

猜你喜欢

热点阅读