前言
在上一篇博文【Linux网络编程 - 基于 I/O 复用的服务器端(epoll 实现)】中我们讲解了 epoll I/O 复用技术的基本知识点和相关函数使用方法,在本篇博文中我们将着重讲解 epoll 对文件描述符的操作的两种工作模式:LT(Level Trigger,水平触发或条件触发)模式 和 ET(Edge Trigger,边缘触发或边沿触发)模式。
一 LT 和 ET 模式
LT 模式是 epoll 默认的工作模式,这种模式下 epoll 相当于一个效率较高的 poll。当往epoll例程中注册一个文件描述符上的 EPOLLET 事件时,epoll 将以ET模式来操作该文件描述符。ET模式是epoll 的高效工作模式。
- LT模式与ET模式的区别:
LT模式:当 epoll_wait 检测到某个文件描述符上有事件发生并将此事件通知应用程序后,应用程序可以不立即处理该事件。这样,当应用程序下一次调用 epoll_wait 时,epoll_wait 还会再次向应用程序通知此事件,直到该事件被处理为止。
ET模式:当 epoll_wait 检测到某个文件描述符上有事件发生并将此事件通知应用程序后,应用程序必须立即处理该事件。因为如果不处理,下次调用epoll_wait 时,epoll_wait 将不会再向应用程序通知这一事件。也就是说 epoll_wait 只会通知你一次,直到该文件描述符上出现第二次该注册事件发生,才会再次通知应用程序。
由此可见,ET模式在很大程度上降低了同一个epoll事件被重复触发的次数,因此效率要比LT模式高。
<注意> 每个使用ET模式的文件描述符都应该是非阻塞的。如果文件描述符是阻塞的,那么读或写操作(read/write)将会因为没有读写事件的发生而导致进程/线程一直处于阻塞状态。
1.1 条件触发和边缘触发的区别在于发生事件的时间点
首先给出一个生活示例帮助理解条件触发和边缘触发。观察如下对话,可以通过对话内容理解条件触发事件的特点。
- 儿子:“妈妈,我收到了5000元压岁钱。”
- 妈妈:“嗯,真棒!”
- 儿子:“我给隔壁家小明买了炒年糕,花了2000元。”
- 妈妈:“嗯,做得好!”
- 儿子:“妈妈。我买了玩具,剩下500元。”
- 妈妈:“用完零花钱就只能挨饿喽!”
- 儿子:“妈妈,我还留着那500元没动,不会挨饿的。”
- 妈妈:“嗯,很明智嘛!”
- 儿子:“妈妈,我还留着那500元没动,我要攒起来。”
- 妈妈:“嗯,加油!”
从上述对话可以看出,儿子从收到压岁钱开始就一直向妈妈报告,这就是条件触发的原理。如果将上述对话的儿子(儿子的钱包)换成输入缓冲(即接收缓冲),压岁钱换成数据,儿子的报告换成事件,则可以发现条件触发的特性:“条件触发方式中,只要输入(接收)缓冲有数据,就会一直通知该事件。”
例如,服务器端输入缓冲收到 50 字节的数据时,服务器端操作系统将通知该事件(即注册在发生变化的文件描述符中的事件)。但服务器端读取20字节后还剩下30字节的情况下,仍会触发该事件。也就是说,条件触发中,只要输入(接收)缓冲中还剩有数据,就将以事件方式再次触发。
接下来通过如下对话介绍边缘触发的事件特性。
- 儿子:“妈妈,我收到了5000元压岁钱。”
- 妈妈:“嗯,再接再厉。”
- 儿子:“......”
- 妈妈:“说话呀!压岁钱呢?不想回答吗?”
从上述对话可以看出,边缘触发中输入(接收)缓冲收到数据时仅触发一次该事件。即使输入缓冲中仍还留有数据,也不会再次触发。
二 实现条件触发(水平触发)的回声服务器端
接下来通过代码了解条件触发的事件注册方式。下列代码是稍微修改【Linux网络编程 - 基于 I/O 复用的服务器端(epoll 实现)】中的 echo_epollserv.c 示例得到的。epoll 默认以条件触发工作,因此可以通过下面的示例验证条件触发特性。
- 条件触发回声服务器端:echo_EPLTserv.c
#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/epoll.h>
#include <errno.h>
#define TRUE 1
#define FALSE 0
#define BUF_SIZE 4
#define EPOLL_SIZE 50 //设置最大的文件描述符数
typedef struct epoll_event EPOLL_EVENT_T;
void error_handling(char *message);
int main(int argc, char *argv[])
{
int serv_sock, clnt_sock;
struct sockaddr_in serv_adr; //服务器端地址信息变量
struct sockaddr_in clnt_adr; //客户端地址信息变量
struct timeval timeout;
fd_set reads, cpy_reads;
socklen_t clnt_adr_sz;
int str_len, i, fd;
char buf[BUF_SIZE];
//epoll相关变量
int epfd, event_cnt;
EPOLL_EVENT_T *ep_events; //指向保存监测对象集合的内存空间
EPOLL_EVENT_T event; //事件
if(argc!=2) {
printf("Usage: %s <port>\n", argv[0]);
exit(1);
}
serv_sock=socket(PF_INET, SOCK_STREAM, 0);
if(serv_sock==-1)
error_handling("socket() error");
//为serv_sock套接字文件描述符设置SO_REUSEADDR可选项
int option = TRUE;
int optlen = sizeof(option);
setsockopt(serv_sock, SOL_SOCKET, SO_REUSEADDR, (void*)&option, optlen);
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("listen() error");
epfd = epoll_create(EPOLL_SIZE); //创建epoll例程并返回文件描述符
ep_events = malloc(sizeof(EPOLL_EVENT_T) * EPOLL_SIZE); //动态分配存储空间
//注册服务器端套接字文件描述符serv_sock,并注册该描述符上的读事件
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)
{
error_handling("epoll_wait() error!");
break;
}
puts("return epoll_wait......"); //增加打印语句
for(i=0; i<event_cnt; i++) //直接处理发生事件的文件描述符
{
fd = ep_events[i].data.fd;
if(fd == serv_sock)
{
clnt_adr_sz = sizeof(clnt_adr);
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &clnt_adr_sz);
if(clnt_sock == -1)
{
printf("accept() error! %d:%s\n", errno, strerror(errno));
break;
}
//注册与客户端进行数据交互的套接字文件描述符clnt_sock,并注册该描述符上的读事件
event.events = EPOLLIN;
event.data.fd = clnt_sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event);
printf("New client connected from address[%s:%d], conn_fd=%d\n",
inet_ntoa(clnt_adr.sin_addr), ntohs(clnt_adr.sin_port), clnt_sock);
}
else //接收数据(读事件)
{
str_len = read(fd, buf, BUF_SIZE);
if(str_len == 0) //接收的数据为EOF时,关闭套接字连接
{
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); //删除套接字文件描述符
close(fd);
printf("closed client, conn_fd=%d\n", fd);
}
else
{
write(fd, buf, str_len); //回送客户端消息
}
}
}//end for(i=0; i<event_cnt; i++)
}//end while(1)
close(serv_sock);
close(epfd);
return 0;
}
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
上述示例与之前的 echo_epollserv.c 之间的差异如下:
- 第13行:将调用 read 函数时使用的缓冲大小缩减为 4 个字节。
- 第79行:插入验证 epoll_wait 函数调用次数的打印语句。
减少缓冲大小是为了阻止服务器端一次性读取完套接字接收缓冲中的数据。换言之,调用 read 函数后,套接字输入(接收)缓冲中仍有数据需要读取。而且会因此再次触发该读事件,并每次从 epoll_wait 函数返回时将输出 “return epoll_wait......” 字符串。接下来观察运行结果。该程序同样可以结合回声客户端 echo_client.c 示例运行。
- 获取 echo_client.c 客户端程序代码,请参见下面的博文链接(第3.2节内容)
- 运行结果
- 回声服务器端:echo_EPLTserv.c
$ gcc echo_EPLTserv.c -o EPLTserv
$ ./EPLTserv 9190
return epoll_wait......
New client connected from address[127.0.0.1:48594], conn_fd=5
return epoll_wait......
New client connected from address[127.0.0.1:48596], conn_fd=6
return epoll_wait......
return epoll_wait......
return epoll_wait......
return epoll_wait......
return epoll_wait......
return epoll_wait......
return epoll_wait......
closed client, conn_fd=5
return epoll_wait......
closed client, conn_fd=6
- 回声客户端1:echo_client.c
$ gcc echo_client.c -o client
$ ./client 127.0.0.1 9190
Connected...........
Input message(Q to quit): 111111~
Message from server: 111111~
Input message(Q to quit): 22~
Message from server: 22~
Input message(Q to quit): Q
- 回声客户端2:echo_client.c
$ ./client 127.0.0.1 9190
Connected...........
Input message(Q to quit): aaaaaa~
Message from server: aaaaaa~
Input message(Q to quit): bb~
Message from server: bb~
Input message(Q to quit): Q
从运行结果中可以注意到,每当服务器端收到客户端数据时,都会触发 clnt_sock 文件描述符的读事件,并因此多次调用 epoll_wait 函数。具体触发次数可以通过打印的输出字符串 “return epoll_wait......” 的次数就可得知,下面具体分析一下触发次数的情况:
- 服务器端与客户端1建立连接,触发serv_sock的读事件,打印 1 次。
- 服务器端与客户端2建立连接,触发serv_sock的读事件,打印 1 次。
- 收到客户端1发来的 "111111~",触发客户端1的clnt_sock(5)的读事件,打印2次。
- 收到客户端1发来的 "22~",触发客户端1的clnt_sock(5)的读事件,打印1次。
- 收到客户端2发来的 "aaaaaa~",触发客户端2的clnt_sock(6)的读事件,打印2次。
- 收到客户端2发来的 "bb~",触发客户端2的clnt_sock(6)的读事件,打印1次。
- 收到客户端1发来的 EOF,触发客户端1的clnt_sock(5)的读事件,打印1次。
- 收到客户端2发来的 EOF,触发客户端2的clnt_sock(6)的读事件,打印1次。
服务器端输出字符串 “return epoll_wait......” 的次数(10次)与我们分析计算得出的次数:1+1+2+1+2+1+1+1 = 10次,刚好是相吻合的。
下面将上述 echo_EPLTserv.c 示例修改成边缘触发方式,需要做一些额外的工作。将上述示例 echo_EPLTserv.c 中的第93行代码修改成如下形式,然后运行服务器端和客户端(不会单独提供这方面的源代码,需要各位自行修改)。
event.events = EPOLLIN|EPOLLET; //设置以边缘触发的方式得到事件通知
更改后,可以验证这样的事实:“服务器端从客户端接收数据时,仅输出一次 'return epoll_wait......' 字符串,这意味着事件仅被触发了一次。”
虽然更改后的服务器端程序可以验证上述事实,但客户端运行时将发生错误,服务器端也将异常终止运行。运行结果如下:
- 回声服务器端:echo_EPLTserv.c
$ ./EPLTserv 9190
return epoll_wait......
New client connected from address[127.0.0.1:48602], conn_fd=5
return epoll_wait......
return epoll_wait......
return epoll_wait......
return epoll_wait......
return epoll_wait......
- 回声客户端:echo_client.c
$ ./client 127.0.0.1 9190
Connected...........
Input message(Q to quit): 1111111~
Message from server: 1111Input message(Q to quit): 2222222~
Message from server: 111~Input message(Q to quit): 33~
Message from server:
222Input message(Q to quit): Q《结果分析》从上面客户端的运行结果可以看到,只有当客户端向服务器端发送消息时,才会触发客户端 clnt_sock(5)的读事件(EPOLLIN),即使服务器端的输入(接收)缓冲中还留有剩余数据,也不会再次触发读事件。而服务器端每次触发clnt_sock(5)文件描述符的读事件时,只能从套接字输入缓冲中读取4个字节的数据,直到下次套接字输入缓冲又收到客户端发来的消息时,才会再次触发该读事件。
在运行过程中发现一个有趣的现象:当在客户端输入 Q 按下回车键后,在服务器端会打印两次 “return epoll_wait......” 字符串,然后服务器端也会终止运行。
现象原因分析:这是因为当客户端输入Q 后,客户端退出while无限循环,紧接着就调用了close函数,那么客户端就会向服务器端发送 EOF,触发clnt_sock(5)文件描述符的读事件,打印一次,注意此时服务器端的套接字接收缓冲中还是有数据的,内容为:“2222~\n33~\nEOF”,调用read函数后,读取其中4个数据("2222"),然后执行第112行的 write 函数,但由于此时客户端已经关闭连接了,可是此时服务器端并不知道,所以会继续回送数据给客户端,然后服务器端就会收到客户端发来的 RST 报文段,告诉服务器端:“Hi, 我已经关闭连接了,别再给我发数据了。” 服务器端收到 RST 报文段时,这又会触发 clnt_sock(5)文件描述符的读事件,再次打印 “return epoll_wait......” 字符串,这就是打印二次的原因所在。服务器端在调用read函数之前,套接字接收缓冲中的内容为:“~\n33~\nEOF”,调用read函数后,读取其中4个数据(~\n33),然后执行到write函数语句,这时操作系统会产生一个 SIGPIPE 信号,服务器端程序终止运行。
调试方法:在 echo_EPLTserv.c 示例中添加关键打印语句,服务器端程序用 GDB 调试。调试运行结果如下:
$ gdb EPLTserv
(gdb) r 9190
Starting program: /home/wxm/wxm/linux_c/network/epoll/epoll_echo/EPLTserv 9190
return epoll_wait......
fd = 7
New client connected from address[127.0.0.1:48620], conn_fd=9
return epoll_wait......
fd = 9
str_len = 4, message=1111
return epoll_wait......
fd = 9
str_len = 4, message=111~
return epoll_wait......
fd = 9
str_len = 4, message=
222
return epoll_wait......
fd = 9
str_len = 4, message=2222
return epoll_wait......
fd = 9
str_len = 4, message=~
33Program received signal SIGPIPE, Broken pipe.
0x00007ffff7afcba0 in __write_nocancel () from /lib64/libc.so.6
Missing separate debuginfos, use: debuginfo-install glibc-2.17-325.el7_9.x86_64
(gdb)《调试说明》fd=7, 是serv_sock文件描述符的值;fd = 9,是clnt_sock文件描述符的值。可以看到,调试结果与我们的原因分析是一致的。
《提示》select 模型是条件触发还是边缘触发?
select 模型是以条件触发的方式工作的,套接字输入缓冲中如果还存留数据,在超时时间到期后肯定会再次触发文件描述符的读事件的。各位感兴趣的话,可以自行编写示例验证 select 模型的工作方式。
三 实现边缘触发(边沿触发)的回声服务器端
3.1 边缘触发的服务器端实现中必知的两点
下面讲解边缘触发服务器端的实现方法。在此之前,需要说明以下两点,这些是实现边缘触发的必知内容。
- 通过 errno 变量验证错误原因。
- 为了完成非阻塞(Non-blocking) I/O,更改套接字特性。
Linux的套接字相关函数(其实这些函数都是系统调用)一般通过返回 -1 通知发生了错误。虽然知道发送了错误,但仅凭这些内容无法得知产生错误的原因。因此,为了在发生错误时提供额外的信息,Linux声明了如下全局变量:
int errno;
为了访问变量,需要引入 <errno.h> 头文件,因为此头文件中有上述变量的 extern 声明。另外,每种系统调用函数发生错误时,保存到 errno 变量中的值都不同,没必要记住所有可能的值。在学习每种函数的过程中逐一掌握,并能在必要时参考即可。本节只介绍如下类型的错误:
“read 函数发现输入(接收)缓冲中没有数据可读时返回-1,同时设置 errno 变量的值为 EAGAIN 错误常量。”
- 将套接字设置为非阻塞方式的方法
Linux 提供了更改或读取文件属性的函数:fcntl。(记住:在Linux系统中,套接字也是一种文件。)
- fcntl() — 更改或读取文件的属性。
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */);
/*参数说明
fd: 属性更改目标的文件描述符。
cmd: 操作命令。
arg: 供命令使用的参数。
*/
//返回值: 成功时返回 cmd 参数相关值,失败时返回-1,并设置errno
- 关于 fcntl() 函数用法博文链接
从上面声明中可以看到,fcntl 函数具有可变参数形式。如果向第二个参数传递 F_GETFL,可以获得第一个参数所指的文件描述符属性(int型)。反之,如果传递 F_SETFL,可以更改文件描述符属性。若希望将文件(套接字)改为非阻塞模式,可以通过如下语句实现。
int flag;
flag = fcntl(fd, F_GETFL, 0); //获取文件描述符原有属性
fcntl(fd, F_SETFL, flag | O_NONBLOCK); //设置文件描述符为非阻塞模式
《说明》通过第二条语句获取之前设置的属性信息,通过第三条语句在原有文件属性基础上添加非阻塞 O_NONBLOCK 标志。调用 read & write 函数时,无论是否存在数据,都会形成非阻塞文件(套接字)。fcntl 函数的适用范围很广,我们既可以在学习Linux系统编程时一次性总结所有适用情况,也可以每次需要时逐一掌握。
3.2 实现边缘触发(边沿触发)的回声服务器端
之所以介绍读取错误原因的方法和非阻塞模式的套接字设置方法,原因在于二者都与边缘触发的服务器端实现有密切联系。首先说明为何需要通过 errno 变量确认错误原因。
“边缘触发模式中,接收数据时,无论数据是否被用户进程读取完,都仅触发一次该事件。”
就是因为这个特点,一旦发生输入(接收)相关读事件,就应该读取输入(接收)缓冲中的全部数据。因此需要验证输入(接收)缓冲是否为空。
“read 函数返回-1,变量 errno 中的值为 EAGAIN 常量时,说明已经没有数据可读。”
既然如此,为何还需要将套接字设置为非阻塞模式呢?这是因为边缘触发模式下,以阻塞方式工作的 read & write 函数有可能引起服务器端的长时间等待。因此,边缘触发模式下,一定要先将套接字文件描述符设置为非阻塞模式,然后再调用 read & write 函数。接下来给出以边缘触发模式工作的回声服务器端示例。
- 边缘触发回声服务器端:echo_EPETserv.c
#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/epoll.h>
#include <fcntl.h>
#include <errno.h>
#define TRUE 1
#define FALSE 0
#define BUF_SIZE 4
#define EPOLL_SIZE 50 //设置最大的文件描述符数
typedef struct epoll_event EPOLL_EVENT_T;
int set_nonblocking(int fd);
void error_handling(char *message);
int main(int argc, char *argv[])
{
int serv_sock, clnt_sock;
struct sockaddr_in serv_adr; //服务器端地址信息变量
struct sockaddr_in clnt_adr; //客户端地址信息变量
struct timeval timeout;
fd_set reads, cpy_reads;
socklen_t clnt_adr_sz;
int str_len, i, fd;
char buf[BUF_SIZE];
//epoll相关变量
int epfd, event_cnt;
EPOLL_EVENT_T *ep_events; //指向保存监测对象集合的内存空间
EPOLL_EVENT_T event; //事件
if(argc!=2) {
printf("Usage: %s <port>\n", argv[0]);
exit(1);
}
serv_sock=socket(PF_INET, SOCK_STREAM, 0);
if(serv_sock==-1)
error_handling("socket() error");
//为serv_sock套接字文件描述符设置SO_REUSEADDR可选项
int option = TRUE;
int optlen = sizeof(option);
setsockopt(serv_sock, SOL_SOCKET, SO_REUSEADDR, (void*)&option, optlen);
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("listen() error");
epfd = epoll_create(EPOLL_SIZE); //创建epoll例程并返回文件描述符
ep_events = malloc(sizeof(EPOLL_EVENT_T) * EPOLL_SIZE); //动态分配存储空间
//注册服务器端套接字文件描述符serv_sock,并注册该描述符上的读事件
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)
{
error_handling("epoll_wait() error!");
break;
}
puts("return epoll_wait......"); //增加打印语句
for(i=0; i<event_cnt; i++) //直接处理发生事件的文件描述符
{
fd = ep_events[i].data.fd;
if(fd == serv_sock)
{
clnt_adr_sz = sizeof(clnt_adr);
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &clnt_adr_sz);
if(clnt_sock == -1)
{
printf("accept() error! %d:%s\n", errno, strerror(errno));
break;
}
set_nonblocking(clnt_sock); //设置clnt_sock文件描述符为非阻塞模式
//注册与客户端进行数据交互的套接字文件描述符clnt_sock,并注册该描述符上的读事件
event.events = EPOLLIN|EPOLLET; //设置以边缘触发的方式得到事件通知
event.data.fd = clnt_sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event);
printf("New client connected from address[%s:%d], conn_fd=%d\n",
inet_ntoa(clnt_adr.sin_addr), ntohs(clnt_adr.sin_port), clnt_sock);
}
else //接收数据(读事件)
{
while(1)
{
str_len = read(fd, buf, BUF_SIZE);
if(str_len == 0) //接收的数据为EOF时,关闭套接字连接
{
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); //删除套接字文件描述符
close(fd);
printf("closed client, conn_fd=%d\n", fd);
break;
}
else if(str_len < 0)
{
if(errno == EAGAIN) //套接字输入缓冲数据为空时,退出while循环
break;
}
else
{
write(fd, buf, str_len); //回送客户端消息
}
}//end while(1)
}
}//end for(i=0; i<event_cnt; i++)
}//end while(1)
close(serv_sock);
close(epfd);
return 0;
}
//将文件描述符设置为非阻塞模式
int set_nonblocking(int fd)
{
int flags;
if((flags = fcntl(fd, F_GETFL, NULL)) < 0){
printf("set non-block error:%d %s\n", errno, strerror(errno));
return -1;
}
if(fcntl(fd, F_SETFL, flags | O_NONBLOCK) < 0){
printf("set non-block error:%d %s\n", errno, strerror(errno));
return -2;
}
return 0;
}
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
- 代码说明
- 第14行:为了验证边缘触发的工作模式,将缓冲大小设置为4字节。
- 第81行:为观察事件发生数而添加的输出打印字符串语句。
- 第94、96行:第94行将accept函数创建的套接字对应的文件描述符 clnt_sock 改为非阻塞模式。第96行向 EPOLLIN 添加 EPOLLET标志,将套接字事件注册方式改为边缘触发。
- 第104、106行:之前的条件触发回声服务器中没有该while循环。在边缘触发方式中,发生事件时需要读取输入(接收)缓冲中的所有数据,因此需要循环调用 read 函数,如第106行所示。
- 第114行,read 函数返回 -1 且 errno 值为 EAGAIN 时,意味着已经读取了套接字输入缓冲中的全部数据,此时套接字输入缓冲为空,因此需要通过 break 语句跳出第104行的 while(1) 循环。
- 运行结果
- 回声服务器端:echo_EPETserv.c
$ gcc echo_EPETserv.c -o EPETserv
$ ./EPETserv 9190
return epoll_wait......
New client connected from address[127.0.0.1:48624], conn_fd=5
return epoll_wait......
return epoll_wait......
return epoll_wait......
return epoll_wait......
closed client, conn_fd=5
- 回声客户端:echo_client.c
$ ./client 127.0.0.1 9190
Connected...........
Input message(Q to quit): I like computer programming
Message from server: I like computer programming
Input message(Q to quit): Do you like cpmputer programming?
Message from server: Do you like cpmputer programming?
Input message(Q to quit): Good bye
Message from server: Good bye
Input message(Q to quit): Q
《结果分析》从上述运行结果可以看到,客户端发送消息的次数与服务器端打印 "return epoll_wait......" 字符串的次数是相同的,也就是 epoll_wait 函数被调用的次数。客户端从请求连接到断开连接共发送5次数据,服务器端也相应触发了5次读事件,其中一次是serv_sock文件描述符的读事件,其余四次是 clnt_sock 文件描述符的读事件。
四 条件触发和边缘触发孰优孰劣
上文中我们从理论和代码的角度充分理解了条件触发和边缘触发,但仅凭这些还无法理解边缘触发相对于条件触发的优点。边缘触发模式下可以做到如下这点:
“可以分离接收数据和处理数据的时间点!”
虽然比较简单,但非常准确有力地说明了边缘触发的优点。关于这句话的含义,大家以后开发不同类型的程序时会有更深入的理解。现阶段给出如下情景帮助大家理解,如下图 1 所示。

上图 1 的运行流程如下:
- 服务器端分别从客户端 A、B、C 接收数据。
- 服务器端按照 A、B、C 的顺序重新组合接收到的数据。
- 组合后的数据将发送任意主机。
为了完成该过程,若能按如下流程运行程序,服务器端的实现并不难。
- 客户端按照 A、B、C 的顺序连接服务器端,并依序向服务器端发送数据。
- 需要接收数据的客户端应在客户端 A、B、C之前连接到服务器端并等待。
但现实中可能频繁出现如下这些情况,换言之,如下情况更符合实际情况。
- 客户端C和B向服务器端发送数据,但A尚未连接到服务器端。
- 客户端A、B、C乱序发送数据。
- 服务器端已收到数据,但要接收数据的目标客户端还未连接到服务器端。
因此,即使服务器端套接字输入(接收)缓冲收到数据(触发相应读事件),服务器端也能决定读取和处理这些数据的时间点,这样就给服务器端的实现带来巨大的灵活性。
“条件触发中无法区分数据接收和处理吗?”
并非不可能。但在套接字输入缓冲收到数据的情况下,如果不读取(延迟处理),则每次调用 epoll_wait 函数时都会触发相应读事件。而且事件触发次数也会累加,服务器端能承受吗?这在实际场景中是不可能的(本身并不合理,因此是根本不想做的事。)
条件触发和边缘触发的区别主要应该从服务器端实现模型的角度讨论。从实现模型角度看,边缘触发更有可能带来高性能,但不能简单地认为 “只要使用边缘触发就一定能提高速度。”
五 习题
1、利用select函数实现服务器端时,代码层面存在的2个缺点是?
- 调用 select 函数后需要使用循环语句轮询遍历所有文件描述符查找已就绪文件描述符。
- 每次调用 select 函数时都需要向该函数传递监视对象信息。
2、无论是select方式还是epoll方式,都需要将监视对象文件描述符信息通过函数调用传递给操作系统。请解释传递该信息的原因。
答:select 和 epoll 函数都是系统调用,而监视对象文件描述符信息标识的套接字是操作系统资源,由操作系统管理,应用程序只有将监视对象文件描述符传递给操作系统,才能获取到套接字状态发生改变的信息。select 和 epoll 是操作系统提供给应用程序访问内核资源的入口函数。
3、select方式和epoll方式的最大差异在于监视对象文件描述符传递给操作系统的方式。请说明具体的差异,并解释为何存在这种差异。
答:epoll 不同于 select 的地方就是 只需将监视对象文件描述符的信息传递一次给操作系统,而 select 每次调用 select 函数时都需要重新传递一次。
存在这种差异的原因是:Linux内核保存监视对象信息的方式不同。epoll 方式中,内核缓冲区中使用红黑树+双链表的数据结构,其中红黑树用来存放监视对象 epoll_event 结构体变量,双链表用来存放文件描述符状态发生变化的 epoll_event 结构体变量,这涉及到 epoll 底层实现原理的内容。
4、虽然epoll是select的改进方式,但select也有自己的缺点。在何种情况下使用select方式更合理。
- 接入服务器端的连接数较少的情况。
- 需要在多种操作系统平台下运行,即程序应具有较好的兼容性的情况。
5、epoll以条件触发或边缘触发方式工作。二者有何区别?从输入缓冲的角度说明这两种方式通知事件的时间点的差异。
答:在条件触发方式中,只要输入缓冲与数据存在,就会持续进行事件通知;而在边缘触发方式中,只有当输入缓冲接收到数据时,才会进行事件通知,即使输入缓冲还留有数据没有读取完,只要没有接收到再次到来的数据,也不会进行事件通知。
6、采用边缘触发时可以分离数据的接收和处理时间点。说明其原因及优点。
答:采用边缘触发方式,在输入缓冲中接收数据时,只会触发一次事件通知,即使输入缓冲中仍有数据时,也不会再次进行事件通知,因此可以在数据被接收后,在想要的时间点处理数据。而且,通过分离数据的接收和处理时间点,这样就给服务器端的实现带来巨大的灵活性,同时也能提高服务器端的性能。
7、实现聊天服务器端,使其可以在连接到服务器端的所有客户端之间交换消息。按照条件触发方式和边缘触发方式分别实现 epoll 服务器端(聊天服务器端的实现中,这两种方式不会产生太大差异)。当然,为了正常运行服务器端,需要聊天客户端,我们可以使用多线程编程模型实现聊天客户端。
答:这是一道编程题,由于代码量有点大,我已单独写一篇博文来实现这个聊天服务器端和聊天客户端程序。其中服务器端程序需要分别使用 LT模式和 ET模式来实现,客户端使用多线程来实现。程序实现代码,请参考下面的博文链接。
参考
《TCP-IP网络编程(尹圣雨)》第17章 - 优于select的epoll
《Linux高性能服务器编程》第9章 - I/O复用:第9.3节 - epoll 系列系统调用