概述
水平触发比较常见,例如select、poll等都是水平触发的,而epoll不仅支持水平触发,也支持边缘触发。实际工作学习中我从未见过使用边缘触发开发的网络程序,但是之前在《Linux多线程服务端编程》这本书中有一段话:我认为理想的做法是对readable事件采用level trigger,对writable事件采用edge trigger,但是目前Linux不支持这种设定。所以写个测试程序想测试一下边缘触发,特别是边缘触发的写事件。这篇博客也会以边缘触发的为主,做一个简单的总结。libhv采用的也是水平触发,所以水平触发的相关内容也算是为libhv的事件触发机制做一些理论说明。本文将大量引用《UNIX网络编程》和《Linux/UNIX系统编程手册》中的文字。
《UNIX网络编程》中对就绪条件的说明:
读就绪条件
- 该套接字接收缓冲区中的数据字节数大于等于套接字接收缓冲区低水位标记的当前大小。对这样的套接字执行读操作不会阻塞并将返回一个大于0的值(也就是返回准备好读入的数据)。我们可以使用SO_RCVLOWAT套接字选项设置该套接字的低水位标记。对于TCP和UDP套接字而言,其默认值为1(所以只要有数据就会触发读)。简单的说,就是只要本端收到数据,即使只有一字节,也会触发读事件。
- 该连接的读半部关闭(也就是接收了FIN的TCP连接)。对这样的套接字的读操作将不阻塞并返回0(也就是返回EOF)。当对端关闭连接,本端会触发读事件,并且调用read会返回0。这里举个muduo的关闭例子,在muduo库中,不会主动调用close关闭连接,而是调用shutdown关闭写端,这样对端会触发读事件, read返回0后对端关闭连接,等对端关闭了连接,本端也会触发读事件,read返回0,本端关闭连接。好处是本端只关闭了写端,仍然可以读,假设仍然有数据需要接收,那么不会丢失这部分数据。
- 该套接字是一个监听套接字且已完成的连接数不为0。对这样的套接字的accept通常不会阻塞。例如,当一个客户端调用connect连接服务端时,服务端收到客户端的连接请求,这时候监听套接字就会成为可读的。
- 其上有一个套接字错误待处理。对该套接字的读操作将不阻塞并返回-1(也就是返回一个错误),同时把errno设置成确切的错误条件。这些待处理错误也可以通过指定SO_ERROR套接字选项调用getsockopt获取并清除。实际上当错误发生时,不仅读会就绪, 写也会处于就绪状态。
写就绪条件
- 该套接字发送缓冲区中的可用空间字节数大于等于套接字发送缓冲区低水位标记的当前大小,并且或者该套接字已连接,或者该套接字不需要连接(如UDP套接字)。这意味着如果我们把这样的套接字设置成非阻塞,写操作将不阻塞并返回一个正值(如有传输层接受的字节数)。我们可以使用SO_SNDLOWAT套接字来设置该套接字的低水位标记。对于TCP和UDP套接字而言,其默认值通常为2048。(在我使用的服务器上,默认SO_SNDLOWAT是1字节,表示发送缓冲区只要有空间可写,哪怕只有一字节的空间,也是可写的)。之前说写比读复杂,一个很重要的原因是写的触发条件是写缓冲区有空间,而正常情况下该条件是一直成立的,所以在注册事件时,一般只注册读类型的事件,如果注册了写,会造成loop-busy,不过有个特例是connect事件,可以参考前面的博客创建一个简单的tcp客户端。
- 该连接的写半部关闭,对这样的套接字的写操作将产生SIGPIPE信号,该信号默认会终止程序,一般网络程序会忽略该信号。
- 使用非阻塞式connect的套接字已建立连接,或者connect已经以失败告终。也可以参考创建一个简单的tcp客户端。
- 其上有一个套接字错误待处理。跟读类似。。。
水平触发
如果文件描述符上可以非阻塞地执行I/O系统调用,此时认为它已经就绪。《UNIX网络编程》所罗列的各种条件对水平触发都是有效的,因为《UNIX网络编程》中介绍的io复用函数都是水平触发的。所以这里不再多说。
边缘触发
如果文件描述符自上次状态检查以来有了新的I/O活动(比如新的输入),此时需要触发通知。上面的就绪条件对边缘触发基本也是有效的,应该说上面的条件在第一次满足时边缘触发和水平触发的就绪状态是相同的,但之后就会有所区别。
边缘触发与水平触发最大的区别在于水平触发是只要上面的条件满足就会一直触发,而边缘触发是只有发生新的I/O活动时才会触发。
举个读的例子:
- 本端调用epoll_wait()等待读就绪
- 套接字上有输入到来,epoll_wait返回,告知我们套接字已经处于就绪态了
- 再次调用epoll_wait()(注意这里没有读数据,直接再次调用epoll_wait)
如果我们采用的是水平触发,那么第二个epoll_wait()调用将告诉我们套接字处于就绪态,因为没有从缓冲区读取数据,接收缓冲区仍然存在数据,所以上面的条件仍然满足。而如果采用边缘触发,那么第二个epoll_wait()调用将阻塞,因为自从上一次调用epoll_wait()以来没有新的输入到来。
写了一个简单的测试程序,程序中的IO是非阻塞的,如果使用阻塞IO那么不管是水平触发还是边缘触发,对写事件的关注可能都没有什么意义了。
#include <stdio.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <string.h>
#include <fcntl.h>
#include <sys/epoll.h>
#define MAXLINE 10240
int get_listen_fd(short port)
{
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd == -1)
{
perror("socket fail");
exit(EXIT_FAILURE);
}
struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(port);
if (bind(listen_fd, (struct sockaddr *)&servaddr, sizeof(servaddr)) == -1)
{
perror("bind fail");
exit(EXIT_FAILURE);
}
if (listen(listen_fd, 1024) == -1)
{
perror("listen fail");
exit(EXIT_FAILURE);
}
return listen_fd;
}
int set_nonblock(int sockfd)
{
int val = fcntl(sockfd, F_GETFL, 0);
if (val == -1)
{
perror("fcntl");
exit(EXIT_FAILURE);
}
if (fcntl(sockfd, F_SETFL, val | O_NONBLOCK) == -1)
{
perror("fcntl");
exit(EXIT_FAILURE);
}
return val;
}
static char buf[1024000];
ssize_t send_len = 0;
int main(int argc, char *argv[])
{
if (argc != 2)
{
printf("usage : %s portn", argv[0]);
exit(EXIT_FAILURE);
}
memset(buf, 'a', sizeof(buf));
int listenfd = get_listen_fd(atoi(argv[1]));
int epfd = epoll_create(5);
if (epfd == -1)
{
perror("epoll_create");
exit(EXIT_FAILURE);
}
struct epoll_event ev;
ev.data.fd = listenfd;
ev.events = EPOLLIN;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev) == -1)
{
perror("epoll_ctl");
exit(EXIT_FAILURE);
}
struct epoll_event evlist[10];
while (1)
{
int nready = epoll_wait(epfd, evlist, 10, -1);
if (nready < 0)
{
perror("epoll_wait");
exit(EXIT_FAILURE);
}
for (int i = 0; i < nready; i++)
{
printf("fd = %d events: %s %s %s %sn", evlist[i].data.fd,
(evlist[i].events & EPOLLIN) ? "EPOLLIN" : "",
(evlist[i].events & EPOLLHUP) ? "EPOLLHUP" : "",
(evlist[i].events & EPOLLERR) ? "EPOLLERR" : "",
(evlist[i].events & EPOLLOUT) ? "EPOLLOUT" : "");
if (evlist[i].events & EPOLLIN)
{
if (evlist[i].data.fd == listenfd)
{
struct sockaddr_in cliaddr;
socklen_t clilen = sizeof(cliaddr);
int connfd = accept(evlist[i].data.fd, (struct sockaddr *)&cliaddr, &clilen);
if (connfd < 0)
{
perror("accept fail");
exit(EXIT_FAILURE);
}
set_nonblock(connfd); //设置为非阻塞
ev.data.fd = connfd;
ev.events = EPOLLIN;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &ev) == -1)
{
perror("epoll_ctl");
exit(EXIT_FAILURE);
}
}
else
{
char recvline[MAXLINE];
ssize_t n = read(evlist[i].data.fd, recvline, 5);
if (n <= 0)
{
close(evlist[i].data.fd);
}
else
{
if (write(evlist[i].data.fd, recvline, n) < 0)
{
perror("write");
exit(EXIT_FAILURE);
}
// ssize_t len = 0;
// while (1)
// {
// len = write(evlist[i].data.fd, buf+send_len, sizeof(buf)-send_len);
// if (len < 0)
// {
// perror("write errno");
// break;
// }
// send_len += len;
// printf("len = %zd send_len=%dn", len, send_len);
// }
}
}
}
if (evlist[i].events & EPOLLOUT)
{
if (send_len != 0 && send_len < sizeof(buf))
{
ssize_t len = write(evlist[i].data.fd, buf+send_len, sizeof(buf)-send_len);
send_len += len;
printf("len = %zd send_len=%dn", len, send_len);
}
}
else if (evlist[i].events & (EPOLLHUP | EPOLLERR))
{
close(evlist[i].data.fd);
}
}
}
}
在这个服务端程序中,accept客户端连接,并将与客户端通信的描述符加入到epoll中,等待客户端发送数据,并将数据返回客户端。
现在程序是使用的水平触发,并且只关心读事件,一次读5个字节。
ev.events = EPOLLIN;
启动该程序,并使用nc作为客户端向该服务端发送超过5个字节的数据,因为该服务端一次只能读5个字节,所以无法一次读完,客户端输出信息:
12345678 //客户端向服务端发送数据
12345678 //服务端向客户端返回数据
可以看到,发送的和返回的是相同的。
服务端输出信息:
fd = 5 events: EPOLLIN
fd = 5 events: EPOLLIN
触发了两次读事件。
根据上面的测试,可以看到,当水平触发时,只要有数据存在,就会一直触发,直到读完所有的数据为止。
现在改为边缘触发:
ev.events = EPOLLIN | EPOLLET; //EPOLLET表示边缘触发
客户端以同样的方式向服务端发送数据,输出为:
12345678 //客户端向服务端发送的数据
12345 //服务端返回的数据
服务端输出为:
fd = 5 events: EPOLLIN
这一次服务端只触发了一次读事件,客户端只收到了5个字节的回复,剩下的字节因为无法再次触发读,所以没有办法读取。只有等到客户端再次向服务端发送数据时,服务端才会再次触发读事件。
不过有个比较有意思的地方是,当我将上面的事件加上EPOLLOUT时,每次也会触发EPOLLOUT事件。现在将代码改为:
ev.events = EPOLLIN | EPOLLOUT | EPOLLET; //EPOLLET表示边缘触发
再用刚才的客户端发送同样的数据,服务端的输出为:
fd = 5 events: EPOLLOUT
fd = 5 events: EPOLLIN EPOLLOUT
第一个EPOLLOUT是在建立连接后,将connfd加入epoll后第一次调用epoll_wait后触发的。而第二次是在有数据输入时和读一起触发的,也就是说当有新的I/O活动时,即使是接收数据,写事件也会一起触发,而不是只有读事件触发。这个是我之前没想到的。
上面的这个例子也说明了边缘触发的写不像水平触发那样会一直触发。如果我将EPOLLET去掉,采用水平触发会怎样呢?? 代码改为:
ev.events = EPOLLIN | EPOLLOUT;
那么当客户端连接后,服务端会疯狂触发写事件:
fd = 5 events: EPOLLOUT
fd = 5 events: EPOLLOUT
fd = 5 events: EPOLLOUT
fd = 5 events: EPOLLOUT
fd = 5 events: EPOLLOUT
fd = 5 events: EPOLLOUT
fd = 5 events: EPOLLOUT
fd = 5 events: EPOLLOUT
fd = 5 events: EPOLLOUT
fd = 5 events: EPOLLOUT
fd = 5 events: EPOLLOUT
fd = 5 events: EPOLLOUT
fd = 5 events: EPOLLOUT
fd = 5 events: EPOLLOUT
fd = 5 events: EPOLLOUT
fd = 5 events: EPOLLOUT
fd = 5 events: EPOLLOUT
对于边缘触发,根据上面的测试,可以发现,第一次加入epoll时写是就绪的,以及之后有输入时写也会成为就绪状态。继续测试边缘触发的写。。。 event事件仍然是ev.events = EPOLLIN | EPOLLOUT | EPOLLET;还有上面代码中描述符设置为非阻塞的了,这个很重要,如果不设置为非阻塞的,那么write会阻塞到发送完才返回。现在服务端把给客户端的回复改为发送1024000字节的数据:
// if (write(evlist[i].data.fd, recvline, n) < 0)
// {
// perror("write");
// exit(EXIT_FAILURE);
// }
ssize_t len = 0;
while (1)
{
len = write(evlist[i].data.fd, buf+send_len, sizeof(buf)-send_len);
if (len < 0)
{
perror("write errno");
break;
}
send_len += len;
printf("len = %zd send_len=%dn", len, send_len);
}
客户端向服务端发送一条数据后,服务端的输出为:
fd = 5 events: EPOLLOUT
fd = 5 events: EPOLLIN EPOLLOUT
len = 250504 send_len=250504
write errno: Resource temporarily unavailable
fd = 5 events: EPOLLOUT
len = 114392 send_len=364896
fd = 5 events: EPOLLOUT
len = 130320 send_len=495216
fd = 5 events: EPOLLOUT
len = 130320 send_len=625536
fd = 5 events: EPOLLOUT
len = 130320 send_len=755856
fd = 5 events: EPOLLOUT
len = 130320 send_len=886176
fd = 5 events: EPOLLOUT
len = 130320 send_len=1016496
fd = 5 events: EPOLLOUT
len = 7504 send_len=1024000
对于非阻塞套接字,如果发送缓冲区没有空间,输出函数调用将立即返回一个EWOULDBLOCK(EAGAIN)错误,因为上面的写的数据量比较大,无法一次写完,在第一次写的时候我故意尝试多次写,直到报错(EAGAIN)为止(注意这个地方如果是阻塞套接字,那么只会调用一次write,会阻塞到写完成为止,那么之后再关注EPOLLOUT就没有意义了)。当发生错误退出本次循环后,epoll_wait等待就绪条件,当发送缓冲区有空间可写时,写就绪(EPOLLOUT),epoll_wait返回,在处理EPOLLOUT时继续写剩下的数据。后面的write只调用了一次,没有想第一次那样重复调用。其实根据上面的输出可以看到,第一次write返回后,第二次很可能就会报错,所以并没有必要多次调用直到write返回错误。
len = 250504 send_len=250504
write errno: Resource temporarily unavailable
等到数据发送完,写事件不再触发。这也是边缘触发处理写比水平触发方便的地方。边缘触发在写数据结束后,不需要特殊处理;而对于水平触发,写数据结束后,如果仍然关注写事件,那么写会一直触发,所以在完成写操作后,需要将写事件从epoll中删除。
不过上面有个地方比较有趣,就是在第一次写的时候,我故意多次写,就是为了先看一下最多可以写入多少数据量不报EWOULDBLOCK(EAGAIN)错误,但每次都不相同,而且并不是最大的发送缓冲区值,在上面代码的set_nonblock下面,加了以下代码:
int val = 0;
socklen_t len = sizeof(val);
getsockopt(connfd, SOL_SOCKET, SO_RCVBUF, &val, &len);
printf("SO_RCVBUF %dn", val);
val = 0;
getsockopt(connfd, SOL_SOCKET, SO_SNDBUF, &val, &len);
printf("SO_SNDBUF %dn", val);
val = 0;
getsockopt(connfd, SOL_SOCKET, SO_RCVLOWAT, &val, &len);
printf("SO_RCVLOWAT %dn", val);
val = 0;
getsockopt(connfd, SOL_SOCKET, SO_SNDLOWAT, &val, &len);
printf("SO_SNDLOWAT %dn", val);
输出信息为:
SO_RCVBUF 374400
SO_SNDBUF 262144
SO_RCVLOWAT 1
SO_SNDLOWAT 1
可以看到这里的发送缓冲区为262144,而上面第一次只写入了250504。
以上的测试可能存在一些问题,等之后了解的更多了再补充修改。。。
最后
以上就是英勇电源为你收集整理的水平触发和边缘触发的全部内容,希望文章能够帮你解决水平触发和边缘触发所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复