原文链接:https://devarea.com/linux-io-multiplexing-select-vs-poll-vs-epoll/#.X7zGamgzaUk
Linux(实际Unix)的一个基本的概念是 Unix/Linux 中一切皆文件。每个进程都有一个指向磁盘文件、socket、硬件设备和其他操作系统对象的文件描述符表。
典型的运行着众多IO来源的系统有一个初始化阶段,进入某种待机模式,等待任意一个客户端来发送请求然后响应它。
一个简单的方案是为每个客户端创建一个线程(或进程),阻塞读取,直到有请求被发送和响应。这对于少量的客户端来说是可行的,但如果我们想扩展到上百的客户端,给每个客户端创建一个线程是个非常糟糕的主意。
IO 多路复用
解决方案是使用一种内核机制来轮询一组文件描述符。在 Linux 中有三种选择可以使用:
- select(2)
- poll(2)
- epoll
上面这几种方法解决的是同一个问题,创建一组文件描述符,告诉内核你想对每个文件描述符做什么(读,写……),以及使用一个线程阻塞函数调用,直到至少一个文件描述符可操作。
Select 系统调用
select()
系统调用提供了一种实现异步IO多路复用的机制。
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
select()
调用在指定的文件描述符IO就绪,或者可选地时间超时之前会一直阻塞。
被监视的文件描述符会放到3个集合中:
readfds
集合中的文件描述符,用来检查数据是否已经可读。writefds
集合中的文件描述符,用来检查写操作是否会无阻塞地完成。exceptfds
集合中的文件描述符,用来检查异常是否发生了,或者带外数据是否可用(这些状态仅适用于socket)。
集合可能为 NULL,这种情况下,select()
不会监视那个相关的事件。
一旦成功返回,每个集合会被修改,这样集合就只包含划定的 I/O 类型的就绪文件描述符。
示例:
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <wait.h>
#include <signal.h>
#include <errno.h>
#include <sys/select.h>
#include <sys/time.h>
#include <unistd.h>
#define MAXBUF 256
void child_process(void)
{
sleep(2);
char msg[MAXBUF];
struct sockaddr_in addr = {0};
int n, sockfd,num=1;
srandom(getpid());
/* Create socket and connect to server */
sockfd = socket(AF_INET, SOCK_STREAM, 0);
addr.sin_family = AF_INET;
addr.sin_port = htons(2000);
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
connect(sockfd, (struct sockaddr*)&addr, sizeof(addr));
printf("child { %d} connected \n", getpid());
while(1){
int sl = (random() % 10 ) + 1;
num++;
sleep(sl);
sprintf (msg, "Test message %d from client %d", num, getpid());
n = write(sockfd, msg, strlen(msg)); /* Send message */
}
}
int main()
{
char buffer[MAXBUF];
int fds[5];
struct sockaddr_in addr;
struct sockaddr_in client;
int addrlen, n,i,max=0;;
int sockfd, commfd;
fd_set rset;
for(i=0;i<5;i++)
{
if(fork() == 0)
{
child_process();
exit(0);
}
}
sockfd = socket(AF_INET, SOCK_STREAM, 0);
memset(&addr, 0, sizeof (addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(2000);
addr.sin_addr.s_addr = INADDR_ANY;
bind(sockfd,(struct sockaddr*)&addr ,sizeof(addr));
listen (sockfd, 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++ ) {
FD_SET(fds[i],&rset); // 添加文件描述符到读集合,译者注
}
puts("round again");
select(max+1, &rset, NULL, NULL, NULL); // 只检查读就绪的文件描述符,译者注
for(i=0;i<5;i++) {
if (FD_ISSET(fds[i], &rset)){ // 如果文件描述符在读集合中,即读就绪,译者注
memset(buffer,0,MAXBUF);
read(fds[i], buffer, MAXBUF); // 读取文件描述符中的数据到buffer,译者注
puts(buffer); // 打印出来,译者注
}
}
}
return 0;
}
我们用5个子进程启动,每个子进程连接并发送数据到服务端。服务端进程使用 accept(2)
来为每个客户端创建一个不同的文件描述符。select(2)
的首个参数在3个集合中应该是最大的文件描述符加1,我们可以检查最大的文件描述符编号。
main
函数中的死循环创建了一组文件描述符,调用 select
然后检查哪个读就绪。 为了简化说明,我没有添加异常检查。
一旦返回,select
修改集合为只包含就绪的文件描述符,这样我们需要每次迭代都再次创建集合。
之所以我们需要告诉 select
哪个是编号最大的文件描述符是因为 fd_set
的内部实现。文件描述符使用 bit 来定义,所以 fd_set
是32位整数数组(32*32bit=1024bits)。这个函数通过检查任意 bit 来检查其集合是否达到了上限。这意味着如果我们有5个文件描述符,但是最大的编号是900,这个函数会从0到900检查所有 bit 来找到监视的文件描述符。posix 有个 select
的替代方案——pselect
,当等待时增加了一个信号掩码(可查看用户手册)。
select
小结:
- 我们需要在每次调用前创建文件描述符集合。
- 这个函数检查所有 bit 直到一个更大的值——\(O(n)\)。
- 我们需要迭代完所有的文件描述符来检查
select
返回的集合中是否有该文件描述符。 select
主要的优点是它非常容易移植——每个类 unix 的操作系统都有。
Poll 系统调用
不像 select()
,使用了三个低效的基于位掩码的文件描述符集合,poll()
使用了一个 nfds pollfd
结构体数组。原型更简单些:
int poll (struct pollfd *fds, unsigned int nfds, int timeout);
pollfd
结构体有和事件、返回的事件相关的不同属性,于是我们不需要每次创建。
struct pollfd {
int fd;
short events;
short revents;
};
为每个文件描述符创建一个 pollfd
类型的对象,填充需要的事件。poll
返回后,检查 revents
属性。
使用 poll
修改上面的示例:
for (i = 0; i < 5; i++) {
memset(&client, 0, sizeof(client));
addrlen = sizeof(client);
pollfds[i].fd = accept(sockfd, (struct sockaddr *) &client, &addrlen);
pollfds[i].events = POLLIN; // POLLIN事件为普通或优先级带数据可读,译者注
}
sleep(1);
while (1) {
puts("round again");
poll(pollfds, 5, 50000);
for (i = 0; i < 5; i++) {
if (pollfds[i].revents & POLLIN) { // 就绪的文件描述符可读,如果是要检测有错误的话使用 &POLLERR,译者注
pollfds[i].revents = 0;
memset(buffer, 0, MAXBUF);
read(pollfds[i].fd, buffer, MAXBUF); // 读取文件描述符中的数据,译者注
puts(buffer); // 打印出数据来,译者注
}
}
}
就像我们使用 select
时那样,我们需要检查每个 pollfd
对象来检查其文件描述符是否就绪但是我们不需要每次迭代都创建集合。
Poll VS Select
poll()
不需要开发者对最大文件描述符编号 +1 计算。poll()
对于大数据的文件描述符更高效。想象下使用 nfds=900 的select()
监视单个文件描述符,系统内核不得不检查每个传递的集合的每位,直到第900个。select()
的文件描述符集合是固定大小的。- 使用
select()
,文件描述符集合一等到返回就要重建,所以每个随后的调用必须重新初始化他们。poll()
系统调用将输入(events
属性)和输出(revents
属性)剥离了。 select()
的超时参数在返回时没有定义,移植代码需要重新初始化。这对pselect()
来说不是问题。select()
移植性更好,然而一些 Unix 系统不支持poll()
。
Epoll* 系统调用
使用 select
和 poll
时,我们在用户空间管理着所有东西,我们每次调用时要发送文件描述符集合,然后等待。为了增加一个 socket 我们需要将其加到集合中然后再调用 select/poll
。
Epoll*
系统调用能让我们在内核中创建和管理上下文。这个问题分为3步:
- 使用
epoll_create
在内核中创建一个上下文。 - 使用
epoll_ctl
添加文件描述符到上下文或从上下文删除文件描述符。 - 在上下文中使用
epoll_wait
等待事件。
我们用 epoll
修改下上面示例:
/*
epoll_event 结构体定义如下,译者注:
struct epoll_event {
uint32_t events; // Epoll 事件
epoll_data_t data;
} __attribute__ ((__packed__));
typedef union epoll_data {
void *ptr;
int fd; // 文件描述符
uint32_t u32;
uint64_t u64;
} epoll_data_t;
*/
struct epoll_event events[5];
int epfd = epoll_create(10); //epoll_create函数定义: int epoll_create (int size),size为告知内核需要监听的文件描述符数量,译者注
...
...
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; // EPOLLIN事件为读取来自文件描述符的数据,译者注
epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd,
&ev); //epoll_ctl函数定义:int epoll_ctl (int epfd, int op, int fd, struct epoll_event *event);译者注
}
while (1) {
puts("round again");
// epoll_wait函数定义:int epoll_wait (int epfd, struct epoll_event * events, int maxevents, int timeout);译者注
nfds = epoll_wait(epfd, events, 5, 10000); // 等待事件就绪的文件描述符,译者注
for (i = 0; i < nfds; i++) {
memset(buffer, 0, MAXBUF);
read(events[i].data.fd, buffer, MAXBUF); // 读取数据到 buffer,译者注
puts(buffer); // 打印数据,译者注
}
}
我们首先创建一个上下文(参数忽略了,但必须是正数)。当一个客户端连接的时候,我们创建一个 epoll_event
对象,然后加到上下文,再然后在死循环中我们只需要等待。
Epoll VS Select/Poll
- 我们可以在等待时添加、删除文件描述符。
epoll_wait
只返回就绪的文件描述符。epoll
有更好的性能——\(O(1)\),而不是\(O(n)\)。epoll
能够实现水平触发或边缘触发(可查看使用手册)。epoll
是 Linux 特有的,所以不可移植。
译者注:
示例源码:https://github.com/dev-area/Multiplexing-IO
水平有限,翻译可能会有错误,欢迎交流指正。