【Linux】--- select和epoll详解

select和epoll的区别(面试常考)

  • 首先select是posix支持的,而epoll是linux特定的系统调用,因此,epoll的可移植性就没有select好,但是考虑到epoll和select一般用作服务器的比较多,而服务器中大多又是linux,所以这个可移植性的影响应该不会很大。

  • 其次,select可以监听的文件描述符有限,最大值为1024,而epoll可以监听的文件描述符则是系统对整个进程限制的最大文件描述符。

  • 接下来就要谈epoll和select的性能比较了,这个一般情况下应该是epoll表现好一些,否则linux也不会去特定实现epoll函数了,那么epoll为什么比select更高效呢?原因有很多,第一点,epoll通过每次有就绪事件时都将其插入到一个就绪队列中,使得epoll_wait的返回结果中只存储了已经就绪的事件,而select则返回了所有被监听的事件,事件是否就绪需要应用程序去检测,那么如果已被监听但未就绪的事件较多的话,对性能的影响就比较大了。第二点,每一次调用select获得就绪事件时都要将需要监听的事件重复传递给操作系统内核,而epoll对监听文件描述符的处理则和获得就绪事件的调用分开,这样获得就绪事件的调用epoll_wait就不需要重新传递需要监听的事件列表,这种重复的传递需要监听的事件也是性能低下的原因之一。除此之外,epoll的实现中使用了mmap调用使得内核空间和用户空间共享内存,从而避免了过多的内核和用户空间的切换引起的开销。

  • 然后就是epoll提供了两种工作模式,一种是水平触发模式,这种模式和select的触发方式是一样的,即只要文件描述符的缓冲区中有数据,就永远通知用户这个描述符是可读的,这种模式对block和noblock的描述符都支持,编程的难度也比较小;而另一种更高效且只有epoll提供的模式是边缘触发模式,只支持nonblock的文件描述符,他只有在文件描述符有新的监听事件发生的时候(例如有新的数据包到达)才会通知应用程序,在没有新的监听时间发生时,即使缓冲区有数据(即上一次没有读完,或者甚至没有读),epoll也不会继续通知应用程序,使用这种模式一般要求应用程序收到文件描述符读就绪通知时,要一直读数据直到收到EWOULDBLOCK/EAGAIN错误,使用边缘触发就必须即将缓冲区中的内容读完,否则有可能引起死等,尤其是当一个listen_fd需要监听到达连接的时候,如果多个连接同时到达,如果每次只是调用accept一次,就会导致多个连接在内核缓冲区中滞留,处理的办法是用while循环抱住accept,直到其出现EAGAIN。这种模式虽然容易出错,但是性能要比前面的模式更高效,因为只需要监听是否有事件发生,发生了就直接将描述符加入就绪队列即可。

select

一、什么是select

系统提供select函数来实现多路复用输入/输出模型.
select系统调用是用来让我们的程序监视多个文件描述符的状态变化的;
程序会停在select这里等待,直到被监视的文件描述符有一个或多个发生了状态改变。

1.select函数原型

select的函数原型如下: #include <sys/select.h>

int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);

2.参数解释

  • fd_set:描述符集合 这个结构体中有一个数组,作用是用于向数组中添加描述符,将描述符添加到集合中,实际上是将描述符这个数字对应的比特位置1;而这个位图中能够添加多少描述符取决于一个宏:_FD_SETSIZE=1024,因此select模型所能够监控的描述符是有最大数量限制的;
  • 参数nfds是需要监视的最大的文件描述符值+1;
  • rdset,wrset,exset分别对应于需要检测的可读文件描述符的集合,可写文件描述符的集 合及异常文件描述符的集合;
  • 参数timeout为结构timeval,用来设置select()的等待时间

3.参数timeout取值

  • NULL:则表示select()没有timeout,select将一直被阻塞,直到某个文件描述符上发生了事件;
  • 0:仅检测描述符集合的状态,然后立即返回,并不等待外部事件的发生。
  • 特定的时间值:如果在指定的时间段里没有事件发生,select将超时返回

4.返回值

  • ( >0 ) 表示当前集合中多少描述符就绪了
  • ( ==0 ) 表示等待超时了(在阻塞的时间段内,一直没有就绪的描述符)
  • ( <0 ) 表示监控出错了(select本次监控出错)

5.监控原理

  • 1.用户向定义各个自己关心的描述符集合,将描述符添加到相应的集合中。

  • 2.调用select接口,将集合传入,将集合中数据拷贝到内核中进行监控,

    • 监控原理:在内核中不断进行轮询遍历,判断哪个描述符就绪`

    • 可读就绪:读缓冲区中,数据大小大于低水位标记(通常是1个字节)

    • 可写就绪:写缓冲区中,剩余空间大小大于低水位标记(通常是1个字节)

    当前任意一个集合中有描述符就绪,则遍历完集合之后select调用返回select在调用返回之前,将集合中所有未就绪的描述符从集合中移除了
    select返回的集合是一个就绪描述符集合

  • 3.用户在select调用返回之后虽然无法立即获取就绪的描述符,但是可以通过判断当前哪个描述符还在集合中来判断描述符就是就绪描述符,然后进行相应操作。

  • 4.因为集合被select在就绪返回前被修改了,仅仅保留了就绪的描述符,因此每次重新监控前需要重新添加到描述符集合中。

二、select就绪条件

1.读就绪

  • socket内核中, 接收缓冲区中的字节数, 大于等于低水位标记SO_RCVLOWAT. 此时可以无阻塞的读该文件描述符,
    并且返回值大于0;
  • socket TCP通信中, 对端关闭连接, 此时对该socket读, 则返回0;
  • 监听的socket上有新的连接请求;
  • socket上有未处理的错误;

2.写就绪

  • socket内核中, 发送缓冲区中的可用字节数(发送缓冲区的空闲位置大小), 大于等于低水位标记SO_SNDLOWAT,
    此时可以无阻塞的写, 并且返回值大于0;
  • socket的写操作被关闭(close或者shutdown). 对一个写操作被关闭的socket进行写操作, 会触发SIGPIPE信号;
  • socket使用非阻塞connect连接成功或失败之后;
  • socket上有未读取的错误

三、select的特点

  1. 可监控的文件描述符个数取决与sizeof(fd_set)的值.
    我这边服务器上sizeof(fd_set)=512,每bit表示一个文件描述符,则我服务器上支持的最大文件描述符是512*8=4096.
  2. 将fd加入select监控集的同时,还要再使用一个数据结构array保存放到select监控集中的fd,
    • ①是用于再select 返回后,array作为源数据和fd_set进行FD_ISSET判断。
    • ②是select返回后会把以前加入的但并无事件发生的fd清空,则每次开始select前都要重新从array取得fd逐一加入(FD_ZERO最先),扫描array的同时取得fd最大值maxfd,用于select的第一个参数。

四、select的优缺点

1.缺点

  • 1. select所你能监控的描述符有最大数量上限,上限取决于__FD_SETSIZE默认等于1024
  • 2. select需要每次将集合数据从用户态拷贝到内核态进行监控
  • 3. select在内核中使用轮询遍历进行监控,监控性能随着描述符增多而降低
  • 4. select不会直接返回给用户就绪的描述符,而是返回的一个就序集合,需要用户自己遍历判断才能找出就绪的描述符,效率较低,增加了代码复杂度
  • 5. select每次调用返回时,都会修改集合,因此每次监控前都需要重新向集合中添加描述符

2.优点

  • 1.遵循posix标准,拥有良好的跨平台移植性

  • 2.监控时间可以精细到微秒

五、select使用实例

使用 select 实现字典服务器
tcp_select_server.hpp

#pragma once
#include <vector>
#include <unordered_map>
#include <functional>
#include <sys/select.h>
#include "tcp_socket.hpp"
// 必要的调试函数
inline void PrintFdSet(fd_set* fds, int max_fd) {
printf("select fds: ");
for (int i = 0; i < max_fd + 1; ++i) {
if (!FD_ISSET(i, fds)) {
continue;
}
printf("%d ", i);
}
printf("\n");
}
typedef std::function<void (const std::string& req, std::string* resp)> Handler;
// 把 Select 封装成一个类. 这个类虽然保存很多 TcpSocket 对象指针, 但是不管理内存
class Selector {
public:
Selector() {
// [注意!] 初始化千万别忘了!!
max_fd_ = 0;
FD_ZERO(&read_fds_);
}
bool Add(const TcpSocket& sock) {
int fd = sock.GetFd();
printf("[Selector::Add] %d\n", fd);
if (fd_map_.find(fd) != fd_map_.end()) {
printf("Add failed! fd has in Selector!\n");
return false;
}
fd_map_[fd] = sock;
FD_SET(fd, &read_fds_);
if (fd > max_fd_) {
max_fd_ = fd;
}
return true;
}
bool Del(const TcpSocket& sock) {
int fd = sock.GetFd();
printf("[Selector::Del] %d\n", fd);
if (fd_map_.find(fd) == fd_map_.end()) {
printf("Del failed! fd has not in Selector!\n");
return false;
}
fd_map_.erase(fd);
FD_CLR(fd, &read_fds_);
// 重新找到最大的文件描述符, 从右往左找比较快
for (int i = max_fd_; i >= 0; --i) {
if (!FD_ISSET(i, &read_fds_)) {
continue;
}
max_fd_ = i;
break;
}
return true;
}
// 返回读就绪的文件描述符集
bool Wait(std::vector<TcpSocket>* output) {
output->clear();
// [注意] 此处必须要创建一个临时变量, 否则原来的结果会被覆盖掉
fd_set tmp = read_fds_;
// DEBUG
PrintFdSet(&tmp, max_fd_);
int nfds = select(max_fd_ + 1, &tmp, NULL, NULL, NULL);
if (nfds < 0) {
perror("select");
return false;
}
// [注意!] 此处的循环条件必须是 i < max_fd_ + 1
for (int i = 0; i < max_fd_ + 1; ++i) {
if (!FD_ISSET(i, &tmp)) {
continue;
}
output->push_back(fd_map_[i]);
}
return true;
}
private:
fd_set read_fds_;
int max_fd_;
// 文件描述符和 socket 对象的映射关系
std::unordered_map<int, TcpSocket> fd_map_;
};
class TcpSelectServer {
public:
TcpSelectServer(const std::string& ip, uint16_t port) : ip_(ip), port_(port) {
}
bool Start(Handler handler) const {
// 1. 创建 socket
TcpSocket listen_sock;
bool ret = listen_sock.Socket();
if (!ret) {
return false;
}
// 2. 绑定端口号
ret = listen_sock.Bind(ip_, port_);
if (!ret) {
return false;
}
// 3. 进行监听
ret = listen_sock.Listen(5);
if (!ret) {
return false;
}
// 4. 创建 Selector 对象
Selector selector;
selector.Add(listen_sock);
// 5. 进入事件循环
for (;;) {
std::vector<TcpSocket> output;
bool ret = selector.Wait(&output);
if (!ret) {
continue;
}
// 6. 根据就绪的文件描述符的差别, 决定后续的处理逻辑
for (size_t i = 0; i < output.size(); ++i) {
if (output[i].GetFd() == listen_sock.GetFd()) {
// 如果就绪的文件描述符是 listen_sock, 就执行 accept, 并加入到 select 中
TcpSocket new_sock;
listen_sock.Accept(&new_sock, NULL, NULL);
selector.Add(new_sock);
} else {
// 如果就绪的文件描述符是 new_sock, 就进行一次请求的处理
std::string req, resp;
bool ret = output[i].Recv(&req);
if (!ret) {
selector.Del(output[i]);
// [注意!] 需要关闭 socket
output[i].Close();
continue;
}
// 调用业务函数计算响应
handler(req, &resp);
// 将结果写回到客户端
output[i].Send(resp);
}
} // end for
} // end for (;;)
return true;
}
private:
std::string ip_;
uint16_t port_;
};

dict_server.cc
这个代码和之前相同, 只是把里面的 server 对象改成 TcpSelectServer 类即可.
客户端和之前的客户端完全相同, 无需单独开发

poll

int poll(struct poofd*fds,nfds_t nfds,int timeout)

struct pollfd
{
int fd; //文件描述符
short events //对当前描述符实际就绪的事件
short revents //当前描述符实际就绪的事件
}

poll对描述符进行监控,是对最关心的描述符组织一个事件结构,填充信息:events中填充,用户关心的事件,进行监控后,若描述符就绪了整个事件,则将这个事件在revents中进行记录

  • 1.用户定义一个struct pollfd时间结构数组,将关心的描述符相关的信息填充进去

  • 2.将调用poll接口,将数据拷贝到内核进行监控,

    • 监控原理:在内核中,对每一个事件进行轮询遍历监控,当有描述符就绪时,则将就绪的事件信息记录到相应的结构数组节点的revents这个成员中,调用返回。
  • 3.用户遍历数组,通过判断每一个节点revents,判断描述是否就绪,进而直接对节点中fd进行操作

epoll

epoll是为了处理大量的句柄二改进的poll

一、epoll_create

int epoll_create(int size);
创建一个epoll的句柄
  • 自从linux2.6.8之后,size参数是被忽略的.
  • 用完之后, 必须调用close()关闭

二、epoll_wait

int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

1.收集在epoll监控的事件中已经发送的事件

  1. 参数events是分配好的epoll_event结构体数组.
  2. epoll将会把发生的事件赋值到events数组中
    (events不可以是空指针,内核只负责把数据复制到这个events数组中,不会去帮助我们在用户态中分配内存).
  3. maxevents告之内核这个events有多大,这个 maxevents的值不能大于创建epoll_create()时的size.
  4. 参数timeout是超时时间 (毫秒,0会立即返回,-1是永久阻塞).
  5. 如果函数调用成功,返回对应I/O上已准备好的文件描述符数目,如返回0表示已超时, 返回小于0表示函数失败.

2.epoll的优点(和 select 的缺点对应)

  1. 接口使用方便: 虽然拆分成了三个函数, 但是反而使用起来更方便高效. 不需要每次循环都设置关注的文件描述符, 也做到了输入输出参数分离开
  2. 数据拷贝轻量: 只在合适的时候调用 EPOLL_CTL_ADD 将文件描述符结构拷贝到内核中,
    这个操作并不频繁(而select/poll都是每次循环都要进行拷贝)
  3. 事件回调机制: 避免使用遍历, 而是使用回调函数的方式, 将就绪的文件描述符结构加入到就绪队列中,epoll_wait
    返回直接访问就绪队列就知道哪些文件描述符就绪. 这个操作时间复杂度O(1). 即使文件描述符数目很多, 效率也不会受到影响.
  4. 没有数量限制: 文件描述符数目无上限

三、epoll的使用场景

  • epoll的高性能, 是有一定的特定场景的. 如果场景选择的不适宜, epoll的性能可能适得其反.
  • 对于多连接, 且多连接中只有一部分连接比较活跃时, 比较适合使用epoll.

例如, 典型的一个需要处理上万个客户端的服务器, 例如各种互联网APP的入口服务器, 这样的服务器就很适合epoll.如果只是系统内部, 服务器和服务器之间进行通信, 只有少数的几个连接, 这种情况下用epoll就并不合适. 具体要根据需求和场景特点来决定使用哪种IO模型.

发布了63 篇原创文章 · 获赞 322 · 访问量 5万+

猜你喜欢

转载自blog.csdn.net/L19002S/article/details/105426779