上一篇写了一个简单的echo服务器,但该服务器只能同时处理一个客户端的连接,而且绝大部分都是空闲的,对服务器来说是严重的资源浪费。本次的目标高并发服务器可以同时为成千上万个客户端服务,这被称为IO多路复用,与多线程有相似之处,但IO复用是针对IO接口,而多线程是针对CPU。
IO多路复用的基本思想是事件驱动,服务器同时保持多个客户端IO连接,当这个IO上有可读或可写事件发生时,表示这个IO对应的客户端在请求服务器的某项服务,此时服务器响应该服务。在Linux系统中,IO复用使用select, poll和epoll来实现。与多进程或多线程相比,该技术最大优势是系统开销小,系统不必创建进程或线程。
五种IO模型
- blockingIO 阻塞IO
- nonblockingIO 非阻塞IO
- IOmultiplexing IO多路复用
- signaldrivenIO 信号驱动IO
- asynchronousIO 异步IO
在Linux的缓存IO机制中,操作系统会将IO的数据缓存在文件系统的页缓存(page cache)。也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓存区拷贝到应用程序的地址空间中。这种做法的缺点就是,需要在应用程序地址空间和内核进行多次拷贝,这些拷贝动作所带来的CPU以及内存开销是非常大的。
至于为什么不能直接让磁盘控制器把数据送到应用程序的地址空间中呢?最简单的一个原因就是应用程序不能直接操作底层硬件。
在用户调用select的时候,select会将正在监控的所有fd(文件描述符)拷贝到内核空间,然后遍历是否有某个socket有事件发生,如果没有用户进程则进入睡眠,同时继续遍历所有socket,直到某个socket有事件发生或者等待超时,那么则唤醒用户进程,并再次遍历所有socket,收集事件返回给用户进程。
但该技术有几项缺点
- 首先调用select时需要拷贝所有fd到内核,消耗了大量系统资源
- 能监听的端口数量有限,32位机默认1024个,64位机默认2048个
- 在循环遍历socket时,如果有一个socket有事件发生,则会遍历所有的socket,因为不知道是哪个socket有事件发生,只能挨个查询。消耗大量资源
poll与select差不多,只是没了最大端口数量限制,因为其基于链表储存,同样的他也需要大量拷贝fd到内核空间,消耗大量系统资源。
本次采用epoll技术,epoll与前两个采用轮询机制不同,其采用被动通知,当监控的所有scoket有事件发生时则被动接收通知,然后去进行处理。同时在等待的时候主程序可以做其他事。大大提高了系统利用率
优点:
- 无最大并发限制
- 效率提升,不是轮询,不会随着fd数量上升而效率下降。因为其只注意活跃连接
- epoll中内核与用户共享同一块内存空间,因此不用大量拷贝fd。
所以今天就把之前写的简单服务器改写成采用epoll机制的,顺便封装一下。
epoll的接口非常简单,一共就三个函数:
-
epoll_create:创建一个epoll句柄
-
epoll_ctl:向 epoll 对象中添加/修改/删除要管理的连接
-
epoll_wait:等待其管理的连接上的 IO 事件
int main() { pSocket servSock(new Socket()); pInetAddress servAddr(new InetAddress("127.0.0.1", 8888)); servSock->bind(servAddr.get()); servSock->listen(); pEpoll ep(new Epoll()); servSock->setNonBlocking(); ep->addFd(servSock->getFd(), EPOLLIN | EPOLLET); while (true) { std::vector<epoll_event> events = ep->poll(-1); int nfds = events.size(); for (int i = 0; i < nfds; i++) { if (events[i].data.fd == servSock->getFd()) { //新客户端连接 pInetAddress clntAddr(new InetAddress()); Socket *clntSock = new Socket(servSock->accept(clntAddr.get())); printf("new client fd %d! IP: %s Port: %d\n", clntSock->getFd(), inet_ntoa(clntAddr->addr.sin_addr), ntohs(clntAddr->addr.sin_port)); clntSock->setNonBlocking(); ep->addFd(clntSock->getFd(), EPOLLIN | EPOLLET); } else if (events[i].events & EPOLLIN) { //可读事件 handleReadEvent(events[i].data.fd); } else { printf("something else happened"); } } } return 0; } void handleReadEvent(int sockfd) { char buf[READ_BUFFER]; while (true) { //由于使用非阻塞IO,读取客户端buffer,一次读取buf大小数据,直到全部读取完毕 bzero(buf, sizeof(buf)); ssize_t bytesRead = read(sockfd, buf, sizeof(buf)); if (bytesRead > 0) { printf("message from clinet fd %d: %s\n", sockfd, buf); write(sockfd, buf, sizeof(buf)); } else if (bytesRead == -1 && errno == EINTR) { //客户端正常中断、继续读取 printf("continue reading\n"); continue; } else if (bytesRead == -1 && ((errno == EAGAIN) || (errno == EWOULDBLOCK))) { //非阻塞IO,这个条件表示数据全部读取完毕 printf("reading finished, errno:%d\n", errno); break; } else if (bytesRead == 0) { //EOF,客户端断开连接 printf("client fd: %d disconnected\n", sockfd); close(sockfd); //关闭socket会自动将文件描述符从epoll树上移除 break; } }
}
这里是跟着一位大佬写的, 把里面的一些指针和数组改成了智能指针和vector,但是有的地方不能改,比如客户端Socket
Socket *clntSock = new Socket(servSock->accep(clntAddr.get()));
已知智能指针会自动销毁自己,如果在这里使用智能指针,那么服务器和客户端会进入一种奇怪的状态,表现为客户端能向服务器写信息,但是服务器接收不到,同理如果在创建该指针的if里最后delete指针,也会导致连接异常,可能该指针有一些特殊的用途,这里也无法解答,所以只能先放着。