美文网首页
Linux/IO学习笔记

Linux/IO学习笔记

作者: 奔向学霸的路上 | 来源:发表于2020-10-28 09:37 被阅读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 监听文件描述符list,进行一个线性的查找 O(n)
    • epoll: 使用了内核文件级别的回调机制O(1)

    select

    • select只能处理有限个socket(不同系统参数:1024/2048)
    • select监控socket时不能准确告诉用户是哪个socket有可用数据,需要轮询判断


      select

    poll

    • 采用链表实现,取消了文件个数的限制


      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);
    
    • epoll_create创建一个epoll实例
    • 返回值【success:返回一个非0 的未使用过的最小的文件描述符;error:-1 errno被设置】

    如果不需要使用这个描述符,需要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是目标文件描述符。

    • EPOLL_CTL_ADD 在epfd中注册指定的fd文件描述符并能把event和fd关联起来。
    • EPOLL_CTL_MOD 改变*** fd和evetn***之间的联系。
    • EPOLL_CTL_DEL 从指定的epfd中删除fd文件描述符。在这种模式中event是被忽略的,并且为可以等于NULL。

    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这个参数是一个字节的掩码构成的。下面是可以用的事件:

    • EPOLLIN - 当关联的文件可以执行 read ()操作时。
    • EPOLLOUT - 当关联的文件可以执行 write ()操作时。
    • EPOLLRDHUP - (从 linux 2.6.17 开始)当socket关闭的时候,或者半关闭写段的(当使用边缘触发的时候,这个标识在写一些测试代码去检测关闭的时候特别好用)
    • EPOLLPRI - 当 read ()能够读取紧急数据的时候。
    • EPOLLERR - 当关联的文件发生错误的时候,epoll_wait() 总是会等待这个事件,并不是需要必须设置的标识。
    • EPOLLHUP - 当指定的文件描述符被挂起的时候。epoll_wait() 总是会等待这个事件,并不是需要必须设置的标识。当socket从某一个地方读取数据的时候(管道或者socket),这个事件只是标识出这个已经读取到最后了(EOF)。所有的有效数据已经被读取完毕了,之后任何的读取都会返回0(EOF)。
    • EPOLLET - 设置指定的文件描述符模式为边缘触发,默认的模式是水平触发。
    • EPOLLONESHOT - (从 linux 2.6.17 开始)设置指定文件描述符为单次模式。这意味着,在设置后只会有一次从epoll_wait() 中捕获到事件,之后你必须要重新调用 epoll_ctl() 重新设置。

    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由三部分组成:

    • Buffer缓冲区
    • Channel通道
    • Selector 选择器

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

    NIO

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

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

    Buffer的实现

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

    • mark:初始值为-1,用于备份当前的position
    • position:初始值为0,用于记录当前可以写入或读取数据的位置,当写入或读取一个数据后,position向前移动到下一个位置
    • limit:写模式下,limit表示最多能往Buffer里写多少数据,等于capacity;读模式下,limit表示最多可以读取多少数据
    • capacity:缓存数组大小

    读写模式,通过调用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总结

    • 事件驱动模型
    • 避免多线程
    • 单线程处理多任务
    • 非阻塞I/O,I/O读写不再阻塞,而是返回0
    • 基于block的传输,通常比基于流的传输更高效
    • 更高级的IO函数,zero-copy
    • IO多路复用大大提高了Java网络应用的可伸缩性和实用性

    伪代码:

    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

    • Reactor是一种设计模式,是NIO的高级版
    • 事件处理模式
    • 一次处理一个或多个输入
    • 多路分解,分发


      reactor-单线程
      多reactor

    一些概念性问题

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

    • 同步:用户线程发起IO请求后需要等待或者轮询内核IO操作完成后才能继续执行
    • 异步:用户线程发起IO请求后扔要继续执行,当内核IO操作完成后会通知用户线程,或者调用用户线程注册的回调函数

    所以阻塞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

    相关文章

      网友评论

          本文标题:Linux/IO学习笔记

          本文链接:https://www.haomeiwen.com/subject/gveoyktx.html