一、select 实现 I/O 复用的优缺点
在实现 I/O 复用客户端时,之前我们使用的是 select 函数。select 复用方法由来已久,利用该技术后,无论如何优化程序性能也无法同时接入上百个客户端。这种 select 方法并不适合 Web 服务器端开发为主流的现代开发环境,所以要学习 Linux 平台下的 epoll。
1.1.基于 select 的 I/O 复用技术速度慢的原因
关于 select 的 I/O 复用服务器实现,看这里。我们从代码中找到一些比较耗时的设计,主要两点如下:
- 调用 select 函数后常见的针对所有文件描述符的循环语句
- 每次调用 select 函数时都需要向函数传递监视对象信息。
调用 select 函数后,并不是把发生变化的文件描述符单独集中到一起,而是通过观察作为监视对象的 fd_set 变量的变化,找出发生变化的文件描述符,因此无可避免针对所有监视对象的循环语句。
所以,作为监视对象的 fd_set 变量会发生变化,所以调用 select 函数前应复制并保存原有信息,并在每次调用 select 函数时传递新的监视对象信息。
上述两个问题给性能带来问题的最主要是哪个?
只看代码会以为是循环的问题,实际上最大的障碍是每次传递监视对象信息。因为每次调用 select 函数时向操作系统传递监视对象信息。
应用程序向操作系统传递数据将会对程序造成很大负担,而且无法通过优化代码解决,因此会成为性能上的智能弱点。
为什么需要把监视对象信息传递给操作系统呢?
有些函数不需要操作系统的帮助就能完成功能,比如简单的 “加减” 运算。而有些函数必须要借助于操作系统,这里的套接字是由操作系统管理的,select 函数是监视套接字变化的函数,所以 select 函数绝对需要借助于操作系统才能完成功能。
弥补方式
select 函数向操作系统传递消息的缺点可以通过以下方式弥补:仅向操作系统传递 1 次监视对象,监视范围或内容发生变化时只通知发生变化的事项。
这样就无需每次调用 select 函数时都向操作系统传递监视对象信息,但前提是操作系统支持这种处理方式。Linux 的支持方式是 epoll,Windows 的支持方式是 IOCP。
1.2.select 优点
尽管 epoll 优于 select,但是 select 也是要掌握的,只要满足如下两个条件,我们也可以使用 select 实现 I/O 复用。
- 服务器端接入者少
- 程序具有兼容性
二、实现 epoll 时必要的函数和结构体
epoll 函数优点
能够克服 select 函数缺点的 epoll 函数具有如下优点,这些优点正好与之前的 select 函数缺点相反。
- 无需编写以监视状态变化为目的的针对所有文件描述符的循环语句。
- 调用对应于 select 函数的 epoll_wait 函数时无需每次传递监视对象信息。
epoll 服务器端常用的 3 个函数
下面是 epoll 服务器端实现中需要用到的 3 个函数,其功能为
- epoll_create: 创建保存 epoll 文件描述符的空间
- select 方式中为了保存监视对象文件描述符,直接声明了 fd_set 变量。但 epoll 方式下由操作系统负责保存监视对象文件描述符,因此需要向操作系统请求创建保存文件描述符的空间,此时使用到的函数就是 epoll_create。
- epoll_ctl:向空间注册并注销文件描述符
- select 方式中为了添加和删除监视对象文件描述符,需要 FD_SET、FD_CLR 函数。但在 epoll 方式下,通过 epoll_ctl 函数请求操作系统完成。
- epoll_wait:与 select 函数类似,等待文件描述符发生变化。
- select 方式中通过 select 函数等待文件描述符的变化,而 epoll 中调用 epoll_wait 函数。
epoll 将监视对象集中到 epoll_event 中
select 方式中通过 fd_set 变量查看监视对象的状态变化,而 epoll 方式中通过如下结构体 epoll_event 将发生变化的文件描述符单独集中到一起。
struct epoll_event {
__uint32_t events;
epoll_data_t data;
}
typedef union epoll_data {
void *ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
} epoll_data_t;
生命足够大的 epoll_event 结构体数组后,传递给 epoll_wait 函数时,发生变化的文件描述符信息将被填入该数组。因此,无需像 select 函数那样针对所有文件描述符进行循环。
2.1.epoll_create 函数
epoll_create 函数原型
#include <sys/epoll.h>
// 成功时返回 epoll 文件描述符,失败时返回 -1
int epoll_create(int size);
调用 epoll_create 函数时创建的文件描述符保存空间称为 “epoll 例程”,通过参数 size 传递的值决定 epoll 例程的大小,但该值只是向操作系统提供建议,真正决定 epoll 例程大小的还是操作系统。
epoll_create 函数创建的资源与套接字相同,也由操作系统管理。因此,该函数和创建套接字的情况相同,也会返回文件描述符。因此,需要终止时,和其他文件描述符相同,也要调用 close 函数。
2.2.epoll_ctl 函数
生成 epoll 例程后,应在其内部注册监视对象文件描述符,此时应该使用 epoll_ctl 函数。
#include <sys/epoll.h>
// 成功时返回 0,失败时返回 -1
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
上述函数参数:
- epfd:用于注册监视对象的 epoll 例程的文件描述符
- op:用于指定监视对象的添加、删除或更改等操作
- fd:需要注册的监视对象文件描述符
- event:监视对象的事件类型
这个函数优点复杂,通过几个例子看一下
- 例子 1
epoll_ctl(A, EPOLL_CTL_ADD, B, C); // EPOLL_CTL_ADD 表示添加
上述语句的意思:向 epoll 例程 A 中添加文件描述符 B,主要目的是监视参数 C 中的事件。
- 例子 2
epoll_ctl(A, EPOLL_CTL_DEL, B, NULL); // EPOLL_CTL_DEL 表示删除
上述语句的意思:从 epoll 例程 A 中删除文件描述符 B。
epoll_ctl 第二个参数
下面介绍 epoll_ctl 第二个参数传递的常量及含义
- EPOLL_CTL_ADD:将文件描述符添加到 epoll 例程
- EPOLL_CTL_DEL:从 epoll 历程中删除文件描述符
- EPOLL_CTL_MOD:更改注册的文件描述符的关注事件发生情况
epoll_ctl 第四个参数
epoll_ctl 函数的第四个参数,就是 epoll_event 结构体指针。
struct epoll_event {
__uint32_t events;
epoll_data_t data;
}
typedef union epoll_data {
void *ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
} epoll_data_t;c
epoll_event 结构体用于保存发生事件的文件描述符集合。但也可以在 epoll 例程中注册文件描述符时,用于注册关注的事件。例子
struct epoll_event event;
event.events = EPOLLIN; // 发生需要读取数据的事件时
event.data.fd = sockfd;
epoll_ctl(pefd, EPOLL_CTL_ADD, sockfd, &event);
上述代码将 sockfd 注册到 epoll 例程 epfd 中,并在需要读取数据的情况下产生相应事件,就相当于 select 函数的待读取事件。
下面时 epoll_event 的 events 成员中可以保存的常量及所指的事件类型。
- EPOLLIN:需要读取数据的情况。
- EPOLLOUT:输出缓冲为空,可以立即发送数据的情况。
- EPOLLPRI:收到 OOB 数据的情况。
- EPOLLRDHUP:断开连接或半关闭的情况,在边缘触发下非常有用。
- EPOLLERR:发生错误的情况。
- EPOLLET:以边缘触发的方式得到事件通知。
- EPOLLONESHOT:发生一次事件后,相应文件描述符不再收到事件通知。因此需要向 epoll_ctl 函数的第二个参数传递 EPOLL_CTL_MOD,再次设置事件。
2.3.epoll_wait 函数
与 select 函数对应的是 epoll_wait 函数,epoll 相关函数中默认最后调用该函数。
#include <sys/epoll.h>
// 成功时返回事件发生的文件描述符数,失败时返回 -1
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeou);
函数参数为:
- epfd:表示事件发生监视范围的 epoll 例程的文件描述符。
- events:保存发生事件的文件描述符集合的结构体地址值。
- maxevents:第二个参数中可以保存的最大事件数。
- timeout:以 1/1000 秒为单位的等待时间,传递 -1 时,一直等待到发生事件。
该函数的调用方式如下。注意,第二个参数所指缓冲需要动态分配。
int event_cnt;
struct epoll_event *ep_events;
// 开辟保存 epoll_event 的空间
ep_events = malloc(sizeof(struct epoll_event)*EPOLL_SIZE); // EPOLL_SIZE 是宏常量
event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
调用函数后,返回发生事件的文件描述符数,同时在第二个参数所指向的缓冲中保存发生事件的文件描述符集合。
三、基于 epoll 的回声服务器端
下列是示例代码
#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 *buf);
int main(int argc, char *argv[]) {
// 1.检查输入
if (argc != 2) {
printf("Usage: %s <port> \n", argv[0]);
exit(1);
}
// 2.初始化套接字
int serv_sock;
struct sockaddr_in serv_adr;
serv_sock = socket(PF_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_port = htons(atoi(argv[1]));
// 3.bind 绑定
if (bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
error_handling("bind() error");
// 4.listen 监听
if (listen(serv_sock, 5) == -1)
error_handling("liten() error");
// 5.epoll_create 创建 epoll 例程,建议大小 EPOLL_SIZE=50
int epfd;
epfd = epoll_create(EPOLL_SIZE);
// 6.将监视对象集中到 event,并且
// 使用 epoll_ctl 在 epfd 例程监听套接字和其对应的情况
struct epoll_event event;
event.events = EPOLLIN; // 需要读取数据的情况
event.data.fd = serv_sock; // 监视的套接字
epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event);
// 7.epoll_wait 所需参数 ep_events
int event_cnt; // epoll_wait 返回值
struct epoll_event *ep_events;
ep_events = malloc(sizeof(struct epoll_event)*EPOLL_SIZE);
int str_len, i;
char buf[BUF_SIZE];
socklen_t adr_sz;
int clnt_sock;
struct sockaddr_in clnt_adr;
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 结构体添加监听对象
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_CTL_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 *buf) {
fputs(buf, stderr);
fputc('\n', stderr);
exit(1);
}
四、条件触发和边缘触发
在学习 epoll 时往往容易无法正确区分条件触发(Level Trigger)和边缘触发(Edge Trigger),但只有理解了二者区别才算完整掌握 epoll。
4.1 条件触发和边缘触发的区别在于发生事件的时间点
下面给出一个例子来帮助了解条件触发和边缘触发。
边缘触发
儿子:“妈妈,我收到了 50 元压岁钱。”
妈妈:“嗯,真棒!”
儿子:“我给隔壁小丽买了棒棒糖,花了 5 元。”
妈妈:“嗯!...”
儿子:“妈妈,我买了玩具,剩下 20 元。”
妈妈:“用完零花钱就要挨饿吗!”
儿子:“妈妈,我还留着那 20 元没动,不会挨饿的。”
妈妈:“嗯,很明智吗!”
儿子:“妈妈,我还留着那 20 元没动,我要攒起来。”
妈妈:“嗯,加油。”
从上述对话可以看出,儿子从收到压岁钱开始就向一直向妈妈报告,这就是条件触发的原理。
如果将上述对话中的儿子(儿子的钱包)换成输入缓冲,压岁钱换成输入数据,儿子的报告换成事件,则可以发现条件触发的特性,即 “条件触发方式中,只要输入缓冲有数据就会一直通知该事件。”
例如,服务器端输入缓冲收到 50 字节的数据时,服务器端操作系统将通知该事件(注册到发生变化的文件描述符)。但服务器端读取 20 字节后还剩下 30 字节的情况下,仍会注册事件。也就是说,条件触发方式中,只要输入缓冲中还剩有数据,将以事件方式再次注册。
边缘触发
儿子:“妈妈,我收到了 50 元压岁钱。”
妈妈:“嗯,真棒!”
儿子:
妈妈:“说话啊!压岁钱呢?不想回答吗”
从上述对话可以看出,边缘触发中输入缓冲收到数据时仅注册 1 次该事件。即使输入缓冲中还留有数据,也不会再次注册。
4.2 掌握条件触发的事件特性
接下来通过代码了解条件触发的事件注册方式。epoll 默认以条件触发方式工作,因此可以通过下面示例验证条件触发的特性。
// 服务器端
#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 4 // 改动缓冲区大小为 4
#define EPOLL_SIZE 50
void error_handling(char *buf);
int main(int argc, char *argv[]) {
// 1.检查输入
if (argc != 2) {
printf("Usage: %s <port> \n", argv[0]);
exit(1);
}
// 2.初始化套接字
int serv_sock;
struct sockaddr_in serv_adr;
serv_sock = socket(PF_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_port = htons(atoi(argv[1]));
// 3.bind 绑定
if (bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
error_handling("bind() error");
// 4.listen 监听
if (listen(serv_sock, 5) == -1)
error_handling("liten() error");
// 5.epoll_create 创建 epoll 例程,建议大小 EPOLL_SIZE=50
int epfd;
epfd = epoll_create(EPOLL_SIZE);
// 6.将监视对象集中到 event,并且
// 使用 epoll_ctl 在 epfd 例程监听套接字和其对应的情况
struct epoll_event event;
event.events = EPOLLIN; // 需要读取数据的情况
event.data.fd = serv_sock; // 监视的套接字
epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event);
// 7.epoll_wait 所需参数 ep_events
int event_cnt; // epoll_wait 返回值
struct epoll_event *ep_events;
ep_events = malloc(sizeof(struct epoll_event)*EPOLL_SIZE);
int str_len, i;
char buf[BUF_SIZE];
socklen_t adr_sz;
int clnt_sock;
struct sockaddr_in clnt_adr;
while(1) {
event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
if (event_cnt == -1) {
puts("epoll_wait() error");
break;
}
puts("return epoll_wait"); // 插入验证 epoll_wait 函数调用次数的语句
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 结构体添加监听对象
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_CTL_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 *buf) {
fputs(buf, stderr);
fputc('\n', stderr);
exit(1);
}
减少缓冲大小是为了阻止服务器端一次性读取接受的数据。当客户端发送的数据大于 4 的时候,调用 read 函数后,输入缓冲中仍有数据需要读取。而且会因此注册新的事件并从 epoll_wait 函数返回时将循环输出 “epoll_wait” 字符串。
以下是 Windows 的客户端,配合以上程序运行
// windows 客户端
#include <stdio.h>
#include <stdlib.h>
#include <WinSock2.h>
#pragma comment(lib, "ws2_32.lib") // 加载 ws2_32.dll
#define BUF_SIZE 100
int main(int argc, char *argv[]) {
if (argc != 3) {
printf("Usage: %s <IP><port>", argv[0]);
exit(1);
}
// 初始化 DLL
WSADATA wsaData;
WSAStartup(MAKEWORD(2, 2), &wsaData);
// 创建 TCP 套接字
SOCKET sock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);
// 向服务器发起请求
struct sockaddr_in sockAddr;
memset(&sockAddr, 0, sizeof(sockAddr)); // 每个字节都用 0 填充
sockAddr.sin_family = PF_INET;
sockAddr.sin_addr.s_addr = inet_addr(argv[1]);
sockAddr.sin_port = htons(atoi(argv[2]));
connect(sock, (SOCKADDR*)&sockAddr, sizeof(SOCKADDR));
// 获取用户输入的字符串并发送给服务器
char bufSend[BUF_SIZE] = {0};
printf("Input a string: ");
scanf("%s", bufSend);
send(sock, bufSend, strlen(bufSend), 0);
// 接收服务器传回的数据
char bufRecv[BUF_SIZE] = {0};
recv(sock, bufRecv, BUF_SIZE, 0);
// 输出接收到的数据
printf("Message from server: %s\n", bufRecv);
// 关闭套接字
closesocket(sock);
// 终止使用 DLL
WSACleanup();
system("pause");
return 0;
}
运行结果如下所示:
客户端:
服务器端:
select 模型是条件触发
4.3 边缘触发服务器端 v0.0
将上述代码简单修改一句就可以了
// 服务器端
#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 4
#define EPOLL_SIZE 50
void error_handling(char *buf);
int main(int argc, char *argv[]) {
if (argc != 2) {
printf("Usage: %s <port> \n", argv[0]);
exit(1);
}
int serv_sock;
struct sockaddr_in serv_adr;
serv_sock = socket(PF_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_port = 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("liten() error");
int epfd;
epfd = epoll_create(EPOLL_SIZE);
struct epoll_event event;
event.events = EPOLLIN;
event.data.fd = serv_sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event);
int event_cnt;
struct epoll_event *ep_events;
ep_events = malloc(sizeof(struct epoll_event)*EPOLL_SIZE);
int str_len, i;
char buf[BUF_SIZE];
socklen_t adr_sz;
int clnt_sock;
struct sockaddr_in clnt_adr;
while(1) {
event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
if (event_cnt == -1) {
puts("epoll_wait() error");
break;
}
puts("return epoll_wait");
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 | EPOLLET;
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_CTL_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 *buf) {
fputs(buf, stderr);
fputc('\n', stderr);
exit(1);
}
客户端:
服务器端:
可以看到,相对比于上面的代码,其通知事件少了一个,这就是边缘触发的作用,表示仅注册 1 次事件。
4.4 边缘触发的服务器实现中必知的两点
下面 2 点是实现边缘触发的必知内容:
- 通过 errno 变量验证错误原因
- 为了完成非阻塞(Non_blocking)I/O,更改套接字特性
Linux 的套接字相关函数一般公国 -1 通知发生了错误。虽然知道发生了错误,但仅凭这些内容无法得知产生错误原因。因此,为了在发生了错误时提供额外的信息,Linux 声明了如下全局变量:
int errno;
为了访问该变量,需要引入 error.h 头文件,因为此头文件中有上述变量的 extern 声明。另外,每种函数发生错误时,保存到 errno 变量中的值都不同,所以没必要记住所有可能的值。学习每种函数的过程中逐一掌握,并能在必要时参考即可。下面看其中的一个
函数 | errno |
---|---|
read 函数发现输入缓冲中没有数据可读时返回 -1 | errno 中保存 EAGAIN 常量 |
下面讲解将套接字改为非阻塞方式的方法。Linux 提供更改或读取文件属性的如下方法。在 § Linux 中的 send & recv · 语雀 (yuque.com) 中将结果 fcntl 函数的用法,这里我们再看一下。
#include <fcntl.h>
// 成功时返回 cmd 参数相关值,失败时返回 -1
int fcntl(int filedes, int cmd, ...);
函数参数:
- filedes:属性更改目标的文件描述符
- cmd:表示函数调用的目的
从上述生命中可以看到,fcntl 具有可变参数的形式。如果向第二个参数传递 F_GETFL,可以获得第一个参数所指的文件描述符属性。反之,如果传递 F_SETFL,可以更改文件描述符属性。若希望将文件套接字改为非阻塞模式,需要如下 2 条语句。
int flag = fcntl(fd, F_GETFL, 0); // F_GETFL 获得 fd 属性
fcntl(fd, F_SETFL, flag|O_NONBLOCK); // F_SETFL 更改文件描述符属性,改为非阻塞模式
通过第一条语句获取之前设置的属性信息,通过第二条语句在此基础上添加非阻塞 O_NONBLOCK 标志。调用 read & write 函数时,无论是否存在数据,都会形成非阻塞文件套接字。fcntl 函数的适用范围很广,可以在学习系统编程时一次性总结所有适用情况,也可以根据需要逐一掌握。
4.5 边缘触发服务器端 v0.1
根据刚才学习到的读取错误原因的方法和非阻塞模式的套接字创建方法,这里实现边缘服务器端 v0.1 版本。
首先说明为何需要通过 errno 确认错误原因。
边缘触发方式中,接收数据时仅注册 1 次该事件。
就因为这种特点,一旦发生输入相关事件,就应该读取输入缓冲中的全部数据。因此需要验证输入缓冲是否为空。
“read 函数返回 -1,变量 errno 中的值为 EAGAIN 时,说明没有数据可读。”
为什么需要将套接字变成非阻塞模式?
边缘触发方式下,以阻塞方式工作的 read & write 函数有可能引起服务器端的长时间停顿。因此,边缘触发方式中一定要采用非阻塞 read & write 函数。
下面给出边缘触发方式工作的回声服务器端示例:
// 服务器端
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <errno.h>
#define BUF_SIZE 4 // 改动缓冲区大小为 4
#define EPOLL_SIZE 50
void setnonblockingmode(int fd);
void error_handling(char *buf);
int main(int argc, char *argv[]) {
// 1.检查输入
if (argc != 2) {
printf("Usage: %s <port> \n", argv[0]);
exit(1);
}
// 2.初始化套接字
int serv_sock;
struct sockaddr_in serv_adr;
serv_sock = socket(PF_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_port = htons(atoi(argv[1]));
// 3.bind 绑定
if (bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
error_handling("bind() error");
// 4.listen 监听
if (listen(serv_sock, 5) == -1)
error_handling("liten() error");
// 5.epoll_create 创建 epoll 例程,建议大小 EPOLL_SIZE=50
int epfd;
epfd = epoll_create(EPOLL_SIZE);
// 6.设置 serv_sock 套接字非阻塞模式
setnonblockingmode(serv_sock);
// 7.将监视对象集中到 event,并且
// 使用 epoll_ctl 在 epfd 例程监听套接字和其对应的情况
struct epoll_event event;
event.events = EPOLLIN; // 需要读取数据的情况
event.data.fd = serv_sock; // 监视的套接字
epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event);
// 8.epoll_wait 所需参数 ep_events
int event_cnt; // epoll_wait 返回值
struct epoll_event *ep_events;
ep_events = malloc(sizeof(struct epoll_event)*EPOLL_SIZE);
int str_len, i;
char buf[BUF_SIZE];
socklen_t adr_sz;
int clnt_sock;
struct sockaddr_in clnt_adr;
while(1) {
event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
if (event_cnt == -1) {
puts("epoll_wait() error");
break;
}
puts("return epoll_wait"); // 插入验证 epoll_wait 函数调用次数的语句
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);
setnonblockingmode(clnt_sock); // 客户端套接字非阻塞模式
event.events = EPOLLIN|EPOLLET; // 边缘触发关键
event.data.fd = clnt_sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event);
printf("connected client: %d \n", clnt_sock);
}
else {
// 之前的条件触发回声服务器端没有 while 循环。在边缘触发中,
// 发生事件时需要读取输入缓冲中的所有数据,因此需要 while 循环
// 调用 read 函数
while(1) {
str_len = read(ep_events[i].data.fd, buf, BUF_SIZE);
if (str_len == 0) { // 客户端断开连接
epoll_ctl(epfd, EPOLL_CTL_DEL, // 删除监听对象
ep_events[i].data.fd, NULL);
close(ep_events[i].data.fd);
printf("closed client: %d \n", ep_events[i].data.fd);
break;
}
else if(str_len < 0) { // 缓冲区无数据,读取完毕
if (errno == EAGAIN) {
printf("No data in buffer \n");
break;
}
}
else { // 回声服务
write(ep_events[i].data.fd, buf, str_len);
}
}
}
}
}
close(serv_sock);
close(epfd);
return 0;
}
void setnonblockingmode(int fd) {
int flag = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flag|O_NONBLOCK);
}
void error_handling(char *buf) {
fputs(buf, stderr);
fputc('\n', stderr);
exit(1);
}
运行以上程序,客户端:
服务器端:
4.6 边缘触发和条件触发孰优孰劣
上述只是从理论角度了解了条件触发和边缘触发,下面我们给出一个总结。
边缘触发可以分离接收数据和处理数据的时间点!
下面给出一个例子出来
上图的运行流程如下
- 服务器端分别从客户端 A、B、C 接收数据
- 服务器端按照 A、B、C 的顺序重新组合收到的数据
- 组合的数将发送给任意主机
为了完成该过程,若能按如下流程运行程序,服务器端的实现并不难
- 客户端按照 A、B、C 的顺序连接服务器端,并依次序向服务器端发送数据
- 需要接收数据的客户端应在客户端 A、B、C 之前连接到服务器端并等待
但是现实中可能会出现如下情况
- 客户端 C 和 B 正向服务器端发送数据,但 A 尚未连接到服务器端。
- 客户端 A、B、C 乱序发送数据。
- 服务器端已收到数据,但要接受数据的目标客户端还未连接到服务器端。
因此,即使输入缓冲收到数据(注册相应事件),服务器端也能决定读取和处理这些数据的时间点,这样就给服务器端的实现带来巨大的灵活性。