先来说说Windows下的完成端口。完成端口号称是Windows下面最复杂的异步IO操作。但是如果你想开发出具有高性能的、支持大量连接的网络服务程序的话,就必须将它拿下。这里假设你已经对完成端口有一定的了解了。
下面引用一下幽默讲解Windows支持的五种Socket I/O模型的例子来通俗的说一下完成端口究竟是怎么回事。
老陈有一个在外地工作的女儿,不能经常回来,老陈和她通过信件联系。他们的信会被邮递员投递到他们的微软信箱里。
我们平时使用的select模型,老陈每隔几分钟便到楼下看看是否有信。这样的方式会浪费老陈很多时间。同理,程序会阻塞在这里等待数据的到来,使得该进程(线程)无法进行其他的操作,导致性能的降低。
WSAAsyncSelect模型、WSAEventSelect模型同为事件触发模型。此时,只要有信到,微软就会主动通知老陈。此时,老陈只需要等待通知即可,在等待过程中可以做其他的事情。
而Overlapped I/O 事件通知模型基本和上面两种类似。只是,老陈不需要上下楼取信了,他只需告诉微软自己在几楼几号,微软就会把信送到老陈家里。
后来微软推出了Overlapped I/O 完成例程模型,老陈将自己拆信—阅读—回复的过程告诉微软,微软就会按照上述步骤去处理信件。
但是,由于微软要处理的信件实在太多了,信箱经常崩溃。于是采用了新技术Completion Port来处理这些信件。
通过Win32的重叠I/O机制,应用程序可以提请一项I/O操作,重叠的操作请求在后台完成,而同一时间提请操作的线程去做其他的事情。等重叠操作完成后线程收到有关的通知。而一个完成端口其实就是一个通知队列,由操作系统把已经完成的重叠I/O请求的通知放入其中。当某项I/O操作一旦完成,某个可以对该操作结果进行处理的工作者线程就会收到一则通知。
完成端口的使用主要分两步。
首先,创建完成端口
HANDLE hIocp;
hIocp = CreateIoCompletionPort(
INVALID_HANDLE_VALUE,
NULL,
(ULONG_PTR)0,
0);
if (hIocp == NULL) {
// Error
}
完成端口创建后,要把将使用该完成端口的套接字与之关联起来。方法是再次调用CreateIoCompletionPort ()函数,第一个参数FileHandle设为套接字的句柄,第二个参数ExistingCompletionPort 设为刚刚创建的那个完成端口的句柄。
以下代码创建了一个套接字,并把它和前面创建的完成端口关联起来:
SOCKET s;
s = socket(AF_INET, SOCK_STREAM, 0);
if (s == INVALID_SOCKET) {
// Error
}
if (CreateIoCompletionPort((HANDLE)s,
hIocp,
(ULONG_PTR)0,
0) == NULL)
{
// Error
}
...
这时就完成了套接字与完成端口的关联操作。在这个套接字上进行的任何重叠操作都将通过完成端口发出完成通知。
其次,使用API函数GetQueuedCompletionStatus来不断的监听查询某个完成端口的I/O操作的结果。通常来讲,在主线程中都只创建一个完成端口,将所有的套接字都与此完成端口关联。而进行监听查询的线程数一般取CPU数量的两倍。
BOOL GetQueuedCompletionStatus(
HANDLE CompletionPort, // handle to completion port
LPDWORD lpNumberOfBytes, // bytes transferred
PULONG_PTR lpCompletionKey, // file completion key
LPOVERLAPPED *lpOverlapped, // buffer
DWORD dwMilliseconds // optional timeout value
);
第一个参数指出了线程要监视哪一个完成端口。GetQueuedCompletionStatus使调用线程挂起,直到指定的端口的I/O完成队列中出现了一项或直到超时。同I/O完成端口相关联的第3个数据结构是使线程得到完成I/O项中的信息:传输的字节数,完成键和OVERLAPPED结构的地址。该信息是通过传递给GetQueuedCompletionSatatus的lpdwNumberOfBytesTransferred,lpdwCompletionKey和lpOverlapped参数返回给线程的。
注意lpOverlapped,这是很重要的一个数据结构,从这里你将获得你想要的数据,并进行判断处理。这里你可能会问,这个lpOverlapped数据结构是哪里来的,是什么类型的呢?接下来你就明白了。
上面讨论了完成端口的使用,这其实是后期的处理,要想真正了解整个过程,还需要学习下面关于之前如何将发送和接收数据的I/O操作提交。
一个是API函数
WSARecv
从一个套接口接收数据。
int WSAAPI WSARecv ( SOCKET s, LPWSABUF lpBuffers,
DWORD dwBufferCount, LPDWORD lpNumberOfBytesRecvd,
LPINT lpFlags, LPWSAOVERLAPPED lpOverlapped,
LPWSAOVERLAPPED_COMPLETION_ROUTINE
l pCompletionRoutine );
lpOverlapped:一个指向WSAOVERLAPPED结构的指针,在这个参数中就可以设置你要接收的数据结构。
另一个是API函数WASSend在一个已连接的套接口上发送数据。
int WSAAPI WSASend (
SOCKET s,
LPWSABUF lpBuffers,
DWORD dwBufferCount,
LPDWORD lpNumberOfBytesSent,
int iFlags,
LPWSAOVERLAPPED lpOverlapped,
LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);
同理,lpOverlapped用来设置发送是数据结构。
对完成端口来说,将一个套结字邦定到完成端口后,WSARecv和WSASend会立即返回,提高了系统的效率。可以调用 GetQueuedCompletionStatus来判断WSARecv和WSASend是否完成。主线程接受到一个连接后,调用WSARecv等待该连接发送的数据(不阻塞,由完成端口实现数据的接受完毕判断)。在线程函数中接受完毕,然后用WSASend函数发送给客户数据(同样是不阻塞,直接返回,由完成端口判断数据是否发送完毕)。这样在线程函数中需要程序员自己设置状态来区分是发送完毕还是接受完毕。
注意WSARecv 只是向系统提交一个异步接收请求,这个请求会在有数据到达之后返回,并且放入完成队列通知工作线程,这个异步接收请求到此完成,继续提交请求是为了接收下一个数据包,也就是说,每次请求返回之后必须再次提交。WSASend也只是向系统提交一个异步发送请求,当发送成功后,需要提交WSARecv接收请求,因为发送是主动的,发送完毕后必然要等待接收对方的回复。如果不提交WSARecv接收请求,则对方发过来的数据后,完成端口不会监听。
在上一篇中,我们主要讨论了Windows下关于完成端口的一些知识。对应于完成端口,Linux下面在2.5.44内核中有了epoll,这个是为处理大批量句柄而引进的。
先来看看为什么要引进epoll以及它带来的好处。
在Linux内核中,原有的select所用到的FD_SET是有限的,在内核中的参数_FD_SETSIZE来设置的。如果想要同时检测1025个句柄的可读(或可写)状态,则select无法满足。而且,而且select是采用轮询方法进行检测的,也就是说每次检测都要遍历所有FD_SET中的句柄。显然,当随着FD_SET中的句柄数的增多,select的效率会不断的下降。如今的服务器,都是要满足上万甚至更多的连接的,显然想要更高效的实现这一要求,必须采用新的方法。于是,不断的修改后,终于形成了稳定的epoll。
epoll
优点:(1)支持大数量的socket描述符(FD)。举个例子来说,在1GB内存的机器上大约可以打开10万个左右的socket,这个数字足以满足一般的服务器需求。(2)epoll的IO效率不会随着FD数量的增加而线性下降(多少肯定会下降的)。至于原理,可以查阅epoll的实现原理;(3)使用mmap加速内核与用户空间的消息传递,无论是select,poll还是epoll都需要内核把FD消息通知给用户空间,如何避免不必要的内存拷贝就很重要,在这点上,epoll是通过内核于用户空间mmap同一块内存实现的。当然epoll还有其他一些优点,这里就不一一列举了。
下面来重点说说epoll的使用,这也是大家最关心的部分。在2.6内核中epoll变的简洁而强大。
先来弄清楚一个概念,即
epoll
的2种工作方式:LT和ET。
LT(level triggered)是缺省的工作方式,同时支持block和non-block。其实这个有点像电路里面的电平触发方式。在这种模式下,内核会告诉你一个文件描述符fd就绪了,然后你就可以对这个fd进行IO操作。如果你不做任何操作,内核会继续通知你。
所以,假如你读取数据没有读取完时,内核会继续通知你。其实传统的select/poll就是这种模式。
ET(edge-triggered)是告诉工作方式,只支持non-block。其实这个有点像电路里面的边沿触发方式。这个是高效服务器必选的方式。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll通知你。然后对这个fd只通知你一次,因为之后一直为就绪态,没有了状态的变化,直到你做了某些操作导致了那个fd不再为就绪态。但是注意,如果一直不对这个fd进行IO操作,内核不会发送更多的通知。
在弄清楚了上述两种模式之后,接下来就可以使用epoll了。主要用到三个函数epoll_create,epoll_ctl,epoll_wait。
int epoll_create(int size);
创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大。当创建好epoll句柄后,它就是会占用一个fd值,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll的事件注册函数第一个参数是epoll_create()的返回值,第二个参数表示动作,用三个宏来表示:
EPOLL_CTL_ADD:注册新的fd到epfd中;
EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
EPOLL_CTL_DEL:从epfd中删除一个fd;
第三个参数是需要监听的fd,第四个参数是告诉内核需要监听什么事,struct epoll_event结构如下:
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
events可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带
外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET:将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水
平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要
继续监听这个socket的话,需要再次把这个socket加入
到EPOLL队列里
int epoll_wait(int epfd, struct epoll_event * events,
int maxevents, int timeout);
等待事件的产生,类似于select()调用。参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。
下面给出epoll编写服务器的模型:
#include <stdio.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <string.h>
#include <errno.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define MAXSIZE 64000
#define MAXEPS 256
//#define EVENTS 100
#define LISTENQ 32
#define SERV_PORT 8000
int setnonblock(int sock)
{
int flags = fcntl(sock, F_GETFL, 0);
if(-1 == flags) {
perror("fcntl(sock, F_GETFL)");
return -1;
}
flags |= O_NONBLOCK;
if(-1 == fcntl(sock, F_SETFL, flags)) {
perror("fcntl(sock, F_SETFT, flags)");
return -2;
}
return 0;
}
int main(int argc, char *argv[])
{
int i, maxi, listenfd, connfd, sockfd, epfd, nfds;
ssize_t n;
char buf[MAXSIZE];
socklen_t clilen;
struct epoll_event ev, events[20];
epfd = epoll_create(MAXEPS);
struct sockaddr_in clientaddr;
struct sockaddr_in serveraddr;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
setnonblock(listenfd);
ev.data.fd = listenfd;
ev.events = EPOLLIN | EPOLLET;
epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);
memset(&serveraddr, 0, sizeof(serveraddr));
serveraddr.sin_family = AF_INET;
/*
char *local_addr = "10.0.2.15";
inet_aton(local_addr, &(serveraddr.sin_addr));
*/
serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);
serveraddr.sin_port = htons(SERV_PORT);
if(bind(listenfd, (struct sockaddr *)&serveraddr, sizeof(serveraddr)) != 0) {
perror("bind failed");
return -1;
}
if(listen(listenfd, LISTENQ) != 0) {
perror("listen failed");
return -2;
}
maxi = 0;
printf("began to accept.../n");
for(;;) {
nfds = epoll_wait(epfd, events, 32, 10000);
if(-1 == m_nfds) {
if(EINTR == errno) {
continue;
}
return -1;
}
for(i=0; i<nfds; ++i) {
if(events[i].data.fd == listenfd) {
connfd = accept(listenfd, (struct sockaddr *)&clientaddr, &clilen);
if(connfd < 0) {
perror("accept failed");
return -3;
}
printf("accepted../n");
setnonblock(connfd);
ev.data.fd = connfd;
ev.events = EPOLLIN | EPOLLET;
epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &ev);
}
else if(events[i].events & EPOLLIN) {
if((sockfd = events[i].data.fd) < 0)
continue;
if((n = read(sockfd, buf, MAXSIZE)) < 0) {
if(errno == ECONNRESET) {
close(sockfd);
events[i].data.fd = -1;
} else {
perror("read failed");
}
}
else if(0 == n) {
close(sockfd);
events[i].data.fd = -1;
}
printf("Read the buf: %s/n", buf);
ev.data.fd = sockfd;
ev.events = EPOLLOUT | EPOLLET;
epoll_ctl(epfd, EPOLL_CTL_MOD, sockfd, &ev);
}
else if(events[i].events & EPOLLOUT) {
sockfd = events[i].data.fd;
char *sndbuf = "I get your message!";
write(sockfd,sndbuf, 10);
ev.data.fd = sockfd;
ev.events = EPOLLIN | EPOLLET;
epoll_ctl(epfd, EPOLL_CTL_MOD, sockfd, &ev);
}
}
}
return 0;
}
个人总结:
一般epoll服务器模型基本就是上面那个。有人你加入线程池来进行处理,即在epoll-wait和收发处理分开到两个线程中,这样在处理收发数据时,仍然不影响epoll进行监听。也有人建议不使用线程池,因为线程切换等带来的开销不亚于数据处理。这里本人没有进行测试,不便下结论。
主要先想说的,很多在上述模型之外,还需要注意的就是如何读写的问题。因为上述模型只是小量的数据收发,比较简单。
由于epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。只有当read(2)或者write(2)返回EAGAIN时才需要挂起,等待。那么如何保证读取数据或者是发送数据都已经结束了呢?(即全部读完或全部发送)。
读数据的时候需要考虑的是当read()返回的大小如果等于请求的大小,那么很有可能是缓冲区还有数据未读完,也意味着该次事件还没有处理完,所以还需要再次读取,直到返回的大小小于请求的大小。也可以在一个while(true)循环当中不断的read,直到返回EAGAIN位置。建议使用前一种方法。因为很长时间没有给对方回复后,对方可能会认为此次数据包丢失,接着再次发送同样的数据包,服务器此时可以控制退出此次读取,重新读取。
同理,如果发送端流量大于接收端的流量(意思是epoll所在的程序读比转发的socket要快),由于是非阻塞的socket,那么send()函数虽然返回,但实际缓冲区的数据并未真正发给接收端,这样不断的读和发,当缓冲区满后会产生EAGAIN错误,同时不理会这次请求发送的数据。这时就需要你对send进行一些改动,等待片刻后继续发送,并检查看发送的数据量是否正确。
epoll为什么这么快
epoll是多路复用IO(I/O Multiplexing)中的一种方式,但是仅用于linux2.6以上内核,在开始讨论这个问题之前,先来解释一下为什么需要多路复用IO.
以一个生活中的例子来解释.
假设你在大学中读书,要等待一个朋友来访,而这个朋友只知道你在A号楼,但是不知道你具体住在哪里,于是你们约好了在A号楼门口见面.
如果你使用的阻塞IO模型来处理这个问题,那么你就只能一直守候在A号楼门口等待朋友的到来,在这段时间里你不能做别的事情,不难知道,这种方式的效率是低下的.
现在时代变化了,开始使用多路复用IO模型来处理这个问题.你告诉你的朋友来了A号楼找楼管大妈,让她告诉你该怎么走.这里的楼管大妈扮演的就是多路复用IO的角色.
进一步解释select和epoll模型的差异.
select版大妈做的是如下的事情:比如同学甲的朋友来了,select版大妈比较笨,她带着朋友挨个房间进行查询谁是同学甲,你等的朋友来了,于是在实际的代码中,select版大妈做的是以下的事情:
int
n
=
select(
&
readset,NULL,NULL,
100
);
for
(
int
i
=
0
; n
>
0
;
++
i)
{
if
(FD_ISSET(fdarray[i],
&
readset))
{
do_something(fdarray[i]);
--n;
}
}
epoll版大妈就比较先进了,她记下了同学甲的信息,比如说他的房间号,那么等同学甲的朋友到来时,只需要告诉该朋友同学甲在哪个房间即可,不用自己亲自带着人满大楼的找人了.于是epoll版大妈做的事情可以用如下的代码表示:
n
=
epoll_wait(epfd,events,
20
,
500
);
for
(i
=
0
;i
<
n;
++
i)
{
do_something(events[n]);
}
在epoll中,关键的数据结构epoll_event定义如下:
typedef union epoll_data {
void
*
ptr;
int
fd;
__uint32_t u32;
__uint64_t u64;
} epoll_data_t;
struct
epoll_event {
__uint32_t events;
/*
Epoll events
*/
epoll_data_t data;
/*
User data variable
*/
};
可以看到,epoll_data是一个union结构体,它就是epoll版大妈用于保存同学信息的结构体,它可以保存很多类型的信息:fd,指针,等等.有了这个结构体,epoll大妈可以不用吹灰之力就可以定位到同学甲.
别小看了这些效率的提高,在一个大规模并发的服务器中,轮询IO是最耗时间的操作之一.再回到那个例子中,如果每到来一个朋友楼管大妈都要全楼的查询同学,那么处理的效率必然就低下了,过不久楼底就有不少的人了.
对比最早给出的阻塞IO的处理模型, 可以看到采用了多路复用IO之后, 程序可以自由的进行自己除了IO操作之外的工作, 只有到IO状态发生变化的时候由多路复用IO进行通知, 然后再采取相应的操作, 而不用一直阻塞等待IO状态发生变化了.
从上面的分析也可以看出,epoll比select的提高实际上是一个用空间换时间思想的具体应用.