IO多路复用底层原理全解

预备知识:

 在TCP协议中,建立连接的两个进程各自有一个socket来标识,那么两个socket组成的socket pair就唯一标识一个连接。

1.Linux操作系统中断

举个例子:比如你在家打游戏,这时候你很饿,点了个外卖,然后你继续打游戏,打到boss剩一丝血的时候,外卖小哥来敲门了,此时你不好意思让外卖小哥一直敲门啊,你就中断了你的游戏,暂停存档了,然后去拿外卖了,回来继续打游戏。

上述例子中的外卖小哥来敲门就是系统收到了中断请求,游戏暂停存档去拿外卖就是系统被中断了。

先来看一个简单的TCP 消息发送接受的demo

服务端

public class TCPServer {
    public static void main(String[] args) {
        try {
            ServerSocket serverSocket = new ServerSocket(6666);
            while (true) {
                Socket accept = serverSocket.accept();
                System.out.println("与一个客户端建立连接");
                DataInputStream dataInputStream = new DataInputStream(accept.getInputStream());
                DataOutputStream dataOutputStream = new DataOutputStream(accept.getOutputStream());
                String str = null;
                if ((str = dataInputStream.readUTF()) != null) {
                    System.out.println(str);
                    System.out.println("from" + serverSocket.getInetAddress() +
                            ", port #" + accept.getPort());
                }
                dataOutputStream.writeUTF("Hello," + accept.getInetAddress() +
                        ", port#" + accept.getPort());
                dataInputStream.close();
                dataOutputStream.close();
                accept.close();
            }
        } catch (IOException e) {
        }
    }
}

客户端

public class TCPClient {

    public static void main(String[] args) {
        try {
            Socket socket = new Socket("127.0.0.1", 6666);
            OutputStream outputStream = socket.getOutputStream();
            DataOutputStream dataOutputStream = new DataOutputStream(outputStream);
            dataOutputStream.writeUTF("哈哈哈哈 我袁大头到此一游!!!");
            DataInputStream dataInputStream = new DataInputStream(socket.getInputStream());
            System.out.println(dataInputStream.readUTF());
            dataOutputStream.flush();
            dataOutputStream.close();
            dataInputStream.close();
            socket.close();
        } catch (IOException e) {
        }
    }
}

先启动服务端,服务端启动了服务,然后通过while循环时刻监听着6666这个端口,此时启动客户端,客户端会与6666端口建立链接,并且发送一个消息,然后服务端接受到消息。

 

一个socket连接的两段  输入输出都是带缓冲区的

写数据:写数据的时候,用户在APP的输入的数据,是放在用户态的,然后将用户态的数据拷贝到内核态,然后内核态的数据放到输出缓冲区,最后通过TCP/IP协议 将数据发送出去。 

读数据:读数据的时候,从输入缓冲区读到数据,此时数据在内核态,然后将读到的放在内核态中的数据复制到用户态中,此时APP端的用户就读到啦。

PS:如果scoked.closed()  那么输出缓冲区会继续发送直到发完   读数据的输入缓冲区就瞬间关闭。 

BIO:APP用户想要写数据的时候,如果输出缓冲区已经满了或者输出缓冲区剩下的容量小于本次要发送的容量,那么此时就会阻塞住。

一个进程只能监听一个客户端socket;

select: 

linux select函数详解:

在Linux中,我们可以使用select函数实现I/O端的复用,传递给select函数的参数会告诉内核:

1.我们所关心的文件描述符

2.对每个描述符,我们所关心的状态

3.我们要等待多长时间

从select函数返回后,内核告诉我们以下信息:

1.对我们的要求已经做好准备的描述符的个数

2.对于三种条件哪些描述符已经做好准备(读 写 异常)

有了这些返回信息,我们可以调用合适的I/O函数(通常是read或write) 并且这些函数不会再阻塞

 首先我们先看一下最后一个参数,它指明我们要等待的时间

struct timeval{
        long tv_sec;  /*秒 */
        long tv_usec; /*微秒*/
}

 有三种情况:

1.timeout == NULL 等待无限长的时间

2.timeout->tv_sec == 0 && timeout->tv_usec == 0 不等待,直接返回(非阻塞)

3.timeout->tv_sec != 0 || timeout->tv_usec != 0 等待指定的时间

中间的三个参数readset writeset exceptset 指向描述符集。这些参数指明了我们关心哪些描述符,和需要满足什么条件(可写,可读,异常)一个文件描述集保存在fd_set类型中,fd_set其实就是位图。fd_set linux定了一个大小为1024长度的位图。

第一个参数:最大有效位,有时候不需要全部检查,通过这个参数来限制检查位数。

demo:

int main()
{
    int sock;
    FILE *fp;
    struct fd_set fds;
//select等待3秒,3秒轮询,要非阻塞就置0
    struct timeval timeout={3,0}; 
    char buffer[256]={0}; //256字节的接收缓冲区
    /* 假定已经建立UDP连接,具体过程不写,简单,当然TCP也同理,
主机ip和port都已经给定,要写的文件已经打开
    sock=socket(...);
    bind(...);
    fp=fopen(...); */
    while(1)
    {
        FD_ZERO(&fds); //每次循环都要清空集合,否则不能检测描述符变化
        FD_SET(sock,&fds); //添加描述符
        FD_SET(fp,&fds); //同上
        maxfdp=sock>fp?sock+1:fp+1; //描述符最大值加1
        switch(select(maxfdp,&fds,&fds,NULL,&timeout)) //select使用
        {
            case -1: exit(-1);break; //select错误,退出程序
            case 0:break; //再次轮询
            default:
            if(FD_ISSET(sock,&fds)) //测试sock是否可读,即是否网络上有数据
            {
                recvfrom(sock,buffer,256,.....);//接受网络数据    
                if(FD_ISSET(fp,&fds)) //测试文件是否可写
                fwrite(fp,buffer...);//写入文件
                //buffer清空;
            }// end if break;
        }// end switch
    }//end while
}//end main

 

select函数原理

select系统调用是用来让我们的程序监视多个文件句柄的状态变化的。程序会停在select这⾥里等待,直到被监视的文件句柄有一个或多个发⽣生了状态改变。关于文件句柄,其实就是⼀一个整数,我们最熟悉的句柄是0、1、2三个,0是标准输入,1是标准输出,2是标准错误输出。0、1、2是整数表示的,对应的FILE *结构的表示就是stdin、stdout、stderr。

1.我们通常需要额外定义一个数组来保存需要监视的文件描述符,并将其他没有保存描述符的位置初始化为一个特定值,一般为-1,这样方便我们遍历数组,判断对应的文件描述符是否发生了相应的事件。

2.采用上述的宏操作FD_SET(int fd,fd_set*set)遍历数组将关心的文件描述符设置到对应的事件集合里。并且每次调用之前都需要遍历数组,设置文件描述符。

3.调用select函数等待所关心的文件描述符。有文件描述符上的事件就绪后select函数返回,没有事件就绪的文件描述符在文件描述符集合中对应的位置会被置为0,这就是上述第二步的原因。

4.select 返回值大于0表示就绪的文件描述符的个数,0表示等待时间到了,小于0表示调用失败,因此我们可以遍历数组采用FD_ISSET(int fd,fd_set *set)判断哪个文件描述符上的事件就绪,然后执行相应的操作。

select的优缺点
优点:
(1)select的可移植性好,在某些unix下不支持poll.
(2)select对超时值提供了很好的精度,精确到微秒,而poll式毫秒。
缺点:
(1)单个进程可监视的fd数量被限制,默认是1024。
(2)需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。
(3)对fd进行扫描时是线性扫描,fd剧增后,IO效率降低,每次调用都对fd进行线性扫描遍历,随着fd的增加会造成遍历速度慢的问题。
(4)select函数超时参数在返回时也是未定义的,考虑到可移植性,每次超时之后进入下一个select之前都要重新设置超时参数。

I/O多路复用之epoll函数
epoll函数预备知识


epoll函数是多路复用IO接口select和poll函数的增强版本。显著减少程序在大量并发连接中只有少量活跃的情况下CPU利用率,他不会复用文件描述符集合来传递结果,而迫使开发者每次等待事件之前都必须重新设置要等待的文件描述符集合,另外就是获取事件时无需遍历整个文件描述符集合,只需要遍历被内核异步唤醒加入ready队列的描述符集合就行了 。

epoll函数相关系统调用

 int epoll_create(int size);

生成一个epoll函数专用的文件描述符,其实是申请一个内核空间,用来存放你想关注的 socket fd 上是否发生以及发生了什么事件。 size 就是你在这个 Epoll fd 上能关注的最大 socket fd 数,大小自定,只要内存足够。

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

控制文件描述符上的事件,包括注册,删除,修改等操作。
epfd : epoll的专用描述符。
op : 相关操作,通常用以下宏来表示
event : 通知内核需要监听的事件,

EPOLL_CTL_ADD:注册新的fd到epfd中;
EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
EPOLL_CTL_DEL:从epfd中删除⼀一个fd;

fd : 需要监听的事件。结构体格式如下:

events的合法参数如下

EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这⾥里应该表⽰示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于⽔水平触发(Level
Triggered)来说的。
EPOLLONESHOT:只监听⼀一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加⼊入到EPOLL队列里。

 int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

epfd : epoll特有的文件描述符
events :从内核中的就绪队列中拷贝出就绪的文件描述符。不可以是空指针,内核只负责将数据拷贝到这里,不会为我们开辟空间。
maxevent : 高速内核events有多大,一般不能超过epoll_create传递的size,
timeout : 函数超时时间,0表示非阻塞式等待,-1表示阻塞式等待,函数返回0表示已经超时。
- epoll函数底层实现过程
首先epoll_create创建一个epoll文件描述符,底层同时创建一个红黑树,和一个就绪链表;红黑树存储所监控的文件描述符的节点数据,就绪链表存储就绪的文件描述符的节点数据;epoll_ctl将会添加新的描述符,首先判断是红黑树上是否有此文件描述符节点,如果有,则立即返回。如果没有, 则在树干上插入新的节点,并且告知内核注册回调函数。当接收到某个文件描述符过来数据时,那么内核将该节点插入到就绪链表里面。epoll_wait将会接收到消息,并且将数据拷贝到用户空间,清空链表。对于LT模式epoll_wait清空就绪链表之后会检查该文件描述符是哪一种模式,如果为LT模式,且必须该节点确实有事件未处理,那么就会把该节点重新放入到刚刚删除掉的且刚准备好的就绪链表,epoll_wait马上返回。ET模式不会检查,只会调用一次

epoll函数的优缺点
优点:
epoll的优点:
(1)支持一个进程打开大数目的socket描述符(FD)
select 最不能忍受的是一个进程所打开的FD是有一定限制的,由FD_SETSIZE设置,默认值是2048。对于那些需要支持的上万连接数目的IM服务器来说显 然太少了。这时候你一是可以选择修改这个宏然后重新编译内核,不过资料也同时指出这样会带来网络效率的下降,二是可以选择多进程的解决方案(传统的 Apache方案),不过虽然linux上面创建进程的代价比较小,但仍旧是不可忽视的,加上进程间数据同步远比不上线程间同步的高效,所以也不是一种完 美的方案。不过 epoll则没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左 右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。
(2)IO效率不随FD数目增加而线性下降
传统的select/poll另一个致命弱点就是当你拥有一个很大的socket集合,不过由于网络延时,任一时间只有部分的socket是”活跃”的, 但是select/poll每次调用都会线性扫描全部的集合,导致效率呈现线性下降。但是epoll不存在这个问题,它只会对”活跃”的socket进行 操作—这是因为在内核实现中epoll是根据每个fd上面的callback函数实现的。那么,只有”活跃”的socket才会主动的去调用 callback函数,其他idle状态socket则不会,在这点上,epoll实现了一个”伪”AIO,因为这时候推动力在os内核。在一些 benchmark中,如果所有的socket基本上都是活跃的—比如一个高速LAN环境,epoll并不比select/poll有什么效率,相 反,如果过多使用epoll_ctl,效率相比还有稍微的下降。但是一旦使用idle connections模拟WAN环境,epoll的效率就远在select/poll之上了。
(3)使用mmap加速内核与用户空间的消息传递。
这点实际上涉及到epoll的具体实现了。无论是select,poll还是epoll都需要内核把FD消息通知给用户空间,如何避免不必要的内存拷贝就 很重要,在这点上,epoll是通过内核于用户空间mmap同一块内存实现的。而如果你想我一样从2.5内核就关注epoll的话,一定不会忘记手工 mmap这一步的。
(4)内核微调
这一点其实不算epoll的优点了,而是整个linux平台的优点。也许你可以怀疑linux平台,但是你无法回避linux平台赋予你微调内核的能力。 比如,内核TCP/IP协议栈使用内存池管理sk_buff结构,那么可以在运行时期动态调整这个内存pool(skb_head_pool)的大小 — 通过echo XXXX>/proc/sys/net/core/hot_list_length完成。再比如listen函数的第2个参数(TCP完成3次握手 的数据包队列长度),也可以根据你平台内存大小动态调整。更甚至在一个数据包面数目巨大但同时每个数据包本身大小却很小的特殊系统上尝试最新的NAPI网 卡驱动架构。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值