跳转至

09 网络编程:如何进行网络IO编程降消耗,提吞吐?

你好,我是徐逸。

前面,我们花了不少篇幅一同深入学习了Go服务高性能编码技巧,来全力保障线上服务的性能。不过呢,除了我们写的业务逻辑代码,服务框架本身对于性能也有着举足轻重的影响。而影响框架性能的一个很重要的因素,就是框架所使用的网络IO模型。

今天我们就来聊聊网络IO模型、epoll技术和Golang底层网络IO的原理。掌握网络IO模型、epoll技术和Golang底层网络IO原理,不仅有助于你更好地做框架选型,而且还能提升你使用Go开发更底层网络程序的能力。

网络IO模型

在介绍具体的网络IO模型之前,先让我们来想一想,一次网络IO的过程大概是什么样的呢?

就像下面的图一样,以读IO为例,网络数据要被咱们的应用程序接收到,可以划分为下面两个阶段。

  1. 数据准备阶段,驱动程序和操作系统内核从网卡读取数据到socket的接收缓冲区。
  2. 数据复制阶段,由应用程序将内核空间socket缓冲区的数据复制到用户空间。

应用程序对这两个阶段的不同处理方式,就形成了不同的网络IO模型。那么应用程序对这两个阶段有哪几种处理方式呢?

阻塞IO

我们先来看看数据准备阶段的处理方式。就像下面的图一样,当我们的应用程序进行网络IO调用时,如果socket缓冲区还没有准备好,我们可以让应用线程阻塞在IO调用方法里,而不直接返回,这就是阻塞IO模型

那么使用阻塞IO的方式,会有什么问题呢?

当我们使用阻塞IO模型时,为了能及时处理多个连接的读写请求,就像下面的伪代码一样,每个连接我们都需要创建一个专门的线程来处理。在高性能服务器场景,当和客户端的连接比较多时,阻塞IO会导致创建比较多的线程,增加内存占用和上下文切换成本,降低服务器处理请求的吞吐

for {
   // 获取本轮待处理的 fd
   fd = accept() // 获取连接
   new Thread(){ // 创建线程处理连接
       // 从 fd 中读数据
       data = read(fd)  
       // 处理数据 
       handle(data)  
   }
}

非阻塞IO

为了解决高性能场景阻塞IO会创建较多线程的问题。操作系统给我们提供了非阻塞IO的方式,就像下面的图一样,当应用线程调用操作系统提供的读写方法时,如果socket缓冲区还没准备好,网络IO系统调用立即返回,不再阻塞应用线程。

使用非阻塞IO编程模型,我们可以实现线程复用,当一个连接的socket缓冲区未就绪时,线程可以处理另一个连接的请求,而不再陷入阻塞,从而解决阻塞IO模式线程数过多的问题。就像下面的伪代码一样。

// 多个待服务的 fd 
fds = [fd1,fd2,fd3,...]
i = 0
for {
   fd = fds[i]        
   // 尝试从连接中获取数据,socket未就绪直接返回,不再阻塞
   data,err = tryRead(fd)  
   // 读取数据成功,处理数据
   if err == nil{
      handle(data) 
   } 
   // 10ms后再推进流程,否则不断轮询会消耗过多CPU资源
   sleep(10ms)
   i++
   if i == len(fds){
     i = 0
   }
}

那么非阻塞IO模型有什么问题呢?

非阻塞IO模型需要利用轮询不断做系统调用,浪费大量CPU资源。而且,当内核接收到数据时,数据需要等到应用线程下一次轮询才能复制到用户空间,得不到立刻处理,这可能会导致请求响应的延时比较高。

IO多路复用

为了能高效、及时地处理大量连接的 I/O 事件,操作系统还提供了IO多路复用的方式,让我们的应用线程能及时感知到socket缓冲区就绪的事件。

就像下面的图一样,我们可以在一个线程里阻塞监听多个连接的网络IO事件,当有连接的socket缓冲区准备好,IO多路复用的方法就会返回,让应用线程能及时处理连接的网络请求。

使用IO多路复用的方式,就像下面的伪代码一样,当没有连接的网络IO就绪时,多路复用的epoll_wait方法会阻塞,避免线程不断轮询消耗CPU资源。同时,网络IO就绪时,epoll_wait方法会立即返回,确保应用线程能及时感知网络IO就绪事件,避免处理请求不及时

// 多个待服务的 fd 
fds = [fd1,fd2,fd3,...]
for {
   // 多路复用,阻塞同时监听多个连接
   readyFds=epoll_wait(fds)
   for i=0;i<len(readyFds);i++{
      // 开线程处理已就绪连接
      new Thread(){
          fd = readyFds[i]
          data,err = read(fd)  
           // 读取数据成功,处理数据
           if err == nil{
              handle(data) 
           } 
      }
   }
}

异步IO

前面3种IO模型,由于在数据处理阶段或者是数据复制阶段,需要阻塞应用线程,因此属于同步IO模型。

实际上,还有一种完全不需要阻塞应用线程的网络IO模型——异步IO模型。就像下面的图一样,使用异步IO模型,应用线程从网络中读数据时,直接调用操作系统的方法并立即返回,由内核负责将socket缓冲区数据复制到用户空间,然后通知线程完成,整个过程完全没阻塞。

当然,因为各个平台对异步I/O模型的支持程度不一,且这种方式使用起来复杂度较高,因此使用并不是很广泛。目前主流网络服务器采用的多是I/O多路复用模型。

那么操作系统提供了哪些系统调用,来支持应用程序实现IO多路复用模型呢?

epoll技术解析

就拿主流的Linux内核来说,它主要提供了select、poll 和 epoll 三种 I/O 多路复用技术。

其中epoll是对select和poll机制的改进,能够提供更好的性能和扩展性,特别适用于高并发的网络服务器程序,我们接下来就重点学习一下epoll的功能。

如果我们想使用epoll技术来实现多路复用,可以使用Linux提供的下面三个系统调用。

  1. epoll_create函数,它的功能是在Linux内核创建一个内核需要监听的网络连接池子。
  2. epoll_ctl函数,它的功能是增删改池子里需要监听的连接和事件。
  3. epoll_wait函数,它的功能是阻塞等待池子里连接的网络IO事件。
#include <sys/epoll.h>
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

当然,在使用epoll技术时,需要特别注意的一点是epoll触发模式的选择。也就是说,当我们每次调用epoll_wait方法时,操作系统是否需要反复通知应用线程某个连接的就绪事件

Linux提供了两种触发模式供我们选择。

一种是水平触发模式(Level - Triggered,LT)。在水平触发模式下,只要连接的socket缓冲区满足可读或可写的条件,epoll_wait函数就会返回这个连接的IO就绪事件

以数据读取为例,假如某个连接的socket接收缓冲区中有 80 字节的数据,当我们调用epoll_wait返回后,应用线程读取了 20 字节。如果应用水平触发模式,那么当我们再次调用epoll_wait方法时,还会返回这个连接的网络IO就绪事件,因为还有 60 字节的数据在缓冲区中,直到这 80 字节的数据也被读完为止。

水平触发模式的优点是编程实现简单,缺点是当应用线程在读写缓冲区数据的过程中,由于没有读写完,epoll_wait会频繁返回这些连接的IO事件,导致应用程序需要不断地处理这些事件,这可能会增加系统的开销,降低性能。

Linux提供的另一种触发模式是边缘触发(Edge - Triggered,ET)。在边缘触发模式下,epoll_wait只会在连接对应的网络IO事件状态发生变化时才会返回这个事件,比如从不可读变为可读,或者从不可写变为可写。

仍以数据读取为例,当连接的socket接收缓冲区一开始没有数据时,如果有新的数据到达,epoll_wait会返回这个连接的可读事件,此时应用线程需要尽可能将缓冲区中的所有数据读取完。如果没有全部读取,下一次epoll_wait调用将不会返回这个连接的可读IO事件,直到又有新的数据到达。

在高并发场景下,边缘触发模式可以减少epoll_wait的返回次数,减少系统调用次数,提高系统的性能。但是边缘触发模式也存在缺点,假如应用线程在处理网络IO事件的过程中出错,或者没有及时处理网络IO事件,由于不会再收到IO事件就绪通知,处理不当很容易导致数据丢失。

Golang网络IO模型

掌握网络IO模型和epoll技术之后,我们已经搭建起了坚实的理论基础。现在,让我们来看看Go语言是如何巧妙运用这些理念和技术,来实现高性能网络通信的。

下面是我用Golang的net库实现的一个简单的TCP服务器,它的核心是下面几个方法的调用。

  • Listen方法,用于创建一个 tcp 端口监听器 listener。
  • Accept方法,用于阻塞获取到达的 tcp 连接。
  • Read方法和Write方法,协程阻塞进行读写网络IO。
package main

import (
    "fmt"
    "net"
)

func handleConnection(conn net.Conn) {
    defer conn.Close()
    // 用于读取客户端发送数据的缓冲区
    buffer := make([]byte, 1024)
    for {
        n, err := conn.Read(buffer)
        if err!= nil {
            fmt.Println("读取客户端数据出错:", err)
            break
        }
        // 输出客户端发送的数据
        fmt.Printf("从客户端接收到: %s\n", buffer[:n])
        // 向客户端发送响应信息
        _, err = conn.Write([]byte("已收到你的消息\n"))
        if err!= nil {
            fmt.Println("向客户端发送响应出错:", err)
            break
        }
    }
}

func main() {
    // 监听的地址和端口
    listenAddr := ":8888"
    // 创建监听的TCP套接字
    listener, err := net.Listen("tcp", listenAddr)
    if err!= nil {
        fmt.Println("监听出错:", err)
        return
    }
    defer listener.Close()

    fmt.Printf("服务器正在监听 %s\n", listenAddr)
    for {
        // 接受客户端连接
        conn, err := listener.Accept()
        if err!= nil {
            fmt.Println("接受客户端连接出错:", err)
            continue
        }
        // 启动一个协程来处理客户端连接
        go handleConnection(conn)
    }
}

Golang I/O 多路复用和epoll调用的细节,就隐藏在这些方法内部和Golang运行时里。

首先,我们来看看Listen方法,实际上,它最终会调用操作系统的epoll_create方法,创建一个epoll池。

//runtime/netpoll_epoll.go
func netpollinit() {
    // 调通epoll_create,创建epoll池
    epfd = epollcreate1(_EPOLL_CLOEXEC)
}

创建完epoll池,它会将需要监听网络连接的文件描述符(fd),通过调用操作系统的epoll_ctl方法,加入到epoll池子里。

//runtime/netpoll_epoll.go
func netpollopen(fd uintptr, pd *pollDesc) int32 {
    var ev epollevent
    ev.events = _EPOLLIN | _EPOLLOUT | _EPOLLRDHUP | _EPOLLET
    *(**pollDesc)(unsafe.Pointer(&ev.data)) = pd
    // 调用epoll_ctl,将需要监听的连接加到epoll池里
    return -epollctl(epfd, _EPOLL_CTL_ADD, int32(fd), &ev)
}

接着,我们来看看Accept方法。Accept方法会尝试非阻塞获取TCP连接,如果能够获取到,则会调用epoll_ctl方法将新连接加入epoll池。

//runtime/netpoll_epoll.go
func netpollopen(fd uintptr, pd *pollDesc) int32 {
    var ev epollevent
    ev.events = _EPOLLIN | _EPOLLOUT | _EPOLLRDHUP | _EPOLLET
    *(**pollDesc)(unsafe.Pointer(&ev.data)) = pd
    return -epollctl(epfd, _EPOLL_CTL_ADD, int32(fd), &ev)
}

如果获取不到连接,协程会陷入阻塞,并触发协程调度。

//runtime/netpoll.go

// returns true if IO is ready, or false if timedout or closed
// waitio - wait only for completed IO, ignore errors
func netpollblock(pd *pollDesc, mode int32, waitio bool) bool {
    // IO未就绪,协程阻塞
    if waitio || netpollcheckerr(pd, mode) == 0 {
        gopark(netpollblockcommit, unsafe.Pointer(gpp), waitReasonIOWait, traceEvGoBlockNet, 5)
    }
}

然后,我们来看看Read和Write方法。Read和Write方法会尝试非阻塞读写数据,如果socket缓冲区就绪,就会进入前面网络IO模型讲到的数据复制阶段;如果未就绪,调用方法的协程会阻塞。

//runtime/netpoll.go

// returns true if IO is ready, or false if timedout or closed
// waitio - wait only for completed IO, ignore errors
func netpollblock(pd *pollDesc, mode int32, waitio bool) bool {
    // IO未就绪,协程阻塞
    if waitio || netpollcheckerr(pd, mode) == 0 {
        gopark(netpollblockcommit, unsafe.Pointer(gpp), waitReasonIOWait, traceEvGoBlockNet, 5)
    }
}

最后,让我们来看看,Golang运行时是如何感知网络IO就绪事件,唤醒因网络IO事件未就绪而陷入阻塞的协程的。Golang运行时里,下面几个地方会调用操作系统的epoll_wait方法完成这个目标。

第一个地方是在全局监控任务 sysmon里。在程序启动时,Golang底层会单独启动一个线程,用于执行 sysmon 监控任务。

// runtime/proc.go
func main() {
    // 启动一个线程
    systemstack(func() {
        newm(sysmon, nil, -1)
    })

}

在监控任务里,每隔 10ms会轮询调用netpoll 函数,这个函数会尝试取出网络IO事件就绪的协程列表,进行唤醒操作。

func sysmon() {
    for {
        // 每隔10s周期处理
        // poll network if not polled for more than 10ms
        lastpoll := sched.lastpoll.Load()
        if netpollinited() && lastpoll != 0 && lastpoll+10*1000*1000 < now {
            sched.lastpoll.CompareAndSwap(lastpoll, now)
            // 获取网络IO就绪的协程列表
            list, delta := netpoll(0) // non-blocking - returns list of goroutines
            if !list.empty() {
                // 唤醒就绪协程
                injectglist(&list)
            }
        }
    }
}

而 netpoll 方法的底层,就像下面的代码一样,会基于非阻塞模式调用操作系统的epoll_wait 方法,获取到就绪事件队列 events。然后遍历事件队列,将对应的协程添加到协程列表中返回给上层用于执行唤醒操作。

//  runtime/netpoll_epoll.go
// netpoll checks for ready network connections.
// Returns list of goroutines that become runnable.
func netpoll(delay int64) (gList, int32) {
    var events [128]syscall.EpollEvent
    // 调用epoll_wait非阻塞获取就绪的网络IO事件
    n, errno := syscall.EpollWait(epfd, events[:], int32(len(events)), waitms)
    //从events中获取事件对应的协程

    return toRun, delta
}

第二个地方是在协程调度流程中。在进行协程调度时,findRunnable函数会为当前处理器寻找下一个可执行的协程。如果此时没有可调度协程,findRunnable函数就会尝试获取网络IO就绪的协程用于调度执行。

// runtime/proc.go
// Finds a runnable goroutine to execute.
func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
    // Poll network.

    // P 本地队列和全局队列都没有待执行的协程
    if netpollinited() && netpollAnyWaiters() && sched.lastpoll.Load() != 0 {
        // 调用epoll_wait获取IO事件就绪协程列表
        if list, delta := netpoll(0); !list.empty() { // non-blocking
            gp := list.pop()
            // 唤醒相关协程
            injectglist(&list)
        }
    }
}

第三个地方是在GC流程中。在 GC 过程中,每次调用完STW(stop the world)后,都会调用 start the world,此时也会对网络IO就绪的协程进行唤醒操作,以便网络IO事件能得到及时处理。

//runtime/proc.go

// stattTheWorldWithSema returns now.
func startTheWorldWithSema(now int64, w worldStop) int64 {
    if netpollinited() {
        list, delta := netpoll(0) // non-blocking
        injectglist(&list) // 唤醒IO就绪的协程列表
    }
}

小结

今天这节课,我们一起学习了网络编程相关的核心知识,包括网络IO模型、epoll技术和Golang底层网络模型的原理。现在让我们来回顾一下这节课学到的网络编程知识。

网络IO模型有阻塞IO、非阻塞IO、IO多路复用和异步IO多种类型,实践中比较常用的是IO多路复用模型。

之后我们重点了解了Linux底层的多路复用技术——epoll,操作系统提供了epoll_create、epoll_ctl和epoll_wait三个方法给我们使用。在使用时,我们需要注意触发模式的选择。

最后,我以一段TCP服务器代码为例,深入分析了Go语言底层是如何巧妙运用网络IO模型和epoll技术,来实现高性能网络通信的。希望你能够用心体会今天讲到的网络编程知识,提升你使用Go开发更底层网络程序的能力。

思考题

虽然Golang网络库的性能已经很高了,但还是有不少高性能网络库在Golang 官方库的基础上进行了改进,请找一个高性能网络库并分析它的改进点。

欢迎你把你的答案分享在评论区,也欢迎你把这节课的内容分享给需要的朋友,我们下节课再见!

精选留言(2)
  • CodeFish-Xiao 👍(3) 💬(1)

    感觉这篇的质量跟之前比下降了,Golang运行时的网络实现和Linux本身的IO复用优化,但是实际上我们用Golang进行网络编程该进行哪些优化没有讲到

    2024-12-27

  • lJ 👍(1) 💬(2)

    1. 老师能讲一讲io_uring吗,有哪些知名的应用,大厂的态度,Golang的支持情况等 2. epoll ET模式存在数据丢失的风险,如果接收缓冲区足够大的情况下,还存在丢失吗,后续新的数据到达重新触发通知,应用程序应该可以读取到之前未读完的数据吧 3. golang net是如何解决数据丢失的风险的,在使用epoll ET编程时有哪些开发规范或最佳实践应对这个问题 4. 思考题 golang net设计了 BIO模式的 API,为每个连接都分配一个 goroutine。 这在高并发下,会产生大量的 goroutine,需要频繁的上下文切换,增大Goroutine 调度器的开销。 a. evio,使用事件驱动模型,采用单线程或多线程事件循环,比协程并发模型更轻量。 b. netpoll,使用gopool池、高效的内存复用、支持检查连接是否存活,可以及时清理池中失效的连接,降低资源占用。 c. gnet,也是使用ants池,高效、可重用而且自动伸缩的内存 buffer。 以上三个库没有看过源码,简单看了官方文档。个人觉得,evio与netpoll,gnet的不同之处是抛开了协程并发模型,完全基于epoll事件循环模型。而后两者还是采用协程模型,主要使用了协程池,内存池优化达到资源复用,减轻了调度器和GC开销。

    2024-12-27