转自:NIO相关基础篇三 https://blog.csdn.net/lirenzuo/article/details/78898430
1、用户空间、内核空间概念
为了保证用户进程不能直接操作内核,保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。
每个进程可以通过系统调用进入内核,因此,【Linux内核由系统内的所有进程共享】。
linux内部结构可以分为三部分,从最底层到最上层依次是:硬件–>内核空间–>用户空间
内核空间中存放的是内核代码和数据,而进程的用户空间中存放的是用户程序的代码和数据。
不管是内核空间还是用户空间,它们都处于虚拟空间中。
2、Linux 网络 I/O模型
1)为了OS的安全性等的考虑,进程是无法直接操作I/O设备的,
其必须【通过系统调用请求内核来协助完成I/O动作】,而内核会为每个I/O设备维护一个buffer
2)请求过程为: 用户进程发起请求,内核接受到请求后,从I/O设备中获取数据到buffer中,再将buffer中的数据copy到用户进程的地址空间,该用户进程获取到数据后再响应客户端。
3)I/O动作五种模式
数据输入至buffer需要时间,从buffer复制数据至用户进程也需要时间。因此根据在这两段时间内等待方式的不同,把I/O分为5中模式。
-
a)阻塞I/O (Blocking I/O)
在linux中,默认情况下所有的socket都是blocking当用户进程调用了recvfrom这个系统调用,内核就开始了IO的第一个阶段:等待数据准备。
对于network io来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的UDP包),这个时候内核就要等待足够的数据到来。
而在用户进程这边,整个进程会被阻塞。当内核一直等到数据准备好了,就会将数据从内核中拷贝到用户内存,然后内核返回结果,用户进程才解除block的状态,重新运行起来。
所以,blocking IO的特点就是在IO执行的两个阶段都被block了。 -
b)非阻塞I/O (Non-Blocking I/O)
linux下,可以通过设置socket使其变为non-blocking当用户进程调用recvfrom时,系统不会阻塞用户进程,而是立刻返回一个ewouldblock错误,从用户进程角度讲 ,并不需要等待,而是马上就得到了一个结果。
用户进程判断标志是ewouldblock时,就知道数据还没准备好,于是它可以再次发送recvfrom。
一旦内核中的数据准备好了。并且又再次收到了用户进程的系统调用,那么它马上就将数据拷贝到了用户内存,然后返回。用户进程非阻塞的调用recvfrom系统调用,我们称为轮询。应用程序不断轮询内核,看看是否已经准备好了某些操作。这通常是浪费CPU时间,但这种模式偶尔会遇到。
-
c)I/O复用(I/O Multiplexing)
select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。
它的基本原理就是 select/epoll 这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。当用户进程调用了select,那么整个进程会被block,而同时,内核会“监视”select负责的所有socket,当任何一个socket中的数据准备好了,select就会返回。
这个时候用户进程再调用read操作,将数据从内核拷贝到用户进程。因为这里需要使用两个system call (select 和 recvfrom),而blocking IO只调用了一个system call (recvfrom)。所以耗时要比阻塞I/O(Blocking I/O)长。
但是,用select的优势在于它可以同时处理多个connection。在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,
但是整个用户的process还是一直被block的,process是被select这个函数block,而不是被socket IO给block。 -
d)信号驱动的I/O (Signal Driven I/O)
用户进程不是阻塞的。
首先用户进程建立SIGIO信号处理程序,并通过系统调用sigaction执行一个信号处理函数,这时用户进程便可以做其他的事了。
一旦数据准备好,系统便为该进程生成一个SIGIO信号,去通知它数据已经准备好了,于是用户进程便调用recvfrom把数据从内核拷贝出来,并返回结果。 -
e)异步I/O (Asynchrnous I/O)
这些函数通过告诉内核启动操作并在整个操作(包括内核的数据到缓冲区的副本)完成时通知我们。
这个模型和前面的信号驱动I/O模型的主要区别是,在信号驱动的I/O中,内核告诉我们何时可以启动I/O操作,但是异步I/O时,内核告诉我们何时I/O操作完成。当用户进程向内核发起某个操作后,会立刻得到返回,并把所有的任务都交给内核去完成(包括将数据从内核拷贝到用户自己的缓冲区),
内核完成之后,只需返回一个信号告诉用户进程已经完成就可以了。
4)文件描述符fd
Linux的内核将所有外部设备都可以看做一个文件来操作。
用户进程通过调用内核提供的系统调用对一个文件读写,内核给我们返回一个filede scriptor(fd,文件描述符)。
对一个socket的读写也会有相应的描述符,称为socketfd(socket描述符)
描述符就是一个数字,指向内核中一个结构体(文件路径,数据区,等一些属性),应用程序对文件的读写通过对描述符的读写完成。
5)select
select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds。
调用后select函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。
当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。
缺点:
-
[1]select最大的缺陷就是单个进程所打开的FD是有一定限制的,它由FD_SETSIZE设置,32位机默认是1024个,64位机默认是2048。
一般来说这个数目和系统内存关系很大,”具体数目可以cat /proc/sys/fs/file-max察看”。32位机默认是1024个。64位机默认是2048. -
[2]对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低。
当套接字比较多的时候,每次select()都要通过遍历FD_SETSIZE个Socket来完成调度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间。 -
[3]需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。
6)poll
poll本质上和select没有区别,将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,
如果设备就绪则在设备等待队列中加入一项并继续遍历,
如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。
这个过程经历了多次无谓的遍历。
它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有一个缺点:
-
[1]大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。
-
[2]poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。
select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。
事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。
7)epoll
epoll没有描述符限制。
epoll使用一个文件描述符管理多个描述符,将【用户关系的文件描述符】的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。
poll支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些fd刚刚变为就绪态,并且只会通知一次。
还有一个特点是,epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,
一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。
epoll的优点:
-
[1]没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口)
-
[2]效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。
只有活跃可用的FD才会调用callback函数;
即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。 -
[3]内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。
8)使用mmap,让数据传输不需要经过user space
应用程序调用mmap(),磁盘上的数据会通过DMA被拷贝的内核缓冲区,接着操作系统会把这段内核缓冲区与应用程序共享,这样就不需要把内核缓冲区的内容往用户空间拷贝。
使用mmap是有代价的。当你使用mmap时,你可能会遇到一些隐藏的陷阱。例如,当你的程序map了一个文件,
但是当这个文件被另一个进程截断(truncate)时, write系统调用会因为访问非法地址而被SIGBUS信号终止。
解决方案避免这种问题:
-
[1]为SIGBUS信号建立信号处理程序
当遇到SIGBUS信号时,信号处理程序简单地返回,write系统调用在被中断之前会返回已经写入的字节数,
并且errno会被设置成success,但是这是一种糟糕的处理办法,因为你并没有解决问题的实质核心。 -
[2]使用文件租借锁
通常我们使用这种方法,在文件描述符上使用租借锁,我们为文件向内核申请一个租借锁,
当其它进程想要截断这个文件时,内核会向我们发送一个实时的 RT_SIGNAL_LEASE 信号,告诉我们内核正在破坏你加持在文件上的读写锁。
这样在程序访问非法内存并且被SIGBUS杀死之前,你的write系统调用会被中断。write会返回已经写入的字节数,并且置errno为success。
我们应该在mmap文件之前加锁,并且在操作完文件后解锁。