计算机由运算器、控制器、存储器、输入设备、输出设备五部分组成,CPU 作为运算器,执行运算,速度是最快的。存储器,输入输出设备存储数据,供 CPU 读写数据,在距离 CPU 的越远读写速度就会越慢。内存读写数据、磁盘寻址、网络传输相对于 CPU 运算都是很慢的。CPU 在需要数据时是通过 I/O 传输数据,I/O 速度较慢,CPU 大部分时间都是在等待 I/O 完成操作,浪费了大量的时间。I/O 成了最大的性能瓶颈。
Linux 系统进程运行时分为内核态和用户态,运行于用户态的进程可以执行的操作和访问的资源都会受到极大的限制,而运行在内核态的进程则可以执行任何操作并且在资源的使用上没有限制。很多程序开始时运行于用户态,但在执行的过程中,一些操作需要在内核权限下才能执行,这时需要从用户态切换到内核态。
网络通信是两个主机之间的通信,一个主机通过网线将数据发送到另一台主机,网络在进行数据传输时,需要用户态和内核态的转换,整个流程如下:
- 操作系统将数据从网络中接收数据并将其复制到系统内核的缓存中
- 应用程序将数据从内核缓存复制到应用的缓存中
- 应用程序将处理完之后的数据再写回内核的 Socket 缓存中
- 操作系统将数据从 Socket 缓存区复制到网卡缓存,然后将其通过网络发出
首先了解一下同步 (synchronous) 和异步 (asynchronous),阻塞 (blocking) 和非阻塞 (non-blocking) 之间的区别。
- 同步意味着有序,多个程序之间协调一致,依次进行。
- 异步意味着无序,多个程序执行顺序不确定,也可以交替运行。
- 阻塞,一个程序在等待某个操作完成,自身停止运行,无法继续做其它任务,直到这个操作完成才继续运行。
- 非阻塞,一个程序在等待某个操作完成,自身不会停止运行,继续执行其它任务。
阻塞/非阻塞和异步/同步是两组不同的概念,阻塞程序也可以实现异步执行,例如多线程执行。非阻塞也可以实现异步执行,例如多路复用。
I/O 指的是相对内存而言的 input 和 output。从文件、数据库、网络向内存中写入数据叫做 input;从内存向文件、数据库、网络中输出数据叫做 output 。Linux 系统 I/O 分为内核准备数据和将数据从内核拷贝到用户空间两个阶段。
Linux 下有五种 I/O 模型:
- 阻塞 I/O(blocking I/O)
- 非阻塞 I/O (nonblocking I/O)
- I/O 复用(I/O multiplexing)
- 信号驱动 I/O (signal driven I/O (SIGIO))
- 异步 I/O (asynchronous I/O)
阻塞 I/O
当用户态进程调用系统函数获取数据时,如果内核中还没有准备好数据,用户态进程将一直等待,不进行其它操作,等内核将数据准备好之后,进程将数据从内核空间拷贝到用户空间,这时候系统调用函数返回,解除阻塞状态,处理接收到的数据。
非阻塞 I/O
用户态进程调用系统函数获取数据时,如果内核中还没有准备好数据,会返回错误信息给进程,而进程接收到错误信息,不会阻塞。但是内核会继续准备数据,进程不知道内核什么时候会准备好数据,所以会不断调用系统函数询问内核,直到内核准备好数据,进程将数据从内核复制到用户空间,系统函数调用结束,进程开始处理接收到到数据。
I/O 复用
在计算机网络里面,有很多关于 “复用” 的用法,比如多路复用,意思就是本来一条链路上一次只能传输一个数据流,如果要实现两个源之间多条数据流同时传输,那就得需要多条链路了。复用技术可以通过将一条链路划分频率,或者划分传输的时间,使得一条链路上可以同时传输多条数据流。I/O 复用是在一个进程里会处理多个消息事件。
信号驱动式 I/O 模型
当用户态进程需要数据时,会向内核发送一个信号,告诉内核要什么数据,然后去做自己其他的事情了,当内核态的数据准备好之后,内核马上给用户态进程发送信号,用户态进程收到信号立马调用系统调用,将数据从内核空间拷贝到用户空间,完成之后用户态进程开始处理接收到的数据。这种技术模型实际应用很少。
异步 I/O 模型
用户态进程需要数据时会告诉内核态需要什么,然后就不用管了,可以做其它事情。内核会将用户态需要的数据准备好,并将数据复制到用户空间,这时才通知用户进程。用户进程可以直接处理用户空间的数据,无需等待任何 I/O 操作。
我们看到前四个 I/O 模型处理数据时,都是用户进程将数据从内核空间拷贝到用户空间,这一段时间对于进程来说是阻塞的。只有异步 I/O 是内核将数据从内核空间拷贝到用户空间,用户进程完全是异步操作,所以从内核层面看前四个模型可以称为同步 I/O ,最后一个是异步 I/O 。
如果要同时响应多个甚至上万个客户端的请求响应时,肯定不可能使用单线程的 TCP 服务端来作为网络服务器使用。
现有大部分的 TCP 服务器都是基于以下三种模式实现的:
- 多进程服务器:通过创建多个进程来响应并发服务。
- 多线程服务器:通过生成与客户端请求等量的线程来提供服务。
- 多路复用服务器:通过统一管理 I/O 对象来优化服务响应效率。
下面我们详细讲解I/O多路复用,I/O 多路复用是一个线程里会监视多个文件描述符(文件描述符是计算机科学中的术语,是一个用于描述指向文件的引用的抽象化概念。文件描述符在形式上是一个非负整数,实际上它是一个指向 “文件打开记录表” 的索引值),在其中一个或者多个描述符准备好之后,内核会通知用户进程,用户进程来处理数据。
如下图所示,本质上同一时间服务器也只是会对一个客户端的请求数据进行处理,但是能够使得其他客户端在这个时间等待,等服务器处理完一个任务再继续处理别的请求任务,这个过程中所有的客户端都与服务器保持连接,由于处理时间很快,所以客户端感觉上都能够正常与服务器通信。所以这种方式其实严格上来说是一种“假并发”。
和多线程、多进程相比,I/O 多路复用没有创建新的进程或线程,不必进行进程或线程的上下文切换,减小了系统开销。目前 Linux 系统中常见的几种 I/O 多路复用是 select 、poll 、epoll。
select详解
在 Linux 系统下运用 select()
函数可以将多个文件描述符(套接字描述符)集中到一个集合中并统一监视,当发现在这个集合中的文件描述符有事件发生就会使得程序去得到当前有数据接收到的文件描述符,然后再去执行数据读取,不再像前面操作的那样阻塞等待数据接收。下图是 select()
函数详细的调用过程:
poll讲解
本质上 poll 和 select 没有区别,但它是基于链表来存储信息,没有最大连接数的限制。poll 会将大量的文件描述符的数组整体复制到内核地址空间,然后查询每个文件描述符的状态,如果文件描述符的读写准备就绪,就将文件描述符放入等待队列中。 如果遍历完所有的文件描述符没有可读写的设备,就挂起当前进程,直到有可读写就绪或者超时唤醒进程再次遍历所有文件描述符。
select 和 poll 都需要通过遍历文件描述符来获取已经就绪的 socket,但是大多数情况是会同时有大量的客户端连接,但是只有少数的是活跃的,这样随着描述符的增多,性能也会下降。
epoll函数讲解
select
方式是通过依次去查看当前所监控的文件描述符(套接字)组中的成员是否有可执行的操作来获得当前能够去进行操作的文件描述符(套接字),但是这样的操作在运行时速度会很慢的,主要原因是:
- 调用
select
函数后必然会执行针对所以可用文件描述符的循环语句; - 每次调用
select
函数时都需要向该函数传递监视对象信息,需要反复初始化参数进行传递;
//创建保存epoll文件描述符的空间,类似创建select方式下的fd_set变量
int epoll_create(int size);
//注册或者注销监视的文件描述符,类似select方式下的FD_SET、FD_CLR操作
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
/*
epfd :由epoll调用产生的文件描述符
op :控制操作参数,有 EPOLL_CTL_ADD EPOLL_CTL_MOD EPOLL_CTL_DEL,
fd :操作对象
event :由以下宏组成:
EPOLLIN:表示对应文件描述符可读
EPOLLOUT:表示对应文件描述符可写
EPOLLPRI:表示对应文件描述符有紧急书籍可读
EPOLLERR:表示对应文件描述符发生错误
EPOLLHUP:表示对应文件描述符被挂断
EPOLLET:设置边缘触发模式
EPOLLONESHOT:表示仅对该描述符实施一次监听操作
*/
//epoll函数的特点处,不同于select方式去轮询探查监视对象,epoll是等待事件触发
int epoll_wait(int epfd, struct epoll_event *event, int maxevents, int timeout);
epoll 事先通过 epoll_ctl() 来注册文件描述符,一旦某个文件描述符就绪,内核会采用类似 callback 回调机制,迅速激活这个文件描述符,当进程调用 epoll_wait() 时便得到通知。
优点:
- 没有最大并发连接的限制
- epoll 基于事件驱动,效率提升,不是轮询的方式,不会随着 FD 数目的增加而效率下降
- 只监控活跃的连接,当大量的连接中只有少量活跃的时,性能比 select 和 poll 高很多
- epoll_ctl 注册事件并注册 callback 回调函数,epoll_wait 只返回发生事件文件描述符
- 内存拷贝,利用 mmap 文件映射共享用户空间和内核空间的内存,避免了数据的来回拷贝
注意:在连接并发量不高且连接活跃度比较高的场景中,epoll 的表现并不会比 select/poll 优秀
epoll 对文件描述符的操作有两种模式: LT(level trigger)和 ET(edge trigger)
- LT 模式:默认的工作方式,同时支持 block 和 no-block socket,文件描述符准备就绪之后,内核会通知进程,如果进程没有进行处理,内核会不断的通知进行。
- ET 模式:高速工作方式,只支持 no-block socket,文件描述符准备就绪之后,内核只会通知一次进程,无论进行有没有进行处理,内核将不会再通知进程。减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。
epoll 最大的缺点是非跨平台的,只有 Linux 实现了,在 BSD 上实现的是 kqueue,win 上实现的是 iocp 。
下面是使用epoll的一个参考范例:
#include <iostream>
#include <unistd.h>
#include <cstring>
#include <sys/time.h>
#include <sys/epoll.h>
#define BUFFSIZE 1024
int main(int argc, char *argv[])
{
char buf[BUFFSIZE];
int timeout;
int epfd, epoll_size, event_cnt;
struct epoll_event *ep_events;
struct epoll_event event;
int str_len;
//设置超时时间 5.5 s , 5500 微秒
timeout = 5500;
//创建epoll 例程,size = 1
epoll_size = 2;
epfd = epoll_create(epoll_size);
if(epfd < 0)
{
//创建失败
std::cout << "epoll_create error." << std::endl;
return -1;
}
//创建用于epoll_wait事件发生的事件结构体缓冲区
ep_events = (struct epoll_event *)malloc(sizeof(struct epoll_event)*epoll_size);
//初始化文件描述符事件注册参数
event.events = EPOLLIN; //监视事件为有数据输入/可读取状态
event.data.fd = 0; //监视文件描述符为标准输入
//设置事件注册
epoll_ctl(epfd, EPOLL_CTL_ADD, 0, &event);
while(1)
{
//清空缓冲区内容
memset(buf, 0, BUFFSIZE);
event_cnt = epoll_wait(epfd, ep_events, epoll_size, timeout);
if(event_cnt < 0)
{
std::cout << "epoll_wait() error!" << std::endl;
break;
}
else if(event_cnt == 0)
{
std::cout << "epoll_wait() timeout!" << std::endl;
continue;
}
else{
//这里只注册了一个文件描述符,所以不需要循环处理,通常还是需要循环处理所有返回的事件消息
//基础判断,发生事件的文件描述符是标准输入
if(ep_events[0].data.fd == 0)
{
//读取数据并打印输出
str_len = read(0, buf, BUFFSIZE);
buf[str_len] = 0;
std::cout << "Message from console : " << buf << std::endl;
}
}
}
//关闭epfd
close(epfd);
return 0;
}
从终端输出可以看到,当我们向终端输入传输数据时,程序就能够通过 epoll_wait()
函数等待事件发生,当函数返回后就会去处理返回的结构体中的文件描述符,这里判断有数据可以读取,所以执行读取数据然后打印输出。当程序长时间没有消息进入,就会进入超时响应,会打印输出 Time-out!
的超时消息。
参考链接