废话不多说,我们直接进入主题。
本次分为三个部分讲解,第一部分为知识准备部分,第二部分为epoll的处理部分,即如何将事件读入,第三部分是如何对报文进行处理的部分(该部分会与线程池连在一起,因此我们放在最后并引出线程池篇)。
开始之前,我们依旧先把整个的http连接的流程先过一遍,这样可以先熟悉一下整个过程。
一.具体连接流程
首先,服务端开启监听,然后客户端发送请求,希望和服务端相连接,当服务器accept()之后,这个请求就被挂到了epoll监听队列上,当有读事件或者写事件发生时(这里可以简单的把读事件当做客户端发来报文请求,写事件当做服务器返回响应报文),epoll先将其注册到对应的读写事件表中,然后采用epoll_wait来通知主线程有读写事件到了,你要开始读写数据了。
就以读事件为例,此时主线程将数据读入读缓冲区中,全部读完后将其封装成一个请求对象挂到线程池的任务队列上,接下来就到了线程池处理这个读入的数据的时候了。
看,是不是http连接这部分就清楚了,他主要做的就是:将数据读入然后封装给线程池里的线程来解析(针对读事件)。
好了,那我们直接进入主题吧,开始讲解第一部分,知识的准备部分。
二.知识准备
首先,我们必须要了解socket到底是怎么用的,如何用socket来实现客户端与服务器端的连接。
其次,我们要了解监听到的连接接下来怎么处理,前面说过我们采用的是epoll来处理,那么为什么要选择epoll,epoll又怎么操作呢?
最后,我们需要了解常见的两种报文get和post,了解他们是如何被发送,又如何被服务器所解析然后返回响应报文的。
可以看到,我们是分为了三个部分来说的,那现在先来第一个:
1.socket到底是怎么用的
看得懂代码的可以先看下代码部分,看不懂的也没关系,请直接划到下面,我先将它拆分一下来讲解,然后大家学完再回过头来看即可。
void WebServer::eventListen()
{
//网络编程基础步骤
//创建监听socket文件描述符
m_listenfd = socket(PF_INET, SOCK_STREAM, 0);
assert(m_listenfd >= 0);
//关闭连接
if (0 == m_OPT_LINGER)
{
struct linger tmp = {0, 1};
setsockopt(m_listenfd, SOL_SOCKET, SO_LINGER, &tmp, sizeof(tmp));
}
else if (1 == m_OPT_LINGER)
{
struct linger tmp = {1, 1};
setsockopt(m_listenfd, SOL_SOCKET, SO_LINGER, &tmp, sizeof(tmp));
}
//创建监听socket的tcp/ip
int ret = 0;
struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_ANY);
address.sin_port = htons(m_port);
int flag = 1;
//允许端口被重复使用
setsockopt(m_listenfd, SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag));
//绑定sokect和他的地址
ret = bind(m_listenfd, (struct sockaddr *)&address, sizeof(address));
assert(ret >= 0);
//创建监听队列存放待处理的客户连接
ret = listen(m_listenfd, 5);
assert(ret >= 0);
utils.init(TIMESLOT);
//epoll创建内核事件表
/* 用于存储epoll事件表中就绪事件的event数组 */
epoll_event events[MAX_EVENT_NUMBER];
/* 创建一个额外的文件描述符来唯一标识内核中的epoll事件表 */
m_epollfd = epoll_create(5);
assert(m_epollfd != -1);
/* 主线程往epoll内核事件表中注册监听socket事件,当listen到新的客户连接时,listenfd变为就绪事件 */
utils.addfd(m_epollfd, m_listenfd, false, m_LISTENTrigmode);
http_conn::m_epollfd = m_epollfd;
/* 创建管道,注册pipefd[0]上的可读事件 */
ret = socketpair(PF_UNIX, SOCK_STREAM, 0, m_pipefd);
assert(ret != -1);
/* 设置管道写端为非阻塞 */
utils.setnonblocking(m_pipefd[1]);
/* 设置管道读端为ET非阻塞,并添加到epoll内核事件表 */
utils.addfd(m_epollfd, m_pipefd[0], false, 0);
//alarm函数会定期触发SIGALRM信号,这个信号交由sig_handler来处理,
//每当监测到有这个信号的时候,都会将这个信号写到pipefd[1]里面,传递给主循环:
utils.addsig(SIGPIPE, SIG_IGN);
utils.addsig(SIGALRM, utils.sig_handler, false);
utils.addsig(SIGTERM, utils.sig_handler, false);
/* 每隔TIMESLOT时间触发SIGALRM信号 */
alarm(TIMESLOT);
//工具类,信号和描述符基础操作
Utils::u_pipefd = m_pipefd;
Utils::u_epollfd = m_epollfd;
}
咋一看是不是感觉很多很复杂,那我们先对他分开讲一下,讲之前先简单介绍一下socket:
Web服务器端通过socket监听来自用户的请求。
远端的很多用户会尝试去connect()这个Web Server上正在listen的这个port,而监听到的这些连接会排队等待被accept()。由于用户连接请求是随机到达的异步事件,每当监听socket(listenfd)listen到新的客户连接并且放入监听队列,我们都需要告诉我们的Web服务器有连接来了,accept这个连接,并分配一个逻辑单元来处理这个用户请求。而且,我们在处理这个请求的同时,还需要继续监听其他客户的请求并分配其另一逻辑单元来处理(并发,同时处理多个事件,后面会提到使用线程池实现并发)。这里,服务器通过epoll这种I/O复用技术(还有select和poll)来实现对监听socket(listenfd)和连接socket(客户请求)的同时监听。
这段话是原作者自己给出的解释,那么我们这里用听得懂的话来说一遍就是:
socket就是用来三次握手建立连接的:
看这个图是不是就很清晰了,这就是整个socket通信的过程:
服务器端socket(), bind()这两个部分其实可以看做是准备工作,包括初始化套接字,创建套接字的地址(ip)来用作连接,其实也就是个初始性工作,不需要我们做些什么,copy就可以;listen()部分也就是创建一个监听队列来等待连接。
具体怎么操作的看下面的拆分的代码就可以:
接下来,客户端想要请求连接,那么就要发一个connect()请求,被服务器端监听到并且accept()后,双方就正式建立了通信。这部分代码其实也不复杂,整个的具体流程大家有兴趣可以随便找一份socket通信的代码去看看。
接下来直接讲epoll了:
前面说到,现在已经监听好了,但是有一个问题,我们如何分辨到底是在监听连接还是在监听已经连接好的客户端发出的请求事件呢?
epoll就派上用场了,务器通过epoll这种I/O复用技术(还有select和poll)来实现对监听socket(listenfd)和连接socket(客户请求)的同时监听。
那为什么不选用select和poll,偏偏要选用epoll呢? 这样吧,我把他们的区别放出来,大家看看:
1.对于select和poll来说,所有文件描述符都是在用户态被加入其文件描述符集合的,每次调用都需要将整个集合拷贝到内核态;epoll则将整个文件描述符集合维护在内核态,每次添加文件描述符的时候都需要执行一个系统调用。系统调用的开销是很大的,而且在有很多短期活跃连接的情况下,epoll可能会慢于select和poll由于这些大量的系统调用开销。
2.select使用线性表描述文件描述符集合,文件描述符有上限;poll使用链表来描述;epoll底层通过红黑树来描述,并且维护一个ready list,将事件表中已经就绪的事件添加到这里,在使用epoll_wait调用时,仅观察这个list中有没有数据即可。
3.select和poll的最大开销来自内核判断是否有文件描述符就绪这一过程:每次执行select或poll调用时,它们会采用遍历的方式,遍历整个文件描述符集合去判断各个文件描述符是否有活动;epoll则不需要去以这种方式检查,当有活动产生时,会自动触发epoll回调函数通知epoll文件描述符,然后内核将这些就绪的文件描述符放到之前提到的ready list中等待epoll_wait调用后被处理。
4.select和poll都只能工作在相对低效的LT模式下,而epoll同时支持LT和ET模式。
好吧,我们来总结一下,epoll采用的是将整个文件描述符都维护在内核态,这里有个概念,用户态是我们能操作的,那我们怎么去访问内核态呢?只能通过系统调用,显然这个开销会很大,那为么还选用它?
第一. 其实连接的常态是这样,大量的连接用户但是只有少量活跃的。
第二. select和poll是遍历的方式去查看是否有事件发生,而epoll直接采用的回调函数来通知epoll的文件描述符。
还是综述开头的场景,10000个连接但是只有几个是活跃的,再看看时间消耗?很明显,遍历10000个描述符和采用回调函数直接返回,时间上完全没有可比性!
好了,我想为什么选用epoll应该已经讲清楚了,那么讲了半天,epoll到底是如何实现的呢?
首先,我们来说下epoll的具体构造,其实epoll分为三个函数
epoll_creat(), epoll_ctl(), epoll_wait()
其中epoll_creat()的作用就是创建这个epoll监听树,相当于是初始化吧。
epoll_ctl()呢?它用来修改,注册,删除,
说到这,我们又要说一下epoll内部维护的三个事件表:
读事件表(存放 发生读事件的连接)
写事件表(存放 发生写事件的连接)
异常事件表(这个当然就是发生错误的了)
那么刚才说到,舰艇的连接中如果发来了请求,比如读事件这种,就需要epoll_ctl()函数来将他注册到读事件表中。
epoll_wait()函数,这个是整个里面最重要的函数:
他的具体用法是:(看一看原作者写的)
主线程调用epoll_wait等待一组文件描述符上的事件,并将当前所有就绪的epoll_event复制到events数组中。然后我们遍历这一数组以处理这些已经就绪的事件。让主线程开始读入数据,同时epoll_wait进行阻塞,等待数据读取完毕。
其实意思就是:会重新生成一个数组来装这些就绪的事件(比如读事件),然后epoll_wait()告诉主线程,让他开始读入当前事件的数据,这个时候epoll_wait()会阻塞住,不去读其他的内容,直到这个事件被读取完毕,epoll_wait()才能被下一个事件调用。
看一下具体的代码吧:
void WebServer::eventLoop()
{
bool timeout = false;
bool stop_server = false;
while (!stop_server)
{
/* 主线程调用epoll_wait等待一组文件描述符上的事件,并将当前所有就绪的epoll_event复制到events数组中 */
int number = epoll_wait(m_epollfd, events, MAX_EVENT_NUMBER, -1);
/* 然后我们遍历这一数组以处理这些已经就绪的事件 */
if (number < 0 && errno != EINTR)
{
LOG_ERROR("%s", "epoll failure");
break;
}
//对所有就绪事件进行处理
for (int i = 0; i < number; i++)
{
int sockfd = events[i].data.fd;// 事件表中就绪的socket文件描述符
// 当listen到新的用户连接,listenfd上则产生就绪事件
//处理新到的客户连接
if (sockfd == m_listenfd)
{
bool flag = dealclinetdata();
if (false == flag)
continue;
}
else if (events[i].events & (EPOLLRDHUP | EPOLLHUP | EPOLLERR))
{
//如有异常,服务器端关闭连接,移除对应的定时器
util_timer *timer = users_timer[sockfd].timer;
deal_timer(timer, sockfd);
}
//处理信号
else if ((sockfd == m_pipefd[0]) && (events[i].events & EPOLLIN))
{
bool flag = dealwithsignal(timeout, stop_server);
if (false == flag)
LOG_ERROR("%s", "dealclientdata failure");
}
//处理客户连接上接收到的数据
else if (events[i].events & EPOLLIN)
{/* 当这一sockfd上有可读事件时,epoll_wait通知主线程。*/
dealwithread(sockfd);
}
else if (events[i].events & EPOLLOUT)
{
dealwithwrite(sockfd);
}
}
if (timeout)
{
utils.timer_handler();
LOG_INFO("%s", "timer tick");
timeout = false;
}
}
}
那么现在就写入了数据了,接下来需要干的事情就是将读入的数据封装成一个请求对象并发送给线程池的请求队列,这个我们下篇再讲。
然后本篇最后一个部分是介绍一下get与post两个报文:
先看get请求报文:
GET /562f25980001b1b106000338.jpg HTTP/1.1
Host:img.mukewang.com
User-Agent:Mozilla/5.0 (Windows NT 10.0; WOW64)
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.106 Safari/537.36
Accept:image/webp,image/*,*/*;q=0.8
Referer:http://www.imooc.com/
Accept-Encoding:gzip, deflate, sdch
Accept-Language:zh-CN,zh;q=0.8
空行
请求数据为空
第一行:请求行,用来说明请求类型,要访问的资源以及所使用的HTTP版本。
请求头部,紧接着请求行(即第一行)之后的部分,用来说明服务器要使用的附加信息。
HOST,给出请求资源所在服务器的域名。
User-Agent,HTTP客户端程序的信息,该信息由你发出请求使用的浏览器来定义,并且在每个请求中自动发送等。
Accept,说明用户代理可处理的媒体类型。
Accept-Encoding,说明用户代理支持的内容编码。
Accept-Language,说明用户代理能够处理的自然语言集。
Content-Type,说明实现主体的媒体类型。
Content-Length,说明实现主体的大小。
Connection,连接管理,可以是Keep-Alive或close。
空行,请求头部后面的空行是必须的即使第四部分的请求数据为空,也必须有空行。
接下来是请求数据,也叫主体,可以添加任意的其他数据。
POST报文:
POST / HTTP1.1
Host:www.wrox.com
User-Agent:Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 2.0.50727; .NET CLR 3.0.04506.648; .NET CLR 3.5.21022)
Content-Type:application/x-www-form-urlencoded
Content-Length:40
Connection: Keep-Alive
空行
name=Professional%20Ajax&publisher=Wiley
和get一样,不过他的请求数据写在请求体里,不像get直接明文写在请求行。
再就是响应报文也说一下吧:
HTTP响应也由四个部分组成,分别是:状态行、消息报头、空行和响应正文。
HTTP/1.1 200 OK
Date: Fri, 22 May 2009 06:07:21 GMT
Content-Type: text/html; charset=UTF-8
空行
<html>
<head></head>
<body>
<!--body goes here-->
</body>
</html>
状态行,由HTTP协议版本号, 状态码, 状态消息 三部分组成。
第一行为状态行,(HTTP/1.1)表明HTTP版本为1.1版本,状态码为200,状态消息为OK。
消息报头,用来说明客户端要使用的一些附加信息。
第二行和第三行为消息报头,Date:生成响应的日期和时间;Content-Type:指定了MIME类型的HTML(text/html),编码类型是UTF-8。
空行,消息报头后面的空行是必须的。
响应正文,服务器返回给客户端的文本信息。空行后面的html部分为响应正文。其实你也可以先写一个html,再传对应的地址,也不一定非要写在这个里面。
好了,整个第一部分结束了,下次应该就是讲一下如何将这个报文解析到线程池去,然后讲之前大家最好先了解一下这两个报文,其实看我上面写的部分也已经够了。
如果发现我有讲错的地方,欢迎指正,我们可以一起探讨一下,因为我也是自己摸索着刚做完这个项目,其中肯定有想的不对的地方。