6.netpoll实现

Go语言值得称道的一点就是用很简单的几行代码就可以写出支持高并发的服务器程序。那么 Go语言里的 TCP 连接是怎样建立的呢?

这里分客户端和服务端两部分看

img

客户端

客户端一般通过如下代码发送请求:

func main() {
    conn, err := net.Dial("tcp", "127.0.0.1:3000")
    conn.Write([]byte(data)) // 向服务端发送数据
    n,err := conn.Read(buf)  //读取服务端端数据
}

net.Dial() 底层实际调用的是系统函数 socket()connet() 创建 socket连接,write()read() 读写数据。

socket (套接字) 是 Unix系统下抽象出来的一层概念,与 Unix系统 file descriptor (文件描述符) 相整合,使得网络读写数据和本地文件一样容易。一般记录五元组(协议 + 双方地址 + 双方端口)

这里不详细解析,主要看服务端下的实现

服务端

服务端一般通过如下代码接收客户端请求:

func main()  {
    listen, err := net.Listen("tcp",":8080") // 创建监听 socket
    for {
        conn, errs := listen.Accept()       // 接收客户端连接
        go handle(conn) 					          // 一个 goroutine 处理一个连接
    }
}

net.Listen() 的实现

net.Listen() 经过层层调用,最底层实际调用的是如下系统函数:

  1. socket() 创建 socket fd

  2. bind() 绑定 socket 与监听地址

  3. listen() 监听 socket

    1. epollcreate() 创建 epoll 对象

    2. epollctl() 将监听 socket fd 加入到 epoll 红黑树里进行监听

重点看这个 net/sock_posix.go/socket() 函数

fd.init() 里主要是对 epoll 对象的创建和跟踪,实现如下:

runtime_pollServerInit() 函数通过 go:linkname 注释由 runtime/netpoll.go/poll_runtime_pollServerInit() 实现,底层调用 epoll_create1() 函数,创建 epoll 对象,并通过 atomic 包保证只创建一次

netpollinit() 函数在 linux 系统下的实现文件为 runtime/netpoll_epoll.go

runtime_pollOpen() 函数由 runtime/netpoll.go/net_runtime_pollOpen() 实现,底层调用 epoll_ctl() 函数,将 socket fd 放入 epoll 对象中监听,以便在和客户端的连接建立时得到通知

这里可以看到通过常量 _EPOLLET 将 epoll 设置为了边缘触发 (Edge Triggered) 模式,并将 fd 通过 epollctr 放入 epoll 对象中管理。

listen.Accept() 的实现

listen.Accept() 的逻辑主要是:

  1. 轮询调用系统函数 accept() 等待并接收连接

  2. 连接到来后,调用 epollcreate()epollctl() (和上面一样)管理 socket

下面这里是个 for循环,轮询调用 accept 函数。因为我们在 Listen 的时候已经把对应的 Listener fd 设置成非阻塞I/O了,所以调用accept 这一步是不会阻塞的。只是下面会进行判断,根据判断 err ==syscall.EAGAIN 来调用fd.pd.waitRead阻塞住用户程序。

netpoll 的调度

前面可以看到,在 Listen()Accept() 里创建并维护了 epoll 对象,那么什么时候会调用 epoll_wait() 获取就绪的 socket 呢?

Go 里通过 runtime.netpoll() 来获取就绪的 socket,这个函数调用的地方主要有两处,在 Go 的调度函数里:

  1. 触发调度的函数 runtime.shcedule() -> runtime.findrunable() 中调用了 runtime.netpoll() 获取待执行的协程

  2. sysmon 监控协程 每次运行会检查距离上一次执行 netpoll() 函数是否超过10ms,如果是则会调用一次 runtime.netpoll()

netpoll() 解析

netpoll() 里调用了 epollwait() 系统调用函数。如果返回的值大于 0,意味着被监控的文件描述符出现了待处理的事件,将这些协程放入 toRun 列表返回给上层调度函数处理。

总结

netpoll 底层就是对I/O多路复用的封装,是 I/O多路复用 + Go调度器 二者的结合。

不同平台对I/O多路复用有不同的实现方式,Go 在不同平台上 netpoll 调用的底层实现也不一样。比如Linux 下使用 epoll,macOS 则是 kqueue,而 Windows 是基于异步I/O实现的 ICOP。编译器在编译 Go 语言程序时,会根据目标平台选择树中特定的分支进行编译。

优点

  1. 每个 goroutine 监听一个 TCP连接,轻量且支持海量

    1. 当连接上没有数据到达时,goroutine 会被 gopark() 函数阻塞。该阻塞不会陷入内核态,也不阻塞 M,M 可以寻找别的 G 执行,切换 G的开销极小

    2. 有数据到达时,再通过运行时调度处理连接

  2. 底层调用 epoll IO多路复用机制,较为高效

不足

  1. 海量连接场景下,goroutine 及内存使用会暴涨,且当前的垃圾回收机制下不会随连接销毁释放

  2. 所有连接维护在一个 epoll 对象里,高频创建和释放连接情况下可能导致性能瓶颈

Last updated