在秋招准备项目时,对于牛客C++WebServer项目的基础理论知识进行了总结
1)HTTP请求
1.首行:[方法]+[url]+[版本]
**2.Header:**请求的属性,冒号分割的键值对;每组属性之间使用\n分隔;遇到空行表示Header部分结束
**3.Body:**空行后面的内容都是body,body允许为空字符串,如果body存在,则在Header中会有一个Content-length属性来标识Body的长度
注:前面三部分是一般是HTTP协议自带的,是由HTTP协议自行设置的,而请求正文一般是用户的相关信息或数据;如果用户在请求时没有信息要上传给服务器,此时请求正文就为空字符串。
1.Host:请求的资源在哪个主机的端口上
2.Connection:该请求支持长连接(heep_alive)
3.Content-Length:正文内容长度
4.Content-Type:数据类型
5.User-Agent:声明用户的操作系统和浏览器版本信息
6.Accent:发起了请求
7.Referer:当前页面是从哪个页面跳转过来的
8.Accept-Encoding:接受的编码
9.Accept-Language:接受的语言类型
10.Cookie:用于在客户端存储少量信息,通常用于实现会话(session)功能
1.请求行和请求报头是HTTP的报头信息,而这里的请求正文实际就是HTTP的有效载荷,而请求当中的空行起到分离报头和有效载荷的作用
2.读取一个请求时,通过报头中的Content-Length(正文的长度)来精准控制读取该请求正文的长度,从而将连续的几个请求进行分开。
2)HTTP响应
1.状态行:[版本号]+[状态码]+[状态码解释]
2.响应报头Header:请求的属性,冒号分割的键值对;每组属性之间使用\n分隔;遇到空行表示Header部分结束
3.相应正文Body:空行后面的内容都是body,body允许为空字符串,如果body存在,则在Header中会有一个Content-length属性来标识Body的长度;如果服务器返回了一个html界面,那么html页面内容就在body中。
最常见的是Get方法和Post方法
1.GET方法一般用于获取某种资源信息,而POST方法一般用于将数据上传给服务器,上传数据时也有可能使用GET方法,比如搜索提交数据时
2.GET方法和POST方法都可以带参:GET方法通过url传参的;POST方法是通过正文传参的
3.POST通过正文传参能传递更多参数,GET方法通过url传参,url的长度是有限的,所以GET方法传参有限
4.POST方法传参更加私密,因为GET方法将参数回显到url上,POST方法在正文中不会被别人轻易看到,但是实际两种方法都不安全,POST方法传参可以被截取,要做到安全只能通过加密来完成
在开发一个网站之后,用户通过URL对资源进行操作,服务端要告诉用户交互的结果。一个较好的方法就是遵循HTTP协议,使用请求相应的HTTP状态码来进行判断。
状态码 | 类别 | 原因短语 |
---|---|---|
1XX | Informatonal(信息性状态码) | 接受的请求正在处理 |
2XX | Success(成功状态码) | 请求正常处理完毕 |
3XX | Redirection(重定向状态码) | 需要进行附加操作以完成请求 |
4XX | Client Error(客户端错误状态码) | 服务器无法处理请求 |
5XX | Server Error(服务器错误状态码) | 服务器处理请求出错 |
注:最常见的状态码如200(OK),404(Not Found),403(Forbidden请求权限不够),302(Redirect),504(Bad Gateway)
1.200 OK:客户端请求正常
2.301 Permanent Redirect:永久重定向,表示资源已经永久移动到另一个地方
3.302/307 Temporary Redirect:临时重定向,表示资源临时移动到了另一个位置
4.403 Forbidden:服务器有能力处理该请求,但是拒绝授权访问
5.404 Not Found:请求资源不存在,比如资源被删除了,或用户输入了错误的url
6.500 Internal Server Error:服务器发生了不可预期的错误,一般是代码的BUG所导致
7.504:Bad Gateway:表示作为网关或代理角色的服务器,从上游服务器接收到的响应是无效的
1.服务端首先调用socket()函数,创建网络协议为IPV4,以及传输协议为TCP的Socket
2.接着调用bind()函数,给这个Socket绑定一个IP地址和端口
3.绑定完IP地址和端口后,就调用listen()函数进行监听
4.服务端进入监听状态后,通过调用accept()函数,来从内核获取客户端的连接,如果客户端没有连接,则会阻塞等待客户端连接的到来。
5.客户端在创建好Socket后,调用connect()函数发起连接,该函数要知名服务器的IP地址和端口号,然后开始TCP三次握手进行连接
6.连接建立后,客户端和服务端就开始互相传输数据,双方都可以通过read()和write()函数来读写数据
1.服务器的主进程负责监听客户的连接,一旦与客户端连接完成,accept()函数就会返回一个[已连接Socket]。
2.通过fork()函数创建一个子进程
3.子进程直接使用[已连接Socket]和客户端通信
1.子进程退出后,内核还会保留进程的一些信息,如果不做好回收工作,就会变成僵尸进程,因此父进程要善后自己的孩子,有两种方式可以在子进程推出后回收资源,分别是调用wait()和waitpid()函数
2.不能应付高数量客户端,因为每产生一个进程必定会占据一定的系统资源,而且进程间上下文切换的“包袱”很重,性能会大打折扣
1.服务端与客户端完成连接后,通过pthread_create() 函数创建线程
2.将[已连接Socket]的文件描述符传递给线程函数,接着在线程里和客户端进行通信
线程池,就是提前创建若干个线程,当新连接建立时,将这些已连接的Socket放入到一个队列中,然后线程池里的线程负责从队列中取出已连接Socket进程处理
一个进程虽然任意时刻只能处理一个请求,但是处理每个请求的时间很短,这样1s就可以处理很多请求,把时间拉长来看,多个请求复用了一个进程,这就是多路复用
我们熟悉的 select/poll/epoll 内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件。
select/poll/epoll 是如何获取网络事件的呢?在获取事件时,先把所有连接(文件描述符)传给内核,再由内核返回产生了事件的连接,然后在用户态中再处理这些连接对应的请求即可。
select实现多路复用的方式是,将已连接的Socket都放到一个文件描述符集合,然后调用select函数将文件描述符拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此Socket标记为可读或可写,接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到刻度或可写的Socket,然后对其处理。
select需要进行两次遍历文件描述符集合,需要发生2次拷贝文件描述符集合
select使用固定长度的BitsMap,表示文件描述符集合,默认最大值为1024,只能监听0~1023的文件描述符。
poll不再用BitsMap来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了select的文件描述符个数限制,当然还会受到系统文件描述符限制。
但是poll和select并没有太大的本质区别,**都是使用线性结构存储进程关注的Socket集合,因此都需要遍历文件描述符来找到可读或可写的Socket,时间复杂度为O(n),而且也需要在用户态和内核态之间拷贝文件描述符集合,**这种方法随着并发数上来,性能的损耗会呈指数级增长。
epoll通过两个方面,很好解决了select/poll的问题。
第一点,epoll在内核中使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的socket通过**epoll_ctrl()**函数加入内核中的红黑树,红黑树是个高效的数据结构,增删查一般时间复杂度是O(logn),通过对这颗红黑树进行操作,这样就不需要像select/epoll每次操作时都传入整个socket集合,只需要传入一个待检测的socket,减少了内核和用户空间大量的数据拷贝和内存分配
第二点,epoll使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个socket有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用**epoll_wait()**函数时,只会返回有事件产生的文件描述符的个数,不需要像select/poll那样轮询扫描整个socket集合,大大提高了检测的效率。
下图表示epoll相关的接口作用:
使用边缘触发模式时,当被监控的Socket描述符上有可读事件发生时,服务器只会从epoll_wait中苏醒一次,即使进程没有调用read函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;
使用水平触发方式时,当被监控的Socket上有可读事件发生时,服务器端不断地从epoll_wait中苏醒,直到内核缓冲区数据被read函数读完才结束,目的是告诉我们有数据需要读取。
如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。因此,我们会循环从文件描述符读写数据,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞 I/O 搭配使用,程序会一直执行 I/O 操作,直到系统调用(如 read
和 write
)返回错误,错误类型为 EAGAIN
或 EWOULDBLOCK
。
基于面向对象的思想,对I/O多路复用做了一层封装,让使用者不必考虑底层API的细节,只需要关注应用代码的编写,这个模式就叫Reactor模式
I/O多路复用监听事件,收到事件后,根据事件类型分配给某个进程/线程。
Reactor模式主要由Reactor和处理资源池这两个核心部分组成,它俩负责的事情如下:
方案示意图:
可以看到进程中有Reactor,Acceptor,Handler这三个对象:
对象中的select,accept,read,send是系统调用函数,dispatch和业务处理是需要完成的操作,其中dispatch是分发事件操作。
介绍单Reactor单进程方案:
这种方案的缺点:
方案示意图如下:
详细说一下这个方案:
上面的三个步骤和单Reactor单线程方案是一样的,接下来的步骤就开始不一样了:
缺点:
单Reactor模式有问题,因为一个Reactor对象承担所有时间的监听和响应,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能的瓶颈的地方。
方案示意图如下:
多 Reactor 多线程的方案虽然看起来复杂的,但是实际实现时比单 Reactor 多线程的方案要简单的多,原因如下:
当用户执行read,线程会被阻塞,一直等到内核数据准备好,并把数据从内核缓冲区拷贝到应用程序的缓冲区中,当拷贝过程完成,read才会返回
注意,阻塞等待的是内核数据准备好和数据从内核态拷贝到用户态这两个过程。过程如下图:
非阻塞的read请求在数据未完全准备好的情况下立即返回,可以继续往下执行,此时应用程序不断轮询内核,直到数据准备好,内核将数据拷贝到应用程序缓冲区,read调用才可以获取到结果。
过程如下图:
这里最后一次read调用,获取数据的过程,是一个同步的过程,是需要等待的过程。这里的同步指的是内核态的数据拷贝到用户程序的缓冲区这个过程。
因此,无论read和send是阻塞I/O还是非阻塞I/O都是同步调用。因为在read调用时,内核将数据从内核空间拷贝到用用户空间都是需要等待的,也就是说这个过程是同步的,如果内核实现的拷贝效率不高,read调用就会在这个同步过程中等待比较长的时间。
而真正的异步I/O是【内核数据准备好】和【数据从内核态拷贝到用户态】这两个过程都不用等待。
当我们发起aio_read(异步I/O)之后,就立即返回,内核自动将数据从内核空间拷贝到用户空间,这个拷贝动作同样是异步的,内核自动完成的,和前面的同步操作不一样,应用程序并不需要主动发起拷贝动作。过程如下图:
Proactor 正是采用了异步 I/O 技术,所以被称为异步网络模型。
- **Reactor是非阻塞同步网络模式,感知的是就绪可读写状态。**在每次感知到有事件发生(比如可读就绪事件)后,就需要应用进程主东调用read方法来完成数据的读取,也就是要应用进程主动将socket接收缓存中的数据读到应用进程内存中,这个过程是同步的,读取完数据后应用进程才能处理数据。
- **Proactor是异步网络过程,感知的是已完成的读写事件。**在发起异步读写请求时,需要传入数据缓冲区的地址(同来存放结果数据)等信息,这样系统内核才可以自动将我们把数据的读写工作完成,这里的读写工作全程由操作系统来做,并不需要像Reactor那样还需要应用进程主动发起read/write来读写数据,操作系统完成读写工作后,就会通知应用进程直接处理数据。
Reactor可以理解为【来了事件操作系统通知应用进程,让应用进程来处理】,而Proactor可以理解为【来了事件操作系统来处理,处理完再通知应用进程】。这里的事件就是有新连接,有数据可读,有数据可写的这些I/O事件这里的处理包含从驱动读取到内核以及从内核读取到用户空间。
无论是Reactor,还是Proactor,都是一种基于【事件分发】的网络编程模式,区别在于Reactor模式是基于待完成的I/O事件,而Proactor模式是基于已完成的I/O事件。
Proactor模式示意图:
- Proactor Initiator 负责创建 Proactor 和 Handler 对象,并将 Proactor 和 Handler 都通过 Asynchronous Operation Processor 注册到内核;
- Asynchronous Operation Processor 负责处理注册请求,并处理 I/O 操作;
- Asynchronous Operation Processor 完成 I/O 操作后通知 Proactor;
- Proactor 根据不同的事件类型回调不同的 Handler 进行业务处理;
- Handler 完成业务处理;
- Proactor Initiator 负责创建 Proactor 和 Handler 对象,并将 Proactor 和 Handler 都通过 Asynchronous Operation Processor 注册到内核;
- Asynchronous Operation Processor 负责处理注册请求,并处理 I/O 操作;
- Asynchronous Operation Processor 完成 I/O 操作后通知 Proactor;
- Proactor 根据不同的事件类型回调不同的 Handler 进行业务处理;
- Handler 完成业务处理;
如果您也正在学习该项目,有相关疑问欢迎进行交流