一、DMA 简介
I/O 主要是磁盘的读写,网络的数据传输,音视频的输入输出等。
倘若CPU要从磁盘中读取文件,我们可能会这样认为,首先CPU对磁盘进行直接进行读取,然后将读取到的数据放入到内存中,此时流程图如下所示:
如果上述这种情况下,我们对CPU的分片或者不分片,效率应该是差不多的。但是实际上,操作系统选择的IO并不是这种方式,而是选择下面这种方式。
CPU直接对DMA下达指令(指令中含有IO设备的信息,以及要读取文件的内容),然后DMA告知磁盘进行文件读取,在文件读取的过程中将数据加载到内存中,加载完毕后,磁盘会给硬盘一个反馈,DMA最终以中断的形式通知CPU,此时CPU再去内存中读取数据。
我们可以看到,CPU 再给 DMA 发送一条指令后,便一直处于空闲状态了,在当前状态下,CPU可以去处理其他的请求。
那么DMA可不可以被复用呢(也就是说一个线程在进行IO时,另一个线程是不是需要等待上一个线程IO结束)? 答案是可以被复用的(即不需要等待上一个线程IO结束,当前线程便可以进行IO读取),在CPU的总线有多条线路,而总线便是信息传输的通道。
二、I\O多路复用模型的引入
考虑这样一种场景,我们要设计一个高性能的网络服务器,如果有大量的客户端进行连接,并且可以处理客户端的请求。
通常情况下,我们可能选择为每个连接创建一个线程,由每一个线程去处理相应请求(记得学习 Socket 通信时,客户端给服务器上传图片时便采用的这种方式)。但是在有大量客户端进行连接的情况下,由于线程太多,造成频繁的CPU上下文切换,导致效率低下,所以此时我们只能选择单线程进行处理。
如果选择单线程的话,不知道你有没有这样的困惑?即倘若服务器在处理客户端A的请求,此时客户端B的请求也来了,那么服务器是怎么处理客户端B的请求,会不会丢弃掉客户端B的请求?
而在上面对DMA的介绍中,DMA 保证了在单线程的环境下,数据的不丢失。
在 Linux 系统中,一切皆文件。每一个网络连接在内核中都是以文件描述符(FD)的形式表示。
所以,单线程环境下,我们可以遍历文件描述符的集合,来进行客户端请求的处理,伪代码如下所示:
while(1){
// 遍历文件描述符的集合
for(fdx in fds){
if(fdx 有数据){
读 fdx
请求处理
}
}
}
三、I\O多路复用的具体实现
I/O多路复用的具体实现有以下三种方式:select、 poll、 以及epoll。
select
// 创建 socket 服务器端
sockfd = socket(AF_INET,SOCK_STREAM,0);
memset(&addr,0,sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(2000);
bind(sockfd,(struct sockaddr*)&addr,sizeof(addr));
listen(sockfd,5);
// 创建 5 个文件描述符
for(i=0;i<5;i++){
memset(&client,0,sizeof(client))
addrlen = sizeof(client);
fds[i] = accept(sockfd,(struct sockaddr*)&client,&addrlen);
if(fds[i] > max)
// 求出文件描述符的最大值
max = fds[i];
}
================================================================
while(1){
FD_ZERO(&rset);
for(i=0; i<5;i++){
// rset 的类型是bitmap,用来表示哪一个文件描述符被启用(监听)
// bitmap 默认是1024位,如果需要监听的位,则置1,否则置0
// 假设文件描述符中存储的值为 1、2、5、7、9
// 则bitmap 中存储的值为 0 1 1 0 0 1 0 1 0 1 0 0 0 ...
FD_SET(fds[i],&rset);
}
puts("round again");
// max+1 的作用是对 bitmap 进行截取,只需要前面的部分,后面的不需要判断,因为是0不需要监听
// 第二个参数:读文件描述符集合
// 第三个参数:写文件描述符集合
// 第四个参数:异常文件描述符集合
// 第五个参数: 超时时间
select(max+1,&rset,NULL,NULL,NULL);
for(i=0;i<5;i++){
// 判断哪一个 FD 被置 1 了
if(FD_ISSET(fds[i],&rset)){
memset(buffer,0,MAXBUF);
// 读取数据
read(fds[i],buffer,MAXBUF);
// 进行处理
purs(buffer);
}
}
}
select 函数,与我们开始的伪代码相比区别在于:判断是否有数据的行为由用户态变为了内核态。
select函数的缺点:
- bitmap 默认为1024,虽然可以调整,但是依然由上限
- 每次循环都需要给 rset 赋初值,不可重用
FD_ZERO(&rset);
for(i=0; i<5;i++){
FD_SET(fds[i],&rset);
}
- 从用户态拷贝到内核态,仍然需要一定开销
- select 返回时,并不能直接返回哪几个是有数据的,需要再遍历一篇。
select函数的优点:
- select 整体是在内核态进行运行
- 由内核监听 FD
poll
struct pollfd{
int fd;
// 监听的事件
short events;
// 对监听的事件回馈
short revents;
}
for(i=0;i<5;i++){
memset(&client,0,sizeof(client));
addrlen = sizeof(client);
// 直接将 fd 赋值给 pollfd 对象
pollfds[i].fd = accept(sockfd,(struct sockaddr*)&client,&addrlen);
// 只在意读事件
pollfds[i].events = POLLIN;
}
sleep(1);
================================================================
while1(1){
puts("round again");
// 第一个参数:传入一个 pollfd 数组
// 第二个参数:数组长度
// 第三个参数:超时时间
poll(pollfds,5,50000);
for(i=0;i<5;i++){
// 如果 revents 被置位
if(pollfds[i].revents & POLLIN){
// 重新置0,这样便可重用
pollfds[i].revents = 0;
memset(buffer,0,MAXBUF);
// 读取数据
read(pollfds[i].fd,buffer,MAXBUF);
// 处理请求
puts(buffer);
}
}
}
poll解决了 select 的哪些缺点:
- 解决了 bitmap 1024 大小的限制,数组远远不止1024的大小
- 每次只需要恢复 revents 即可,而 select 则是需要恢复整个 reset
epoll
struct epoll_event events[5];
// epfd 相当于一个白板
int epfd = epoll_create(10);
...
...
for(i=0;i<5;i++){
static struct epoll_event ev;
memset(&client,0,sizeof(client));
addrlen = sizeof(client);
ev.data.fd = accept(sockfd,(struct sockaddr *)&client,&addrlen);
ev.events = EPOLLIN;
// 相当于在白板上写字
// 写入ev.data.fd以及监听的事件
epoll_ctl(epfd,EPOLL_CTL_ADD,ev.data.fd,&ev);
}
================================================================
while(1){
puts("round again");
nfds = epoll_wait(epfd,events,5,10000);
fo(i=0;i<nfds;i++){
memset(buffer,0,MAXBUF);
read(events[i].data.fd,buffer,MAXBUF);
puts(buffer);
}
}
epoll解决了 select 的缺点:
- 解决了用户态拷贝到内核态的开销
- 判断哪一个
fd
有数据的时间复杂度由O(n)
变为O(1)