购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

3.5 文件I/O和网络I/O

数据的I/O大体可以分为文件I/O和网络I/O两大类。它们在Go语言中的处理方式存在本质的差异,而理解这些差异对于编写高效的I/O程序是至关重要的。其实,在Go的编程实践中,网络I/O密集型的应用更适合,这是由内部机制决定的。

1)I/O模型不同:在Go语言中,文件I/O通常采用同步阻塞的读写方式。简单来说,文件的读写操作实际上是系统调用的直接体现。而网络I/O,在Go的内部则表现为用异步非阻塞的方式进行读写,即读写操作时,如果有网络数据就进行读取;没数据就返回EAGAIN报错。然后,当前Goroutine会主动挂起并陷入睡眠,让出执行权,当前线程得到释放,执行其他的Goroutine任务。后续通过epoll机制发现网络数据就绪,会唤醒对应的Goroutine,然后继续执行代码。

2)传输方式不同:文件I/O操作在应用程序和操作系统之间进行,通常以同步的方式传输数据,数据可以直达存储硬件。网络I/O则通常用异步的方式传输数据,例如写操作中,数据到达内核的缓冲之后,应用程序就认为发送完成,后续操作交给网络协议栈处理。

3)数据处理方式不同:由于底层硬件的区别,文件I/O通常有大小对齐的要求,还有顺序I/O和随机I/O的区别。网络I/O通常是把数据按照分组,流式地传输,每个分组都包含数据的一部分,组合起来才是完整的数据。

提示

本章节所涉及文件I/O的文件是指常见的Ext3、Ext4、Xfs等磁盘文件系统中的文件,此类文件通常是不支持poll方法的,且本章默认以epoll池作为事件管理器进行分析。

3.5.1 文件I/O

在Go语言程序中,文件I/O实现非常简洁,其本质是对相关的系统调用的一个轻量级封装。文件I/O的相关实现主要由标准库os包支持。

1.打开文件

文件I/O都始于“打开文件”这一基本操作。在打开文件时,本质上其实是在内存中初始化相关的数据结构,构建起文件的访问路径,为随后的I/O操作做好准备。代码清单3-45展示了os.OpenFile函数的实现。

代码清单3-45 os.OpenFile函数的实现

os.OpenFile函数内部调用了openFileNolog函数,这个函数的实现根据不同的操作系统平台有所不同。openFileNolog函数的实现如代码清单3-46所示。

代码清单3-46 openFileNolog函数的实现

打开文件的过程主要包括两个核心步骤。

1)通过执行Open系统调用,获取到一个非负整数的文件描述符,这是最关键的操作。

2)利用获取到的文件描述符构建Go语言的os.File结构,该结构是文件I/O的核心结构。

os.OpenFile函数返回的os.File结构体实例代表了一个被打开的文件,其内部的实现强依赖于操作系统。以Linux系统为例,os.File相关的结构定义如代码清单3-47所示。

代码清单3-47 os.File结构体定义

值得注意的是,在poll.FD结构体内有一个pollDesc类型的字段,此字段代表I/O轮询器。然而,在文件I/O的场景下这个字段并不被使用,其主要原因是磁盘文件的文件描述符无法加入epoll这样的事件池。pollDesc类型主要用在网络I/O的场景。

2.读写文件

接下来将探讨一下Go语言是如何实现文件的Read和Write操作的。对于文件写入操作,File结构体提供了Write方法,该方法的实现细节如代码清单3-48所示。

代码清单3-48 File.Write方法的实现

对于文件读取操作,File结构体提供了ReadAt方法,该方法的实现细节如代码清单3-49所示。

代码清单3-49 File.ReadAt方法的实现

由此可见,文件I/O操作本质上是一系列简单的系统调用,如Read、Write、Pread和Pwrite,这些是文件操作的核心。因此Go语言的文件I/O特性与这些系统调用的行为几乎是一致的。这意味着当进行文件I/O操作时,发起这些操作的Goroutine及其线程都不得不进入阻塞状态,等待操作完成,其间它们无法执行其他任务。

随着执行文件I/O的Goroutine越来越多,可能会导致可运行Goroutine的线程越来越少,被阻塞的线程越来越多,从而影响整个程序的请求处理能力。为了缓解这种影响,Go程序会持续新建线程,从而维持一定数量的活跃线程,确保Goroutine持续运行。特别是在文件I/O密集且磁盘I/O响应慢的场景,这种线程数量持续增多的情况会非常明显,一旦线程数量超过系统阈值,程序就可能因资源过载而崩溃。

因此,对于高并发且文件I/O密集的场景,Go程序可能面临性能瓶颈。目前,一些第三方的库已经开始探索操作系统的io_uring的功能,旨在为文件I/O引入异步处理能力。但由于io_uring特性对Linux内核版本要求较高,并且尚未在广泛的生产环境下得到充分验证。因此这一技术虽然令人期待,但在实际应用的表现还有待市场和用户的确认。

3.5.2 网络I/O

在Go语言中,网络I/O与文件I/O在工作原理上存在显著差异。表面上,网络I/O给使用者的感觉好像是同步执行的,但Go的内部巧妙地运用了异步策略。这种设计的核心是,在等待网络操作时,不会阻塞整个线程,只会挂起涉及的Goroutine。这允许线程被释放,去处理其他任务,极大提升了系统的并发处理能力。

1.网络句柄

以服务端的网络I/O的实现方式为例,通常是通过调用Accept函数获取一个代表客户端连接的socket句柄,后续的网络I/O操作都是基于这个句柄进行的。在Go语言中,socket句柄默认设置为非阻塞模式,这确保了Go程序在读写socket句柄时,能够主动控制线程,而不会被动阻塞在调度线程上。

接下来将深入了解Go语言在处理网络连接时,Accept方法的实现细节,如代码清单3-50所示。

代码清单3-50 Accept方法的实现

在Go语言中,网络的句柄都是放在epoll池里进行管理,epoll机制的封装在internal/poll包提供了支持。网络句柄创建的核心流程如下:

1)在FD.Accept方法中,会执行Accept系统调用以获取一个新的网络socket句柄。如果发生EAGAIN报错,这表明当前没有连接请求,系统会通过fd.pd.waitRead方法进行等待,直到有新的连接请求到达。

2)在netFD.accept方法中,一旦接收到新的连接请求,并获得网络socket句柄,系统便会构造一个netFD结构体。紧接着,通过调用netFD.init方法,把新的网络句柄注册到epoll池中,后续的流程由epoll事件管理器来驱动。

2.网络读写

网络I/O操作是通过net.Conn接口进行的,它代表一条网络连接。具体到实现层面,这一接口由net.conn结构体实现。为了深入理解其工作机制,我们将深入分析net.conn的Write和Read方法的实现原理。

conn.Write实现了往网络中写数据,以下是conn.Write方法的实现代码,如代码清单3-51所示。

代码清单3-51 conn.Write方法的实现

conn.Read函数实现了从网络连接中读取数据的功能,其代码实现如代码清单3-52所示。

代码清单3-52 conn.Read函数的实现

回顾3.5.1节介绍的文件I/O的原理,读者朋友可能会发现一个有趣的现象:尽管网络I/O和文件I/O看似是完全不同的实现,但它们最终汇聚于同一点。在底层,两者都采用了poll.FD这一抽象的结构体。图3-9直观地展示了这两种I/O方法之间的关系。

为了更深入地理解网络I/O的底层机制,下面将从poll.FD结构体着手,逐步揭开网络I/O的深层原理。

(1)poll.FD

在poll包中,FD结构体是文件I/O和网络I/O在底层实现中的关键元素,代表一个通用的文件描述符。不论是文件还是网络的I/O的操作,它们都依赖于这个结构体提供的方法。以下是poll.FD结构体的定义,如代码清单3-53所示。

图3-9 文件I/O与网络I/O相关句柄结构

代码清单3-53 poll.FD结构体的定义

在poll.FD结构体的定义中,文件I/O和网络I/O之间的差异得到了体现,其关键字段包括以下几种。

❑ Sysfd:无论是文件还是网络操作,这都是最关键的字段,代表由操作系统内核提供的文件描述符。

❑ pd:对I/O事件轮询机制的封装。文件I/O中这个字段一般都无实际用途,而网络I/O则依赖该字段来实现高效的事件处理。

❑ isBlocking:文件句柄通常是阻塞模式(1),网络句柄通常是非阻塞模式(0)。

❑ isFile:对于文件句柄来说,此字段为true;对于网络句柄,则为false。

下面通过探讨一个典型的读请求示例来深入理解FD的实现细节。无论是文件还是网络的读请求,最终都是调用FD.Read方法。代码清单3-54展示了FD.Read方法的实现。

代码清单3-54 FD.Read方法的实现

在这段代码中,FD.Read方法的行为因文件I/O与网络I/O的不同而有所差异。对于文件I/O,syscall.Read调用可能会导致线程阻塞。而在网络I/O中,如果数据未就绪时,syscall.Read会快速返回EAGAIN错误码。此时,FD.Read会调用fd.pd.waitRead(fd.isFile)方法,这是用来等待I/O事件就绪状态的机制。它的特点是不会阻塞整个线程,而只会阻塞挂起当前的Goroutine,并允许它在网络I/O事件就绪之后恢复执行。这种机制有效地释放了线程资源,使其可以去执行其他的Goroutine任务,这体现了Go并发处理Goroutine的能力。

waitRead是poll.pollDesc结构的重要方法,接下来将进一步探讨poll.pollDesc结构体的实现细节。

(2)poll.pollDesc

poll.pollDesc结构体是对I/O事件轮询机制的抽象封装,其设计旨在提供统一的接口来管理各种I/O事件。它的定义如代码清单3-55所示。

代码清单3-55 poll.pollDesc结构体的定义

在poll.pollDesc结构体中,唯一的成员变量是runtimeCtx字段,这是一个指针的值,指向runtime.pollDesc结构体。在pollDesc.init方法中完成了runtimeCtx字段的赋值,该方法有两个关键场景被调用:文件句柄是在newFile函数中被调用,网络句柄是在netFD.init方法中调用。

pollDesc.init方法的实现如代码清单3-56所示。

代码清单3-56 pollDesc.init方法的实现

pollDesc.init方法的作用是尝试用runtime_pollOpen函数把文件描述符注册到事件管理器中,并把得到的ctx(runtime.pollDesc类型)赋值给runtimeCtx字段。对于网络句柄来说,通常能得到一个有效的runtimeCtx值。然而,文件的句柄通常在执行runtime_pollOpen时会遇到EPERM错误码,这导致文件句柄的runtimeCtx字段保持为nil。

我们可以通过runtimeCtx字段来判断当前文件描述符添加到了事件管理器。这正是pollDesc.pollable方法的实现。如代码清单3-57所示。

代码清单3-57 pollDesc.pollable方法的实现

下面将进一步探索runtimeCtx字段的类型:runtime.pollDesc结构体的实现细节。

(3)runtime.pollDesc

runtime.pollDesc类型是Go语言的事件驱动架构中的核心的结构体。这个结构体是将epoll机制和Goroutine调度巧妙结合的枢纽,它在网络I/O操作的Goroutine切换和唤醒过程中扮演着关键角色。runtime.pollDesc结构的定义如代码清单3-58所示。

代码清单3-58 runtime.pollDesc结构的定义

runtime包的pollDesc结构体的设计旨在保存单个I/O请求的上下文信息,例如,它能够存储当前Goroutine的地址。该结构体中包括以下关键字段。

❑ fd:文件描述符,注册到事件管理器。

❑ rg:与读事件相关,存储触发读操作的Goroutine的地址。

❑ wg:与写事件相关,存储触发写操作的Goroutine的地址。

当文件描述符被注册到epoll池时,runtime.pollDesc结构会被用作对应句柄的私有数据。此设计允许Go运行时系统有效追踪和管理I/O事件以及与之相关的Goroutine。

接下来将深入探讨与Goroutine调度紧密相关的具体实现部分,这一功能主要由netpoll*系列函数提供支持,这些函数构成了Go网络编程的基础,使得高效的并发I/O成为可能。

(4)netpoll

Go语言runtime包实现了对I/O的事件管理器的封装。在Linux下,其实就是对epoll池的封装。runtime包定义了一系列netpoll的函数,如代码清单3-59所示。

代码清单3-59 netpoll系列函数的定义

Go程序运行时维护着一个全局的epoll池,这个epoll池是持续处理I/O事件的关键。当网络句柄被创建时,netpollopen函数会被调用,将该句柄注册到这个全局的epoll池中进行管理。并且Go程序会持续调用netpoll函数,从epoll池里处理就绪的I/O事件。

1)Goroutine的挂起流程。

我们回顾网络I/O的读流程,在FD.Read方法内部执行了poll包的pollDesc.waitRead方法。我们之前曾提到,这个函数会导致Goroutine阻塞并挂起,将执行权暂时让给其他的Goroutine。对应的调用栈如下所示:

因此,实际上影响Goroutine调度的是netpollblock函数。它在处理等待事件时发挥着重要作用。netpollblock函数的实现如代码清单3-60所示。

代码清单3-60 netpollblock函数的实现

如上所述,netpollblock函数会先获取对应的pd.rg或pd.wg的地址,然后利用gopark函数将Goroutine主动挂起。在挂起之前,netpollblockcommit函数被调用,它会将当前Goroutine的地址设置到pd.rg或pd.wg中,使它之后可以被唤醒。

2)Goroutine的唤醒流程。

下面我们进一步分析Goroutine是如何被唤醒的,前文因为网络I/O未就绪而导致被挂起的Goroutine,最终将从netpoll函数开始唤醒。netpoll函数的实现如代码清单3-61所示。

代码清单3-61 netpoll函数的实现

在netpoll函数核心操作流程如下所示:

❑ 通过epollwait系统调用,可以获取到一组就绪的I/O事件,随后启动一个for循环逐个对它们进行处理。

❑ 提取事件句柄的私有数据,该数据类型为runtime.pollDesc。

❑ 使用netpollready函数获取pollDesc结构体中的rg、wg字段,从而定位到当前事件对应的Goroutine地址。

❑ 把对应的Goroutine加入toRun链表中,最终由netpoll函数返回这个Goroutine的链表。

❑ netpoll函数的调用者使用runtime.injectglist函数,将得到的Goroutine链表全部投递到运行队列中,完成唤醒过程。

图3-10展示了文件I/O和网络I/O在结构上的关联关系。

图3-10 文件I/O和网络I/O在结构上的关联关系

至此,文件I/O和网络I/O的全部流程都讲完了。总的来说,Go语言内部对网络I/O的处理采用了异步执行的方式,使其与Go语言的Goroutine调度机制无缝对接,确保了系统对执行流程的主导权。而文件I/O则不然,它主要以系统调用的形式直接实施,采取同步阻塞的策略。由于这种方式会在等待I/O操作完成时阻塞执行线程,因此它可能对线程调度和程序的整体吞吐量产生负面影响。 kgo6iDQ2xDAhZg74ilNv+rJ6STJeD8iOK41WmZrBIW/KSMnj9knrElnXcbeBHnGo

点击中间区域
呼出菜单
上一章
目录
下一章
×