如果想兼顾开发效率,又能保证高并发,协程就是最好的选择。它可以在保持异步化运行机制的同时,用同步方式写代码(goroutine-per-connection),这在实现高并发的同时,缩短了开发周期,是高性能服务未来的发展方向。
- CPU 和 IO 设备是不同的设备,能并行运行。合理调度程序,充分利用硬件,就能跑出很好的性能;
- Go 的 IO 最最核心的是 io 库,除了定义 interface (Reader/Writer),还实现了通用的函数,比如 Copy 之类的;
- 内存字节数组可以作为 Reader ,Writer ,实现在 bytes 库中,字符串可以作为 Reader,实现在 strings 库中,strings.NewReader;网络句柄可以作为 Reader ,Writer ,实现在 net 库中,net.Conn;文件句柄可以作为 Reader ,Writer ,实现在 os 库中,os.File ;
整体理念
从tcp socket诞生后,网络编程架构模型也几经演化,大致是:“每进程一个连接” –> “每线程一个连接” –> “Non-Block + I/O多路复用(linux epoll/windows iocp/freebsd darwin kqueue/solaris Event Port)”。伴随着模型的演化,服务程序愈加强大,可以支持更多的连接,获得更好的处理性能。不过I/O多路复用也给使用者带来了不小的复杂度,以至于后续出现了许多高性能的I/O多路复用框架, 比如libevent、libev、libuv等,以帮助开发者简化开发复杂性,降低心智负担。不过Go的设计者似乎认为I/O多路复用的这种通过回调机制割裂控制流的方式依旧复杂,且有悖于“一般逻辑”设计,为此Go语言将该“复杂性”隐藏在Runtime中了:Go开发者无需关注socket是否是 non-block的,也无需亲自注册文件描述符的回调,只需在每个连接对应的goroutine中以“block I/O”的方式对待socket处理即可。
原理
- 多路复用 有赖于 linux 的epoll 机制,具体的说 是 epoll_create/epoll_ctl/epoll_wait 三个函数
- epoll 机制包含 两个fd: epfd 和 待读写数据的fd(比如socket)。先创建efpd,然后向epfd 注册fd事件, 之后触发epoll_wait 轮询注册在epfd 的fd 事件发生了没有。
- netpoller 负责将 操作系统 提供的nio 转换为 goroutine 支持的blocking io。为屏蔽linux、windows 等底层nio 接口的差异,netpoller 定义一个 虚拟接口来封装底层接口。
func netpollinit() func netpollopen(fd uintptr, pd *pollDesc) int32 func netpoll(delta int64) gList func netpollBreak() func netpollIsPollDescriptor(fd uintptr) bool
netpoller 基于 linux 的epoll 接口 的实现
Goroutine 让出线程并等待读写事件:当我们在文件描述符上执行读写操作时,如果文件描述符不可读或者不可写,当前 Goroutine 就会执行 runtime.poll_runtime_pollWait
检查 runtime.pollDesc
的状态并调用 runtime.netpollblock
等待文件描述符的可读或者可写。runtime.netpollblock
会使用运行时提供的 runtime.gopark
让出当前线程,将 Goroutine 转换到休眠状态并等待运行时的唤醒。
I/O 多路复用需要使用特定的系统调用/select,java 语言需要显式调用select ,而golang 则通过netpoller 组件将select调用 重新隐藏了。
多路复用等待读写事件的发生并返回:netpoller并不是由runtime中的某一个线程独立运行的,runtime中的调度和系统调用会通过 runtime.netpoll 与网络轮询器交换消息,获取待执行的 Goroutine 列表,恢复Goroutine 为运行状态,并将待执行的 Goroutine 加入运行队列等待处理。
io 前后的GPM
G1 正在 M 上执行,还有 3 个 Goroutine 在 LRQ 上等待执行。网络轮询器空闲着,什么都没干。
G1 想要进行网络系统调用,因此它被移动到网络轮询器并且处理异步网络系统调用。然后,M 可以从LRQ 执行另外的 Goroutine。此时,G2 就被上下文切换到 M 上了。
异步网络系统调用由网络轮询器完成,G1 被移回到 P 的 LRQ 中。一旦 G1 可以在 M 上进行上下文切换,它负责的 Go 相关代码就可以再次执行。
执行网络系统调用不需要额外的 M。网络轮询器使用系统线程,它时刻处理一个有效的事件循环/eventloop。
带缓存的网络 I/O
每次从 net.Conn 尝试读取其内部缓存大小的数据,而不是用户传入的希望读取的数据大小。这些数据缓存在内存中,这样,后续的 Read 就可以直接从内存中得到数据,而不是每次都要从 net.Conn 读取,从而降低 Syscall 调用的频率。 有一个 生产-消费 []byte
过程:netpoller 协程 从connection/socket 读[]byte
到buffer,业务协程 从buffer 中读取[]byte
decode 处理。这个“队列” 如何实现?
RingBuffer 天然合适,但是RingBuffer 存满后需要扩容,扩容需要copy,copy 会造成data race(read/write 指针不能碰面)。再进一步,如何实现一个无锁的ring buffer?使用链表解决扩容问题;使用sync.Pool 复用链表节点;维护一个length字段,通过atomic 避免data race。
重用内存对象
go tool pprof 可以观测占用内存最多的函数 和 代码(哪一行)。比如 每次服务端收到一个客户端 submit 请求时,都会在堆上分配一块内存表示 Submit 类型的实例
s := Submit{}
// 改为
var SubmitPool = sync.Pool{
New: func() interface{} {
return &Submit{}
},
}
s := SubmitPool.Get().(*Submit) // 从SubmitPool池中获取一个Submit内存对象
...
SubmitPool.Put(submit) // 将submit对象归还给Pool池
标签:调用,多路复用,epoll,Goroutine,golang,fd,io,runtime,优化
From: https://www.cnblogs.com/muzinan110/p/18031536