I/O多路复用
先了解几个基本概念。
基本概念
同步与异步
同步:发起一个调用后,被调用者未处理完请求之前,调用不返回。
异步:发起一个调用后,被调用者立刻回应表示已接收到请求,但被调用者没有返回结果,此时调用者可以处理其它请求。
同步和异步最大的区别是:异步的调用者不需要等待结果,被调用者会通过回调等机制来通知调用者返回结果。
阻塞与非阻塞
阻塞:发起一个请求,调用者一直在等待返回结果,当前线程被挂起,无法干其它事。
非阻塞:发起一个请求,调用者不用一直等待结果,可以先去干其它事。
通过例子理解同步与异步、阻塞与非阻塞
1.老张把水壶放到火上,立等水开。(同步阻塞)
2.老张把水壶放到火上,去看电视,时不时去查看水开了没有。(同步非阻塞)
3.老张把响水壶放到火上,立等水开。(异步阻塞)
4.老张把响水壶放到火上,去看电视,等响了再去拿壶。(异步非阻塞)
BIO(Blocking IO)模型
同步阻塞IO模式,数据的读取写入必须阻塞在一个线程内等待完成(一个客户端连接对应一个处理线程)。
Acceptor线程监听客户端的连接,在while(true)循环中,服务端调用accept()方法监听请求,一旦接收到一个连接请求,就建立通信套接字并进行读写操作。此时,不再接收其它客户端的连接请求,只能等待当前连接的客户端的操作执行完成,不过可以通过多线程来支持多个客户端的连接,如上图所示。
多线程处理也有弊端:有大量客户端连接请求时,线程数急剧膨胀,导致堆栈溢出、创建新线程失败等问题,最终导致进程宕机。
且线程是宝贵的资源:
1.线程的创建和销毁的成本很高。
2.线程本身占用较大的内存,像Java的线程栈,一般至少分配512k~1M的空间,如果系统中线程数过千,恐怕整个JVM内存都会被吃掉一半。
3.线程的切换成本很高。在切换线程时,需要保留线程的上下文,如果线程数过高,会导致系统负载升高,CPU sy使用率升高。
4.容易造成锯齿状的系统负载。一旦线程数高但外部网络环境不稳定,很容易造成大量请求的结果同时返回,激活大量阻塞线程,从而使系统负载压力过大。
为了解决上述问题,提出了伪异步IO模型:后端通过一个线程池来处理多个客户端的请求,线程池设置最大线程数,所以即使客户端请求远大于线程池中线程的个数,也可以通过线程池灵活调配线程资源,防止由于海量并发导致线程耗尽。
BIO适用于连接数小且固定的架构,这种方式对服务器资源要求比较高,但程序简单易于理解。
NIO(Non Blocking IO)
同步非阻塞,服务器实现模式为一个线程可以处理多个连接,客户端发送的连接请求都会注册到多路复用器selector上,多路复用器轮询到连接有IO请求就处理。
NIO有三大核心组件:
1.Channel(通道)
2.Buffer(缓冲区)
3.Selector(选择器)
channel类似于流,每个channel对应一个buffer,buffer底层是个数组。
channel会注册到selector上,由selector根据channel读写事件的发生将其交由某个空闲的线程处理(类似于上帝视角)。
selector可以对应一个或多个线程。
NIO中,所有的数据都是用缓冲区处理的。读数据时,直接读到缓冲区;写数据时,写入缓存中。
NIO中的IO都是从channel开始的:
1.读取数据:创建一个缓冲区,请求通道读取数据
2.写入数据:创建一个缓冲区,要求通道写入数据
NIO适用于连接数多且连接比较短(清操作)的架构,如弹幕系统。
AIO(Asynchronous IO)
异步非阻塞,由操作系统完成后回调通知服务端程序启动线程去处理,适用于连接数多且连接时间长的应用。
用户空间/内核空间
操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核,保证内核的安全,操作系统将虚拟存储空间分为两部分,一部分为内核空间,一部分供各个进程使用,称为用户空间。
文件描述符fd
File description用于表述指向文件的引用的抽象化概念,形式上是一个非负整数,实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。
当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。
什么是IO多路复用?
1.IO多路复用是一种同步IO模型,一个线程可以监视多个文件句柄
(文件句柄:就是给一个文件、设备、套接字或管道的一个名字,以便记住正在处理的名字,隐藏缓存等的复杂性。)
2.一旦某个文件句柄就绪,就能通知应用程序进行相应的读写操作
3.没有文件句柄就绪就会阻塞应用程序,交出CPU
多路是指网络连接,复用是指同一个线程。
服务器采用单线程通过select/poll/epoll等系统调用获取fd列表,遍历有事件的fd进行accept/recv/send,使其能支持更多的并发连接请求。
IO多路复用的三种实现
select
poll
epoll
select
它仅仅知道有IO事件发生了,但不知道是哪几个流,所以只能无差别轮询所有流,找出能读出数据,或者写入数据的流。select具有O(n)的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。
select调用过程
1.使用copy_from_user从用户空间拷贝fd_set到内核空间
2.注册回调函数_pollwait
3.遍历所有fd,调用其对应的poll方法
4._pollwait的主要工作是把当前进程挂到设备的等待队列中,在设备收到一条消息(网络设备)或填写完文件数据(磁盘设备)后,会唤醒设备等待队列上睡眠的进程,这时当前进程就被唤醒了
5.poll方法会返回一个描写读写操作是否就绪的mask掩码,根据这个mask掩码给fd_set赋值
6.如果遍历完所有的fd,还没有返回一个可读写的mask掩码,就会使调用select的进程进入睡眠。当设备发生资源可读写后,会唤醒等待队列上睡眠的进程。如果超过一定时间,还是没有唤醒(就是说没有发生资源可读写),调用select的进程会重新被唤醒获得CPU,重新遍历fd,判断有没有就绪的fd
7.把fd_set从内核空间拷贝到用户空间
select的缺点:
1.单个进程所打开的fd是有限制的
2.每次调用select,都需要把fd_set从用户态拷贝到内核态,这个开销在fd_set很大时会很大
3.对socket扫描时是线性扫描,采用轮询的方法,效率较低(高并发)。
poll
poll本质上和select没有区别,也是将用户传入的数组拷贝到内核空间,然后轮询,但是它没有最大连接数的限制,因为它是基于链表存储的。
poll的缺点和select的第2,3个缺点是一样的
epoll
epoll可以理解为event poll,不同于无差别轮询,epoll会把哪个流发生了怎样的IO事件通知我们。所以epoll实际上是**事件驱动(每个事件关联上fd)**的,此时我们对这些流的操作都是有意义的。(复杂度降到了O(1))
每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。这些事件都会挂载在红黑树中,这样,重复添加的事件就可以通过红黑树高效地识别出来。
而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,当相应的事件发生时就会调用这个回调方法。这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中。
当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可。如果rdlist不为空,就把发生的事件复制到用户态,同时将事件数量返回给用户。
通过红黑树和双链表的数据结构,结合回调机制,造就了epoll的高效。
epoll的用法
1.epoll_create():返回一个句柄,之后所有的使用都依靠这个句柄来标识
2.epoll_ctl():通过此调用向epoll对象中添加、删除、修改感兴趣的事件,返回0表示成功,返回-1表示失败。
3.epoll_wait():此调用收集在epoll监控中已经发生的事件。
epoll的优点
1.没有最大并发连接数的限制,能打开的fd的上限远大于1024
2.效率提升。只有活跃可用的fd才会调用callback函数,就不用轮询了
3.内存拷贝开销减少,利用mmap()文件映射内存加速与内核空间的消息传递
epoll的缺点
只能在linux下工作
epoll LT与ET模式的区别
EPOLLLT(水平触发):只要这个fd还有数据可读,每次epoll_wait都会返回它的事件,提醒用户操作
EPOLLET(边缘触发):它只会提示一次,无论fd中是否还有数据可读,直到下次再有数据流入之前都不会再提示了。所以在ET模式下,read一个fd的时候一定要把它的buffer读完
select、poll、epoll的区别
别
EPOLLLT(水平触发):只要这个fd还有数据可读,每次epoll_wait都会返回它的事件,提醒用户操作
EPOLLET(边缘触发):它只会提示一次,无论fd中是否还有数据可读,直到下次再有数据流入之前都不会再提示了。所以在ET模式下,read一个fd的时候一定要把它的buffer读完