5.1 ET模式下的读写 经过前面几节分析,我们可以知道,当epoll工作在ET模式下时,对于读操作,如果read一次没有读尽buffer中的数据,那么下次将得不到读就绪的通知,造成buffer中已有的数据无机会读出,除非有新的数据再次到达。对于写操作,主要是因为ET模式下fd通常为非阻塞造成的一个问题——如何保证将用户要求写的数据写完。 要解决上述两个ET模式下的读写问题,我们必须实现: a. 对于读,只要buffer中还有数据就一直读; b. 对于写,只要buffer还有空间且用户请求写的数据还未写完,就一直写。 要实现上述a、b两个效果,我们有两种方法解决。 l 方法一 (1) 每次读入操作后(read,recv),用户主动epoll_mod IN事件,此时只要该fd的缓冲还有数据可以读,则epoll_wait会返回读就绪。 (2) 每次输出操作后(write,send),用户主动epoll_mod OUT事件,此时只要该该fd的缓冲可以发送数据(发送buffer不满),则epoll_wait就会返回写就绪(有时候采用该机制通知epoll_wai醒过来)。 这个方法的原理我们在之前讨论过:当buffer中有数据可读(即buffer不空)且用户对相应fd进行epoll_mod IN事件时ET模式返回读就绪,当buffer中有可写空间(即buffer不满)且用户对相应fd进行epoll_mod OUT事件时返回写就绪。 所以得到如下解决方式: if(events.events&EPOLLIN)//如果收到数据,那么进行读入 { cout << "EPOLLIN" << endl; sockfd = events.data.fd; if ( (n = read(sockfd, line, MAXLINE))>0) { line[n] = '/0'; cout << "read " << line << endl; if(n==MAXLINE) { ev.data.fd=sockfd; ev.events=EPOLLIN|EPOLLET; epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev); //数据还没读完,重新MOD IN事件 } else { ev.data.fd=sockfd; ev.events=EPOLLIN|EPOLLET; epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev); //buffer中的数据已经读取完毕MOD OUT事件 } } else if (n == 0) { close(sockfd); } } else if(events.events&EPOLLOUT) // 如果有数据发送 { sockfd = events.data.fd; write(sockfd, line, n); ev.data.fd=sockfd; //设置用于读操作的文件描述符 ev.events=EPOLLIN|EPOLLET; //设置用于注测的读操作事件 epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev); //修改sockfd上要处理的事件为EPOLIN } 注:对于write操作,由于sockfd是工作在阻塞模式下的,所以没有必要进行特殊处理,和LT使用一样。 分析:这种方法存在几个问题: (1) 对于read操作后的判断——if(n==MAXLINE),不能说明这种情况buffer就一定还有没有读完的数据,试想万一buffer中一共就有MAXLINE字节数据呢?这样继续 MOD IN就不再得到通知,而也就没有机会对相应sockfd MOD OUT。 (2) 那么如果服务端用其他方式能够在适当时机对相应的sockfd MOD OUT,是否这种方法就可取呢?我们首先思考一下为什么要用ET模式,因为ET模式能够减少epoll_wait等系统调用,而我们在这里每次read后都要MOD IN,之后又要epoll_wait,势必造成效率降低,这不是适得其反吗? 综上,此方式不应该使用。 l 方法二 读: 只要可读, 就一直读, 直到返回 0, 或者 errno = EAGAIN 写: 只要可写, 就一直写, 直到数据发送完, 或者 errno = EAGAIN if (events.events & EPOLLIN) { n = 0; while ((nread = read(fd, buf + n, BUFSIZ-1)) > 0) { n += nread; } if (nread == -1 && errno != EAGAIN) { perror("read error"); } ev.data.fd = fd; ev.events = events.events | EPOLLOUT; epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev); } if (events.events & EPOLLOUT) { int nwrite, data_size = strlen(buf); n = data_size; while (n > 0) { nwrite = write(fd, buf + data_size - n, n); if (nwrite < n) { if (nwrite == -1 && errno != EAGAIN) { perror("write error"); } break; } n -= nwrite; } ev.data.fd=fd; ev.events=EPOLLIN|EPOLLET; epoll_ctl(epfd,EPOLL_CTL_MOD,fd,&ev); //修改sockfd上要处理的事件为EPOLIN } 注:使用这种方式一定要使每个连接的套接字工作于非阻塞模式,因为读写需要一直读或写直到出错(对于读,当读到的实际字节数小于请求字节数时就可以停止),而如果你的文件描述符如果不是非阻塞的,那这个一直读或一直写势必会在最后一次阻塞。这样就不能在阻塞在epoll_wait上了,造成其他文件描述符的任务饿死。 综上:方法一不适合使用,我们只能使用方法二,所以也就常说“ET需要工作在非阻塞模式”,当然这并不能说明ET不能工作在阻塞模式,而是工作在阻塞模式可能在运行中会出现一些问题。 l 方法三 仔细分析方法二的写操作,我们发现这种方式并不很完美,因为写操作返回EAGAIN就终止写,但是返回EAGAIN只能说名当前buffer已满不可写,并不能保证用户(或服务端)要求写的数据已经写完。那么如何保证对非阻塞的套接字写够请求的字节数才返回呢(阻塞的套接字直到将请求写的字节数写完才返回)? 我们需要封装socket_write()的函数用来处理这种情况,该函数会尽量将数据写完再返回,返回-1表示出错。在socket_write()内部,当写缓冲已满(send()返回-1,且errno为EAGAIN),那么会等待后再重试. ssize_t socket_write(int sockfd, const char* buffer, size_t buflen) { ssize_t tmp; size_t total = buflen; const char* p = buffer; while(1) { tmp = write(sockfd, p, total); if(tmp < 0) { // 当send收到信号时,可以继续写,但这里返回-1. if(errno == EINTR) return -1; // 当socket是非阻塞时,如返回此错误,表示写缓冲队列已满, // 在这里做延时后再重试. if(errno == EAGAIN) { usleep(1000); continue; } return -1; } if((size_t)tmp == total) return buflen; total -= tmp; p += tmp; } return tmp;//返回已写字节数 } 分析:这种方式也存在问题,因为在理论上可能会长时间的阻塞在socket_write()内部(buffer中的数据得不到发送,一直返回EAGAIN),但暂没有更好的办法。 不过看到这种方式时,我在想在socket_write中将sockfd改为阻塞模式应该一样可行,等再次epoll_wait之前再将其改为非阻塞。 5.2 ET模式下的accept 考虑这种情况:多个连接同时到达,服务器的 TCP 就绪队列瞬间积累多个就绪 连接,由于是边缘触发模式,epoll 只会通知一次,accept 只处理一个连接,导致 TCP 就绪队列中剩下的连接都得不到处理。 解决办法是用 while 循环抱住 accept 调用,处理完 TCP 就绪队列中的所有连接后再退出循环。如何知道是否处理完就绪队列中的所有连接呢? accept 返回 -1 并且 errno 设置为 EAGAIN 就表示所有连接都处理完。 的正确使用方式为: while ((conn_sock = accept(listenfd,(struct sockaddr *) &remote, (size_t *)&addrlen)) > 0) { handle_client(conn_sock); } if (conn_sock == -1) { if (errno != EAGAIN && errno != ECONNABORTED && errno != EPROTO && errno != EINTR) perror("accept"); } 扩展:服务端使用多路转接技术(select,poll,epoll等)时,accept应工作在非阻塞模式。 原因:如果accept工作在阻塞模式,考虑这种情况: TCP 连接被客户端夭折,即在服务器调用 accept 之前(此时select等已经返回连接到达读就绪),客户端主动发送 RST 终止连接,导致刚刚建立的连接从就绪队列中移出,如果套接口被设置成阻塞模式,服务器就会一直阻塞在 accept 调用上,直到其他某个客户建立一个新的连接为止。但是在此期间,服务器单纯地阻塞在accept 调用上(实际应该阻塞在select上),就绪队列中的其他描述符都得不到处理。 解决办法是把监听套接口设置为非阻塞, 当客户在服务器调用 accept 之前中止 某个连接时,accept 调用可以立即返回 -1, 这时源自 Berkeley 的实现会在内核中处理该事件,并不会将该事件通知给 epoll,而其他实现把 errno 设置为 ECONNABORTED 或者 EPROTO 错误,我们应该忽略这两个错误。
|