IO复用使程序能同时监听多个文件描述符,这可以提高程序的性能,通常网络程序在以下情况需要使用IO复用:
1.客户端进程需要同时处理多个socket。
2.客户端进程需要同时处理用户输入和网络连接。
3.TCP服务器要同时处理监听socket和连接socket。
4.服务器要同时处理TCP请求和UDP请求。
5.服务器要同时监听多个端口,或处理多种服务,如xinetd服务器。
IO复用能同时监听多个文件描述符,但它本身是阻塞的,且当多个文件描述符同时就绪时,如果不采取额外措施,进程只能按顺序依次处理其中的每个文件描述符,这使得服务器看起来像是串行工作的,如果要实现并发,只能用多进程或多线程等编程手段。
Linux下实现IO复用的系统调用主要有select、poll、epoll。
select系统调用的用途是:在一段指定时间内,监听用户感兴趣的文件描述符上的可读、可写、异常事件,它的原型如下:
nfds参数指定被监听的文件描述符中的最大值加1,因为文件描述符是从0开始计数的。
readfds、writefds、exceptfds参数分别指向可读、可写、异常事件对应的文件描述符集合,应用调用select时,我们通过这3个参数传入自己感兴趣的文件描述符,当select函数返回时,内核将修改它们来通知应用进程哪些文件描述符已经就绪。这3个参数是fd_set类型的指针:
由上图,fd_set结构中仅包含一个整型数组,该数组的每个元素的每一位(bit)标记一个文件描述符,fd_set能容纳的文件描述符数量由FD_SETSIZE指定,这就限制了select函数能同时处理的文件描述符总量。
由于位操作过于繁琐,我们应使用以下宏来访问fd_set结构中的位:
timeout参数用来设置select函数的超时时间,它是一个timeval结构的指针,采用指针参数是因为内核将修改它以告诉应用select函数等待了多久(在Linux上,其他实现不这么做),但我们不能完全信任select函数返回后的timeout参数值,如调用失败时timeout参数值是不确定的。timeval结构的定义如下:
由上图,select函数给我们提供了一个微秒级的定时方式,如果给timeout参数的tv_sec成员和tv_usec成员都传递0,则select函数将立即返回,如果给timeout参数传递NULL,则select函数将一直阻塞,直到某个文件描述符就绪。
select函数成功时返回就绪(可读、可写、异常)文件描述符总数,如果在超时时间内没有任何文件描述符就绪,select函数将返回0,select函数失败时返回-1并设置errno,如果select函数等待期间,进程接收到信号,则select函数立即返回-1并将errno设为EINTR。
在网络编程中,以下情况认为socket可读:
1.socket内核接收缓存区中的字节数大于其低水位标记SO_RCVLOWAT,此时我们可以无阻塞地读该socket,且读操作返回的字节数大于0。
2.socket通信的对方关闭连接,此时对该socket的读操作将返回0。
3.监听socket上有新的连接请求。
4.socket上有未处理的错误,此时我们可用getsockopt函数来读取和清除该错误。
以下情况认为socket可写:
1.socket内核发送缓存区中的可用字节数大于或等于其低水位标记SO_SNDLOWAT,此时我们可以无阻塞地写该socket,且写操作返回的字节数大于0。
2.socket的写操作被关闭,对写操作被关闭的socket执行写操作将触发SIGPIPE信号。
3.socket使用非阻塞connect连接成功或失败(超时)后。
4.socket上有未处理的错误,此时我们可用getsockopt函数来读取和清除该错误。
网络程序中,select函数能处理的异常情况只有一种,socket上接收到带外数据。
socket上接收到普通数据和带外数据都将使select函数返回,以下代码同时处理这两者:
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <libgen.h>
int main(int argc, char *argv[]) {
if (argc <= 2) {
printf("usage: %s ip_address port_number\n", basename(argv[0]));
return 1;
}
const char *ip = argv[1];
int port = atoi(argv[2]);
int ret = 0;
struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
inet_pton(AF_INET, ip, &address.sin_addr);
address.sin_port = htons(port);
int listenfd = socket(PF_INET, SOCK_STREAM, 0);
assert(listenfd >= 0);
ret = bind(listenfd, (struct sockaddr *)&address, sizeof(address));
assert(ret != -1);
ret = listen(listenfd, 5);
assert(ret != -1);
struct sockaddr_in client_address;
socklen_t client_addrlength = sizeof(client_address);
int connfd = accept(listenfd, (struct sockaddr *)&client_address, &client_addrlength);
if (connfd < 0) {
printf("errno is: %d\n", errno);
close(listenfd);
}
char buf[1024];
fd_set read_fds;
fd_set exception_fds;
FD_ZERO(&read_fds);
FD_ZERO(&exception_fds);
while (1) {
memset(buf, '\0', sizeof(buf));
FD_SET(connfd, &read_fds);
FD_SET(connfd, &exception_fds);
ret = select(connfd + 1, &read_fds, NULL, &exception_fds, NULL);
if (ret < 0) {
printf("selection failure\n");
break;
}
if (FD_ISSET(connfd, &read_fds)) {
ret = recv(connfd, buf, sizeof(buf) - 1, 0);
if (ret <= 0) {
break;
}
printf("get %d bytes of normal data: %s\n", ret, buf);
} else if (FD_ISSET(connfd, &exception_fds)) {
ret = recv(connfd, buf, sizeof(buf) - 1, MSG_OOB);
if (ret <= 0) {
break;
}
printf("get %d bytes of oob data: %s\n", ret, buf);
}
}
close(connfd);
close(listenfd);
return 0;
}
poll系统调用和select系统调用类似,也是在指定时间内轮询一定数量的文件描述符,以测试其中是否有就绪者,poll函数原型如下:
fds参数是一个pollfd结构类型的数组,它指定所有我们感兴趣的文件描述符上发生的可读、可写、异常事件,pollfd结构定义如下:
pollfd结构的fd成员指定文件描述符;events成员告诉poll函数监听fd成员上的哪些事件,它是一系列事件的按位或;revents成员由内核修改,以通知应用进程fd成员上世纪发生了哪些事件。poll函数支持的事件类型如下:
上表中,POLLRDNORM、POLLRDBAND、POLLWRNORM、POLLWRBAND由XOPEN规范定义,它们实际上是将POLLIN事件和POLLOUT事件分得更细致,以区别对待普通数据和优先数据,但Linux并不完全支持它们。
通常,应用需要根据recv函数的返回值来区分socket上接收到的是有效数据还是对方关闭连接的请求,并做相应处理,但自Linux内核2.6.17开始,GNU为poll系统调用增加了POLLRDHUP事件,它在socket上接收到对方关闭连接的请求后触发,这为我们区分上述两种情况提供了一种更简单的方式。如果要使用POLLRDHUP事件,需要在代码最开始处定义_GNU_SOURCE。
nfds参数指定被监听事件集合fds参数数组的元素数,nfds参数类型nfds_t的定义如下:
timeout参数指定poll函数的超时值,单位是毫秒,当timeout参数为-1时,poll函数将永远阻塞,直到某个事件发生,当timeout参数为0时,poll函数将立即返回。
poll系统调用的返回值含义与select函数相同。
epoll函数是Linux特有的IO复用函数,它在实现和使用上与select、poll函数有很大差异,首先,epoll函数使用一组函数来完成任务,而非单个函数,其次,epoll函数把用户关心的文件描述符上的事件放在内核里的一个事件表中,从而无须像select和poll函数那样每次调用都要重复传入文件描述符集或事件集。epoll需要使用一个额外的文件描述符,来唯一标识内核中的这个事件表,这个文件描述符使用epoll_create函数来创建:
size参数只是给内核一个提示,告诉它事件表需要多大,该函数返回的文件描述符将用作其他所有epoll系统调用的第1个参数,以指定要访问的内核事件表。
以下函数用来操作epoll的内核事件表:
fd参数是要操作的文件描述符。op参数指定操作类型,操作类型有以下三种:
1.EPOLL_CTL_ADD:往事件表中注册fd上的事件。
2.EPOLL_CTL_MOD:修改fd上的注册事件。
3.EPOLL_CTL_DEL:删除fd上注册事件。
event参数指定事件,它是epoll_event结构指针类型:
events成员描述事件类型,epoll函数支持的事件类型和poll函数基本相同,表示epoll事件类型的宏是在poll对应的宏前加上E,如epoll的数据可读事件是EPOLLIN,但epoll有两个额外的事件类型EPOLLET和EPOLLONESHOT,它们对于epoll的高效运作非常关键,后面再讨论它们。data成员用于存储用户数据,其类型epoll_data_t的定义如下:
epoll_data_t是一个联合体,其4个成员中使用最多的是fd成员,它指定要监视的文件描述符,prt成员是指向用户定义数据的指针,但由于epoll_data_t是一个联合体,我们不能同时使用其ptr成员和fd成员,因此,我们可以不使用epoll_data_t的fd成员,而在ptr成员指向的用户数据中包括fd。
epoll_ctl函数成功时返回0,失败时返回-1并设置errno。
epoll系列系统调用的主要接口是epoll_wait,它在一段超时时间内等待一组文件描述符上的事件:
epoll_wait函数成功时返回就绪的文件描述符个数,失败时返回-1并设置errno。
timeout参数与poll函数的timeout参数相同。maxevents参数指定最多监听多少事件,它必须大于0。
epoll_wait函数如果检测到事件,就将所有就绪的事件从内核事件表(由epfd参数指定的)中复制到它的第二个参数events指向的数组中,这个数组只用于输出epoll_wait函数检测到的就绪事件,而不像select和poll函数的数组参数那样既用于传入用户注册的事件,又用于输出内核检测到的就绪事件,这样就极大地提高了应用进程索引就绪文件描述符的效率,以下代码体现了这个差别:
// 索引poll函数返回的就绪文件描述符的过程
int ret = poll(fds, MAX_EVENT_NUMBER, -1);
// 遍历所有已注册文件描述符并找到其中的就绪者(当然可用ret来稍做优化)
for (int i = 0; i < MAX_EVENT_NUMBER; ++i) {
if (fds[i].revents & POLLIN) { // 判断第i个文件描述符是否就绪
int sockfd = fds[i].fd;
// 处理socket
}
}
// 索引epoll返回的就绪文件描述符的过程
int ret = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
// 仅遍历就绪的ret个文件描述符
for (int i = 0; i < ret; ++i) {
int sockfd = events[i].data.fd;
// socketfd肯定就绪,直接处理
}
epoll对文件描述符的操作有两种模式:LT(Level Trigger,电平触发)模式和ET(Edge Trigger,边沿触发)模式。LT模式是默认的工作模式,这种模式下epoll相当于一个效率较高的poll。当往epoll内核事件表中注册一个文件描述符上的EPOLLET事件时,epoll将以ET模式来操作该文件描述符。ET模式是epoll的高效工作模式。
对于采用LT工作模式的文件描述符,当epoll_wait函数检测到其上有事件发生并将此事件通知应用进程后,应用进程可以不立即处理该事件,这样,当应用进程下次调用epoll_wait时,epoll_wait函数还会再次向应用进程通告此事件,直到该事件被处理。而对于采用ET工作模式的文件描述符,当epoll_wait函数检测到其上有事件发生并将此事件通知应用进程后,应用进程应立即处理该事件,因为后续的epoll_wait调用将不再向应用进程通知这一事件。可见ET模式降低了同一个epoll事件被重复触发的次数,因此效率比LT模式高。以下代码体现了LT和ET在工作方式上的差异:
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <pthread.h>
#include <libgen.h>
#define MAX_EVENT_NUMBER 1024
#define BUFFER_SIZE 10
// 将文件描述符设为非阻塞的
int setnonblocking(int fd) {
int old_option = fcntl(fd, F_GETFL);
int new_option = old_option | O_NONBLOCK;
fcntl(fd, F_SETFL, new_option);
return old_option;
}
// 将文件描述符fd参数上的EPOLLIN注册到epollfd参数指示的内核事件表中
// 参数enable_et指定是否对fd参数启用ET模式
void addfd(int epollfd, int fd, bool enable_et) {
epoll_event event;
event.data.fd = fd;
event.events = EPOLLIN;
if (enable_et) {
event.events |= EPOLLET;
}
epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
setnonblocking(fd);
}
// LT模式的工作流程
void lt(epoll_event *events, int number, int epollfd, int listenfd) {
char buf[BUFFER_SIZE];
for (int i = 0; i < number; ++i) {
int sockfd = events[i].data.fd;
if (sockfd == listenfd) {
struct sockaddr_in client_address;
socklen_t client_addrlength = sizeof(client_address);
int connfd = accept(listenfd, (struct sockaddr *)&client_address, &client_addrlength);
addfd(epollfd, connfd, false);
} else if (events[i].events & EPOLLIN) {
// 只要socket读缓存中还有未读出的数据,这段代码就被触发
printf("event trigger once\n");
memset(buf, '\0', BUFFER_SIZE);
int ret = recv(sockfd, buf, BUFFER_SIZE - 1, 0);
if (ret <= 0) {
close(sockfd);
continue;
}
printf("get %d bytes of content: %s\n", ret, buf);
} else {
printf("something else happened\n");
}
}
}
// ET模式的工作流程
void et(epoll_event *events, int number, int epollfd, int listenfd) {
char buf[BUFFER_SIZE];
for (int i = 0; i < number; ++i) {
int sockfd = events[i].data.fd;
if (sockfd == listenfd) {
struct sockaddr_in client_address;
socklen_t client_addrlength = sizeof(client_address);
int connfd = accept(listenfd, (struct sockaddr *)&client_address, &client_addrlength);
addfd(epollfd, connfd, true);
} else if (events[i].events & EPOLLIN) {
// 这段代码不会被重复触发,所以需要循环读取数据
printf("event trigger once\n");
while (1) {
memset(buf, '\0', BUFFER_SIZE);
int ret = recv(sockfd, buf, BUFFER_SIZE - 1, 0);
if (ret < 0) {
if ((errno == EAGAIN) || (errno == EWOULDBLOCK)) {
printf("read later\n");
break;
}
close(sockfd);
break;
} else if (ret == 0) {
close(sockfd);
} else {
printf("get %d bytes of content: %s\n", ret, buf);
}
}
} else {
printf("something else happened\n");
}
}
}
int main(int argc, char *argv[]) {
if (argc != 3) {
printf("usage: %s ip_address port_number\n", basename(argv[0]));
return 1;
}
const char *ip = argv[1];
int port = atoi(argv[2]);
int ret = 0;
struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
inet_pton(AF_INET, ip, &address.sin_addr);
address.sin_port = htons(port);
int listenfd = socket(PF_INET, SOCK_STREAM, 0);
assert(listenfd >= 0);
ret = bind(listenfd, (struct sockaddr *)&address, sizeof(address));
assert(ret != -1);
ret = listen(listenfd, 5);
assert(ret != -1);
epoll_event events[MAX_EVENT_NUMBER];
int epollfd = epoll_create(5);
assert(epollfd != -1);
addfd(epollfd, listenfd, true);
while (1) {
int ret = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
if (ret < 0) {
printf("epoll failure\n");
break;
}
// 使用LT模式
lt(events, ret, epollfd, listenfd);
// 使用ET模式
// et(events, ret, epollfd, listenfd);
}
close(listenfd);
return 0;
}
可以运行以上代码,然后telnet到这个服务器上一次传输超过10字节(BUFFER_SIZE的大小)的数据,然后会发现,ET模式下事件被触发的次数比LT模式下少很多。
使用ET模式的文件描述符应该是非阻塞的,如果文件描述符是阻塞的,那么读或写操作将会因为没有后续事件而一直处于阻塞状态。
即使我们使用ET模式,一个socket上的某个事件还是可能被触发多次,这在并发程序中会引起问题,比如一个线程(或进程,下同)在读取完某个socket上的数据后开始处理这些数据,而在数据的处理过程中该socket上又有新数据可读(EPOLLIN再次被触发),此时另一个线程被唤醒来读取这些新数据,于是就出现了两个线程同时操作一个socket的局面,这不是我们所期望的,我们期望的是一个socket连接在任一时刻都只被一个线程处理,这可用EPOLLONESHOT事件实现。
对于注册了EPOLLONESHOT事件的文件描述符,操作系统最多触发其上注册的一个可读、可写、异常事件,且只触发一次,除非我们使用epoll_ctl函数重置该文件描述符上注册的EPOLLONESHOT事件,这样,当一个线程在处理某个socket时,其他线程不可能有机会操作socket。注册了EPOLLONESHOT事件的socket一旦被某个线程处理完毕,该线程就应立即重置这个socket上的EPOLLONESHOT事件,以确保这个socket下次可读时,其EPOLLIN事件能触发,从而让其他线程有机会处理这个socket。
以下代码展示了EPOLLONESHOT事件的使用:
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <pthread.h>
#define MAX_EVENT_NUMBER 1024
#define BUFFER_SIZE 1024
struct fds {
int epollfd;
int sockfd;
};
int setnonblocking(int fd) {
int old_option = fcntl(fd, F_GETFL);
int new_option = old_option | O_NONBLOCK;
fcntl(fd, F_SETFL, new_option);
return old_option;
}
// 将fd参数上的EPOLLIN和EPOLLET事件注册到epollfd参数指示的内核事件表中
// 参数oneshot指定是否注册fd参数上的EPOLLONESHOT事件
void addfd(int epollfd, int fd, bool oneshot) {
epoll_event event;
event.data.fd = fd;
event.events = EPOLLIN | EPOLLET;
if (oneshot) {
event.events |= EPOLLONESHOT;
}
epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
setnonblocking(fd);
}
// 重置fd参数上的事件,这样操作后,可以再次触发fd参数上的事件
void reset_oneshot(int epollfd, int fd) {
epoll_event event;
event.data.fd = fd;
event.events = EPOLLIN | EPOLLET | EPOLLONESHOT;
epoll_ctl(epollfd, EPOLL_CTL_MOD, fd, &event);
}
// 工作线程
void *worker(void *arg) {
int sockfd = ((fds *)arg)->sockfd;
int epollfd = ((fds *)arg)->epollfd;
printf("start new thread to receive data on fd: %d\n", sockfd);
char buf[BUFFER_SIZE];
memset(buf, '\0', BUFFER_SIZE);
// 循环读取sockfd上的数据,直到遇到EAGAIN错误
while (1) {
int ret = recv(sockfd, buf, BUFFER_SIZE - 1, 0);
if (ret == 0) {
close(sockfd);
printf("foreiner closed the connection\n");
break;
} else if (ret < 0) {
if (errno == EAGAIN) {
reset_oneshot(epollfd, sockfd);
printf("read later\n");
break;
}
} else {
printf("get content: %s\n", buf);
// 休眠5s,模拟数据处理过程
sleep(5);
}
}
printf("end thread receiving data on fd: %d\n", sockfd);
}
int main(int argc, char *argv[]) {
if (argc != 3) {
printf("usage: %s ip_address port_number\n", basename(argv[0]));
return 1;
}
const char *ip = argv[1];
int port = atoi(argv[2]);
int ret = 0;
struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
inet_pton(AF_INET, ip, &address.sin_addr);
address.sin_port = htons(port);
int listenfd = socket(PF_INET, SOCK_STREAM, 0);
assert(listenfd >= 0);
ret = bind(listenfd, (struct sockaddr *)&address, sizeof(address));
assert(ret != -1);
ret = listen(listenfd, 5);
assert(ret != -1);
epoll_event events[MAX_EVENT_NUMBER];
int epollfd = epoll_create(5);
assert(epollfd != -1);
// 监听socket上不能注册EPOLLONESHOT事件,否则只能处理一个客户连接
// 后续的连接请求将不再触发listenfd上的EPOLLIN事件
addfd(epollfd, listenfd, false);
while (1) {
int ret = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
if (ret < 0) {
printf("epoll failure\n");
break;
}
for (int i = 0; i < ret; ++i) {
int sockfd = events[i].data.fd;
if (sockfd == listenfd) {
struct sockaddr_in client_address;
socklen_t client_addrlength = sizeof(client_address);
int connfd = accept(listenfd, (struct sockaddr *)&client_address, &client_addrlength);
// 对每个非监听文件描述符都注册EPOLLONESHOT事件
addfd(epollfd, connfd, true);
} else if (events[i].events & EPOLLIN) {
pthread_t thread;
fds fds_for_new_worker;
fds_for_new_worker.epollfd = epollfd;
fds_for_new_worker.sockfd = sockfd;
// 对每个客户请求都启动一个工作线程为其服务
pthread_create(&thread, NULL, worker, (void *)&fds_for_new_worker);
} else {
printf("something else happened\n");
}
}
}
close(listenfd);
return 0;
}
从工作线程函数worker来看,如果一个工作线程处理完某个socket上的一次请求(我们用休眠5秒来模拟此过程)之后,又接收到该socket上新的客户请求,则该线程将继续为这个socket服务,并且由于该socket上注册了EPOLLONESHOT事件,主线程中epoll_wait函数不会返回该描述符的可读事件,从而不会有其他线程读这个socket,如果工作线程等待5秒后仍没收到该socket上的下一批客户数据,则它将放弃为该socket服务,同时调用reset_oneshot来重置该socket上的注册事件,这将使epoll有机会再次检测到该socket上的EPOLLIN事件,进而使得其他线程有机会为该socket服务。
有了EPOLLONESHOT,尽管一个socket在不同时间可能被不同的线程处理,但同一时刻肯定只有一个线程在为它服务,这就保证了连接的完整性,从而避免了很多可能的竞态条件。
select、poll、epoll三组IO复用系统调用都能同时监听多个文件描述符,它们将等待由timeout参数指定的超时时间,直到一个或多个文件描述符上有事件发生时返回,返回值是就绪的文件描述符的数量,返回0表示没有事件发生。
这3组IO复用函数都通过某种结构体变量来告诉内核监听哪些文件描述符上的哪些事件,并使用该结构体类型的参数来获取内核处理的结果。select函数的参数类型fd_set没有将文件描述符和事件绑定,它仅仅是一个文件描述符集合,因此select函数需要3个fd_set类型参数来分别传入和输出可读、可写、异常事件,这使得select函数不能处理更多类型的事件,另一方面,由于内核对fd_set集合的修改,应用进程下次调用select前不得不重置这3个fd_set集合。poll函数的参数类型pollfd则聪明一些,它把文件描述符和事件都定义其中,任何事件都被统一处理,从而使得编程接口简洁地多,且内核每次修改的是pollfd结构体的revents成员,而events成员保持不变,因此下次调用poll时应用进程无需重置pollfd类型中我们关系的事件集。由于每次select和poll函数都返回整个用户注册的事件集合(包括就绪和未就绪的),所以应用索引就绪文件描述符的时间复杂度为O(n)。epoll则采用与select和poll函数不同的方式来管理用户注册的事件,它在内核中维护一个事件表,并提供一个独立的系统调用epoll_ctl来往内核事件表中添加、删除、修改事件,这样,每次epoll_wait调用都直接从内核表中取得用户注册的事件,而无须反复从用户空间读入这些事件,epoll_wait函数的events参数仅用来返回就绪的事件,这使得应用进程索引就绪文件描述符的时间复杂度达到O(1)。
poll和epoll_wait函数分别用nfds和maxevents参数指定最多监听多少文件描述符和事件,这两个数值都能达到系统允许打开的最大文件描述符数目,即65535(cat /proc/sys/fd/file-max
)。而select函数允许监听的最大文件描述符数量通常有限制,虽然用户可以修改这个限制,但这可能导致不可预期的后果。
select和poll函数只能工作在相对低效的LT模式,而epoll函数能工作在高效的ET模式,且epoll函数还支持EPOLLONESHOT事件,该事件能进一步减少可读、可写、异常事件触发的次数。
从实现原理上说,select和poll函数采用的都是轮询方式,即每次调用都要扫描整个注册文件描述符集合,因此它们检测就绪事件的算法时间复杂度是O(n)。而epoll_wait函数采用回调的方式,内核检测到就绪的文件描述符时,将触发回调函数,回调函数将该文件描述符上对应的事件插入内核就绪事件队列,然后内核在适当的时机将该就绪事件队列中的内容拷贝到用户空间,因此epoll_wait函数无须轮询整个文件描述符集合来检测哪些事件已经就绪,其算法时间复杂度为O(1)。
connect系统调用的man手册中有如下一段内容:
这段话描述了connect函数出错时的一种errno值(EINPROGRESS),这种错误发生在对非阻塞的socket调用connect,而连接又没有立即建立时,此时,我们可以调用select、poll等函数来监听这个正在进行连接的socket上的可写事件,当select、poll等函数返回后,再利用getsockopt函数来读取错误码并清除该socket上的错误,如果错误码是0,表示连接成功建立,否则连接建立失败。
通过非阻塞connect,我们就能同时发起多个连接并一起等待,以下代码使用非阻塞connect:
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <assert.h>
#include <stdio.h>
#include <time.h>
#include <errno.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <string.h>
#define BUFFER_SIZE 1023
int setnonblocking(int fd) {
int old_option = fcntl(fd, F_GETFL);
int new_option = old_option | O_NONBLOCK;
fcntl(fd, F_SETFL, new_option);
return old_option;
}
// 执行非阻塞connect,ip参数是ip地址,port参数是端口号,time参数是超时时间(毫秒)
// 函数成功时返回处于连接状态的socket,失败时返回-1
int unblock_connect(const char *ip, int port, int time) {
int ret = 0;
struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
inet_pton(AF_INET, ip, &address.sin_addr);
address.sin_port = htons(port);
int sockfd = socket(PF_INET, SOCK_STREAM, 0);
int fdopt = setnonblocking(sockfd);
ret = connect(sockfd, (struct sockaddr *)&address, sizeof(address));
if (ret == 0) {
// 如果连接成功,恢复sockfd的属性,并立即返回sockfd
printf("connect with server immediately\n");
fcntl(sockfd, F_SETFL, fdopt);
return sockfd;
} else if (errno != EINPROGRESS) {
// 如果连接没有建立,只有当errno是EINPROGRESS才表示连接正在进行,否则出错返回
printf("unblock connect not support\n");
return -1;
}
fd_set readfds;
fd_set writefds;
struct timeval timeout;
FD_ZERO(&readfds);
FD_SET(sockfd, &writefds);
timeout.tv_sec = time;
timeout.tv_usec = 0;
ret = select(sockfd + 1, NULL, &writefds, NULL, &timeout);
if (ret <= 0) {
// select函数超时或出错,立即返回
printf("connection time out\n");
close(sockfd);
return -1;
}
if (!FD_ISSET(sockfd, &writefds)) {
printf("no events on sockfd found\n");
close(sockfd);
return -1;
}
int error = 0;
socklen_t length = sizeof(error);
// 调用getsockopt来获取并清除sockfd上的错误
if (getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &error, &length) < 0) {
printf("get socket option failed\n");
close(sockfd);
return -1;
}
// 错误号不为0表示连接出错
if (error != 0) {
printf("connection failed after select with the error: %d\n", error);
close(sockfd);
return -1;
}
// 连接成功
printf("connection ready after select with the socket: %d\n", sockfd);
fcntl(sockfd, F_SETFL, fdopt);
return sockfd;
}
int main(int argc, char *argv[]) {
if (argc != 3) {
printf("usage: %s ip_address port_number\n", basename(argv[0]));
return 1;
}
const char *ip = argv[1];
int port = atoi(argv[2]);
int sockfd = unblock_connect(ip, port, 10);
if (sockfd < 0) {
return 1;
}
close(sockfd);
return 0;
}
但以上方法存在移植性问题,对于出错的socket,getsockopt函数在有些系统上(如Linux上)返回-1,而在有些系统上(如伯克利的UNIX)返回0。
像ssh这样的登录服务通常需要同时处理套接字描述符和用户输入输出描述符,这可用IO复用来实现,下面用poll函数为例实现一个简单的聊天室程序,该聊天室程序能让所有用户同时在线群聊,它分为客户端和服务器两部分。客户端有两个功能:一是从标准输入终端读入用户数据,并将用户数据发送至服务器;二是往标准输出终端打印服务器发来的数据。服务器的功能是接收客户数据,并把客户数据发送给每个登录到该服务器上的客户端(数据发送者除外)。
客户端程序使用poll函数同时监听用户输入和网络连接,并利用splice函数将用户输入内容直接定向到网络连接上发送,从而实现数据零拷贝,提高了程序执行效率,客户端代码如下:
// 启用GNU扩展,其中包含一些非标准的函数和特性
#define _GNU_SOURCE 1
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <poll.h>
#include <fcntl.h>
#define BUFFER_SIZE 64
int main(int argc, char *argv[]) {
if (argc != 3) {
printf("usage: %s ip_address port_number\n", basename(argv[0]));
return 1;
}
const char *ip = argv[1];
int port = atoi(argv[2]);
struct sockaddr_in server_address;
bzero(&server_address, sizeof(server_address));
server_address.sin_family = AF_INET;
inet_pton(AF_INET, ip, &server_address.sin_addr);
server_address.sin_port = htons(port);
int sockfd = socket(PF_INET, SOCK_STREAM, 0);
assert(sockfd >= 0);
if (connect(sockfd, (struct sockaddr *)&server_address, sizeof(server_address)) < 0) {
printf("connection failed\n");
close(sockfd);
return 1;
}
pollfd fds[2];
// 注册文件描述符0(标准输入)和文件描述符sockfd上的可读事件
fds[0].fd = 0;
fds[0].events = POLLIN;
fds[0].revents = 0;
fds[1].fd = sockfd;
fds[1].events = POLLIN | POLLRDHUP;
fds[1].revents = 0;
char read_buf[BUFFER_SIZE];
int pipefd[2];
int ret = pipe(pipefd);
assert(ret != -1);
while (1) {
ret = poll(fds, 2, -1);
if (ret < 0) {
printf("poll failure\n");
break;
}
if (fds[1].revents & POLLRDHUP) {
printf("server close the connection\n");
break;
} else if (fds[1].revents & POLLIN) {
memset(read_buf, '\0', BUFFER_SIZE);
recv(fds[1].fd, read_buf, BUFFER_SIZE - 1, 0);
printf("%s\n", read_buf);
}
if (fds[0].revents & POLLIN) {
// 使用splice函数将用户输入的数据直接写到sockfd上(零拷贝)
splice(0, NULL, pipefd[1], NULL, 32768, SPLICE_F_MORE | SPLICE_F_MOVE);
splice(pipefd[0], NULL, sockfd, NULL, 32768, SPLICE_F_MORE | SPLICE_F_MOVE);
}
}
close(sockfd);
return 0;
}
服务器使用poll函数同时管理监听socket和连接socket,且使用牺牲空间换取事件的策略来提高服务器性能:
#define _GNU_SOURCE 1
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <poll.h>
// 最大用户数量
#define USER_LIMIT 5
// 读缓冲区的大小
#define BUFFER_SIZE 64
// 文件描述符数量限制
#define FD_LIMIT 65535
// 客户信息:客户socket地址、待写到客户端的数据的位置、从客户端已读入的数据
struct client_data {
sockaddr_in address;
char *write_buf;
char buf[BUFFER_SIZE];
};
int setnonblocking(int fd) {
int old_option = fcntl(fd, F_GETFL);
int new_option = old_option | O_NONBLOCK;
fcntl(fd, F_SETFL, new_option);
return old_option;
}
int main(int argc, char *argv[]) {
if (argc != 3) {
printf("usage: %s ip_address port_number\n", basename(argv[0]));
return 1;
}
const char *ip = argv[1];
int port = atoi(argv[2]);
int ret = 0;
struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
inet_pton(AF_INET, ip, &address.sin_addr);
address.sin_port = htons(port);
int listenfd = socket(PF_INET, SOCK_STREAM, 0);
assert(listenfd >= 0);
ret = bind(listenfd, (struct sockaddr *)&address, sizeof(address));
assert(ret != -1);
ret = listen(listenfd, 5);
assert(ret != -1);
// 分配FD_LIMIT个client_data对象,我们直接把socket的值用作索引
// 这样socket和客户数据的关联比较简单
client_data *users = new client_data[FD_LIMIT];
// 虽然我们分配了足够多的client_data对象,但为了提高poll函数性能,仍然有必要限制用户数量
pollfd fds[USER_LIMIT + 1];
int user_counter = 0;
// 初始化客户数据对象
for (int i = 1; i <= USER_LIMIT; ++i) {
fds[i].fd = -1;
fds[i].events = 0;
}
fds[0].fd = listenfd;
fds[0].events = POLLIN | POLLERR;
fds[0].revents = 0;
while (1) {
ret = poll(fds, user_counter + 1, -1);
if (ret < 0) {
printf("poll failure\n");
break;
}
for (int i = 0; i < user_counter + 1; ++i) {
if ((fds[i].fd == listenfd) && (fds[i].revents & POLLIN)) {
struct sockaddr_in client_address;
socklen_t client_addrlength = sizeof(client_address);
int connfd = accept(listenfd, (struct sockaddr *)&client_address, &client_addrlength);
if (connfd < 0) {
printf("errno is: %d\n", errno);
continue;
}
// 如果请求过多,则关闭新到的连接
if (user_counter >= USER_LIMIT) {
const char *info = "too many users\n";
printf("%s", info);
send(connfd, info, strlen(info), 0);
close(connfd);
continue;
}
// fds和users数组中新增连接,users[connfd]就是新连接的客户信息
++user_counter;
users[connfd].address = client_address;
setnonblocking(connfd);
fds[user_counter].fd = connfd;
fds[user_counter].events = POLLIN | POLLRDHUP | POLLERR;
fds[user_counter].revents = 0;
printf("comes a new user, now have %d users\n", user_counter);
} else if (fds[i].revents & POLLERR) {
printf("get an error from %d\n", fds[i].fd);
char errors[100];
memset(errors, '\0', 100);
socklen_t length = sizeof(errors);
if (getsockopt(fds[i].fd, SOL_SOCKET, SO_ERROR, &errors, &length) < 0) {
printf("get socket option failed\n");
}
continue;
} else if (fds[i].revents & POLLRDHUP) {
// 如果客户端关闭连接,则服务器也关闭对应的连接,并将总用户数减1
// 此处作者想把fds数组中,最后一个位置的元素放到此处正要关闭的位置
// 但users数组的索引是fd,因此users数组不应做改变,此处应删除下一句代码
users[fds[i].fd] = users[fds[user_counter].fd]; // delete this
close(fds[i].fd);
fds[i] = fds[user_counter];
--i;
--user_counter;
printf("a client left\n");
} else if (fds[i].revents & POLLIN) {
int connfd = fds[i].fd;
memset(users[connfd].buf, '\0', BUFFER_SIZE);
ret = recv(connfd, users[connfd].buf, BUFFER_SIZE - 1, 0);
printf("get %d bytes of client data %s from %d\n", ret, users[connfd].buf, connfd);
if (ret < 0) {
// 如果读出错,就关闭连接
if (errno != EAGAIN) {
close(connfd);
// 此处关闭连接时,也不应移动users数组,因为users数组是按套接字索引的
users[fds[i].fd] = users[fds[user_counter].fd]; // delete this
fds[i] = fds[user_counter];
--i;
--user_counter;
}
} else if (ret == 0) {
} else {
// 如果接收到客户数据,则通知其他socket连接准备写数据
for (int j = 1; j <= user_counter; ++j) {
// 跳过发来消息的客户
if (fds[j].fd == connfd) {
continue;
}
// 作者在干什么?可能是想关闭读,但关闭读应该是用&=
// 如果关闭读,说明套接字处于写状态时不能读,感觉没必要,可以同时检测读和写
fds[j].events |= ~POLLIN;
fds[j].events |= POLLOUT;
users[fds[j].fd].write_buf = users[connfd].buf;
}
}
} else if (fds[i].revents & POLLOUT) {
int connfd = fds[i].fd;
if (!users[connfd].write_buf) {
continue;
}
ret = send(connfd, users[connfd].write_buf, strlen(users[connfd].write_buf), 0);
users[connfd].write_buf = NULL;
// 写完数据后重新注册fds[i]上的可读事件,此处应使用&=
fds[i].events |= ~POLLOUT;
fds[i].events |= POLLIN;
}
}
}
delete[] users;
close(listenfd);
return 0;
}
以上讨论的服务器程序只监听一个端口,实际应用中,有些服务器程序能同时监听多个端口,如超级服务器inetd和android的调试服务adbd。
从bind系统调用的参数来看,一个socket只能与一个socket地址绑定,即一个socket只能用来监听一个端口,因此,如果服务器要同时监听多个端口,就必须创建多个socket,并将它们分别绑定到各个端口上,这样,服务器就需要同时管理多个监听socket,这可使用IO复用技术实现。另外,即使是同一个端口,如果服务器要同时处理该端口上的TCP和UDP请求,也需要创建两个不同的socket,一个是流socket,另一个是数据报socket,并将它们都绑定到该端口上。以下回射服务器能同时处理同一端口上的TCP和UDP请求:
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <pthread.h>
#include <libgen.h>
#define MAX_EVENT_NUMBER 1024
#define TCP_BUFFER_SIZE 512
#define UDP_BUFFER_SIZE 1024
int setnonblocking(int fd) {
int old_option = fcntl(fd, F_GETFL);
int new_option = old_option | O_NONBLOCK;
fcntl(fd, F_SETFL, new_option);
return old_option;
}
void addfd(int epollfd, int fd) {
epoll_event event;
event.data.fd = fd;
event.events = EPOLLIN | EPOLLET;
epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
setnonblocking(fd);
}
int main(int argc, char *argv[]) {
if (argc != 3) {
printf("usage: %s ip_address port_number\n", basename(argv[0]));
return 1;
}
const char *ip = argv[1];
int port = atoi(argv[2]);
int ret = 0;
struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
inet_pton(AF_INET, ip, &address.sin_addr);
address.sin_port = htons(port);
// 创建TCP socket,并将其绑定在端口port上
int listenfd = socket(PF_INET, SOCK_STREAM, 0);
assert(listenfd >= 0);
ret = bind(listenfd, (struct sockaddr *)&address, sizeof(address));
assert(ret != -1);
ret = listen(listenfd, 5);
assert(ret != -1);
// 创建UDP socket,并将其绑定到端口port上
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
inet_pton(AF_INET, ip, &address.sin_addr);
address.sin_port = htons(port);
int udpfd = socket(PF_INET, SOCK_DGRAM, 0);
assert(udpfd >= 0);
epoll_event events[MAX_EVENT_NUMBER];
int epollfd = epoll_create(5);
assert(epollfd != -1);
// 注册TCP socket和UDP socket上的可读事件
addfd(epollfd, listenfd);
addfd(epollfd, udpfd);
while (1) {
int number = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
if (number < 0) {
printf("epoll failure\n");
break;
}
for (int i = 0; i < number; ++i) {
int sockfd = events[i].data.fd;
if (sockfd == listenfd) {
struct sockaddr_in client_address;
socklen_t client_addrlength = sizeof(client_address);
int connfd = accept(listenfd, (struct sockaddr *)&client_address, &client_addrlength);
addfd(epollfd, connfd);
} else if (sockfd == udpfd) {
char buf[UDP_BUFFER_SIZE];
memset(buf, '\0', UDP_BUFFER_SIZE);
struct sockaddr_in client_address;
socklen_t client_addrlength = sizeof(client_address);
ret = recvfrom(udpfd, buf, UDP_BUFFER_SIZE - 1, 0, (struct sockaddr *)&client_address,
&client_addrlength);
if (ret > 0) {
sendto(udpfd, buf, UDP_BUFFER_SIZE - 1, 0, (struct sockaddr *)&client_address,
client_addrlength);
}
} else if (events[i].events & EPOLLIN) {
char buf[TCP_BUFFER_SIZE];
while (1) {
memset(buf, '\0', TCP_BUFFER_SIZE);
ret = recv(sockfd, buf, TCP_BUFFER_SIZE - 1, 0);
if (ret < 0) {
if ((errno == EAGAIN) || (errno == EWOULDBLOCK)) {
break;
}
close(sockfd);
break;
} else if (ret == 0) {
close(sockfd);
} else {
send(sockfd, buf, ret, 0);
}
}
} else {
printf("something else happened\n");
}
}
}
close(listenfd);
return 0;
}
Linux因特网服务inetd是超级服务,它同时管理着多个子服务,即监听多个端口,现在Linux上使用的inetd服务程序通常是其升级版本xinetd,xinetd程序的原理与inetd的相同,但增加了一些控制选项,并提高了安全性。
xinetd采用/etc/xinetd.conf主配置文件和/etc/xinetd.d目录下的子配置文件来管理所有服务。主配置文件包含的是通用选项,这些选项将被所有子配置文件继承,但子配置文件可以覆盖这些选项,每一个子配置文件用于配置一个子服务的参数,如telnet子服务的配置文件/etc/xinetd.d/telnet的典型内容如下:
xinetd配置文件的内容很丰富,不止上图这些,可通过其man文档获得更多信息。
xinetd管理的子服务中有的是标准服务,如时间日期服务daytime、回射服务echo、丢弃服务discard,xinetd服务器在内部直接处理这些服务,但还有的子服务需要调用外部服务器程序来处理,xinetd通过调用fork和exec来加载运行这些服务器程序,如telnet、ftp都是需调用的外部服务器程序。下面以telnet服务为例探讨xinetd的工作流程。
首先查看xinetd守护进程的PID:
然后开启两个终端分别使用以下命令telnet到本机:
然后使用ps命令查看与进程9543相关的进程:
由上图可见,我们每次运行telnet登录到xinetd服务,它都创建一个子进程为该telnet客户服务,子进程运行in.telnetd程序,这是在/etc/xinetd.d/telnet配置文件中定义的。每个子进程都处于自己独立的进程组和会话中。我们可用lsof命令查看子进程都打开了哪些文件描述符:
上图中lsof命令的-p选项的作用是查看进程ID为9810的进程的所有打开的文件(包括普通文件、目录、设备文件和网络套接字等)。
上图省略了一些无关的输出,可见子进程9810关闭了其标准输入、标准输出、标准错误,而将socket文件描述符dup到它们上面,即telnet服务器程序将网络连接上的输入当作标准输入,并把标准输出和标准错误定向到同一个网络连接上。
对xinetd进程使用lsof命令:
上图说明xinetd在监听telnet连接请求,因此in.telnetld子进程只处理连接socket,而不处理监听socket,这是子配置文件中的wait参数所定义的行为。
以下是wait选项的值是no时,xinetd的工作流程: