select
select
时,Linux都做了什么?使用select
的应用程序用多路复用器,把我们想要监听的文件描述符分成三类(可读,可写,异常)一次性全部传给Linux内核,然后内核轮询所有文件描述符,监视其上的就绪事件,经过给定时长后,返回就绪事件的个数。
应用程序拿到返回值后,要自己遍历所有文件描述符,找出哪些被内核标记为有事件就绪。
当应用程序想再次使用select
查询就绪事件时,要再次把文件描述符传给内核,也就是上述过程全部重新来一遍。所以select
其实是非常低效的。
select
的细节 及 注意事项select
使用一组数字表示socket文件描述符集合,从而实现多路复用。select
的核心是如下函数:
#include
int selece(int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout);
上面说的 “一组数字”,就是函数中的第2,3,4个参数,参数类型是fd_set
结构体类型(用typedef
起的别名),别看他是个结构体,其实结构体里面仅仅定义了一个long
类型的整数。这个整数转化成二进制后 的每一位标记一个文件描述符,文件描述符的值为几,他就是二进制数中的第几位,该位的0、1值在调用select
前后有不同的含义。
select
前,该位的0,1表示是否要让select
监视其对应socket文件描述符的事件,1表示要监视,0表示不用监视select
后,该位的0,1表示其对应的socket文件描述符上的事件是否发生,1表示发生了,0表示没发生查看这些位的值要使用位操作,很麻烦,不过有现成的函数可以调用:
#include
FD_ZERO(&set); 将set的所有位置0,如set在内存中占8位则将set置为00000000
FD_SET(0, &set); 将set的第0位置1,如set原来是00000000,则现在变为100000000,这样fd==1的文件描述字就被加进set中了
FD_CLR(4, &set); 将set的第4位置0,如set原来是10001000,则现在变为10000000,这样fd==4的文件描述字就被从set中清除了
FD_ISSET(5, &set); 测试set的第5位是否为1,如果原来set是10000100,则返回非零,表明fd==5的文件描述符在set中,否则返回0
select
参数说明:nfds
指定了被监听的文件描述符的总数,咱就设置为select
监听的所有文件描述符中的最大值+1,因为文件描述符的值是从0开始往上计数的。select
能监视的文件描述符最大数量由函数第2,3,4个参数的数组大小决定。select
函数的超时时间,他是一个指针,指向结构体:struct timeval
{
long tv_sec; 秒数
long tv_usec; 微秒数
};
使用指针是因为,内核会修改该结构体,告诉应用程序select
实际等待了多久。函数调用失败时,其值不确定。
传入时有两种特殊情况:
——把tv_sec
和 tv_usec
都设置为0,则select
非阻塞,调用后立即返回。
——给timeout
传递NULL
,则·select
阻塞,直到某个文件描述符就绪。
这是一个服务器程序,使用select同时处理普通数据 和 带外数据。
这是一个客户端程序,使用select实现 非阻塞 connect。
poll
poll
时,Linux做了什么?类似于select
,poll
也使用多路复用器将想要监听的文件描述符集合传给内核,由内核遍历所有的文件描述符,并监视其事件,最终返回被触发事件的个数。
不同点是:
poll
的调用接口更简单一些,他没有把监控的事件分为三类(可读,可写,异常),而是统一用一个变量来表示poll
能够监视的事件种类远不止三种,下面会列出poll
不需要在调用前做预处理,因为内核每次修改的是pollfd
结构体的revents
成员,而events
和fd
成员保持不变。poll
的细节 及 注意事项#include
int poll(struct pollfd* fds, nfds_t nfds,int timeout);
fds
fds
结构体数组就是我们传入的要监听的文件描述符集合,他的每个元素都包含 文件描述符
、监听的事件
监听结果
这3个信息,结构体其定义如下:
struct pollfd
{
int fd; 文件描述符
short events; 注册的事件
short revents; 实际发生的事件,由内核填充
};
events
可以是一系列事件的按位或:POLLRDHUP
事件,他为我们区分 调用recv
接收到的是用户数据还是关闭连接的请求提供了一个更简单的方式(之前我们要根据recv
的返回值区分)。此外,使用该事件时必须在代码最开始出定义_GUN_SOURCE
,如下所示:#define _GUN_SOURCE 1
revents
:我们用这个值判断到第那些事件被触发了。使用时就是用进行与运算,如revents & POLLIN
就是判断可读事件是否被触发。nfds
指定被监听事件集合的大小,没太多说的,其定义如下:
typedef unsigned long int nfds_t;
timeout
这个参数很重要,其含义被下面的epoll
沿用。它指定poll
的超时值,单位是毫秒。
——当其值为-1时,poll
将永远阻塞,直到监听到事件发生。
——当其值为0时,poll
调用立即返回。
poll
的实例程序是一个聊天室程序,分为服务端和客户端两部分。多个客户端可以连接到同一个服务器,当一个客户端向服务器发送消息时,该消息会被转发给除发送端外的其他客户端,其他客户端收到该消息并输出到标准输出。
这是一个聊天室程序,分为客户端和服务器两部分,服务器使用epoll同时处理客户端的连接 和 客户输入数据。
epoll
epoll
的核心就是三个函数:
#include
int epoll_create(int size);
int epoll_ctl(int epollfd, int op, int fd, struct epoll_event* event);
int epoll_wait(int epollfd, struct epoll_event* events, int maxevent, int timeout);
这三个函数不能死记硬背,要记住调用他们时应用程序及Linux内核都做了什么,才能更好地掌握epoll
的用法。
当调用epoll_create
函数时,Linux内核中创建一张内核事件表,用于后续注册我们想要监听的时间,该函数返回一个文件描述符,他唯一标识内核事件表。
而调用epoll_ctl
函数可以在内核事件表上注册我们想要监听的事件,当然喽,也可以注销(删除)和修改这些事件。
调用epoll_wait
函数会开始监听内核事件表上的所有时间,只要有注册的事件发生,就会将其添加到返回值的一员。规定的监听时长结束后,将发生的事件返回。这时,应用程序就可以直接通过返回值进行数据的读写。
除了了解以上执行过程以外,还应该了解epoll
的实现原理。实现epoll
的核心是红黑树。内核中创建的内核事件表其实就是一颗红黑树。我们往内核事件表中注册的每个事件,都对应红黑树中的一个节点。
那么,注册在内核事件表中的事件是怎么被触发的呢?
当网卡上收到数据时,会发生中断,提醒CPU去处理这批数据,在处理时,事件就会被触发。也就是中断间接触发事件。(这个描述极其简单,实际的过程远不止这样,而且涉及到的对象也不止网卡、CPU,这里大概意会就行了。)
epoll_create
size
参数不起作用,他告诉内核事件表需要多大。随便传一个正整数即可。
epoll_ctl
int epoll_ctl(int epollfd, int op, int fd, struct epoll_event* event);
op
:operator,操作。也就是本次epoll_ctl
调用要执行什么操作。共有三种取值:
EPOLL_CTL_ADD 注册(添加)事件
EPOLL_CTL_MOD 修改事件
EPOLL_CTL_DEL 注销(删除)事件
socket
文件描述符。event
:第四个参数是一个指针,指向一个epoll_event
结构体,结构体定义如下:
struct epoll_event
{
uint32_t events; epoll 事件
epoll_data_t data; 保存用户数据的结构体
};
typedef union epoll_data
{
void* ptr;
int fd; 这个值最常用,他指定事件从属的 socket 文件描述符
uint32_t u32;
uint64_t u64;
};
epoll_event
的events
成员需要注意。他描述事件类型,epoll
支持的事件类型和poll
基本相同。但是表示epoll
事件类型的宏要在poll
对应的宏前加上E
。epoll
有两个额外的事件类型:
EPOLLET
EPOLLONESHOT
他们对于epoll
的功耗小运作非常关键,下面有使用到他们的实例程序。
成功返回0,失败返回-1并设置errno
。
epoll_wait
int epoll_wait(int epollfd, struct epoll_event* events, int maxevent, int timeout);
成功返回就绪事件的个数,失败返回-1并设置errno
。
events
:第二个参数是epoll_event
结构体数组,数组的每个元素都是就绪的事件。在实际应用中,我们通常结合该函数的返回值与本参数遍历并处理所有就绪的事件。
maxevents
:指定最多监听多少个事件,其值必须大于0。
timeout
:该参数的含义与poll
接口的timeout
参数相同。实际应用中,通常设置为-1
,阻塞的调用该函数。
LT
和 ET
模式ET
模式是epoll
的另一个独特且强悍的特性。ET
模式的存在进一步提升了epoll
的性能。
LT
模式LT
(Level Tigger,电平触发)模式是epoll
的默认工作模式。该模式下,当epoll_wait
检测到某事件被触发时,若应用程序不去处理该事件,那么下次调用epoll_wait
函数还会再次向应用程序报告该事件,直到事件被处理。
ET
模式ET
(Edge Tigger,边沿触发)模式下,当epoll_wait
检测到某事件被触发时,不管应用程序处不处理,下次调用epoll_wait
都不会再此报告该事件。也就是说,程序员只有一个选择,那就是在第一次报告该事件时就处理。这变向降低了同一个事件被重复出发的次数(每次出发都要从内核拷贝该事件),因此效率更高。
实际使用ET
模式时要注意:
ET
模式下处理某个被触发的事件时,你处理一遍可能没处理完(如:可读事件),所以要在while(1)
里面处理事件,从而确保你确实处理完了这个事件。(下面有这种情况的实例程序)ET
模式必须将文件描述符设置为非阻塞的。因为,上一条要求在while(1)
里处理事件,那么在最后一次循环中,事件一定已被处理完了,如果socket是阻塞的,那么程序就会一直阻塞在这里。这是一个服务器程序,包含ET,LT两种工作模式。
EPOLLONESHOT
事件EPOLLONSHOT
事件在多线程程序中使用。在多线程成程序中,即使使用ET
模式,一个socket上的某事件还是可能触发多次,比如,一个线程读取完某个socket
上的数据后,开始处理这些数据,而在数据处理的过程中EPOLLIN
事件再次被新来的数据触发,此时另外一个线程被唤醒来读取这些新的数据。于是,就出现多个线程同时操作一个socket的局面。这时,需要使用EPOLLONESHOT
避免这种情况。
对于注册了EPOLLONESHOT
事件的文件描述符,操作系统最多触发其上注册的一个可读、可写或者异常事件,且只触发一次,除非我们使用epoll_ctl
函数重置该文件描述符上注册的EPOLLONESHOT事件。这样,当一个线程在处理某个socket时,其他线程不可能操作该socket。但是,注册了EPOLLONESHOT
事件的socket一旦被某个线程处理完毕,该线程就应该立即重置这个socket 上的EPOLLONESHOT
事件,以确保这个socket下一次可读时,其EPOLLIN
事件能被触发。
使用EPOLLONESHOT
时要注意:
while(1)
确保某个被触发的事件被处理完毕EPOLLONESHOT
事件ET
模式和EPOLLONESHOT
事件,这两个没有任何冲突,且功能上也没有任何冲突。这是一个服务器程序,使用了EPOLLONESHOT事件。
这是一个客户端程序,使用 epoll 实现同时处理一个端口的TCP和UDP服务。
这里重点说一下实现原理:
poll
和 select
都是轮询的方式,内核每次都扫描整个注册的文件描述符集合;而epoll_wait
采用回调方式,内核检测到就绪的文件描述符时,触发回调函数,回调函数将该文件描述符上对应的事件插入内核就绪队列,最后返回给用户。
这也就引出了epoll
的一个缺点,当事件触发比较频繁时,回调函数也会被频繁触发,此时效率就未必比select
或 poll
高了。所以epoll
的最佳使用情景是:连接数量多,但活跃的连接数量少。