目录
浅析Golang网络库设计
大家好,我是编程小灶。面对层出不穷的网络库,网络编程名词Epoll、多路复用、零拷贝、高性能缓冲区、百万连接、、、常常在各种技术文章穿梭,笔者在开发存储网关时,翻阅很多资料,关于Golang网络库设计的文章甚少,能讲清楚如何设计网络库以及网络编程细节的文章少之又少。今天我们一起来深入理解网络库到底做了哪些事,网络库一些重要机制以及一些设计思路,希望你在解决业务实际问题时能有所帮助。
-
本文在介绍每个理论后提供了大量golang编码案例(伪代码,调试请稍加修改)希望让你更好地理解枯燥的理论。
-
文章中很多地方提到“线程”,或许你觉得在Golang中叫“线程”更好,你可以通过上下文自行区分。
-
这篇文章主要讲解原理与设计思路,后续会分为多个章节来透析网络库核心组件设计。
初探网络库
简单来讲每个网络库都在是SocketFD(套接字文件描述符)基础上做了封装,屏蔽了复杂的底层实现,提供给开发者较为简洁、好用的API。和大多数语言一样Golang内置了net包提供给开发人员,但众口难调,因为各厂家的业务场景和Golang net包的不足,各家公司都会自研网络库以满足场景需求。
不少网络库为了满足高性能,易用性,在提供网络连接的管理,连接上收发数据的可靠性基础上,提供异步编程、多线程处理,消息编解码等等能力。开源的网络库和语言内置网络库也会在跨平台(unix、windows)、多协议(tcp、udp、websocket)方面提供更多的支持。
以下是个人觉得网络库设计的一些要点:
-
线程与IO模型: IO multiplexing+non-blocking IO ;
-
跨平台性:主要支持Linux(生产)、次之Mac(FreeBSD/Darwin),Windows(x86-64) 。Mac&Windows更多是兼顾开发调试;
-
线程安全性:支持多核多线程;
-
传输层协议:主tcp,次udp;
-
可控性强:网络库占用的线程、内存可由用户控制,而非为了性能自动无休止扩张。
-
提供可插拔的协议编解码器:提供通用的插件式协议编码器将网络数据(字节流)转化为用户进程数据(Object),如Http、常见RPC/文件传输协议编解码器。
-
在以上几点的基础上,可以添加更多利好用户的设计。
如果你有一些经验可能对于这些要点较为熟悉,如果初次了解可以从下面的内容理解这些要点解决了什么,以及为什么需要它?
首先我们对网络库关键的组成部分(线程与IO模型)做深入的理解,因为其他机制都是在这两个核心机制上“锦上添花”,明白为什么这样选择设计,它解决了什么问题,可以让在你处理业务难点时可能更得心应手。
浅析Linux IO 模型
阻塞IO模型与非阻塞IO模型
本文主要将阻塞、非阻塞、多路复用IO模型
阻塞IO模型
下面是一个Golang客户端 Socket编程案例,首先创建了tcpSocketFD(Golang默认为阻塞模式),建立好tcp连接并发送"hello world"到服务端,在建立好连接后,网络没有阻塞的情况下,Write函数会立刻返回,但如果你将hello world字符串改为一个10G大小的字符串,受限于操作系统Socket缓冲区大小、网络速率、服务端处理速度,数据会在整个网络中传输挺久的时间,从而在应用进程中Write函数会阻塞挺久的时间,直到客户端将10G内容被网络传输链路消化。
package main
import (
"fmt"
"syscall"
)
func main() {
// 创建tcp socket
tcpSocketFD, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_STREAM, 0)
if err != nil {
panic(err)
}
if err := syscall.Connect(tcpSocketFD, &syscall.SockaddrInet4{
Port: 8888,
Addr: [4]byte{127, 0, 0, 1},
}); err != nil {
panic(err)
}
// 阻塞式发送数据
n, err := syscall.Write(tcpSocketFD, []byte("hello world"))
if err != nil {
panic(err)
}
fmt.Printf("write %d bytes\n", n)
}
用户应用进程和操作系统内核交互过程如下
简单总结:阻塞IO即用户应用进程向操作系统发送/读取数据时(IO调用),在操作系统未完成IO操作前(比如对Socket写操作,操作系统缓冲区没有空闲空间),用户应用进程阻塞,直到操作系统返回完成。
非阻塞IO模型
如下代码,在原来阻塞式IO编程模型上,添加一行syscall.SetNonblock(tcpSocketFD, true),此行代码意味着将SocketFD从阻塞模式变为非阻塞模式,如果操作系统不能立即完成IO操作(Socket缓冲区满),操作系统内核会返回EAGAIN/EWOULDBLOK,提示你稍后可以再次尝试IO操作,为了将数据全部发送到网络,你不得“轮训“探测发送。
package main
import (
"fmt"
"syscall"
"time"
)
func main() {
// 创建tcp socket
tcpSocketFD, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_STREAM, 0)
if err != nil {
panic(err)
}
// 设置socketFD为非阻塞模式
if err := syscall.SetNonblock(tcpSocketFD, true); err != nil {
panic(err)
}
if err := syscall.Connect(tcpSocketFD, &syscall.SockaddrInet4{
Port: 8888,
Addr: [4]byte{127, 0, 0, 1},
}); err != nil {
panic(err)
}
var totalWrite int
for {
// 非阻塞模式发送数据,操作系统不能立即完成会返回syscall.EAGAIN/EWOULDBLOCK 错误
n, err := syscall.Write(tcpSocketFD, []byte("hello world"))
if err != nil {
if err == syscall.EAGAIN || err == syscall.EWOULDBLOCK {
// 定时5s重试
time.Sleep(5 * time.Second)
continue
}
panic(err)
}
fmt.Printf("write %d bytes\n", n)
totalWrite += n
if totalWrite == len([]byte("hello world")) {
break
}
}
}
用户应用进程与操作系统内核交互如下
简单总结:非阻塞IO即用户应用进程向操作系统向操作系统发送/读取数据时(IO调用),如果操作系统无法立即完成IO操作,会给用户应用进程返回EAGAIN/EWOULDBLOK错误,不会阻塞业务应用进程,通过重试错误你可以稍后再次尝试发起IO调用。
阻塞IO与非阻塞IO对比
IO模型 | 优点 | 缺点 |
---|---|---|
阻塞IO | 使用与理解简单 | 阻塞用户应用进程,影响性能 |
非阻塞IO | 不阻塞用户应用进程,使用更加灵活 | 极端情况会不断轮训式系统调用,CPU开销大 |
IO多路复用模型
非阻塞IO模型的问题在于,尽管应用程序可以在当前IO操作不能完成的时候迫使系统调用立刻返回而不至于睡眠,但是却无法知道什么时候再次进行IO操作可以地顺利完成,只能间断性地做很多无谓的轮询。
IO多路复用提供了IO事件回调机制,它可以用一个线程来监控多个SocketFD,当任意的一个或多个SocketFD出现注册的事件类型后(可读、可写、异常等事件)时操作系统会返回对应事件,用户应用进程可以根据不同的事件类型读、写、异常等来进行相应的处理。下面是一段linux下服务端使用epoll的案例:
//go:build linux && arm64
package main
import (
"log"
"syscall"
)
func main() {
// 处理连接建立的SocketFD,我们称为listenerFD
listenerFD, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_STREAM, 0)
if err != nil {
log.Fatal(err)
}
// 绑定端口
if err := syscall.Bind(listenerFD, &syscall.SockaddrInet4{Port: 8888}); err != nil {
log.Fatal(err)
}
// 开始监听
if err := syscall.Listen(listenerFD, 10); err != nil {
log.Fatal(err)
}
// 创建epoll句柄
epollFd, err := syscall.EpollCreate1(0)
if err != nil {
log.Fatal(err)
}
defer syscall.Close(epollFd)
// 注册可读事件
if err := syscall.EpollCtl(epollFd, syscall.EPOLL_CTL_ADD, listenerFD, &syscall.EpollEvent{
Events: syscall.EPOLLIN,
Fd: int32(listenerFD),
}); err != nil {
log.Fatal(err)
}
events := make([]syscall.EpollEvent, 10)
for {
// 等待事件发生
n, err := syscall.EpollWait(epollFd, events, -1)
if err != nil {
log.Fatal(err)
}
for i := 0; i < n; i++ {
// 服务端listenerFD有可读事件发生,即有客户端发起了连接请求
if int(events[i].Fd) == listenerFD {
// 获取新连接
connFD, _, err := syscall.Accept(listenerFD)
if err != nil {
log.Println(err)
continue
}
log.Printf("Accepted a connection")
// 设置connFd为非阻塞模式
if err := syscall.SetNonblock(connFD, true); err != nil {
log.Fatal(err)
}
// 将新连接的文件描述符添加到epoll中
event := syscall.EpollEvent{
Events: syscall.EPOLLIN,
Fd: int32(connFD),
}
// 为新的连接注册可读事件(客户端发送数据时,Epoll回返回此事件)
// 服务端每建立一个tcp连接,都会管理在epollFd对应的epoll实例中。
if err := syscall.EpollCtl(epollFd, syscall.EPOLL_CTL_ADD, connFD, &event); err != nil {
log.Fatal(err)
}
} else {
// 新连接有可读事件发生,即有客户端发送了数据
connFD := int(events[i].Fd)
buf := make([]byte, 1024)
n, err := syscall.Read(connFD, buf)
if err != nil {
log.Println(err)
continue
}
// 如果客户端发送了 hello world,这行会打印出来
log.Printf("Read %d bytes: %s\n", n, buf)
}
}
}
}
用户应用进程与操作内核交互如下
细谈IO多路复用模型
很多初学者对于阻塞和非阻塞IO模型突然跳到IO多路复用模型有点难以接受,这个机制到底解决了什么,看了很多文章都是理论看的晕晕的,下来一起看下IO多路复用到底解决了什么问题。
-
解决非阻塞IO只能靠“轮训”探测内核是否就绪的问题
上面使用Epoll的服务端代码案例,服务端SocketFD注册可读事件,只需调用EpollWait等待内核返回注册的可读事件即可,此时内核保证服务端收到SocketFD可读事件后,可以调用syscall.Accept来返回客户端新建的tcp链接。同理新建的tcp连接注册了可读事件到epoll后,如果客户端发送数据,下次调用EpollWait时这个可读事件返回。
-
单线程管理多个连接上的事件(可读、可写、异常)
如果不用epoll,如上服务端编码会变为下面这个样子,每个新连接需要创建一个线程处理读写事件。所以对于高并发场景服务端使用epoll可以大幅度减少线程开销,这也是大多数网络库选择IO多路复用的原因。
//go:build linux && arm64
package main
import (
"log"
"syscall"
)
func main() {
// 创建 tcp socket
socketFD, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_STREAM, 0)
if err != nil {
log.Fatal(err)
}
// 绑定端口
if err := syscall.Bind(socketFD, &syscall.SockaddrInet4{Port: 8888}); err != nil {
log.Fatal(err)
}
// 开始监听
if err := syscall.Listen(socketFD, 10); err != nil {
log.Fatal(err)
}
for {
connFD, _, err := syscall.Accept(socketFD)
if err != nil {
log.Println(err)
continue
}
log.Printf("Accepted a connection")
// 每个连接创建线程,处理新连接数据
go func() {
buf := make([]byte, 1024)
// 阻塞式IO
n, err := syscall.Read(connFD, buf)
if err != nil {
log.Println(err)
}
log.Printf("Read %d bytes: %s\n", n, buf)
}()
}
}
如何更好利用IO多路复用机制
我们以Epoll服务端代码为案例,下面是简化后核心的处理逻辑,我们一起看下这段代码存在性能瓶颈点
// 每次获取100个epollEvent
events := make([]syscall.EpollEvent, 100)
for {
// 阻塞并等待事件响应
n, err := syscall.EpollWait(epollFd, events, -1)
if err != nil {
log.Fatal(err)
}
// 循环依次处理事件
for i := 0; i < n; i++ {
// 监听的服务端tcp Socket有可读事件发生,即有客户端发起了连接请求
if int(events[i].Fd) == listenerFD {
// 处理连接连接建立,并阻塞到Epoll上
} else {
// 新连接有可读事件发生,即有客户端发送了数据
connFD := int(events[i].Fd)
// 处理 tcp 连接可读事件
}
}
}
分析一下代码核心逻辑:首先第4行等待EpollWait返回事件列表,第10行依次处理事件逻辑。如上服务端代码中主要有两种SocketFD和事件,我们将处理连接建立的SocketFD称为listenerFD(处理连接建立事件),每个新连接对应的SocketFD称为connFD(完成连接上的读、写、异常事件)。当服务端建立连接过多后以下是部分性能瓶颈点:
-
服务端无法建立新连接:如果服务端已经建立了上千个连接,每个连接都存在可读事件,一次EpollWait返回的事件列表(100个)里可能都没有ListenerFD对应的连接建立事件,即使EpollWait返回的事件列表里存在ListenerFD对应的连接建立事件,也可能因为其他连接上的读写事件处理积累导致的延迟,客户端已经出现连接超时。
-
连接响应速度极慢:如果上千连接同时发生可读事件,理想情况下需要依次调用10次EpollWait和for循环处理才能处理完成,那么越靠后处理的连接可读事件整体延迟越久,客户端视角来看服务端响应时间很慢。
-
本质问题就是单个线程工作,处理效率低,无法利用CPU多核的优势。
如何解决核心难点,也许你觉得可以创建多个线程同时处理多个Epoll实例,将事件均匀的注册在每个Epoll实例以减轻单个Epoll实例的压力;也许你觉得是否可以在处理连接事件时可以单独为每个事件创建一个线程或者包装成任务放入线程池以此来减少彼此之间的影响;这些都是正确的思路,下面我们一起看Reactor线程模型是如何优化这些难题的。
浅析网络编程Reactor模型
Reactor模型根据不同的业务场景分为三种:单Reactor单线程模型、单Reactor多线程模型、多Reactor(主从Reactor)多线程模型。
Reactor设计模式是基于事件驱动(基于IO多路复用)的,其包含三个主要的角色:
-
Reactor:负责监听和分配事件,将IO事件根据类型分派给对应Acceptor或Handler进行处理。
-
Acceptor:负责处理连接建立。
-
Handler:事件处理器,当IO事件发生时Hanlder进行处理,处理过程主要包括读取,解码,计算,编码,发送,其中只有计算逻辑可以交给线程池处理。
单Reactor单线程模型
其整个服务端处理流程如下,一个Reactor监听和分发事件,所有逻辑处理都在一个线程中。
golang中单Reactor单线程模型代码已经在IO多路复用模型案例中给出了,为了让你给好的理解,我做了些许改动,整体上符合上图流程,你可以参考代码来理解。
//go:build linux && arm64
package main
import (
"log"
"syscall"
)
var (
listenerFD int
)
func main() {
// 处理连接建立的SocketFD,我们称为listenerFD
listenerFD, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_STREAM, 0)
if err != nil {
log.Fatal(err)
}
// 绑定端口
if err := syscall.Bind(listenerFD, &syscall.SockaddrInet4{Port: 8888}); err != nil {
log.Fatal(err)
}
// 开始监听
if err := syscall.Listen(listenerFD, 10); err != nil {
log.Fatal(err)
}
reactor := newReactor()
defer reactor.Close()
acceptor := func(listenerFD int) {
// 获取新连接
connFD, _, err := syscall.Accept(listenerFD)
if err != nil {
log.Println(err)
return
}
log.Printf("Accepted a connection")
// 设置connFd为非阻塞模式
if err := syscall.SetNonblock(connFD, true); err != nil {
log.Fatal(err)
}
// 将新连接的文件描述符添加到epoll中
event := syscall.EpollEvent{
Events: syscall.EPOLLIN,
Fd: int32(connFD),
}
// 为新的连接注册可读事件
// 服务端每建立一个tcp连接,都会管理在epollFd对应的epoll实例中。
if err := reactor.EpollCtl(syscall.EPOLL_CTL_ADD, connFD, &event); err != nil {
log.Fatal(err)
}
}
handler := func(connFD int) {
buf := make([]byte, 1024)
n, err := syscall.Read(connFD, buf)
if err != nil {
log.Println(err)
return
}
log.Printf("Read %d bytes: %s\n", n, buf)
}
reactor.setAcceptor(acceptor)
reactor.setHandler(handler)
for {
if err := reactor.EpollWait(); err != nil {
log.Fatal(err)
}
}
}
type Reactor struct {
// epoll句柄
epollFD int
acceptor func(listenerFD int)
handler func(connFD int)
}
func newReactor() *Reactor {
epollFD, err := syscall.EpollCreate1(0)
if err != nil {
log.Fatal(err)
}
return &Reactor{
epollFD: epollFD,
}
}
func (r *Reactor) setAcceptor(acceptor func(listenerFD int)) {
r.acceptor = acceptor
}
func (r *Reactor) setHandler(handler func(connFD int)) {
r.handler = handler
}
func (r *Reactor) EpollWait() error {
events := make([]syscall.EpollEvent, 10)
// 等待事件发生
n, err := syscall.EpollWait(r.epollFD, events, -1)
if err != nil {
log.Fatal(err)
}
for i := 0; i < n; i++ {
// acceptor 如果式连接建立事件,分发给acceptor处理
if int(events[i].Fd) == listenerFD {
r.acceptor(listenerFD)
} else {
// 如果是可读事件,分发给handler处理
connFD := int(events[i].Fd)
r.handler(connFD)
}
}
return nil
}
func (r *Reactor) EpollCtl(op int, fd int, event *syscall.EpollEvent) error {
return syscall.EpollCtl(r.epollFD, op, fd, event)
}
func (r *Reactor) Close() error {
return syscall.Close(r.epollFD)
}
这段代码的优势就是编码实现起来简单,没有线程安全问题,这个模型比较适用于连接事件和每个IO事件都处理很快的应用,如Redis。其明显的缺点在上文(如何更好利用IO多路复用)已经提到过了:
-
一个Reactor同时负责监听连接事件和IO读事件;
-
只有一个线程在工作,处理效率低,无法利用多核CPU的优势。
单Reactor多线程模型
单Reactor多线程模型就是在单Reactor模型上,Handler可以将具体的处理逻辑交给线程池处理,具体流程如下图
其实现与单Reactor单线程代码来比只有一处不同
handler := func(connFD int) {
buf := make([]byte, 1024)
n, err := syscall.Read(connFD, buf)
if err != nil {
log.Println(err)
return
}
task := func() {
// do something
log.Printf("Read %d bytes: %s\n", n, buf)
}
goroutinePool.Put(task)
}
单Reactor多线程模型明显解决了顺序Handler处理带来的延迟积累问题,进而避免了后续处理中连接响应时间极慢的情况。但是单个Reactor处理连接事件和IO事件仍然无法解决极端情况下连接无法建立的问题(如上万个连接建立后,每个连接都存在IO事件,何时程序才会让Acceptor处理连接建立事件是未知的)。这种模型很适合不频繁建立连接,或者说适合客户端短时间建立大量连接后不会新建连接,且顺序处理IO事件导致的累积延迟问题。
主从Reactor多线程模型
这个模型旨在解决单Reactor多线程模型下的一个问题,即当存在大量持续连接建立时,由于单一的Reactor受限导致新建连接事件的延迟处理,从而引发客户端连接超时问题。
其核心优化点为创建多个Reactor(Epoll实例)事件分摊给每个Reactor,负责处理连接建立事件的Reactor起名为主Reactor,负责处理IO事件称之为从Reactor。其中主Reactor只有一个,从Reactor可能存在多个。
其优化原理图如下
主从Reactor处理事件流程如下
以下是Golang实现的主从Reactor的伪代码,代码思路基本符合上述流程
//go:build linux && arm64
package main
import (
"log"
"syscall"
)
var (
listenerFD int
)
func main() {
// 处理连接建立的SocketFD,我们称为listenerFD
listenerFD, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_STREAM, 0)
if err != nil {
log.Fatal(err)
}
// 绑定端口
if err := syscall.Bind(listenerFD, &syscall.SockaddrInet4{Port: 8888}); err != nil {
log.Fatal(err)
}
// 开始监听
if err := syscall.Listen(listenerFD, 10); err != nil {
log.Fatal(err)
}
mainReactor := newReactor()
defer mainReactor.Close()
acceptor :=
handler := func(connFD int) {
}
mainReactor.setAcceptor(acceptor)
mainReactor.setHandler(handler)
for {
if err := mainReactor.EpollWait(); err != nil {
log.Fatal(err)
}
}
}
type ReactorManager struct {
mainReactor *Reactor
subReactors []*Reactor
}
func newReactorManager(subReactorNums int) *ReactorManager {
subReactors := make([]*Reactor, subReactorNums)
for i := 0; i < subReactorNums; i++ {
subReactors[i] = newReactor()
}
rm := &ReactorManager{
mainReactor: newReactor(),
subReactors: subReactors,
}
rm.mainReactor.setAcceptor(rm.Acceptor)
for _, subReactor := range rm.subReactors {
subReactor.setHandler(rm.Handler)
}
return rm
}
func (rm *ReactorManager) Run() error {
go func() {
for {
if err := rm.mainReactor.EpollWait(); err != nil {
log.Fatal(err)
}
}
}()
for i := 0; i < len(rm.subReactors); i++ {
go func(i int) {
for {
if err := rm.subReactors[i].EpollWait(); err != nil {
log.Fatal(err)
}
}
}(i)
}
return nil
}
func (rm *ReactorManager) Close() error {
if err := rm.mainReactor.Close(); err != nil {
return err
}
for _, subReactor := range rm.subReactors {
if err := subReactor.Close(); err != nil {
return err
}
}
return nil
}
func (rm *ReactorManager) Acceptor(listenerFD int) {
// 获取新连接
connFD, _, err := syscall.Accept(listenerFD)
if err != nil {
log.Println(err)
return
}
log.Printf("Accepted a connection")
// 设置connFd为非阻塞模式
if err := syscall.SetNonblock(connFD, true); err != nil {
log.Fatal(err)
}
// 随机一个subReactor
subReactor := rm.subReactors[listenerFD%len(rm.subReactors)]
// 将新连接的文件描述符添加到epoll中
event := syscall.EpollEvent{
Events: syscall.EPOLLIN,
Fd: int32(connFD),
}
// 为新的连接注册可读事件
// 服务端每建立一个tcp连接,注册给从reactor。
if err := subReactor.EpollCtl(syscall.EPOLL_CTL_ADD, connFD, &event); err != nil {
log.Fatal(err)
}
}
func (rm *ReactorManager) Handler(connFD int) {
// 读取数据
buf := make([]byte, 1024)
n, err := syscall.Read(connFD, buf)
if err != nil {
log.Println(err)
return
}
task := func() {
// do something
log.Printf("Read %d bytes: %s\n", n, buf)
}
// 提交给线程池
goroutinePool.Put(task)
}
type Reactor struct {
// epoll句柄
epollFD int
acceptor func(listenerFD int)
handler func(connFD int)
}
func newReactor() *Reactor {
epollFD, err := syscall.EpollCreate1(0)
if err != nil {
log.Fatal(err)
}
return &Reactor{
epollFD: epollFD,
}
}
func (r *Reactor) setAcceptor(acceptor func(listenerFD int)) {
r.acceptor = acceptor
}
func (r *Reactor) setHandler(handler func(connFD int)) {
r.handler = handler
}
func (r *Reactor) EpollWait() error {
events := make([]syscall.EpollEvent, 10)
// 等待事件发生
n, err := syscall.EpollWait(r.epollFD, events, -1)
if err != nil {
log.Fatal(err)
}
for i := 0; i < n; i++ {
// acceptor 如果式连接建立事件,分发给acceptor处理
if int(events[i].Fd) == listenerFD {
r.acceptor(listenerFD)
} else {
// 如果是可读事件,分发给handler处理
connFD := int(events[i].Fd)
r.handler(connFD)
}
}
return nil
}
func (r *Reactor) EpollCtl(op int, fd int, event *syscall.EpollEvent) error {
return syscall.EpollCtl(r.epollFD, op, fd, event)
}
func (r *Reactor) Close() error {
return syscall.Close(r.epollFD)
}
主从Reactor将新建连接事件和连接上IO事件分别管理,这个模型也被很多网络库使用,更加通用一点。
事件驱动网络库设计
这个小节我们一起看下文章开始部分谈到的网络库设计要点。之后我会给出Knetty最终的架构图,设计Knetty最终的目的是为了解决公存储Sidecar的性能瓶颈(具体业务暂时可能无法分享),最终我会开源3个库分别为网络库Knetty,一个高性能的文件传输库和一个类似gorilla/mux 的路由管理库。
我们回到前文内容网络库设计的要点:
-
线程与IO模型: IO multiplexing+non-blocking IO (主从Reactor+non-blocking IO);
-
将这两个极致拆开可能很容易理解,结合起来有人可能疑惑,为什么不是IO multiplexing+blocking IO。我们以主从Reactor为例,网络阻塞所有连接IO Write事件全部阻塞(对应线程池Task阻塞),我们知道线程池也是有最大数量的,要么新来的任务被丢弃要么整个主从Reactor流程阻塞在goroutinePool.Put(task)这行代码中,整个链路卡死。如果为non-blocking IO,可以给Epoll注册可写事件,等待网络可以写时再将内容发送到网络中。
-
-
线程安全性:支持多核多线程;
-
支持多核多线程,多Reactor可以设置数量,充分利用多线程完成IO事件的处理。当然多线程不仅表现在Reactor,在网络库提供的API也需体现,如线程池任务不可避免地会并发调用API,如何保证线程安全和性能也是网络库实现的难点。
-
-
跨平台性:主要支持Linux(生产)、次之Mac(FreeBSD/Darwin),Windows(x86-64) 。Mac&Windows更多是兼顾开发调试;
-
这一点对于适用于大多数公司,生产/线程都为linux系统,开发机往往因为不同群体分为了Mac/Windows。
-
-
传输层协议:主tcp,次udp;
-
重要的业务基本都是基于tcp协议,较少会使用udp,所以以tcp为核心,有精力可以对udp做更多的支持。
-
-
可控性强:网络库占用的线程、内存可由用户控制,而非为了性能自动无休止扩张。
-
这一点在库设计中很重要,特别是业务对系统资源敏感的场景,网络库以简洁的API提供的用户,但是必须提供可配置的系统资源参数如IO线程数,单连接缓冲区大小,线程池数目等。
-
-
提供可插拔的协议编解码器:提供通用的插件式协议编码器将网络数据(字节流)转化为用户进程数据(Object),如Http、常见RPC/文件传输协议编解码器。
-
网络库在Socket之上,单也是处理字节流传输,往往业务进程需要的一个Object(对象),这时提供可插拔的协议编解码器,用户可以实现字节流到Object和Object到字节流到转化。这也是主从Reactor中每个Hanlder要处理的事情。
-
-
在以上几点的基础上,可以添加更多利好用户的设计。
-
如Middleware机制,可以提供给用户非侵入业务代码的前提进行一些额外的行为。
-
Knetty架构设计
整体架构分层
-
EventListener:事件处理接口,Knetty提供5种常见IO事件:
-
OnOpen:连接建立成功后被调用。
-
OnMessage:连接收到对端数据时被调用。
-
OnClose:连接被关闭时被调用。
-
OnError:连接中逻辑处理出现异常时被调用。
-
OnCron:连接建立成功后立即发送一次,此后定时调用。
-
-
Session:为了让Connection聚焦在Socket和IO事件处理,抽象出Session,一个Session会话对应网络连接,Session主要处理业务层逻辑。
-
编解码实现:Session会根据不同的事件回调EventListener,往往OnMessage函数需要一个Object,此时Session需要完成协议解码(字节流->对象),反之完成编码。
-
中间件机制:Session提供中间件机制,中间件分为两种类型PreEventMiddleware和PostEventMiddleware,他们调用场景如下
-
PreEventMiddleware:字节被解码为Object后,未调用OnMessage前执行。
-
PostEventMiddleware:业务进程在OnMessage函数调用写函数发送Object,Object未被编码为字节前执行。
-
-
线程池:Session解码字节流得到Object后,将调用OnMessage函数,业务进程拿到Object根据场景选择是否需要线程池执行,Session提供线程池,但你也可以选择其他线程池实现。
-
-
Connection:主要处理Socket实现和IO事件处理,以及非阻塞模式下必须的Socket Buffer机制,以及零拷贝机制提供给存储网关,IO Event Handler主要处理连接上读、写、异常事件。
-
Reactor:主要实现主从Reactor管理,IO多路复用实现(Epoll、Kqueue等机制)以及特殊的系统调用封装。
整体处理流程
总结
这一小节主要从浅到深,从基础的IO模型、线程模型的选型,到以Kneety为案例梳理整个网络库设计要点,让你在设计网络库时有更好的把握,当然Knetty只是一个基于主从Reactor+Goroutine_Pool 实现的通用模型,根据不同的业务场景你应当学会如何抉择。
后面的文章,我会依次分享每个环节的设计细节,Reactor、缓冲区、零拷贝、系统调用细节,让你在更容易地从0到1实现网络库。
感谢你的阅读,欢迎留言讨论,如何对你理解有帮助也欢迎推荐给更多的朋友。
公众号
编程小灶
不定时推送Golang技术文章