既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Go语言开发知识点,真正体系化!
由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新
由于 epoll 原理已有较多文章描述,本文将仅简单介绍 netpoll 的设计;随后,我们会尝试梳理一下我们基于 netpoll 所做的一些实践;最后,我们将分享一个我们遇到的问题,以及我们解决的思路。同时,欢迎对于 Go 语言以及框架感兴趣的同学加入我们!
新型网络库设计
=======
Reactor - 事件监听和调度核心
netpoll 核心是 Reactor 事件监听调度器,主要功能为使用 epoll 监听连接的文件描述符(fd),通过回调机制触发连接上的 读、写、关闭 三种事件。
Server - 主从 Reactor 实现
netpoll 将 Reactor 以 1:N 的形式组合成主从模式。
-
MainReactor 主要管理 Listener,负责监听端口,建立新连接;
-
SubReactor 负责管理 Connection,监听分配到的所有连接,并将所有触发的事件提交到协程池里进行处理。
-
netpoll 在 I/O Task 中引入了主动的内存管理,向上层提供 NoCopy 的调用接口,由此支持 NoCopy RPC。
-
使用协程池集中处理 I/O Task,减少 goroutine 数量和调度开销。
Client - 共享 Reactor 能力
client 端和 server 端共享 SubReactor,netpoll 同样实现了 dialer,提供创建连接的能力。client 端使用上和 net.Conn 相似,netpoll 提供了 write -> wait read callback 的底层支持。
Nocopy Buffer
=============
为什么需要 Nocopy Buffer ?
在上述提及的 Reactor 和 I/O Task 设计中,epoll 的触发方式会影响 I/O 和 buffer 的设计,大体来说分为两种方式:
-
采用水平触发(LT),则需要同步的在事件触发后主动完成 I/O,并向上层代码直接提供 buffer。
-
采用边沿触发(ET),可选择只管理事件通知(如 go net 设计),由上层代码完成 I/O 并管理 buffer。
两种方式各有优缺,netpoll 采用前者策略,水平触发时效性更好,容错率高,主动 I/O 可以集中内存使用和管理,提供 nocopy 操作并减少 GC。事实上一些热门开源网络库也是采用方式一的设计,如 easygo、evio、gnet 等。
但使用 LT 也带来另一个问题,即底层主动 I/O 和上层代码并发操作 buffer,引入额外的并发开销。比如:I/O 读数据写 buffer 和上层代码读 buffer 存在并发读写,反之亦然。为了保证数据正确性,同时不引入锁竞争,现有的开源网络库通常采取 同步处理 buffer(easygo, evio) 或者将 buffer 再 copy 一份提供给上层代码(gnet) 等方式,均不适合业务处理或存在 copy 开销。
另一方面,常见的 bytes、bufio、ringbuffer 等 buffer 库,均存在 growth 需要 copy 原数组数据,以及只能扩容无法缩容,占用大量内存等问题。因此我们希望引入一种新的 Buffer 形式,一举解决上述两方面的问题。
Nocopy Buffer 设计和优势
Nocopy Buffer 基于链表数组实现,如下图所示,我们将 []byte 数组抽象为 block,并以链表拼接的形式将 block 组合为 Nocopy Buffer,同时引入了引用计数、nocopy API 和对象池。
Nocopy Buffer 相比常见的 bytes、bufio、ringbuffer 等有以下优势:
- 读写并行无锁,支持 nocopy 地流式读写
-
读写分别操作头尾指针,相互不干扰。
-
高效扩缩容
-
- 扩容阶段,直接在尾指针后添加新的 block 即可,无需 copy 原数组。
-
缩容阶段,头指针会直接释放使用完毕的 block 节点,完成缩容。每个 block 都有独立的引用计数,当释放的 block 不再有引用时,主动回收 block 节点。
-
灵活切片和拼接 buffer (链表特性)
-
- 支持任意读取分段(nocopy),上层代码可以 nocopy 地并行处理数据流分段,无需关心生命周期,通过引用计数 GC。
-
支持任意拼接(nocopy),写 buffer 支持通过 block 拼接到尾指针后的形式,无需 copy,保证数据只写一次。
-
Nocopy Buffer 池化,减少 GC
-
- 将每个 []byte 数组视为 block 节点,构建对象池维护空闲 block,由此复用 block,减少内存占用和 GC。
基于该 Nocopy Buffer,我们实现了 Nocopy Thrift,使得编解码过程内存零分配零拷贝。
连接多路复用
======
RPC 调用通常采用短连接或者长连接池的形式,一次调用绑定一个连接,那么当上下游规模很大的情况下,网络中存在的连接数以 MxN 的速度扩张,带来巨大的调度压力和计算开销,给服务治理造成困难。因此,我们希望引入一种 “在单一长连接上并行处理调用” 的形式,来减少网络中的连接数,这种方案即称为 “连接多路复用”。
当前业界也存在一些开源的连接多路复用方案,掣肘于代码层面的束缚,这些方案均需要 copy buffer 来实现数据分包和合并,导致实际性能并不理想。而上述 Nocopy Buffer 基于其灵活切片和拼接的特性,很好的支持了 nocopy 的数据分包和合并,使得实现高性能连接多路复用方案成为可能。
基于 netpoll 的连接多路复用设计如下图所示,我们将 Nocopy Buffer(及其分片) 抽象为虚拟连接,使得上层代码保持同 net.Conn 相同的调用体验。与此同时,在底层代码上通过协议分包将真实连接上的数据灵活的分配到虚拟连接上;或通过协议编码合并发送虚拟连接数据。
连接多路复用方案包含以下核心要素:
- 虚拟连接
-
实质上是 Nocopy Buffer,目的是替换真正的连接,规避内存 copy。
-
上层的业务逻辑/编解码 均在虚拟连接上完成,上层逻辑可以异步独立并行执行。
-
Shared map
-
- 引入分片锁来减少锁力度。
-
在调用端使用 sequence id 来标记请求,并使用分片锁存储 id 对应的回调。
-
在接收响应数据后,根据 sequence id 来找到对应回调并执行。
-
协议分包和编码
-
- 如何识别完整的请求响应数据包是连接多路复用方案可行的关键,因此需要引入协议。
-
这里采用 thrift header protocol 协议,通过消息头判断数据包完整性,通过 sequence id 标记请求和响应的对应关系。
ZeroCopy
========
这里所说的 ZeroCopy,指的是 Linux 所提供的 ZeroCopy 的能力。上一章中我们说了业务层的零拷贝,而众所周知,当我们调用 sendmsg 系统调用发包的时候,实际上仍然是会产生一次数据的拷贝的,并且在大包场景下这个拷贝的消耗非常明显。以 100M 为例,perf 可以看到如下结果:
网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。
一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
外链图片转存中…(img-RKH6qpeR-1715620728576)]
网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。
一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!