大家好,我是飞哥!
在协程没有流行以前,传统的网络编程中,同步阻塞是性能低下的代名词,一次切换就得是3 us左右的 CPU 开销。各种基于 epoll 的异步非阻塞的模型虽然提高了性能,但是基于回调函数的编程方式却非常不符合人的的直线思维模式。开发出来的代码的也不那么容易被人理解。
Golang 的出现,可以说是将协程编程模式推向了一个高潮。这种新的编程方式既兼顾了同步编程方式的简单易用,也在底层通过协程和 epoll 的配合避免了线程切换的性能高损耗。换句话说就是既简单易用,性能又还不挺错。
飞哥当年也是相中的 golang 的这个特点,开始带领团队转型 golang 开发的。那么今天我们来深刻地和大家分享一下 golang 官方提供的 net 包,来看看它是如何达成上面所说的这样的效果的。
一、Golang net的使用方式
考虑到不少读者没有使用过 golang,那么开头我先把一个基于官方 net 包的 golang 服务的简单使用代码给大家列出来。为了方便大家理解,我只保留骨干代码。
在这个示例服务程序中,先是使用 net.Listen 来监听了本地的 9008 这个端口。然后调用 Accept 进行接收连接处理。如果接收到了连接请求,通过go process来启动一个协程进行处理。在连接的处理中我展示了读写操作(Read 和 Write)。
整个服务程序看起来,妥妥的就是一个同步模型,包括 Accept、Read 和 Write 都会将当前协程给“阻塞”掉。比如 Read 函数这里,如果服务器调用时客户端数据还没有到达,那么 Read 是不带返回的,会将当前的协程 park 住。直到有了数据 Read 才会返回,处理协程继续执行。
你如果在其它语言,例如 C 和 Java 中写出这样类似的服务器代码,估计会被打死的。因为每一次同步的 Accept、Read、Write 都会导致你当前的线程被阻塞掉,会浪费大量的 CPU 进行线程上下文的切换。
但是在 golang 中这样的代码运行性能却是非常的不错,为啥呢?我们继续看本文接下来的内容。
二、Listen 底层过程
在传统的 C、Java 等传统语言中,listen 所做的事情就是直接调用内核的 listen 系统调用。参见《为什么服务端程序都需要先 listen 一下?》。但是如果你也这么同等地理解 golang net 包里的 Listen, 那可就大错特错了。
和其它语言不同,在 golang net 的 listen 中,会完成如下几件事:
创建 socket 并设置非阻塞,
bind 绑定并监听本地的一个端口
调用 listen 开始监听
epoll_create 创建一个 epoll 对象
epoll_etl 将 listen 的 socket 添加到 epoll 中等待连接到来
一次 Golang 的 Listen 调用,相当于在 C 语言中的 socket、bind、listen、epoll_create、epoll_etl 等多次函数调用的效果。封装度非常的高,更大程度地对程序员屏蔽了底层的实现细节。
插一句题外话:现在的各种开发工具的封装程度越来越高,真不知道对码农来说是好事还是坏事。好处是开发效率更高了,缺点是将来的程序员想了解底层也越来越难了,越来越像传统企业里流水线上的工人。
口说无凭,我们挖开 Golang 的内部源码瞅一瞅,这样更真实。
Listen 的入口在 golang 源码的 net/dial.go 文件中,让我们展开来看更细节的逻辑。
2.1 Listen 入口执行流程
源码不用细看,看懂大概流程就可以。
可见,这个 Listen 只是一个入口。接下来会进入到 ListenConfig 下的 Listen 方法中。在 ListenConfig 的 Listen 中判断这是一个 TCP 类型的话,会进入到 sysListener 下的 listenTCP 方法里(src/net/tcpsock_posix.go)。然后再经过两三次的函数调用跳转,会进入到 net/sock_posix.go 文件下的 socket 函数中。我们直接看它。
接下来我们分别在 2.2 和 2.3 小节来介绍 sysSocket 和 listenStream 这两个函数。
2.2 创建 socket
sysSocket 这个函数和其它语言中的 socket 函数有很大的不同。在这个一个函数内就完成了三件事,创建 socket、bind 和 listen 监听。我们来看 sysSocket的具体代码。
在 sysSocket 中,调用的 socketFunc 其实就是 socket 系统调用。见如下代码。
创建完 socket 之后,再调用 syscall.SetNonblock 将其设置为非阻塞模式。
2.3 绑定和监听
我们接着再来看 listenStream。这个函数一进来就调用了系统调用 bind 和 listen 来完成了绑定和监听。
其中 listenFunc 是一个宏,指向的就是 syscall.Listen 系统调用
2.4 epoll创建和初始化
接下来在 fd.init 这一行,经过多次的函数调用展开以后会执行到 epoll 对象的创建,并还把在 listen 状态的 socket 句柄添加到了 epoll 对象中来管理其网络事件。
我们来看它是如何完成的。
serverInit.Do 这个是用来保证参数内的函数只执行一次的。不过多展开介绍。其参数 runtime_pollServerInit 是对 runtime 包的函数 poll_runtime_pollServerInit 的调用,其源码位于 runtime/netpoll.go 下。
该函数会执行到 netpollGenericInit, epoll 就是在它的内部创建的。
再来看 runtime_pollOpen。它的参数就是前面 listen 好了的 socket 的文件描述符。在这个函数里,它将被放到 epoll 对象中。
三、Accept 过程
服务端在 Listen 完了之后,就是对 Accept 的调用了。该函数主要做了三件事
调用 accept 系统调用接收一个连接
如果没有连接到达,把当前协程阻塞掉
新连接到来的话,将其添加到 epoll 中管理,然后返回
通过 Golang 里的单步调试可以看到它进入到了 TCPListener 下的 Accept 里了。
我们上面说的三步都是在 netFD 的 accept 函数里处理的。
接下来我们详细看每一步的细节。
3.1 接收一个连接
经过单步跟踪后发现 Accept 进入到了 FD 对象的 Accept 方法下。在这里将调用操作系统的 accept 系统调用。
其中 accept 方法内部会触发 linux 操作系统的 accept 系统调用,我们就不过度展开了。调用 accept 目的是获取一个来自客户端的连接。如果接收到了,就把他返回回去。
3.2 阻塞当前协程
我们来说说如果没 accept 调用的时候,客户端的连接请求还一个都没有过来怎么办。
这时候,accept 系统调用会返回 syscall.EAGAIN。Golang 在对这个状态的处理中,会把当前协程给阻塞起来。关键代码在这里
runtime_pollWait 的源码在 runtime/netpoll.go 下。gopark(协程的阻塞)就是在这里完成的。
gopark 这个函数就是 golang 内部阻塞协程的入口。
3.3 将新连接添加到 epoll 中。
我们再来说说假如客户端连接已经到来了的情况。这时 fd.pfd.Accept 会返回新建的连接。然后会将该新连接也一并加入到 epoll 中进行高效的事件管理。
我们来看 netfd.init
runtime_pollOpen 这个runtime 函数我们在上面的 2.4 节介绍过了,就是把文件句柄添加到 epoll 对象中。
四、Read 和 Write 内部过程
当连接接收完成后,剩下的就是在连接上的读写了。
4.1 Read 内部过程
我们先来看 Read 大体过程。
来看详细的代码。
Read 函数会进入到 FD 的 Read 中。在这个函数内部调用 Read 系统调用来读取数据。如果数据还尚未到达则也是把自己阻塞起来。
其中 waitRead 是如何将当前协程阻塞掉的,这个和我们前面 3.2 节介绍的是一样的,就不过多展开叙述了。
4.2 Write 内部过程
Write 的大体过程和 Read 是类似的。先是调用 Write 系统调用发送数据,如果内核发送缓存区不足的时候,就把自己先阻塞起来,然后等可写时间发生的时候再继续发送。其源码入口位于 net/net.go。
pd.wait 之后的事情就又和 3.2 节介绍的过程一样了。调用 runtime_pollWait 来讲当前协程阻塞掉。
五、Golang 唤醒
前面我们讨论的很多步骤里都涉及到协程的阻塞。例如 Accept 时如果新连接还尚未到达。再比如像 Read 数据的时候对方还没有发送,当前协程都不会占着 cpu 不放,而是会阻塞起来。
那么当要等待的事件就绪的时候,被阻塞掉的协程又是如何被重新调度的呢?相信大家一定会好奇这个问题。
Go 语言的运行时会在调度或者系统监控中调用sysmon,它会调用 netpoll,来不断地调用 epoll_wait 来查看 epoll 对象所管理的文件描述符中哪一个有事件就绪需要被处理了。如果有,就唤醒对应的协程来进行执行。
其实除此之外还有几个地方会唤醒协程,如
startTheWorldWithSema
findrunnable 在 schedule 中调用有top 和 stop 之分。其中 stop 中会导致阻塞。
pollWork
不过为了简便起见,我们只选择 sysmon 来作为一个切入口。sysmon 是一个周期性的监控协程,来看源码。
它会不断触发对 netpoll 的调用,在 netpoll 会调用 epollwait 看查看是否有网络事件发生。
在 epoll 返回的时候,ev.data 中是就绪的网络 socket 的文件描述符。根据网络就绪 fd 拿到 pollDesc。在 netpollready 中,将对应的协程推入可运行队列等待调度执行。
本文总结
同步编码方式的优点是符合人的直线思维。在这种模式下的代码很容易写,写出来也容易理解,但是缺点就是性能奇差。因为会导致频繁的线程上下文切换。
所以现在 epoll 是 Linux 下网络程序工作的最主要的模式。现在各种语言下的流行的网络框架模型都是基于 epoll 来工作的。区别就是各自对 epoll 的使用方式上存在一些差别。主流各种基于 epoll 的异步非阻塞的模型虽然提高了性能,但是基于回调函数的编程方式却非常不符合人的的直线思维模式。开发出来的代码的也不那么容易被人理解。
我个人一直觉得,Golang 封装的网络编程模型非常之精妙,是世界级的代码。它非常值得你好好学习一下。学完了觉得好的话,转发给你的朋友们一起来了解了解吧!