Go 网络 IO 模型源码分析|转载

2021年1月23日 0 条评论 1.17k 次阅读 1 人点赞

在过去,传统的网络编程模型是多线程模型,在主线程中开启一个网络监听,然后每次有一个客户端进行连接,就会单独开启一个线程来处理这个客户端请求。

然而,如果并发量比较大,服务端就会创建大量的线程,而且会有大量的线程阻塞在网络IO上,频繁的线程上下文切换会占用大量的cpu时间片,严重影响服务性能,而且大量的线程也需要占用大量的系统资源

这样就引出著名的C10K问题,如何在单台服务器上支持并发10K量级的连接

我们知道,虽然同一时间有大量的并发连接,但是同一时刻,只有少数的连接是可读/写的,我们完全可以只使用一个线程来服务提供服务,这也是目前解决C10K问题的主要思路,对应的解决方案叫做IO多路复用,现在主流的高性能网络服务器/框架都是基于该网络模型,比如nginxredis或者netty网络库等。

说到这,就不得不提epoll,这是linux内核提供的用于实现IO多路复用的系统调用,其他操作系统上也有类似的接口,关于epoll具体内容网上有一大堆的资料,这里就不重复介绍了

IO多路复用模型,也可以称作是事件驱动模型,虽然能够有效解决C10K问题,但是相对传统的多线程模型也带来了一点复杂性。比如说,在多线程模型下,每个连接独占一个线程,而线程本身有自己的上下文;而如果是IO多路复用模型,需要在一个线程中处理多个连接,而每个需要有自己的上下文,需要开发者手动管理。比如服务端还没有接收到一个完整的协议报文时,我们需要把先前接收的部分内容保存到当前连接上下文中,等到下次其余内容到底时再一起处理。

今天,我们主要来看一下go中的网络模型。

go中我们可以像传统的多线程模型那样为每个网络连接单独使用一个goroutine来提供服务,但是goroutine的资源占用相比系统级线程来说非常小,而且其切换在运行在用户态的,并且只需要交换很少的寄存器,因此goroutine的上下文切换代价也是极小的,更重要的是,其底层也是基于epoll(linux系统下)来实现事件通知的,因此只需要占用很少的系统级线程。

很明显可以看出,go中的网络IO模型是传统多线程模型和IO多路复用模型的结合,既有前者的易用性,又有后者的效率,因此使用go可以很容易地开发高性能服务器。

今天我们就来看一下,go中的网络IO模型是如何实现的。

一切从创建Listener开始

我们从创建Listener开始说起。

我们从 http.ListenAndServe() 这个方法追踪

先看下面代码:

我们使用Listen来创建一个Listener,那么底层具体会发生什么呢?让我们一步一步来揭开

首先查看net.Listen方法

可以看到实际上工作的是ListenConfig.Listen,我们继续往下看:

因为我们创建的是tcp连接,这里我们只关注sl.listenTCP方法,继续往下

我们看函数第一行,调用了internetSocket,很明显里面就是创建实际socket的逻辑了,继续往下走

这里我们只看linux的情况,因此继续看socket方法:

我们先来看sysSocket方法:

sysSocket主要通过系统调用创建了socket同时设置了SOCK_NONBLOCK标志位,这点非常重要,这里要明确,我们在go中使用的网络连接一般都是非阻塞的。关于阻塞IO和非阻塞IO的区别网上有一大堆的资料,这里就不重复说明了。使用非阻塞IO的主要的原因是,在go中,当使用阻塞系统调用时,当前goroutine对应的底层系统级线程就会被占用,无法与当前g解绑为其他g提供服务,这样当需要执行其他g时就需要创建新的线程来执行

接着来看netFd.listenStream

这里就是常规的绑定监听地址和端口,然后开始监听,这里重要的是netFD.init函数,先来看netFD的结构:

接着看上面的netFD.init函数:

我们来看一下pollFD.Init

可以看到上面又有个init函数,我们先来看一下fd.pd对应的pollDesc类型:

我们来看一下init函数:

上面这个函数才是关键所在,这里涉及到了runtime_pollServerInitruntime_pollOpen两个函数,从命名可以很容易看出这两个函数是在runtime包中实现的,然后在链接器链接过来的

先来看一下runtime_pollServerInit实现:

很简单,就是创建了一个epoll

再来看一下runtime_pollOpen的实现:

至此一个net.Listener就创建完成了,总结一下主要的逻辑:

  1. 创建一个非阻塞socket,并执行bindlisten
  2. 如果没有初始化过runtime包的epoll,则执行初始化,创建一个epoll
  3. 以边缘触发模式将socket添加到epoll
  4. 返回封装后的net.Listener

runtime包中的一些注释

pollDescrgwg字段,可能的取值情况:

  • pdReadyrg表示当前有可读事件,wg表示可写
  • pdWait:表示即将进入等待
  • G的指针:需要先进入pdWait,然后调用gopark,设置等待事件类型,如果是等待读,则设置rg,等待写则设置wg为当前G的指针,然后挂起;当事件到达,runtime会将对应的G唤醒
  • 0:其他情况

Accept又是如何执行的呢

接下来我们来看一下执行Accept时会发生什么

我们上面创建的是一个TcpListener,因此自然是执行对应的Accept,可以看到是调用netFD.Accept

接下来看一下poll.FDAccept方法:

接着来看pollDesc.waitRead实现:

接着看一下runtime_pollWait实现:

至此,Accept的流程也很清晰了:

  1. 首先直接尝试通过socket执行accept来获取可能的客户端连接
  2. 如果此时客户端没有连接,因为socket是非阻塞模式,会直接返回EAGAIN
  3. 调用runtime.poll_runtime_pollWait将当前协程挂起,并且根据是等待读还是等待写将当前g的引用保存到pollDesc中的rg或者wg
  4. 当有新的客户端连接到来时,epoll会通知将当前阻塞的协程恢复,然后重新执行第一步

那么epoll的wait又是什么时候调用的呢

我们可以在协程的调度逻辑中看到这样一段代码段:

我们来看一下netpoll的执行:

可以看到,在执行协程的调度时,会执行epoll_wait系统调用,获取已经准备好的socket,并唤醒对应的goroutine

除了在调度时会执行epoll_wait,在后台线程sysmon中也会定时执行epoll_wait

大同小异的读写操作

那么接下来,我们来看一下Read操作,实际上Read最后会执行

最后到了poll.FDRead方法:

再来看一下写过程,最后会执行:

差点被遗忘的close

接着来看一下Close方法,实际执行的是:

综上,关闭一个连接时:

  1. 设置pollDesc相关flag为已关闭,唤醒该连接上阻塞的协程
  2. 减少对应poll.FD的引用,如果引用为0,则只需真正的关闭
  3. 执行关闭操作,先从epoll删除对应的fd,然后执行close系统调用关闭

最后

可以看到,go使用非阻塞IO来防止大量系统线程阻塞带来的上下文切换,取而代之的是让轻量级的协程阻塞在IO事件上,然后通过epoll来实现IO事件通知,唤醒阻塞的协程。

兰陵美酒郁金香

大道至简 Simplicity is the ultimate form of sophistication.

文章评论(0)

你必须 登录 才能发表评论