一. 基本概念
1.1 用户空间和内核空间
1、我们知道现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)
2、操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。
3、 为了保证用户进程不能直接操作内核,保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。
4、针对linux操作系统而言,将最高的1G字节,供内核使用,称为内核空间,而将较低的3G字节,供各个进程使用,称为用户空间。
5、每个进程可以通过系统调用进入内核,因此,Linux内核由系统内的所有进程共享。于是,从具体进程的角度来看,每个进程可以拥有4G字节的虚拟空间。
1.2 缓存IO
- linux内部结构的组成
1、有了用户空间和内核空间,整个linux内部结构可以分为三部分,从最底层到最上层依次是:硬件–>内核空间–>用户空间。
2、内核空间中存放的是内核代码和数据,而进程的用户空间中存放的是用户程序的代码和数据。不管是内核空间还是用户空间,它们都处于虚拟空间中。
3、Linux使用两级保护机制:0级供内核使用,3级供用户程序使用
- 缓存IO
1、我们都知道,为了OS的安全性等的考虑,进程是无法直接操作I/O设备的,其必须通过系统调用请求内核来协助完成I/O动作,而内核会为每个I/O设备维护一个buffer。
2、整个请求过程为: 用户进程发起请求,内核接受到请求后,从I/O设备中获取数据到buffer中,再将buffer中的数据copy到用户进程的地址空间,该用户进程获取到数据后再响应客户端。
3、在整个请求过程中,数据输入至buffer需要时间,而从buffer复制数据至进程也需要时间。
1.3 文件描述符fd
- 文件描述符是计算机科学中的一个术语,用于表述指向文件引用的抽象化概念。
- 文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。
- 当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。
二. 多路复用之select、poll、epoll详解
1、select,poll,epoll都是IO多路复用的机制。I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。
2、select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写
3、select,poll,epoll本质上都是阻塞I/O,也就是说这个读写过程是阻塞的。
2.1 select的使用
int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);
参数 | 解析 |
---|---|
nfds | 添加到集合中的最大文件描述符+1 |
readfds | 监测可读的文件描述符的集合 |
writefds | 监测可写的文件描述符的集合 |
exceptfds | 监测发生异常的文件描述符的集合 |
timeout | 监测超时时间,超过这个时间将不再等待 |
- 这个函数会一直阻塞等待并不停监测集合里的文件描述符是否发生变化,如果发生变化就返回,不再阻塞,并且会将集合里没有发生变化的文件描述符从集合里踢出去。当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。
- select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。select的一 个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但是这样也会造成效率的降低。
void FD_CLR(int fd, fd_set *set);
将某个文件描述符从集合里清除:fd-要清除的文件描述符,set-要从哪个集合清除文件描述符
void FD_SET(int fd, fd_set *set);
将某个文件描述符添加到集合里:fd-要添加的文件描述符,set-要将文件描述符添加到哪个集合
2.2 poll的使用
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数 | 解析 |
---|---|
fds | 存储监听的文件描述符的结构体指针 |
nfds | struct pollfd结构体数组的个数 |
timeout | 监测超时时间,超过这个时间将不再等待 |
存储监听的文件描述符的结构体指针
struct pollfd
{
int fd; /* 要监听的文件描述符*/
short events; /* 请求的事件*/
short revents; /* 返回的事件 */
};
events取值:
POLLIN 普通或优先级带数据可读
POLLRDNORM 普通数据可读
POLLRDBAND 优先级带数据可读
POLLPRI 高优先级数据可读
POLLOUT 普通数据可写
POLLWRNORM 普通数据可写
POLLWRBAND 优先级带数据可写
后面这三个只能作为描述字的返回结果存储在revents中, 而不能作为测试条件用于events中
POLLERR 发生错误
POLLHUP 发生挂起
POLLNVAL 描述字不是一个打开的文件
- poll函数与select函数的最大不同之处在于:select函数有最大文件描述符的限制,一般1024个,而poll函数对文件描述符的数量没有限制。
- 但select和poll函数都是通过轮询的方式来查询某个文件描述符状态是否发生了变化,并且需要将整个文件描述符集合在用户空间和内核空间之间来回拷贝,这样随着文件描述符的数量增加,相应的开销也随之增加。
2.3 epoll的使用
epoll是在Linux内核2.6引进的,是select和poll函数的增强版。与select相比,epoll没有文件描述符数量的限制。epoll使用一个文件描述符管理多个文件描述符,将用户关心的文件描述符事件存放到内核的一个事件列表中,这样在用户空间和内核空间只需拷贝一次。
int epoll_create(int size);
创建句柄,参数: 1、size 支持的最大句柄数,在linux内核2.6.8后被忽略,但必须大于0
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
功能:添加、删除、修改文件描述符的情况
参数epfd: epoll_create()的返回值
参数op:新增、修改、还是删除操作
参数events: 事件结构体
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
功能:监测等待
参数epfd: epoll_create()的返回值
参数events: 事件结构体
参数maxevents 告诉内核events的大小
struct epoll_event {
uint32_t events; /* 事件 */
epoll_data_t data; /* 数据 */
};
typedef union epoll_data {
void *ptr; //如果需要发送数据
int fd; //哪个文件描述符变化了
uint32_t u32;
uint64_t u64;
} epoll_data_t;
events可以是以下几个宏的集合:
EPOLLIN : 表示对应的文件描述符可以读(包括对端 SOCKET正常关闭);
EPOLLOUT: 表示对应的文件描述符可以写;
EPOLLPRI: 表示对应的文件描述符有紧急的数据可读(这 里应该表示有带外数据到来);
EPOLLERR: 表示对应的文件描述符发生错误;
EPOLLHUP: 表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话, 需要再次把这个socket加入到EPOLL队列里
-
水平触发LT和边缘触发ET
1、LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。
2、ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。
3、ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。 -
epoll和select、poll的区别
1、epoll使用“事件”的就绪通知方式,通过epollctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epollwait便可以收到通知。
2、效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。(只有活跃可用的FD才会调用callback函数;即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。)
3、没有最大并发连接的限制,能打开的FD的上限远大于1024(1G内存上能监听约10万个端口)。
4、内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。
三. I/O五种模式
在整个请求过程中,数据输入至buffer需要时间,而从buffer复制数据至进程也需要时间。因此根据在这两段时间内等待方式的不同,I/O动作可以分为五种模式。
记住这两点很重要
1 等待数据准备 (Waiting for the data to be ready)
2 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)
3.1 阻塞IO
- 在linux中,默认情况下所有的socket都是blocking,一个典型的读操作流程大概是这样:
- 当用户进程调用了recvfrom这个系统调用,内核就开始了IO的第一个阶段:等待数据准备。
- 对于network io来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的UDP包),这个时候内核就要等待足够的数据到来。
- 而在用户进程这边,整个进程会被阻塞。当内核一直等到数据准备好了,它就会将数据从内核中拷贝到用户内存,然后内核返回结果,用户进程才解除block的状态,重新运行起来。
- 所以blocking IO的特点就是在IO执行的两个阶段都被block了。
3.2 非阻塞IO
- Linux下,可以通过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操作时,流程是这个样子:
- 当用户进程调用recvfrom时,系统不会阻塞用户进程,而是立刻返回一个ewouldblock错误,从用户进程角度讲 ,并不需要等待,而是马上就得到了一个结果。
- 用户进程判断标志是ewouldblock时,就知道数据还没准备好,于是它就可以去做其他的事了,于是它可以再次发送recvfrom,一旦内核中的数据准备好了。并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。
- 当一个应用程序在一个循环里对一个非阻塞调用recvfrom,我们称为轮询。应用程序不断轮询内核,看看是否已经准备好了某些操作。这通常是浪费CPU时间,但这种模式偶尔会用到。
3.3 多路IO复用
-
IO multiplexing这个词可能有点陌生,但是如果我说select,epoll,大概就都能明白了。有些地方也称这种IO方式为event driven IO。我们都知道,select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select/epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。它的流程如下:
-
当用户进程调用了select,那么整个进程会被block,而同时,内核会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。
-
这个时候用户进程再调用read操作,将数据从内核拷贝到用户进程。
3.4 信号驱动的IO
- 很明显可以看出用户进程不是阻塞的。
- 首先用户进程建立SIGIO信号处理程序,并通过系统调用sigaction执行一个信号处理函数,这时用户进程便可以做其他的事了。
- 一旦数据准备好,系统便为该进程生成一个SIGIO信号,去通知它数据已经准备好了,于是用户进程便调用recvfrom把数据从内核拷贝出来,并返回结果。
3.5 异步IO
- 当用户进程向内核发起某个操作后,会立刻得到返回,并把所有的任务都交给内核去完成(包括将数据从内核拷贝到用户自己的缓冲区)。
- 内核完成之后,只需返回一个信号告诉用户进程已经完成就可以了。