go 网络 network poller
网络基础
协议架构
tcp链接
假如需要开发者去实现一套新的网络协议(例如 redis 的resp), 是基于TCP的, 那tcp这层的协议,是否需要开发者自己去实现?
这层如果自己实现, 其实很复杂, 会涉及很多算法相关.
因此, 出现了 socket 对传输层进行了抽象, 开发者不需要关注传输层具体的实现, 使用socket提供的接口, socket内部会实现,比如三次握手, 四次挥手.
Socket
很多系统都提供 Socket 作为 TCP(也有UDP) 网络连接的抽象,
Linux-> Internet domain socket -> SOCK STREAM
Linux 中 Socket 以 “文件描述符〞FD 作为标识
每建立一次连接接, sever都会创建一个新的 socket 专门和client通信, 原来监听的socket还是一直处于监听状态.
socket 之前如何进行通信, 假设client 给 server发送消息, sever 没有回复, 那client是阻塞, 还是先执行别的工作, 这就是IO模型的范畴了.
IO模型
阻塞
非阻塞
多路复用
阻塞
当read中没有数据过来, 线程(sever或者client都一样)会阻塞等待.
同步读写Socket时,线程陷入内核态
当读写成功后,切换回用户态,继续执行
优点:开发难度小,代码简单
缺点:内核态切换开销大
非阻塞
如果暂时无法收发数据,会返回错误
应用会不断轮询,直到Socket可以读写
优点:不会陷入内核态,自由度高
缺点:需要自旋轮询
多路复用
大厂面试经常喜欢问的 epoll就是 多路复用的一种实现, 还有 select, poll 都是, 这三个主要的区别: 在返回数据时候的读取方式.
业务把关注的事件注册到 event poll池中, 当epoll接受到对应事件后,通知业务.
注册多个Socket事件
调用epool,当有事件发生,返回
优点:提供了事件列表,不需要查询轮询各个Scoket
缺点:开发难度大,逻辑复杂
epoll 在 Mac: kqueue ; Windows: IOCP
小结
操作系统提供了Socket作为TCP和UDP通信的抽象
IO模型指的是操作Socket的方案
阻塞模型最利于业务编写,但是性能差
多路复用性能好,但业务编写麻烦
go socket的实现
实现方法
- 对系统的 epoll进行封装, 因为go是可以在多个系统上运行的, 底层需要对平台差异化封装.
- 如果用户去调用 封装好的 epoll方法, 需要开发者执行去关注 epoll的状态, 了解epoll的大致实现原理,肯定知道还需要去 解析epoll的返回, 然后 分析那些 socket 有事件来了. 这些工作都是通用性的,所以,go把这层一起封装在了它的 网络层中.
- 然后,go为了更加简化使用, 采用了 阻塞的思想, 一个协程对应一个 socket连接 , 当epoll 没有对应事件返回时候, 就休眠等待, 有事件来了,就唤醒处理. 这样就简化了上层开发者的使用.
在底层使用操作系统的多路复用10
在协程层次使用阻塞模型
阻塞协程时,休眠协程
具体抽象 go代码相关
多路复用器
各个系统的多路复用都有以下功能: 系统原生
1. 新建多路复用器 epoll create()
2. 往多路复用器里插入需要监听的事件 epoll ctl()
3. 查询发生了什么事件 epoll wait()
Go Network Poller 多路复用器的抽象
epoll create() -> netpollinit() // 新建
epoll cti0 -> netpollopen() // 插入事件
epoll wait -> netooll() // 查询
netpollinit 新建多路复用器
func netpollinit() {
var errno uintptr
// 新建了 epoll 系统底层实现
epfd, errno = syscall.EpollCreate1(syscall.EPOLL_CLOEXEC)
// 这里的 epfd是个全局变量,初始化之后, 在操作时候就用这个 epfd
// 新建管道 Linux的管道,用于多线程多进程间的通信, 用于结束这个epoll
r, w, errpipe := nonblockingPipe()
ev := syscall.EpollEvent{
Events: syscall.EPOLLIN,
}
*(**uintptr)(unsafe.Pointer(&ev.Data)) = &netpollBreakRd
// 管道中加入了事件
errno = syscall.EpollCtl(epfd, syscall.EPOLL_CTL_ADD, r, &ev)
netpollBreakRd = uintptr(r)
netpollBreakWr = uintptr(w)
}
归纳:
新建了一个底层 epoll对象 epfd
新建一个pipe管道用于中断Epoll
将“管道有数据到达〞 事件注册在Epoll中
netpollopen() 插入事件
func netpollopen(fd uintptr, pd *pollDesc) uintptr {
// fd 是socket的对象 pd是 socket 和协程的对应关系
var ev syscall.EpollEvent
ev.Events = syscall.EPOLLIN | syscall.EPOLLOUT | syscall.EPOLLRDHUP | syscall.EPOLLET
tp := taggedPointerPack(unsafe.Pointer(pd), pd.fdseq.Load())
*(*taggedPointer)(unsafe.Pointer(&ev.Data)) = tp
// 上面这两行代码 是在把 ev.Data 和 pd 联系起来, 后面epoll 返回时候, 就能直接和pd的协程联系起来
// 将fd要关注的事件, 注册到 epoll中, 调用的是 EpollCtl 和上面也对上了
return syscall.EpollCtl(epfd, syscall.EPOLL_CTL_ADD, int32(fd), &ev)
}
type pollDesc struct {
_ sys.NotInHeap
link *pollDesc // in pollcache, protected by pollcache.lock
fd uintptr // socket ID
fdseq atomic.Uintptr // protects against stale pollDesc
atomicInfo atomic.Uint32 // atomic pollInfo
// 想要读的协程
rg atomic.Uintptr // pdReady, pdWait, G waiting for read or pdNil
// 想要写的协程
wg atomic.Uintptr // pdReady, pdWait, G waiting for write or pdNil
lock mutex // protects the following fields
closing bool
user uint32 // user settable cookie
rseq uintptr // protects from stale read timers
rt timer // read deadline timer (set if rt.f != nil)
rd int64 // read deadline (a nanotime in the future, -1 when expired)
wseq uintptr // protects from stale write timers
wt timer // write deadline timer
wd int64 // write deadline (a nanotime in the future, -1 when expired)
self *pollDesc // storage for indirect interface. See (*pollDesc).makeArg.
}
归纳:
传入一个Socket的FD,和pollDesc指针
pollDesc指针是Socket相关详细信息
pollIDesc中记录了哪个协程休眠在等待此Socket
将Socket可读、可写、断开事件注册到Epoll中
netpoll 查询
这里有必要讲下 epoll的使用的一些特点, 能更好理解go中的实现.
epoll_wait
是阻塞函数, 但是可以传一个 超时时间, 超过时间后就先返回, 不阻塞了。
int epoll_wait(int epfd,struct epoll_event * events, int maxevents,int timeout)
epfd epoll
的id ,就是创建时候的 epfd
.
epoll_event
是传回有事件的fd和even的记录
timeout
是 超时时间
返回值 是指示 epoll_event 中,前多个元素,是有值的, 避免遍历.
// 超时时间 delay
func netpoll(delay int64) gList {
var events [128]syscall.EpollEvent
retry:
// 去调了系统的 EpollWait 然后 看参数, 第二个参数里面是包含了 有事件的even
n, errno := syscall.EpollWait(epfd, events[:], int32(len(events)), waitms)
// n 就表示 events 前多少个是有值的
var toRun gList
for i := int32(0); i < n; i++ {
var mode int32
if ev.Events&(syscall.EPOLLIN|syscall.EPOLLRDHUP|syscall.EPOLLHUP|syscall.EPOLLERR) != 0 {
mode += 'r'
}
if ev.Events&(syscall.EPOLLOUT|syscall.EPOLLHUP|syscall.EPOLLERR) != 0 {
mode += 'w'
}
if mode != 0 {
tp := *(*taggedPointer)(unsafe.Pointer(&ev.Data))
pd := (*pollDesc)(tp.pointer())
tag := tp.tag()
// 通过ev.Data 和 pd关联了起来 参入事件的时候, 有讲过这个
if pd.fdseq.Load() == tag {
pd.setEventErr(ev.Events == syscall.EPOLLERR, tag)
netpollready(&toRun, pd, mode) // 通过pd ,获取到了对应的 协程队列
}
}
}
return toRun // 返回的是 关注这个事件的协程队列
}
归纳:
调用epoll wait(),查询有哪些事件发生
根据Socket相关的pollDesc信息,返回哪些协程可以唤醒
Go Network Poller 如何工作
收发数据
场景 1:Socket 已经可读写
- netpoll 方法需要循环执行,这样才能及时获得那些事件有返回了, 才能通知对应的协程.
谁来调这个方法, 入口方法放到了 gcStart , 因为 go会保证一段时间,一定会调下 gc, 在讲 go 内存,垃圾回收时候讲过.
再来看 netpoll()
中 netpollready()
的实现:
func netpollready(toRun *gList, pd *pollDesc, mode int32) {
var rg, wg *g
if mode == 'r' || mode == 'r'+'w' { // 可读
rg = netpollunblock(pd, 'r', true)
}
if mode == 'w' || mode == 'r'+'w' { // 可写
wg = netpollunblock(pd, 'w', true)
}
// 通过上面的方法,标记好了 pdReady之后,
if rg != nil {
toRun.push(rg) //把对应的协程,加入到一个可执行的 队列中,一个链表
}
if wg != nil {
toRun.push(wg)
}
}
func netpollunblock(pd *pollDesc, mode int32, ioready bool) *g {
gpp := &pd.rg // 默认是 可读协程
if mode == 'w' { //如果是 可写 w, 就取可写的协程
gpp = &pd.wg
}
// 下面的代码就是把 对应的 rg 或者 wg 这个字段改成了 pdReady, 标记下有事件来了.
for {
// 这里的old值 , 第一次取可能是一个等待协程的地址 ,通过下面的情景能看到
old := gpp.Load()
if old == pdReady {
return nil
}
if old == pdNil && !ioready {
return nil
}
var new uintptr
if ioready {
new = pdReady
}
if gpp.CompareAndSwap(old, new) { // 如果值相等,就写入新的值
if old == pdWait {
old = pdNil
}
return (*g)(unsafe.Pointer(old)) // 当old为一个协程的地址,这里返回的就是一个协程
}
}
}
上面方法就是把 pollDesc 的 rg或者wg置为 pdReady
当协程去调用 poll_runtime_pollWait()
方法 询问时候, 发现已经是 ready
状态,就开始进行 读写
//go:linkname poll_runtime_pollWait internal/poll.runtime_pollWait
// 这种方式 可以调用声明包中的小写方法
func poll_runtime_pollWait(pd *pollDesc, mode int) int {
errcode := netpollcheckerr(pd, int32(mode))
for !netpollblock(pd, int32(mode), false) { // 这里就是判断这个 pd的rg或者wg 是否是 ready状态
errcode = netpollcheckerr(pd, int32(mode))
if errcode != pollNoError {
return errcode
}
}
return pollNoError
}
归纳:
runtime 循环调用 netpoll() 方法 (g0协程 的gcStart 垃圾回收开始方法)
发现Socket可读写时,给对应的rg或者wg置为pdReady(1)
协程调用poll_runtime_pollWait()
判断rg或者wg已经置为pdReady(1),返向0
场景 2:Socket 暂时无法读写
需要深入看上面那个判断 pd
的rg
是否为ready
状态的方法
当协程去调用 poll_runtime_pollWait()
会跳转到:
func netpollblock(pd *pollDesc, mode int32, waitio bool) bool {
gpp := &pd.rg
if mode == 'w' {
gpp = &pd.wg
}
for {
if gpp.CompareAndSwap(pdReady, pdNil) {
return true
}
if gpp.CompareAndSwap(pdNil, pdWait) {
break
}
if v := gpp.Load(); v != pdReady && v != pdNil {
throw("runtime: double wait")
}
}
// 如果不是ready ,那么协程就会休眠等待, 并把协程的地址,赋值给 pd的rg或wg,在`netpollblockcommit`中
if waitio || netpollcheckerr(pd, mode) == pollNoError {
gopark(netpollblockcommit, unsafe.Pointer(gpp), waitReasonIOWait, traceBlockNet, 5)
}
// be careful to not lose concurrent pdReady notification
old := gpp.Swap(pdNil)
if old > pdWait {
throw("runtime: corrupted polldesc")
}
return old == pdReady
}
归纳:
runtime 循环调用netpoll()方法 (g0协程)
协程调用poll_runtime_pollWait()
发现对应的rg或者wg为0
给对应的rg或者wg置为协程地址
休眠等待
当有事件来的时候, netpoll 中返回了 对应事件的 glist
, 然后,runtime就会通知这些协程开始工作.
runtime 循环调用 netpoll() 方法 (g0协程)
发现Socket可读写时,给对应的查看对应的rg或者wg
若为协程地址,返回协程地址
调度器开始调度对应协程
大体的机构如下: