一、基础概念
先操作系统的一些基础概念做一下解释,便于后续内容的理解。
1、用户空间和内核空间
操作系统为了保证内核程序运行不会被用户程序破坏,对虚拟内存分成了两部分:和用户空间内核空间。用户程序只能访问用户空间,如果需要访问内核空间,则必须先交出控制权,由内核程序来访问内核空间,再将数据拷贝到用户空间。
比如32位的操作系统,其寻址空间为4G(2的32次方),Linux将地址高的1G交由内核使用,3G留给用户程序。
2、文件描述符
文件描述符 File descriptor,即fd,是一个指向文件操作记录的索引。当程序对文件进行操作的时候,内核就会向程序返回一个文件描述符。
3、缓存I/O
缓存I/O是指数据先会进入操作系统内核的缓冲区,然后才从缓冲区拷贝到应用程序的地址空间。
缓存I/O也是为了避免应用程序直接操作硬件资源,但会带来CPU内存开销,也会影响处理效率。
二、I/O模式
由于缓存I/O的存在,数据操作会分为两个阶段:
1)等待数据准备,数据先被拷到内核缓冲区
2)将数据从内核缓冲区拷贝到用户空间
下面介绍Linux具体的四种网络模式:同步阻塞I/O(Blocking IO,BIO)、同步非阻塞 I/O(nonblocking IO, NIO)、I/O 多路复用( IO multiplexing)、异步 I/O(asynchronous IO,AIO)。
1、BIO 同步阻塞I/O
BIO 是指用户程序会一直阻塞直到上述两个阶段执行完成。
2、NIO 同步非阻塞I/O
NIO 是指用户程序发出read操作时,直接返回结果,如果数据没有准备好,就会返回错误。因此用户需要不断主动询问是否已准备好数据。
3、I/O 多路复用
I/O 多路复用网络编程中的概念,就是下文将要讲的select、epoll做的事情,是为了解决同时监听多个socket的问题。
如果要同时监听多个socket,原本可以用多线程加阻塞I/O的方式实现,但不断增加线程会很快耗尽系统资源;而I/O多路复用的思路就是用单线程处理多个网络I/O,以select模式为例,用户进程只会阻塞在调用select函数,一但有数据准备好,select模式就可以继续进行数据读取,该模式在后文详解。
4、AIO 异步I/O
AIO 是指用户程序发出read操作后,直接去做其他事了,当内核将数据拷到用户空间后,会通知用户程序操作已完成。
NIO 和 AIO 的区别
NIO 和 AIO 的区别在于,NIO 用户程序持续关心结果,因此虽然用户程序不会被阻塞,但仍需要主动调用去通知内核程序处理数据;而 AIO 用户程序是不直接关心处理进度,直到内核将数据处理完,通知用户程序就好了。
三、简单网络程序执行过程
一个简单的TCP服务端伪代码如下:
// 创建socket
int s = socket(...);
// 绑定端口等
bind(s, ...)
// 监听socket
listen(s, ...)
// 接受客户端连接
int c = accept(s, ...)
// 接收客户端数据
recv(c, ...);
// 数据处理程序
...
recv是阻塞方法,当执行到recv的时候,应用程序进程会进入阻塞状态,并加入到socket的等待队列(等待队列可以理解为socket对象里的一个列表,当socket接收到数据,会通知队列成员)。当有数据到来时,网络数据会写入内存,网卡发起中断信号,CPU执行中断程序,将数据写入socket缓冲区,并唤醒应用程序,应用程序从等待队列进入工作队列,从阻塞态转为就绪态,得到系统资源后执行读程序。
由于应用程序监听某个socket的时候会阻塞在recv,因此无法监听多个socket,为了处理这个问题,出现了select、poll和epoll,poll相对于select改进不大,因此不做介绍了。
四、select
select处理的伪代码如下:
// 多个socket
int s1 = socket(...);
bind(s1, ...);
listen(s1, ...);
int s2 = socket(...);
bind(s2, ...);
listen(s2, ...);
...
int fds[] = {s1, s2, ...};
while(1){
int n = select(..., fds, ...)
for(int i=0; i < fds.count; i++){
if(FD_ISSET(fds[i], ...)){
// 处理有数据的socket
}
}}
应用程序创建n个socket后,用一个文件描述符数据fds存放所有需要监听的socket,然后调用select函数,此时应用程序会阻塞。注意这时应用程序会被加入所有socket的等待队列(上文讲了每个socket都有一个等待队列),当有网络数据到来时,应用程序会被内核唤醒,然后遍历所有socket,处理对应socket的数据。
select用单个线程处理了所有socket的监听,但有两个问题:
1)将socket添加到fds数组效率很低,需要遍历;
2)处理数据需要遍历所有socket才知道哪个socket要处理;
epoll针对上述两点做了处理。
五、epoll
epoll处理伪代码如下:
// 创建n个socket
int s = socket(...);
bind(s, ...)
listen(s, ...)
...
// 创建epoll对象eventpoll
int eventpoll = epoll_create(...);
// 将所有需要监听的socket添加到eventpoll中
epoll_ctl(eventpoll, ...);
while(1){
// 此处阻塞
int n = epoll_wait(...)
// 遍历需要处理的socket
}
如上,epoll有三步:
1)调用epoll_create创建eventpoll对象,eventpoll有一个监听着socket的红黑树和一个就绪列表rdllist。
2)调用epoll_ctl将所有需要监听的socket添加到event对象中;
3)调用epoll_wait阻塞进程,观察就绪列表rdllist是否为空,当就绪列表rdllist为空表示没有数据,继续sleep;当就绪列表有不空,就处理rdllist中socket的数据。
epoll针对select问题的两点优化:
1、epoll将fds数据改为了红黑树,查找和删除效率提高很多;
2、epoll将就绪的socket存在rdllist中,不需要遍历所有的socket。rdllist结构是双向链表,也是为了提高插入、删除效率。
非常赞的几篇参考:
I/O模式 https://segmentfault.com/a/1190000003063859
select 和 epoll https://www.jianshu.com/p/e6b9481ca754
epoll https://blog.csdn.net/daaikuaichuan/article/details/83862311