你好,我是徐逸。
前面,我们花了不少篇幅一同深入学习了Go服务高性能编码技巧,来全力保障线上服务的性能。不过呢,除了我们写的业务逻辑代码,服务框架本身对于性能也有着举足轻重的影响。而影响框架性能的一个很重要的因素,就是框架所使用的网络IO模型。
今天我们就来聊聊网络IO模型、epoll技术和Golang底层网络IO的原理。掌握网络IO模型、epoll技术和Golang底层网络IO原理,不仅有助于你更好地做框架选型,而且还能提升你使用Go开发更底层网络程序的能力。
网络IO模型
在介绍具体的网络IO模型之前,先让我们来想一想,一次网络IO的过程大概是什么样的呢?
就像下面的图一样,以读IO为例,网络数据要被咱们的应用程序接收到,可以划分为下面两个阶段。
- 数据准备阶段,驱动程序和操作系统内核从网卡读取数据到socket的接收缓冲区。
- 数据复制阶段,由应用程序将内核空间socket缓冲区的数据复制到用户空间。

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

那么使用阻塞IO的方式,会有什么问题呢?
当我们使用阻塞IO模型时,为了能及时处理多个连接的读写请求,就像下面的伪代码一样,每个连接我们都需要创建一个专门的线程来处理。在高性能服务器场景,当和客户端的连接比较多时,阻塞IO会导致创建比较多的线程,增加内存占用和上下文切换成本,降低服务器处理请求的吞吐。
1 | for { |
非阻塞IO
为了解决高性能场景阻塞IO会创建较多线程的问题。操作系统给我们提供了非阻塞IO的方式,就像下面的图一样,当应用线程调用操作系统提供的读写方法时,如果socket缓冲区还没准备好,网络IO系统调用立即返回,不再阻塞应用线程。

使用非阻塞IO编程模型,我们可以实现线程复用,当一个连接的socket缓冲区未就绪时,线程可以处理另一个连接的请求,而不再陷入阻塞,从而解决阻塞IO模式线程数过多的问题。就像下面的伪代码一样。
1 | // 多个待服务的 fd |
那么非阻塞IO模型有什么问题呢?
非阻塞IO模型需要利用轮询不断做系统调用,浪费大量CPU资源。而且,当内核接收到数据时,数据需要等到应用线程下一次轮询才能复制到用户空间,得不到立刻处理,这可能会导致请求响应的延时比较高。
IO多路复用
为了能高效、及时地处理大量连接的 I/O 事件,操作系统还提供了IO多路复用的方式,让我们的应用线程能及时感知到socket缓冲区就绪的事件。
就像下面的图一样,我们可以在一个线程里阻塞监听多个连接的网络IO事件,当有连接的socket缓冲区准备好,IO多路复用的方法就会返回,让应用线程能及时处理连接的网络请求。

使用IO多路复用的方式,就像下面的伪代码一样,当没有连接的网络IO就绪时,多路复用的epoll_wait方法会阻塞,避免线程不断轮询消耗CPU资源。同时,网络IO就绪时,epoll_wait方法会立即返回,确保应用线程能及时感知网络IO就绪事件,避免处理请求不及时。
1 | // 多个待服务的 fd |
异步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提供的下面三个系统调用。
- epoll_create函数,它的功能是在Linux内核创建一个内核需要监听的网络连接池子。
- epoll_ctl函数,它的功能是增删改池子里需要监听的连接和事件。
- epoll_wait函数,它的功能是阻塞等待池子里连接的网络IO事件。
1 |
|
当然,在使用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。
1 | package main |
Golang I/O 多路复用和epoll调用的细节,就隐藏在这些方法内部和Golang运行时里。
首先,我们来看看Listen方法,实际上,它最终会调用操作系统的epoll_create方法,创建一个epoll池。
1 | //runtime/netpoll_epoll.go |
创建完epoll池,它会将需要监听网络连接的文件描述符(fd),通过调用操作系统的epoll_ctl方法,加入到epoll池子里。
1 | //runtime/netpoll_epoll.go |
接着,我们来看看Accept方法。Accept方法会尝试非阻塞获取TCP连接,如果能够获取到,则会调用epoll_ctl方法将新连接加入epoll池。
1 | //runtime/netpoll_epoll.go |
如果获取不到连接,协程会陷入阻塞,并触发协程调度。
1 | //runtime/netpoll.go |
然后,我们来看看Read和Write方法。Read和Write方法会尝试非阻塞读写数据,如果socket缓冲区就绪,就会进入前面网络IO模型讲到的数据复制阶段;如果未就绪,调用方法的协程会阻塞。
1 | //runtime/netpoll.go |
最后,让我们来看看,Golang运行时是如何感知网络IO就绪事件,唤醒因网络IO事件未就绪而陷入阻塞的协程的。Golang运行时里,下面几个地方会调用操作系统的epoll_wait方法完成这个目标。
第一个地方是在全局监控任务 sysmon里。在程序启动时,Golang底层会单独启动一个线程,用于执行 sysmon 监控任务。
1 | // runtime/proc.go |
在监控任务里,每隔 10ms会轮询调用netpoll 函数,这个函数会尝试取出网络IO事件就绪的协程列表,进行唤醒操作。
1 | func sysmon() { |
而 netpoll 方法的底层,就像下面的代码一样,会基于非阻塞模式调用操作系统的epoll_wait 方法,获取到就绪事件队列 events。然后遍历事件队列,将对应的协程添加到协程列表中返回给上层用于执行唤醒操作。
1 | // runtime/netpoll_epoll.go |
第二个地方是在协程调度流程中。在进行协程调度时,findRunnable函数会为当前处理器寻找下一个可执行的协程。如果此时没有可调度协程,findRunnable函数就会尝试获取网络IO就绪的协程用于调度执行。
1 | // runtime/proc.go |
第三个地方是在GC流程中。在 GC 过程中,每次调用完STW(stop the world)后,都会调用 start the world,此时也会对网络IO就绪的协程进行唤醒操作,以便网络IO事件能得到及时处理。
1 | //runtime/proc.go |
小结
今天这节课,我们一起学习了网络编程相关的核心知识,包括网络IO模型、epoll技术和Golang底层网络模型的原理。现在让我们来回顾一下这节课学到的网络编程知识。
网络IO模型有阻塞IO、非阻塞IO、IO多路复用和异步IO多种类型,实践中比较常用的是IO多路复用模型。
之后我们重点了解了Linux底层的多路复用技术——epoll,操作系统提供了epoll_create、epoll_ctl和epoll_wait三个方法给我们使用。在使用时,我们需要注意触发模式的选择。
最后,我以一段TCP服务器代码为例,深入分析了Go语言底层是如何巧妙运用网络IO模型和epoll技术,来实现高性能网络通信的。希望你能够用心体会今天讲到的网络编程知识,提升你使用Go开发更底层网络程序的能力。
思考题
虽然Golang网络库的性能已经很高了,但还是有不少高性能网络库在Golang 官方库的基础上进行了改进,请找一个高性能网络库并分析它的改进点。
欢迎你把你的答案分享在评论区,也欢迎你把这节课的内容分享给需要的朋友,我们下节课再见!