nghttp2技术实现
参考资料
https://datatracker.ietf.org/doc/html/rfc7540
https://www.nghttp2.org/documentation/
h2的优势:
- 性能更强:HTTP/2的多路复用技术,不仅可以减少连接次数,同时使用非阻塞I/O技术可以在一次握手过程中提高传输性能。
- 更强大的头部压缩:HTTP/2使用HPACK技术对头部信息进行压缩,可以有效减少传输的头部信息大小,从而节省带宽消耗。
- 服务端推送:服务器可提前将客户端可能需要的资源进行推送,从而可以减少客户端的重复请求,提升页面访问速度。
- 请求优先级:HTTP/2支持浏览器控制请求和响应的优先级,从而可以减少资源的加载时间。
- 更安全:HTTP/2默认使用HTTPS,可以有效防止中间人攻击,保障用户信息传输的安全性。
nghttp2 demo使用方法
demo是使用的libevent框架进行网络收发的,不需要关注具体的网络细节处理,只需要处理好相对应的回调函数即可。
nghttp2 demo编译
Makefile文件
1 | ./configure |
1 | #测试方法 |
nghttp2 http2Server实现方案(参考)
http2具体架构
http2具体实现
nghttp2使用框架
nghttp2的api参考https://www.nghttp2.org/documentation/apiref.html, 所有api使用是单线程环境,即同一个session下的流调用api时需要保证是串行的,多线程使用时需要加锁,否则会出现一些不可预料的问题。
官方提供的httpserver例程是一个session放在一个线程里面执行,这样可以避免加锁的问题。
node由于stream会被httpObj接管,而httpObj是在多线程环境下使用的,所以调用nghttp2的api时需要加锁,同一个session下的stream共用sessionLock。
h2的多个stream收发数据其实还是共用一个fd,这个fd统一由session管理,也就是多个stream是同一个tcp连接,只是说其中一个stream的数据如果阻塞了,不影响别的stream数据的收发。
如果同一个session下的stream都是在同一个线程中进行处理,即类似于官方提供的httpserver例程,那么实际数据收发的fd都交由该线程的epoll管理,有收发事件时触发对应的回调函数即可,这种逻辑比较容易处理。
但是如果同一个session下的stream都是在不同的线程中进行处理,但我们实际收发数据只有一个fd,虽然fd可以被多个线程同时使用,所以收发的数据变成了一个临界资源,处理临界资源时需要加sessionLock才行。
而且实际nghttp2_session_mem_send序列化数据时是序列化整个session下的所有stream,不是单单对应那个线程的stream,序列化完后再通过session下的fd进行发送,所以可能当前线程的stream的数据会被其他线程发送完,此时当前线程只需要检测是否发完,或者等待其他获取sessionLock的线程发完释放锁后尝试获取sessionLock再进行发送,相比官方提供的那种单线程reactor模型,这种多线程模型处理起来要复杂一些。
node为了结合之前的网络框架,使用的是这种多线程模型。
node里面使用nghttp2的难点在于数据的发送,因为接收只是一些http请求而已,不会特别大,哪怕一次没有接收完,那先放入缓存,下次再进行接收即可,而发送数据是大文件数据,一次性基本是发不完的,可能sendbuf满需要触发下次的epollout继续重发等等,另外文件是分批次发送的,发完当前批次再发送下一批次,所以没有办法直接调用官方推荐的nghttp2_submit_response进行处理,目前是采用的nghttp2_submit_headers+nghttp2_submit_data的方式。
数据(header和body)都是需要先经过submit,然后通过nghttp2_session_mem_send序列化后发送出去,如果发送过程中sendbuf满了,那下次重发时不需要进行submit了,接着之前发送的序列化后的数据继续发即可。
H2建连
设置ssl握手回调函数,是否要走h2协议握手阶段进行的,所以这里可以加是否开启h2的开关。
1 | static unsigned char next_proto_list[256]; |
h2c升级:接收的数据里面有HTTP2-Settings的话,先回复响应头,再nghttp2_submit_settings,最后解析HTTP2-Settings进行nghttp2_session_upgrade2,就有id为1的流了。
1 | string::iterator decode(string &settings, string::iterator d_first) |
如果是h2协议:设置回调后nghttp2_submit_settings,并nghttp2_session_send即可。
数据发送方法
使用nghttp2_submit_data进行数据提交,提交需要nghttp2_data_provider类型的数据,包括nghttp2_data_source,这是一个union对象,一个是fd、一个是ptr,一般提供ptr地址即可,另外还需要nghttp2_data_source_read_callback回调方法,也就是说告诉了地址还要告诉它每次读多少数据,是否需要设置标志,如流结束NGHTTP2_FLAG_END_STREAM,怎么从这个地址去读数据参考nghttp2_session_callbacks_set_send_data_callback(callbacks, SendDataCallback),提交是先提交到队列里面,调用nghttp2_session_mem_send时,从队列里面取数据才真正开始读数据,读出来的数据通过拷贝的方式先放sessionData->writeBuffer里面,这个过程是需要拷贝的,如果这个buffer满了就触发NGHTTP2_ERR_WOULDBLOCK, writeBuffer里面记录了这个session当前发送的位置pos,以及当前缓冲区最后的位置last,这个缓冲区相当于一个消息队列,当io检测到连接的发送缓冲区sendbuf可以发送数据时,就会从这个writeBuffer缓冲区取数据,取完多少数据pos就要加多少,数据取完后的标志为pos==last,此时要把pos和last都置为0,重新开始,这里放数据和取数据的过程session都是加锁的,因为这个writeBuffer相当于这个session的临界资源了,取到的数据通过SSL_write发送即可。如果发送数据时设置的不是NGHTTP2_DATA_FLAG_NO_COPY,那么nghttp2_session_mem_send接口还会返回数据,这些数据是序列化后的数据,这些数据也是要放入sessionData->writeBuffer里面等待网络发送的。如果序列化后的数据放在buffer中放不下(writeBuffer是有大小限制的),需要先pending起来,下次buffer里面的数据发完了再优先从pending里面取。
1 | // 提交数据header |
所有发送帧的格式如下,都是固定9个字节开头接数据,9字节里面包括的长度、类型、标志和流id。nghttp2里每一个数据帧都包含nghttp2_frame_hd.
帧有不同的类型,数据帧格式,可以设置标志END_STREAM (0x1)、PADDED (0x8).
1 | typedef struct { |
头帧:里面含有流权重优先级,头部请求头等数据,可以设置END_STREAM (0x1)、END_HEADERS (0x4)、PADDED (0x8)、PRIORITY (0x20)标志.
1 | typedef struct { |
优先级帧:可以在流的任何阶段使用,但不包含任何标志,默认是16,8位无符号整型,所以范围1 ~ 256,值越大分配的资源越多,优先级越高。
1 | // It can be sent in any stream state, including idle or closed streams. |
RST_STREAM帧: 32位的整数,不包含任何标志.
1 | typedef struct { |
当前流的数据被其它流的线程再发送时,当前流这个线程就要不断检测是否已经替它发完了,但是忙查询的话就费cpu,于是可以通过条件变量的方式来进行通知,没发完的时候阻塞等待。
1 | void Http2StreamSignal(SHttp2StreamData *streamData) |
数据接收
收到数据后调用nghttp2_session_mem_recv进行处理,具体的可以处理的逻辑都在回调函数里面。
1 | // OnBeginHeadersCallback,创建流对象 |
数据重发逻辑
客户端未及时接收导致sendbuf满、服务端限速需要间隔发送、stream的流控等都需要进行数据重发。
重发逻辑是一样的
sendbuf满后,需要将当前的stream保存下来,等待epollout时再重新触发数据发送
1 | // 发完的暂不做统计,触发epollout后接着发 |
服务端限速,需要将当前stream保存下来,并记录下次发送的时间,由定时事件触发下次发送
1 | if (limitSpeed > 0) |
stream的流控时,nghttp2_session_get_stream_remote_window_size/nghttp2_session_get_remote_window_size小于等于0,暂停当前发送,当epollin事件有窗口更新时即窗口大于0时,再继续发送
1 | // 更新remote_window_size后,对session下flowControlStreams的所有stream进行重发 |
主连接与流连接
主连接需要在所有流连接释放后才会得到释放,在新建流连接对象时,会接管父连接,使父连接的引用计数加一,这样只要该父连接下还有一个流连接没有释放,父连接对象的析构函数就不会触发。
共享指针原理:这里的每一个连接对象都是一个引用计数对象,都继承自这个引用计数类,该类有一个计数变量counter,初始化为1,也就是只有自己本身使用,如果有另外的变量依赖这个变量,比如引用了这个变量的指针,就需要把这个counter加一,依赖的变量不再依赖后,这个counter再减一,这里加一减一都需要原子操作__sync_add_and_fetch/__sync_sub_and_fetch,最后这个变量释放时counter减一后为0了,就能真正释放这个变量了。
1 | class RefCountedObject |
限制出口带宽原理:比如当前速度为5MB/s,最大允许上传带宽为4MB/s,那就是超过了1MB/s,做法是把这1MB/s分摊到当前用户限速