目录
select
Socket就绪条件
读就绪
写就绪
异常就绪
实现select服务器
当前存在的问题:
select优点
select的缺点
poll
实现poll服务器
总结
epoll
epoll工作原理
epoll的优缺点总结
实现epoll服务器
epoll的工作模式
ET vs LT 谁更高效?
select是一个多路转接接口,使用select调用来监视多个文件描述符的变化。
函数原型:
参数说明:
nfds:需要监视的文件描述符中,最大的文件描述符+1
相似用法的三个参数:
readfds:输入输出型参数,调用时用户告诉内核需要监视哪些文件描述符的读事件是否就绪,返回时,内核告诉用户哪些文件描述符的读事件已经就绪
writefds:输入输出型参数,调用时用户告诉内核需要监视哪些文件描述符的写事件是否就绪,返回时,内核告诉用户哪些文件描述符的写事件已经就绪
exceptfds:输入输出型参数,调用时用户告诉内核需要监视哪些文件描述符的异常事件是否就绪,返回时,内核告诉用户哪些文件描述符的异常事件已经就绪
timeout:输入输出型参数,调用时由用户设置select的等待时间,返回时表示timeout的剩余时间
timeout取值:
nullptr:select调用后进行阻塞等待,直到某个被监视的文件描述符上的某个事件就绪
0:select调用后进行非阻塞等待,无论被监视的文件描述符是否就绪,select检测后都会立即返回
特定的时间值:select调用后在指定的时间内进行阻塞等待,如果被监视的文件描述符都没有就绪,函数就返回0,表示超时返回
返回值:
大于0:有几个fd就绪
=0:timeout超时
<0:表示调用错误,错误码被设置
错误码类型:
EBADF:在其中一个集合中给定了无效的文件描述符(可能是已关闭的文件描述符,或发生错误的文件描述符)。
EINTR:捕获到一个信号。
EINVAL:nfds为负数或timeout包含的值无效。
ENOMEM:无法为内部表分配内存。
了解fd_set结构
其实就是一个位图,本质是long 类型的数组,数组元素个数为2^4=16个,
查看源码得:
__FD_SETSIZE = 1024
__NFDBITS = 8*sizeof (long) = 64
也即为 long fds_bits[16],所以能标识的比特位为8*16*8=1024个,也就是能检测的最多的文件描述符个数了,可见select能监测的文件描述符是有上限的。
这个位图跟之前学过的信号集sigset_t有点类似,系统提供了一组接口方便操作位图:
void FD_CLR(int fd, fd_set *set); // 用来清除描述词组 set 中相关 fd 的位int FD_ISSET(int fd, fd_set *set); // 用来测试描述词组 set 中相关 fd 的位是否为真void FD_SET(int fd, fd_set *set); // 用来设置描述词组 set 中相关 fd 的位void FD_ZERO(fd_set *set); // 用来清除描述词组 set 的全部位
了解timeval结构
- tv_sec:表示等待时间的秒数部分。
- tv_usec:表示等待时间的微秒数部分(即剩余时间的小数部分,以毫秒为单位)。
socket内核中,接收缓冲区中的字节数,大于等于低水位标记SO_RCVLOWAT。此时可以无阻塞的读该文件描述符,并且返回值大于0。socket TCP通信中,对端关闭连接,此时对该socket读。则返回0。监听的socket上有新的连接请求。socket上有未处理的错误。
socket内核中,发送缓冲区中的可用字节数(发送缓冲区的空闲位置大小),大于等于低水位标记SO_SNDLOWAT,此时可以无阻塞的写,并且返回值大于0。
socket的写操作被关闭(close或者shutdown)。对一个写操作被关闭的socket进行写操作, 会触发SIGPIPE信号。socket使用非阻塞connect连接成功或失败之后。socket上有未读取的错误。
socket上收到带外数据,带外数据和TCP紧急模式相关,TCP报头当中的URG标志位和16位紧急指针搭配使用,就能发送和接收带外数据。
基本工作流程(比如只实现只关心读事件)
1.创建套接字,完成套接字的绑定,监听,这部分代码后续服务器都会使用,直接封装成Sock类,放在头文件。
2.定义一个fdsArray数组,表示用户需要内核关心的套接字,并将监听套接字添加入其中
3.服务器开始循环调用select进行监听,监听之前,需要重设readfds,遍历fdsArray数组添加入readfds中,并更新maxfd作为select的首参数
4.根据select返回值执行后续代码,<=0就继续循环监听,=0表示超时,-1表示出错,>0就调用事件处理函数
5.事件处理函数:判断就绪的文件描述符,如果是监听文件描述符就绪,就accept获取连接,并将其连接对应的套接字加入fdsArray(连接没有达到上限);如果是普通文件描述符就绪,就调用recv读取,返回值<=0时就关闭fd,清除对应fdsArray上的fd,返回值大于0就输出。
代码实现:
Sock类实现:
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
using namespace std;
class Sock
{
public:
static const int gBacklog = 20;
static int Socket()
{
// 1.创建套接字
int listenSock = socket(AF_INET, SOCK_STREAM, 0);
if (listenSock < 0)
{
cerr << "socket error" << endl;
exit(1);
}
int opt = 1;
setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
return listenSock;
}
static void Bind(int listenSock, uint16_t port)
{
// 2. bind
// 2.1 填充信息到结构体
struct sockaddr_in local;
memset(&local, 0, sizeof local);
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = INADDR_ANY;
// 2.2 bind,本地sock写入内核sock
if (bind(listenSock, (const struct sockaddr *)&local, sizeof(local)) < 0)
{
cerr << "bind error" << endl;
exit(2);
}
}
static void Listen(int listenSock)
{
// 3.监听
if (listen(listenSock, gBacklog) < 0)
{
cerr << "listen error" << endl;
exit(2);
}
}
static int Accept(int listenSock, uint16_t *peerPort, string *ip) // 后两个是输出型参数
{
// 4.连接
struct sockaddr_in peer;
socklen_t len = sizeof peer;
int serviceSock = accept(listenSock, (struct sockaddr *)&peer, &len);
if (serviceSock < 0)
{
cerr << "accept error" << endl;
return -1;
}
// 4.1获取客户端基本信息
if (peerPort)
*peerPort = ntohs(peer.sin_port);
if (ip)
*ip = inet_ntoa(peer.sin_addr);
return serviceSock;
}
};
selectServer.cc实现:
#include "Socket.hpp"
#define DFL -1
int fdsArray[sizeof(fd_set) * 8] = {0};
int gnum = sizeof(fdsArray) / sizeof(fdsArray[0]);
static void showArray()
{
cout << "当前托管的fd:" << endl;
for (int i = 0; i < gnum; i++)
{
if (fdsArray[i] != DFL)
cout << fdsArray[i] << " ";
}
cout << endl;
}
static void HandlerEvent(int listensock, fd_set &readfds)
{
for (int i = 0; i < gnum; i++)
{
if (i == 0 && FD_ISSET(listensock, &readfds))
{
cout << "已有新的连接到来了,需要获取!" << endl;
uint16_t peerPort;
string ip;
int sockfd = Sock::Accept(listensock, &peerPort, &ip);
int k = 0;
while (fdsArray[k] != DFL)
++k;
if (k == gnum)
{
cout << "服务器已经达到了最大的上限了,无法保持更多连接了" << endl;
close(sockfd);
}
else
{
fdsArray[k] = sockfd;
showArray();
}
}
else if (FD_ISSET(fdsArray[i], &readfds))
{
char buffer[1024];
ssize_t s = recv(fdsArray[i], buffer, sizeof(buffer), 0);
if (s <= 0)
{
cout << "client closed or read error,server close" << fdsArray[i] << endl;
close(fdsArray[i]);
fdsArray[i] = DFL;
;
showArray();
}
else
{
buffer[s] = 0;
cout << "client[" << fdsArray[i] << "]#" << buffer << endl;
}
}
}
}
int main(int argc, char *argv[])
{
if (argc != 2)
{
cout << "Usage:\n\t" << argv[0] << " port" << endl;
exit(-1);
}
int listenSock = Sock::Socket();
Sock::Bind(listenSock, atoi(argv[1]));
Sock::Listen(listenSock);
for (int i = 0; i < gnum; i++)
fdsArray[i] = DFL;
fdsArray[0] = listenSock;
while (1)
{
int maxfd = DFL;
fd_set readfds;
FD_ZERO(&readfds);
for (int i = 0; i < gnum; i++)
{
if (fdsArray[i] == DFL)
continue;
FD_SET(fdsArray[i], &readfds);
if (maxfd < fdsArray[i])
maxfd = fdsArray[i];
}
timeval timeout = {5, 0};
int n = select(maxfd + 1, &readfds, nullptr, nullptr, &timeout);
switch (n)
{
case 0:
cout << "time out..." << time(nullptr) << endl;
break;
case -1:
cerr << errno << strerror(errno) << endl;
break;
default:
HandlerEvent(listenSock, readfds);
break;
}
}
return 0;
}
简单测试:
服务器:
客户端:
1.没有定制协议:可能造成粘包问题,因为没有定制协议,读取的报文没有格式边界,不知何时读完了完整报文,HTTP用空行表示报头读取完毕,报头中有Content-Length字段表示正文的长度,最终能读取到完整的报文。
2.没有输入输出缓冲区,代码直接将读取的数据拷贝到字符数组buff中,不严谨,因为可能读取到的不是一个完整的报文,应该将数据读取到缓冲区中,当读取到了一个完整的报文,服务器再对其进行处理。
可以同时等待多个文件描述符,提高IO效率,相比多线程/多进程占用资源少,高效。
1.因为输入输出使用的是同一参数,所以每次调用前需要重置参数,效率低
2.检测的文件描述符数量有上限,这是由内核结构实现决定的(最多sizeof(fd_set)*8=1024个)
3.每次调用内核向用户传递位图参数,是较为大量的数据拷贝工作
4.编码不方便,需要用户自己维护数组
5.底层需要遍历的方式(遍历maxfd+1次),检测所有需要监听的文件描述符
poll函数原型
参数说明:
fds:数组传参退化成指针,传递需要监视的文件描述符数组
查看struct pollfd:
内含监视的文件描述符fd,需要监听的事件events,已经就绪的事件revents
events和revents的取值
nfds:表示fds数组的大小
timeout:表示poll函数的超时时间,单位是ms
timeout取值:
-1:poll调用后进行阻塞等待,直到被监视的某个文件描述符就绪
0:poll调用后进行非阻塞等待,无论被监视的文件描述符是否就绪,poll都会返回
特定的时间值:poll调用后在指定的时间内进行阻塞等待,时间到了还没有就绪事件就绪,就超时返回
返回值:
>0:函数调用成功,返回有事件就绪的文件描述符个数
=0:过了timeout时间,超时返回
-1:调用失败,错误码被设置
可见poll函数与select函数的返回值和timeout的取值作用是十分相似的。
poll调用失败,错误码类型:
EFAULT: 给定作为参数的数组未包含在调用程序的地址空间中。
EINTR:函数调用时被信号中断。
EINVAL:nfds值超过了RLIMIT_NOFILE值。
ENOMEM:没有空间来分配文件描述符表。
基本工作流程(比如只实现只关心读事件)(与select类似)
1.创建套接字,完成套接字的绑定监听工作,与select服务器相同
2.定义fdsArray数组,表示用户需要关心的套接字,将数组初始化并将listenSock加入其中
3.服务器开始循环调用poll监听,根据返回值执行后续代码,这步也同select
4.事件处理函数:遍历fdsArray数组,处理就绪的文件描述符事件,当文件描述符是listenSock,accept获取连接,遍历fdsArray将其加入首个fd为DFL的位置,如果是普通文件描述符,则调用recv读取,根据返回值不同行为。这步也和select类似。
具体代码:
pollServer.cc
#include "Socket.hpp"
#include
#define DFL -1
#define NUM 1024
struct pollfd fdsArray[NUM];
static void showArray(struct pollfd arr[], int num)
{
cout << "当前合法sock list# ";
for (int i = 0; i < num; i++)
{
if (arr[i].fd == DFL)
continue;
else
cout << arr[i].fd << " ";
}
cout << endl;
}
static void HandEvents(int listenSock)
{
for (int i = 0; i < NUM; i++)
{
if (fdsArray[i].fd == DFL)
continue;
if (i == 0 && fdsArray[i].fd == listenSock && (fdsArray[i].revents & POLLIN))
{
cout << "有新的链接到来了,正在处理" << endl;
string ip;
uint16_t port;
int sockfd = Sock::Accept(listenSock, &port, &ip);
if (sockfd < 0)
return;
cout << "获取连接成功: [" << ip << ":" << port << "]sockfd:" << sockfd << endl;
int index = 0;
while (fdsArray[index].fd != DFL)
++index;
if (index == NUM)
{
cout << "服务器已经达到了最大的连接上限了,无法维持更多的连接了。" << endl;
close(sockfd);
}
else
{
fdsArray[index].fd = sockfd;
fdsArray[index].events = POLLIN;
fdsArray[index].revents = 0;
}
showArray(fdsArray, NUM);
}
else if (fdsArray[i].revents & POLLIN)
{
char buff[1024];
ssize_t s = recv(fdsArray[i].fd, buff, sizeof(buff), 0);
if (s > 0)
{
buff[s] = 0;
cout << "client[" << fdsArray[i].fd << "]# " << buff << endl;
}
else
{
if(s==0)cout << "client[" << fdsArray[i].fd << "]quit,server close" <
poll相比select改进:
(1)输入输出同一个参数,每次要重设参数
(2)所等待的文件描述符有上限
select和poll具有的不足:
(1)select,poll都是基于对各个fd进行遍历检测识别事件就绪的,当连接数多,遍历周期长
(2)事件使用的数据结构需要程序员自己维护
(3)每次调用函数都需要把内核结构从用户态拷贝到内核态,随着文件描述符增多是一笔较大的开销
(4)都存在惊群现象(后面谈)
这些不足在后续的epoll基本得到了很好的解决。
简介:epoll也是系统提供的多路转接接口,epoll名字可以理解为扩展的poll,但是它们的区别很大,epoll几乎具备了select和poll的优点,被公认为Linux2.6下性能最好的多路IO就绪通知方法。
三个核心系统调用:
1.epoll_create:用于创建epoll模型
参数size在2.6版本后被忽略,但需要size>0
返回值:创建epoll模型成功返回文件描述符,创建失败返回-1,错误码被设置
2.epoll_ctl函数:用于向指定的epoll模型中注册事件,
参数说明:
epfd:指定的epoll模型
op:表示具体的操作
传入宏:
EPOLL_CTL_ADD:注册新的文件描述符到指定的epoll模型中
EPOLL_CTL_MOD:修改已经注册的文件描述符的监听事件
EPOLL_CTL_DEL:从 epoll模型中删除指定的文件描述符
返回值:函数调用成功返回0,调用失败返回-1,错误码被设置
fd:表示要操作的文件描述符
event:传入用户要内核关心的事件结构
查看其结构如下:
其中uint32_t events常用取值如下:
EPOLLIN : 表示对应的文件描述符可以读 (包括对端SOCKET正常关闭);EPOLLOUT : 表示对应的文件描述符可以写;EPOLLPRI : 表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外数据到来);EPOLLERR : 表示对应的文件描述符发生错误;EPOLLHUP : 表示对应的文件描述符被挂断;EPOLLET : 将EPOLL设为边缘触发(Edge Triggered)模式, 这是相对于水平触发(Level Triggered)来说的EPOLLONESHOT:只监听一次事件, 当监听完这次事件之后, 如果还需要继续监听这个socket的话, 需要再次把这个socket加入到EPOLL模型中
参数说明:
epfd:指定的epoll模型
events:用户提供数组,内核负责将就绪事件拷贝到数组(内核只负责拷贝,不为用户分配内存)
maxevents:数组最多从就绪队列获取的事件个数,不能大于创建epoll模型时传入的size
timeout,表示函数的超时时间,单位是ms
timeout取值与前面类似:
-1:表示阻塞等待,直到某个被监视的文件描述符就绪
0:调用后立即进行非阻塞等待,无论是否有事件就绪,函数都直接返回
特定时间值:在指定时间内阻塞等待,如果在指定时间过后没有事件就绪,函数就超时返回
返回值:
>0:就绪的文件描述符个数
=0:timeout时间耗尽,超时返回
-1:调用失败,错误码被设置
错误码列表:
EBADF:传入epoll模型的文件描述符无效
EFAULT:events指向的数组空间没有写入权限
EINTR:函数调用被信号中断
EINVAL:epfd不是epoll模型对应的文件描述符或者传入的maxevents的值小于等于0
epoll的工作原理由三个部分组成
1.使用红黑树来维护
struct eventpoll{
....
/*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
struct list_head rdlist;
....
};
struct epitem{
struct rb_node rbn;//红黑树节点
struct list_head rdllink;//双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发生的事件类型
}
从工作原理我们就可以看出来epoll模型的高效性:
优点:(1)接口使用方便高效,不用重置参数,输入输出参数分离(select)
(2)数据拷贝轻量:只在新增事件的时候,调用epoll_ctl将数据从用户拷贝到内核,而select和poll每次都需要将需要监视的事件从用户拷贝到内核。(因为监视的文件描述符在内核被红黑树维护)此外,epoll_wait获取就绪事件的时候,只会拷贝就绪的事件,不会进行不必要的拷贝(就绪队列)。
(3)事件回调机制:不用OS主动轮询检测事件就绪,采用回调函数的方式,将就绪的文件描述符构建队列节点加入就绪队列,检测是否有文件描述符就绪的时间复杂度O(1),只需检测就绪队列是否为空。
(4)没有数量限制,只要内存允许,就可以往红黑树添加节点。
缺点:
(1)短连接会导致epoll_ctl会调用频繁,对于任何想要从epollfd中添加或者删除的fd都要调用epoll_ctl,比如需要关注一万个事件,就得手动调用一万次该系统调用,频繁的用户态到内核态的切换,会大大降低效率。
(2)会出现惊群现象,降低效率。
(3)跨平台性不够好
基本工作流程
1.创建套接字,绑定,监听
2.调用epoll_create创建epoll模型
3.创建listenSock的epoll结构,然后调用epoll_ctl将其添加到epoll模型
4.创建epoll_event数组用于存放就绪的事件
5.循环调用epoll_wait,判断返回值与之前的多路转接接口类似
6.事件处理函数:遍历就绪的数组,倘若是监听套接字就绪,调用accept获取连接,创建epoll_event并调用epoll_ctl将其加入epoll模型中,如果是普通文件描述符,调用recv读取,根据recv的返回值有不同的行为。
代码实现:(封装成epollServer类)
epollServer.hpp
#include "log.hpp"
#include "Socket.hpp"
#include
class EpollServer
{
using func_t = function;
private:
int epfd_;
int listenSock_;
int port_;
func_t func_; // 处理普通fd的函数,返回读取的字符个数,其实就是封装recv
public:
static const int gsize = 128; // epoll实例的大小,即能够监视的文件描述符的数量
static const int num = 128; // 从就绪队列至多能获取的文件描述符个数
EpollServer(int port, func_t func) : epfd_(-1), listenSock_(-1), port_(port), func_(func) {}
~EpollServer()
{
if (listenSock_ != -1)
close(listenSock_);
if (epfd_ != -1)
close(epfd_);
}
public:
void InitEpollServer()
{
// 1.创建套接字,绑定,监听
listenSock_ = Sock::Socket();
Sock::Bind(listenSock_, port_);
Sock::Listen(listenSock_);
// 2.创建epoll模型
epfd_ = epoll_create(gsize);
if (epfd_ < 0)
{
logMessage(FATAL, "%d:%s", errno, strerror(errno));
exit(1);
}
logMessage(DEBUG, "listen :%d success", listenSock_);
logMessage(DEBUG, "epoll_create :%d success", epfd_);
}
void Run()
{
// 1.创建listenSock_的epoll结构,然后将其添加到epoll模型
epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = listenSock_;
int n = epoll_ctl(epfd_, EPOLL_CTL_ADD, listenSock_, &ev);
assert(n == 0);
(void)n;
// 2.循环检测就绪队列
epoll_event evs[num];
int timeout = 1000; // 等待的毫秒数
while (1)
{
int n = epoll_wait(epfd_, evs, num, timeout);
switch (n)
{
case 0:
cout << "time out ...... " << endl;
/* code */
break;
case -1:
cerr << errno << ":" << strerror(errno) << endl;
break;
default:
HandlerEvents(evs, n);
break;
}
}
}
void HandlerEvents(epoll_event *evs, int n) // 就绪的fd个数
{
for (int i = 0; i < n; i++)
{
epoll_event &ev = evs[i];
int sock = ev.data.fd;
uint32_t revent = ev.events;
if (revent & EPOLLIN) // 读事件就绪
{
if (sock == listenSock_) // 监听套接字就绪
{
// 1.accept
string ip;
uint16_t port;
int sockfd = Sock::Accept(listenSock_, &port, &ip);
if (sockfd < 0)
{
logMessage(FATAL, "%d:%s", errno, strerror(errno));
continue;
}
logMessage(DEBUG, "获取连接成功:ip是%s,port是%d", ip.c_str(), port);
// 2.创建epoll结构体,添加到epoll模型
epoll_event newEv;
newEv.data.fd = sockfd;
newEv.events = EPOLLIN;
int n = epoll_ctl(epfd_, EPOLL_CTL_ADD, sockfd, &newEv);
assert(n == 0);
(void)n;
}
else // 普通文件描述符
{
int n = func_(sock);
if (n <= 0)
{
int x = epoll_ctl(epfd_, EPOLL_CTL_DEL, sock, nullptr);
assert(x == 0);
(void)x;
logMessage(DEBUG, "client quit:%d", sock);
close(sock);
}
}
}
}
}
};
main.cc
#include "epollServer.hpp"
#include
using std::unique_ptr;
int myread(int sock)
{
char buff[1024];
ssize_t s = recv(sock, buff, sizeof(buff) - 1, 0);
if (s > 0)
{
buff[s] = 0;
logMessage(DEBUG, "client[%d]# %s", sock, buff);
}
return s;
}
int main(int argc, char *argv[])
{
if (argc != 2)
{
cout << "Usage:" << argv[0] << " port" << endl;
exit(1);
}
int port = atoi(argv[1]);
unique_ptr uptr(new EpollServer(port, myread));
uptr->InitEpollServer();
uptr->Run();
return 0;
}
epoll有2种工作方式-水平触发(LT)和边缘触发(ET)