为了构建并发服务器,只要有客户端连接请求就会创建新进程。这的确是实际操作中采用的一种方案,但创建进程时需要大量的运算和内存空间,因此采用I/O复用技术提高服务器性能。
select 系统调用
select系统调用的用途是:在一段指定时间内,监听用户感兴趣的文件描述符上的可读、可写和异常等事件。
#include <sys/select.h>
#include <sys/time.h>
int select(int maxfd, fd_set* readset, fd_set* writeset, fd_set* exceptset,
struct timeval* timeout);
sclect 成功时返回就绪(可读、可写和异常)文件描述符的总数。如果在超时时间内没有任何文件描述符就绪,select 将返回 0。select 失败时返回 -1 并设置 errno。如果在 select 等待期间,程序接收到信号,则 select 立即返回 -1,并设置 errno 为 EINTR。
-
maxfd 参数
maxfd 指定被监听的文件描述符的总数。它通常被设置为seclect监听的所有文件描述符中的最大值加 1,因为文件描述符是从 0 开始计数的。 -
readset、writeset、exceptset 参数
readset、writeset、exceptset 分别指向可读、可写和异常等事件对应的文件描述符集合。应用程序调用 select 函数时,通过这 3 个参数传入自己感兴趣的文件描述符。select 调用返回时,内核将修改它们来通知应用程序哪些文件描述符已经就绪。 -
fd_set 结构体
fd_set 结构体包含一个整型数组,该数组是存有 0 和 1 的位数组,每一位(bit)标记一个文件描述符。fd_set 能容纳的文件描述符数量为 1024,这限制了 select 能同时处理的文件描述符的总量。
-
fd_set 的操作
// 将fd_set变量的所有位初始化为0 void FD_ZERO(fd_set *set); // 在参数fdset指向的变量中注册文件描述符fd的信息 void FD_SET(int fd, fd_set *set); // 在参数fdset指向的变量中清除文件描述符fd的信息 void FD_CLR(int fd, fd_set *set); // 若参数fdset指向的变量中包含文件描述符ft的信息,则返回“真” int FD_ISSET(int fd, fd_set *set);
上述函数的功能如下图所示:
-
timeout 参数
timeout 用于设置 select 函数的超时时间。它是一个 timeval 结构类型的指针,采用指针参数是因为内核将修改它以告诉应用程序select等待了多久。struct timeval { long tv_sec; // 秒数 long tv_usec; // 微妙数 }
如果给 timcout 变量的 tv_sec 成员和 tv_usec 成员都传递 0,则 select 将立即返回。如果给 timeout 传递 NULL,则 select 将一直阻塞,直到某个文件描述符就绪。
-
缺点
① 调用 select 函数后需针对所有文件描述符进行循环遍历;
② 每次调用 select 函数时都需要向该函数(操作系统)传递监视对象信息,这个缺点是无法通过优化代码解决的,是性能上致命的缺点;
③ 有最大 1024 个文件描述符的限制。 -
优点
① 跨平台。
示例
通过 select 函数实现基于I/O复用的回声服务器。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <sys/select.h>
#define BUF_SIZE 100
void error_handling(char *message);
int main(int argc, char* argv[]) {
int serv_sock, clnt_sock;
struct sockaddr_in serv_adr, clnt_adr;
struct timeval timeout;
fd_set reads, cpy_reads;
socklen_t adr_sz;
int fd_max, str_len, fd_num, i;
char buf[BUF_SIZE];
if(argc != 2) {
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}
serv_sock = socket(AF_INET, SOCK_STREAM, 0);
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_adr.sin_ort = htons(atoi(argv[1]));
if(bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
error_handling("bind() error");
if(listen(serv_sock, 5) == -1)
error_handling("listen() error");
FD_ZERO(&reads);
FD_SET(serv_sock, &reads);
fd_max = serv_sock;
while(1) {
cpy_reads = reads;
timeout.tv_sec = 5;
timeout.tv_usec = 5000;
// select调用失败或收到信号,返回-1
if(fd_num = select(fd_max+1, &cpy_reads, 0, 0, &timeout) == -1)
break;
// 没有任何文件描述符就绪,返回0
if(fd_num == 0)
continue;
for(i=0; i<fd_max+1; i++) { // 轮询文件描述符
if(FD_ISSET(i, &cpy_reads)) {
if(i == serv_sock) { // 有新的连接请求
adr_sz = sizeof(clnt_adr);
clnt_sock =
accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
FD_SET(clnt_sock, &reads);
if(fd_max < clnt_sock)
fd_max = clnt_sock;
printf("Connected client: %d \n", clnt_sock);
}
else { // 有数据需要读
str_len = read(i, buf, BUF_SIZE);
// 接收的数据为EOF时需要关闭套接字,并从reads中删除相应信息
if(str_len == 0) {
FD_CLR(i, &reads);
close(i);
printf("closed client: %d \n", i);
}
else {
write(i, buf, str_len);
}
}
}
}
}
close(serv_sock);
return 0;
}
void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
poll 系统调用
(仅作了解)
poll 和 select 类似,也是在指定时间内沦轮询一定数量的文件描述符,以测试其中是否有就绪者。
epoll 系列系统调用
Linux 系统特有的 I/O 复用方式。
epoll 把用户关心的文件描述符上的事件放在内核里的一个事件表中,从而无需像 select 和 poll 那样每次调用都要重复传入文件描述符集或事件集。但 epoll 需要使用一个额外的文件描述符,来唯一标识内核中的这个事件表。这个文件描述符使用 epoll_create 函数来创建。
epoll 使用一组函数来完成任务:
- epoll_create:创建保存 epoll 文件描述符的空间
- epoll_ctl:向空间注册并注销文件描述符
- epoll_wait:与 select 函数类似,等待文件描述符发生变化
epoll_create
epoll_create 创建标识内核事件表的文件描述符。
#include <sys/epoll.h>
int epoll_create(int size);
// size:向内核 建议 的事件表大小。
成功时返回 epoll 文件描述符(其他所有 epoll 系统调用的第一个参数),失败返回 -1。
epoll_ctl
epoll_ctl 用来操作内核事件表。
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
/*
epfd:标识内核事件表的epoll文件描述符
op:用于指定监视对象的添加、删除或更改等操作
fd:需要注册的监视对象文件描述符
event:监视对象的事件类型
成功时返回 0,失败返回 -1,并设置 errno。
*/
-
op 参数
操作类型有 3 种:
① EPOLL_CTL_ADD:将 fd 注册到内核事件表种
② EPOLL_CTL_DEL:删除 fd 上的注册事件
③ EPOLL_CTL_MOD:修改 fd 上的注册事件 -
epoll_event 结构体
event 参数指定事件,它是 epoll_event 结构指针类型。struct epoll_event { __uint32_t events; // epoll事件 epoll_data_t data; // 用户数据 }; // 由于是共用体,不能同时使用 ptr 和 fd, // 使用最多的是 fd。 typedef union epoll_data { void* ptr; // 可指定与 fd 相关的用户数据 int fd; // 指定事件所从属的目标文件描述符 uint32_t u32; uint64_t u64; } epoll_data_t;
其中 events 成员描述事件类型如下:
事件名 | 描述 |
---|---|
EPOLLIN | 需要读取数据的情况 |
EPOLLOUT | 输出缓冲为空,可以立即发送数据的情况 |
EPOLLPRI | 收到OOB数据的情况 |
EPOLLRDHUP | 断开连接或半关闭的情况,这在边缘触发方式下非常有用 |
EPOLLERR | 发生错误的情况 |
EPOLET | 以边缘触发的方式得到事件通知 |
EPOLLONESHOT | 发生一次事件后,相应文件描述符不再收到事件通知。因此需要向 epoll_ctl 函数的第二个参数传递 EPOLL _CTL_MOD,再次设置事件 |
epoll_wait
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event* events,
int maxevents, int timeout);
/*
epfd:标识内核事件表的epoll文件描述符
events:保存发生事件的文件描述符集合的结构体 地址
maxevents:第二个参数中可以保存的最大事件数
timeout:以1/1000秒为单位的等待时间,传递-1时,一直等待直到发生事件
注意:第二个参数所指缓冲需要动态分配内存。
成功时返回发生事件的文件描述符数,失败返回 -1。
*/
epoll_wait 函数如果检测到事件,就将所有就绪的事件从内核事件表(由 epfd 参数指定)中复制到它的第二个参数 events 指向的数组中。这个数组只用于输出 epoll_wait 检测到的就绪事件,而不像 select 和 poll 的数组参数那样既用于传入用户注册的事件,又用于输出内核检测到的就绪事件。这就极大地提高了应用程序索引就绪文件描述符的效率。
示例
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#define BUF_SIZE 100
#define EPOLL_SIZE 50
void error_handling(char *message);
int main(int argc, char* argv[]) {
int serv_sock, clnt_sock;
struct sockaddr_in serv_adr, clnt_adr;
socklen_t adr_sz;
int str_len, i;
char buf[BUF_SIZE];
struct epoll_event *ep_events;
struct epoll_event event;
int epfd, event_cnt;
if(argc != 2) {
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}
serv_sock = socket(AF_INET, SOCK_STREAM, 0);
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_adr.sin_ort = htons(atoi(argv[1]));
if(bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
error_handling("bind() error");
if(listen(serv_sock, 5) == -1)
error_handling("listen() error");
epfd = epoll_create(EPOLL_SIZE);
ep_events = malloc(sizeof(struct epoll_event)*EPOLL_SIZE);
event.events = EPOLLIN;
event.data.fd = serv_sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event);
while(1) {
event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
if(event_cnt == -1) {
puts("epoll_wait() error");
break;
}
for(i=0; i<event_cnt; i++) {
if(ep_events[i].data.fd == serv_sock) {
adr_sz = sizeof(clnt_adr);
clnt_sock =
accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
event.events = EPOLLIN;
event.data.fd = clnt_sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event);
printf("connected client: %d \n", clnt_sock);
}
else {
str_len = read(ep_events[i].data.fd, buf, BUF_SIZE);
if(str_len == 0) {
epoll_ctl(epfd, EPOLL_CLT_DEL, ep_events[i].data.fd, NULL);
close(ep_events[i].data.fd);
printf("closed client: %d \n", ep_events[i].data.fd);
}
else {
write(ep_events[i].data.fd, buf, str_len);
}
}
}
}
close(serv_sock);
close(epfd);
return 0;
}
void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
LE 和 ET 模式
epoll 对文件描述符的操作有两种模式:LT(Level Trigger,电平触发)模式和 ET(Edge Trigger,边沿触发)模式。LT 模式是默认的工作模式。当往 epoll 内核事件表中注册一个文件描述符上的 EPOLLET 事件时,epoll 将以 ET 模式来操作该文件描述符。ET 模式是 epoll 的高效工作模式。
-
LT 模式
电平触发方式中,只要输入缓冲有数据就会一直通知该事件。例如,服务器端输入缓冲收到 50 字节的数据时,服务器端操作系统将通知该事件(注册到发生变化的文件描述符)。但服务器端读取 20 字节后还剩 30 字节的情况下,仍会注册事件。也就是说,电平触发方式中,只要输入缓冲中还剩有数据,就将以事件方式再次注册。
-
ET 模式
边沿触发中输入缓冲收到数据时仅注册 1 次该事件。即使输入缓冲中还留有数据,也不会再进行注册。 -
EPOLLONESHOT 事件
即使我们使用 ET 模式,一个 socket 上的某个事件还是可能被触发多次。这在并发程序中就会引起一个问题。比如一个线程(或进程,下同)在读取完某个 socket 上的数据后开始处理这些数据,而在数据的处理过程中该 socket 上又有新数据可读(EPOLLIN 再次被触发),此时另外一个线程被唤醒来读取这些新的数据。于是就出现了两个线程同时操作一个 socket 的局面。这当然不是我们期望的。我们期望的是一个 socket 连接在任一时刻都只被一个线程处理。这一点可以使用 epoll 的 EPOLLONESHOT 事件实现。对于注册了 EPOLLONESHOT 事件的文件描述符,操作系统最多触发其上注册的一个可读、可写或者异常事件,且只触发一次,除非我们使用 epoll_ctl 函数重置该文件描述符上注册的 EPOLLONESHOT 事件。这样,当一个线程在处理某个 socket 时,其他线程是不可能有机会操作该 socket 的。但反过来思考,注册了 EPOLLONESHOT 事件的 socket 一旦被某个线程处理完毕,该线程就应该立即重置这个 socket 上的 EPOLLONESHOT 事件,以确保这个 socket 下一次可读时,其 EPOLLIN 事件能被触发,进而让其他工作线程有机会继续处理这个 socket。
对比
从实现原理上来说,select 和 poll 采用的都是轮询的方式,即每次调用都要扫描整个注册文件描述符集合,并将其中就绪的文件描述符返回给用户程序,因此它们检测就绪事件的算法的时间复杂度是O(n)。epoll_wait 则不同,它采用的是回调的方式。内核检测到就绪的文件描述符时,将触发回调函数,回调函数就将该文件描述符上对应的事件插人内核就绪事件队列。内核最后在适当的时机将该就绪事件队列中的内容拷贝到用户空间。因此 epoll_wait 无须轮询整个文件描述符集合来检测哪些事件已经就绪,其算法时间复杂度是О(1)。但是,当活动连接比较多的时候,epoll_wait 的效率未必比 select 和 poll 高,因为此时回调函数被触发得过于频繁。所以 epoll_wait 适用于连接数量多,但活动连接较少的情况。
评论区