panjf2000

panjf2000 查看完整档案

海外编辑石家庄新东方烹饪学校  |  消灭辣椒专业 编辑gnet  |  coder 编辑 andypan.me 编辑
编辑

程序猿。

个人动态

panjf2000 发布了文章 · 1月3日

分布式事务/系统之底层原理揭秘

博客原文

分布式事务/系统之底层原理揭秘

导言

分布式事务是分布式系统必不可少的组成部分,基本上只要实现一个分布式系统就逃不开对分布式事务的支持。本文从分布式事务这个概念切入,尝试对分布式事务最核心的底层原理逐一进行剖析,内容包括但不限于 BASE 原则两阶段原子提交协议三阶段原子提交协议Paxos/Multi-Paxos 分布式共识算法的原理与证明Raft 分布式共识算法分布式事务的并发控制等内容。

事务

事务是访问并可能更新各种数据项的一个程序执行单元(unit)。事务由一个或多个步骤组成,一般使用形如 begin transactionend transaction 语句或者函数调用作为事务界限,事务内的所有步骤必须作为一个单一的、不可分割的单元去执行,因此事务的结果只有两种:1. 全部步骤都执行完成,2. 任一步骤执行失败则整个事务回滚。

事务最早由数据库管理系统(database management systemDBMS)引入并实现,数据库事务是数据库管理系统执行过程中的一个逻辑单位,由一个有限的数据库操作序列构成。数据库事务严格遵循 ACID 原则,属于刚性事务,一开始数据库事务仅限于对单一数据库资源对象的访问控制,这一类事务称之为本地事务 (Local Transaction),后来随着分布式系统的出现,数据的存储也不可避免地走向了分布式,分布式事务(Distributed Transaction)便应运而生。

刚性事务

刚性事务(如单一数据库事务)完全遵循 ACID 规范,即数据库事务的四大基本特性:

  • Atomicity(原子性):一个事务(transaction)中的所有操作,或者全部完成,或者全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。即,事务不可分割、不可约简。
  • Consistency(一致性):在事务开始之前和事务结束以后,数据库的完整性没有被破坏。这表示写入的资料必须完全符合所有的预设约束、触发器、级联回滚等。
  • Isolation(隔离性):数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括未提交读(Read uncommitted)、提交读(read committed)、可重复读(repeatable read)和串行化(Serializable)。
  • Durability(持久性):事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。

刚性事务也能够以分布式 CAP 理论中的 CP 事务来作为定义

柔性事务

在电商领域等互联网场景下,传统的事务在数据库性能和处理能力上都遇到了瓶颈。因此,柔性事务被提了出来,柔性事务基于分布式 CAP 理论以及延伸出来的 BASE 理论,相较于数据库事务这一类完全遵循 ACID 的刚性事务来说,柔性事务保证的是 “基本可用,最终一致”,CAP 原理相信大家都很熟悉了,这里我们讲一下 BASE 原则:

  • 基本可用(Basically Available):系统能够基本运行、一直提供服务。
  • 软状态(Soft-state):系统不要求一直保持强一致状态。
  • 最终一致性(Eventual consistency):系统需要在某一时刻后达到一致性要求。

柔性事务(如分布式事务)为了满足可用性、性能与降级服务的需要,降低一致性(Consistency)与隔离性(Isolation)的要求,遵循 BASE 理论,传统的 ACID 事务对隔离性的要求非常高,在事务执行过程中,必须将所有的资源对象锁定,因此对并发事务的执行极度不友好,柔性事务(比如分布式事务)的理念则是将锁资源对象操作从本地资源对象层面上移至业务逻辑层面,再通过放宽对强一致性要求,以换取系统吞吐量的提升。

此外,虽然柔性事务遵循的是 BASE 理论,但是还需要遵循部分 ACID 规范:

  • 原子性:严格遵循。
  • 一致性:事务完成后的一致性严格遵循;事务中的一致性可适当放宽。
  • 隔离性:并行事务间不可影响;事务中间结果可见性允许安全放宽。
  • 持久性:严格遵循。

本地事务

本地事务(Local Transaction)指的是仅仅对单一节点/数据库资源对象进行访问/更新的事务,在这种事务模式下,BASE 理论派不上用场,事务完全遵循 ACID 规范,确保事务为刚性事务。

分布式事务

在分布式架构成为主流的当下,系统对资源对象的访问不能还局限于单节点,多服务器、多节点的资源对象访问成为刚需,因此,本地事务无法满足分布式架构的系统的要求,分布式事务应运而生。

访问/更新由多个服务器管理的资源对象的平面事务或者嵌套事务称之为分布式事务(Distributed Transaction),分布式事务是相对于本地事务来说的。

平面事务:单一事务,访问多个服务器节点的资源对象,一个平面事务完成一次请求之后才能发起下一个请求。

嵌套事务:多事务组成,顶层事务可以不断创建子事务,子事务又可以进一步地以任意深度嵌套子事务。

对于分布式事务来说,有两个最核心的问题:

  1. 如何管理分布式事务的提交/放弃决定?如果事务中的一个节点在执行自己的本地事务过程中遇到错误,希望放弃整个分布式事务,与此同时其他节点则在事务执行过程中一切顺利,希望提交这个分布式事务,此时我们应该如何做决策?
  2. 如何保证并发事务在涉及多个节点上资源对象访问的可串行性(规避分布式死锁)?如果事务 T 对某一个服务器节点上的资源对象 S 的并发访问在事务 U 之前,那么我们需要保证在所有服务器节点上对 S 和其他资源对象的冲突访问,T 始终在 U 之前。

问题 1 的解决需要引入一类分布式原子提交协议的算法如两阶段提交协议等,来对分布式事务过程中的提交或放弃决策进行管理,并确保分布式提交的原子性。而问题 2 则由分布式事务的并发控制机制来处理。

原子提交协议

原子性是分布式事务的前置性约束,没有原子性则分布式事务毫无意义。

原子性约束要求在分布式事务结束之时,它的所有操作要么全部执行,要么全部不执行。以分布式事务的原子性来分析,客户端请求访问/更新多个服务器节点上的资源对象,在客户端提交或放弃该事务从而结束事务之后,多个服务器节点的最终状态要么是该事务里的所有步骤都执行成功之后的状态,要么恢复到事务开始前的状态,不存在中间状态。满足这种约束的分布式事务协议则称之为原子提交协议。

当一个分布式事务结束时,事务的原子特性要求所有参与该事务的服务器节点必须全部提交或者全部放弃该事务,为了实现这一点,必须引入一个协调者(Coordinator)的角色,从参与事务的所有服务器节点中挑选一个作为协调者,由它来保证在所有服务器节点上最终获得同样的结果。协调者的工作原理取决于分布式事务选用的协议。

一般来说,分布式事务中包含的两个最基础的角色就是:

  • Coordinator -- 协调者
  • Participants -- 参与者

单阶段原子提交协议

单阶段原子提交协议(one-phase atomic commit protocol, 1APC)是最简单的一种原子提交协议,它通过设置一个协调者并让它不断地向所有参与者发送提交(commit)或放弃(abort)事务的请求,直到所有参与者确认已执行完相应的操作。

1APC 协议的优点是简单易用,对一些事务不复杂的场景比较合适,但在复杂事务场景则显得捉襟见肘,因为该协议不允许任何服务器节点单方面放弃事务,事务的放弃必须由协调者来发起,这个设计会导致很多问题:首先因为只有一次通信,协调者并不会收集所有参与者的本地事务执行的情况,所以协调者决定提交还是放弃事务只基于自己的判断,在参与者执行事务期间可能会遇到错误从而导致最终事务未能真正提交,错误一般与事务的并发控制有关,比如事务执行期间对资源对象加锁,遇到死锁,需要放弃事务从而解开死锁,而协调者并不知道,因此在发起下一个请求之前,客户端完全不知道事务已被放弃。另一种情况就是利用乐观并发控制机制访问资源对象,某一个服务器节点的验证失败将导致事务被放弃,而协调者完全不知情。

两阶段提交协议

定义

两阶段提交协议(two-phase commit protocol, 2PC)的设计初衷是为了解决 1APC 不允许任意一个服务器节点自行放弃它自己的那部分本地事务的痛点,2PC 允许任何一个参与者自行决定要不要放弃它的本地事务,而由于原子提交协议的约束,任意一个本地事务被放弃将导致整个分布式事务也必须放弃掉。

两阶段提交协议基于以下几个假设:

  • 存在一个节点作为协调者(Coordinator),分布式事务通常由协调者发起(当然也可以由参与者发起),其余节点作为参与者(Participants),且节点之间可以自由地进行网络通信,协调者负责启动两阶段提交流程以及决定事务最终是被提交还是放弃。
  • 每个节点会记录该节点上的本地操作日志(op logs),日志必须持久化在可靠的存储设备上(比如磁盘),以便在节点重启之后需要恢复操作日志。另外,不记录全局操作日志。
  • 所有节点不能发生永久性损坏,也就是说节点就算是损坏了也必须能通过可靠性存储恢复如初,不允许出现数据永久丢失的情况。
  • 参与者对协调者的回复必须要去除掉那些受损和重复的消息。
  • 整个集群不会出现拜占庭故障(Byzantine Fault)-- 服务器要么崩溃,要么服从其发送的消息。

原理

两阶段提交协议,顾名思义整个过程需要分为两个阶段:

  1. 准备阶段(Prepare Phase)
  2. 提交阶段(Commit Phase)

在进行两阶段提交的过程中,协调者会在以下四种状态间流转:

  1. init
  2. preparing
  3. committed
  4. aborted

而参与者则会在以下三种状态间流转:

  1. working
  2. prepared
  3. committed

阶段 I(投票表决阶段)

  1. 任意一个参与者发起分布式事务 T 并执行本地事务成功,接着将一条 <ready T> 记录追加到本地日志 buffer 中并 flush 到可靠性存储设备如磁盘上,从 working 状态进入 prepared 状态,然后向协调者发送 prepare T 消息;
  2. 收到参与者发来的 prepare T 消息后,协调者将一条 <prepare T> 记录追加到日志中,然后从 init 状态进入 preparing 状态,紧接着向分布式事务的其他参与者发出 canCommit? 消息,发起事务表决过程;
  3. 当参与者收到 canCommit? 请求后,除了发起事务的那一个之外,其他还在 working 状态的参与者会先尝试执行本地事务,如果本地事务执行成功,则会往本地日志 buffer 写入一条 <ready T> 记录并 flush 到可靠性存储中,但不提交事务,进入 prepared 状态,然后回复一条 ready T 消息对此事务投 YES 票;如果本地事务执行失败,则参与者会往本地日志 buffer 写入一条 <don't commit T> 记录并 flush 到可靠性存储中,然后回复一条 don't commit T 消息投 NO 票。

阶段 II(收集投票结果完成事务)

  1. 协调者收集所有的投票(包括它自己的投票);

    (a) 如果所有的投票都是 ready T,则表示没有故障发生,那么协调者决定提交该事务,首先它会在其本地日志中追加一条 <commit T> 记录,从 preparing 状态进入 committed 状态,然后向所有的参与者发送 doCommit 请求消息,要求参与者提交它们的本地事务;

    (b) 如果有任一个投票是 No,则协调者决定放弃掉该事务,首先它会往本地日志中追加一条 <abort T> 记录,从 preparing 状态进入 aborted 状态,然后发送 doAbort 请求消息给所有的参与者,通知它们回滚各自的本地事务。

  2. 投了 YES 票的参与者阻塞等待协调者给它发来 doCommitdoAbort 消息,如果接收到的是 doCommit 消息则提交本地事务并在此过程中记录日志 <commit T>,然后进入 committed 状态,最后回复一个 haveCommitted 的消息通知协调者本地事务已经成功提交;反之,如果收到的是 doAbort 消息则回滚本地事务并写入日志 <abort T>,然后进入 aborted状态。

上面的过程是一种更通用的流程,即由任意的参与者发起一个分布式事务,而在实践中一般把分布式事务的发起交给协调者来做,减少事务发起者确认该事务已被提交所需等待的网络消息延迟:

性能

网络 I/O 开销

假设两阶段提交过程一切运行正常,即协调者和参与者都不出现崩溃和重启,网络通信也都正常。那么假设有一个协调者和 N 个参与者,两阶段提交过程中将会发送如下的消息:

  • 任意一个参与者从 working 状态进入 prepared 状态并发送 Prepared 消息给协调者,1 条消息。
  • 协调者收到消息后,向其他参与者发送 canCommit? 请求消息,N - 1 条消息。
  • 收到 canCommit? 消息的参与者各自回复协调者投票消息,N - 1 条消息。
  • 协调者统计投票情况之后,发送 doCommit 消息给其他参与者,N 条消息。

所以,事务发起者在经过 4 条网络消息延迟之后确认该分布式事务已被提交,而整个过程共计发送 3N - 1 条网络消息(因为 haveCommitted 在 2PC 仅仅是用于最后通知协调者而已,属于可有可无的一次网络消息,2PC 在该消息缺省的情况下也能正常运行,因此 haveCommitted 一般不计入网络延迟成本中)。

前面我们提到,在实践中一般是由协调者来发起事务,如果考虑这种情况的话,事务发起者 -- 协调者在经过 3 条网络消息延迟之后确认该分布式事务已经被提交,而整个过程实际发送的网络消息则变成 3N 条。

总而言之,两阶段提交协议的网络通信开销和集群节点的数量成 3 倍正比。

本地存储设备 I/O 开销

基于前文中叙述的两阶段提交协议的基本假设之一:每个节点会通过日志来记录在本地执行的操作,以便在节点发生故障并重启节点之后能利用日志恢复到故障前的状态,因此两阶段提交过程中除了网络 I/O 的开销之外,还有本地存储设备 I/O 的开销:

  • 发起事务的参与者执行本地事务,1 次写操作。
  • 其余参与者执行各自的本地事务,N - 1 次写操作。
  • 协调者统计投票结果并决定提交事务,1 次写操作。

所以事务发起者在经过 3 次本地存储设备 I/O 延迟之后确认该事务已被提交,整个过程总计有 N + 1 次本地存储设备 I/O,而如果由协调者来发起事务的话,则还是需要 N + 1 次本地存储设备 I/O,但是只需要经过 2 次本地存储设备 I/O 延迟即可确认事务已被提交。

恢复

在分布式事务中,所有的参与者节点都可能发生故障,所以我们需要保证在该故障节点恢复时发生的一切都和分布式事务 T 的全局决策保持一致。节点在恢复的时候会读取 T 的最后一个本地日志记录并作出相应的操作:

  1. 如果 T 的最后一条日志记录是 <commit T>,那么说明协调者在节点发生故障时的全局决策是提交 T,根据本地事务所使用的日志方式,在该节点上可能需要执行 redo T
  2. 如果 T 的最后一条日志记录是 <abort T>,那么说明协调者在节点发生故障时的全局决策是中止 T,根据本地事务所使用的日志方式,在该节点上可能需要执行 undo T
  3. 如果 T 的最后一条日志记录是 <don't commit T>,则和第 2 中情况类似,执行 undo T
  4. 如果 T 的最后一条日志记录是 <ready T>,这种情况比较麻烦,因为恢复节点无法确认在它故障之后协调者发出的最终全局决策是什么,因此它必须要和集群中其余至少一个节点取得联系,询问 T 的最终结果是什么:恢复节点先尝试询问协调者,如果此时协调者正在工作,则告知恢复节点 T 的最终结果,如果是提交就执行 redo T,中止就执行 undo T;如果协调者因故不在工作,则恢复节点可以要求其他某一个参与者节点去查看本地日志以找出 T 的最终结果并告知恢复节点。在最坏的情况下,恢复节点无法和集群中其他所有节点取得联系,这时恢复节点只能阻塞等待,直至得知 T 的最终结果是提交还是中止。
  5. 如果本地日志中没有记录任何关于 T 在两阶段提交过程中的操作,那么根据前面的两阶段提交流程可知恢复节点还没来得及回复协调者的 canCommit? 请求消息就发生了故障,因此根据两阶段算法,恢复节点只能执行 undo T

缺陷

  1. 同步阻塞:两阶段提交协议是一个阻塞的协议,在第二阶段期间,参与者在事务未提交之前会一直锁定其占有的本地资源对象,直到接收到来自协调者的 doCommitdoAbort 消息。
  2. 单点故障:两阶段提交协议中只有一个协调者,而由于在第二阶段中参与者在收到协调者的进一步指示之前会一直锁住本地资源对象,如果唯一的协调者此时出现故障而崩溃掉之后,那么所有参与者都将无限期地阻塞下去,也就是一直锁住本地资源对象而导致其他进程无法使用。
  3. 数据不一致:如果在两阶段提交协议的第二阶段中,协调者向所有参与者发送 doCommit 消息之后,发生了局部网络抖动或者异常,抑或是协调者在只发送了部分消息之后就崩溃了,那么就只会有部分参与者接收到了 doCommit 消息并提交了本地事务;其他未收到 doCommit 消息的参与者则不会提交本地事务,因而导致了数据不一致问题。

XA 标准接口

2PC 两阶段提交协议本身只是一个通用协议,不提供具体的工程实现的规范和标准,在工程实践中为了统一标准,减少行业内不必要的对接成本,需要制定标准化的处理模型及接口标准,国际开放标准组织 Open Group 定义了分布式事务处理模型 DTP(Distributed Transaction Processing)Model,现在 XA 已经成为 2PC 分布式事务提交的事实标准,很多主流数据库如 Oracle、MySQL 等都已经实现 XA。

两阶段事务提交采用的是 X/OPEN 组织所定义的 DTP Model 所抽象的 AP(应用程序), TM(事务管理器)和 RM(资源管理器) 概念来保证分布式事务的强一致性。 其中 TM 与 RM 间采用 XA 的协议进行双向通信。 与传统的本地事务相比,XA 事务增加了准备阶段,数据库除了被动接受提交指令外,还可以反向通知调用方事务是否可以被提交。 TM 可以收集所有分支事务的准备结果,并于最后进行原子提交,以保证事务的强一致性。

Java 通过定义 JTA 接口实现了 XA 模型,JTA 接口中的 ResourceManager 需要数据库厂商提供 XA 驱动实现, TransactionManager 则需要事务管理器的厂商实现,传统的事务管理器需要同应用服务器绑定,因此使用的成本很高。 而嵌入式的事务管器可以以 jar 包的形式提供服务,同 Apache ShardingSphere 集成后,可保证分片后跨库事务强一致性。

通常,只有使用了事务管理器厂商所提供的 XA 事务连接池,才能支持 XA 的事务。Apache ShardingSphere 在整合 XA 事务时,采用分离 XA 事务管理和连接池管理的方式,做到对应用程序的零侵入。

三阶段提交协议

由于前文提到的两阶段提交协议的种种弊端,研究者们后来又提出了一种新的分布式原子提交协议:三阶段提交协议(three-phase commit protocol, 3PC)。

三阶段提交协议是对两阶段提交协议的扩展,它在特定假设下避免了同步阻塞的问题。该协议基于以下两个假设:

  1. 集群不发生网络分区;
  2. 故障节点数不超过 K 个(K 是预先设定的一个数值)。

基于这两个假设,三阶段提交协议通过引入超时机制和一个额外的阶段来解决阻塞问题,三阶段提交协议把两阶段提交协议的第一个阶段拆分成了两步:1) 评估,2) 资源对象加锁,最后才真正提交:

  1. CanCommit 阶段:协调者发送 CanCommit 请求消息,询问各个参与者节点,参与者节点各自评估本地事务是否可以执行并回复消息(可以执行则回复 YES,否则回复 NO),此阶段不执行事务,只做判断;
  2. PreCommit 阶段:协调者根据上一阶段收集的反馈决定通知各个参与者节点执行(但不提交)或中止本地事务;有两种可能:1) 所有回复都是 YES,则发送 PreCommit 请求消息,要求所有参与者执行事务并追加记录到 undo 和 redo 日志,如果事务执行成功则参与者回复 ACK 响应消息,并等待下一阶段的指令;2) 反馈消息中只要有一个 NO,或者等待超时之后协调者都没有收到参与者的回复,那么协调者会中止事务,发送 Abort 请求消息给所有参与者,参与者收到该请求后中止本地事务,或者参与者超时等待仍未收到协调者的消息,同样也中止当前本地事务。
  3. DoCommit 阶段:协调者根据上一阶段收集到的反馈决定通知各个参与者节点提交或回滚本地事务,分三种情况:1) 协调者收到全部参与者回复的 ACK,则向所有参与者节点广播 DoCommit 请求消息,各个参与者节点收到协调者的消息之后决定提交事务,然后释放资源对象上的锁,成功之后向协调者回复 ACK,协调者接收到所有参与者的 ACK 之后,将该分布式事务标记为 committed;2) 协调者没有收到全部参与者回复的 ACK(可能参与者回复的不是 ACK,也可能是消息丢失导致超时),那么协调者就会中止事务,首先向所有参与者节点广播 Abort 请求消息,各个参与者收到该消息后利用上一阶段的 undo 日志进行事务的回滚,释放占用的资源对象,然后回复协调者 ACK 消息,协调者收到参与者的 ACK 消息后将该分布式事务标记为 aborted;3) 参与者一直没有收到协调者的消息,等待超时之后会直接提交事务。

事实上,在最后阶段,协调者不是通过追加本地日志的方式记录提交决定的,而是首先保证让至少 K 个参与者节点知道它决定提交该分布式事务。如果协调者发生故障了,那么剩下的参与者节点会重新选举一个新的协调者,这个新的协调者就可以在集群中不超过 K 个参与者节点故障的情况下学习到旧协调者之前是否已经决定要提交分布式事务,若是,则重新开始协议的第三阶段,否则就中止该事务,重新发起分布式事务。

在最后的 DoCommit 阶段,如果参与者一直没有收到协调者的 DoCommit 或者 Abort 请求消息时,会在等待超时之后,直接提交事务。这个决策机制是基于概率学的:当已经进入第三阶段之后,说明参与者在第二阶段已经收到了 PreCommit 请求消息,而协调者发出 PreCommit 请求的前提条件是它在第二阶段开头收集到的第一阶段向所有参与者发出的 CanCommit 请求消息的反馈消息都是 YES。所以参与者可以根据自己收到了 PreCommit 请求消息这一既定事实得出这样的一个结论:其他所有参与者都同意了进行这次的事务执行,因此当前的参与者节点有理由相信,进入第三阶段后,其他参与者节点的本地事务最后成功提交的概率很大,而自己迟迟没有收到 DoCommitAbort 消息可能仅仅是因为网络抖动或异常,因此直接提交自己的本地事务是一个比较合理的选择

三阶段提交协议主要着重于解决两阶段提交协议中因为协调者单点故障而引发的同步阻塞问题,虽然相较于两阶段提交协议有所优化,但还是没解决可能发生的数据不一致问题,比如由于网络异常导致部分参与者节点没有收到协调者的 Abort 请求消息,超时之后这部分参与者会直接提交事务,从而导致集群中的数据不一致,另外三阶段提交协议也无法解决脑裂问题,同时也因为这个协议的网络开销问题,导致它并没有被广泛地使用,有关该协议的具体细节可以参阅本文最后的延伸阅读一节中的文献进一步了解,这里不再深入。

共识算法

共识(Consensus),很多时候会见到与一致性(Consistency)术语放在一起讨论。严谨地讲,两者的含义并不完全相同。

一致性的含义比共识宽泛,在不同场景(基于事务的数据库、分布式系统等)下意义不同。具体到分布式系统场景下,一致性指的是多个副本对外呈现的状态。如前面提到的顺序一致性、线性一致性,描述了多节点对数据状态的共同维护能力。而共识,则特指在分布式系统中多个节点之间对某个事情(例如多个事务请求,先执行谁?)达成一致意见的过程。因此,达成某种共识并不意味着就保障了一致性。

实践中,要保证系统满足不同程度的一致性,往往需要通过共识算法来达成。

共识算法解决的是分布式系统对某个提案(Proposal),大部分节点达成一致意见的过程。提案的含义在分布式系统中十分宽泛,如多个事件发生的顺序、某个键对应的值、谁是主节点……等等。可以认为任何可以达成一致的信息都是一个提案。

对于分布式系统来讲,各个节点通常都是相同的确定性状态机模型(又称为状态机复制问题,State-Machine Replication),从相同初始状态开始接收相同顺序的指令,则可以保证相同的结果状态。因此,系统中多个节点最关键的是对多个事件的顺序进行共识,即排序。

算法共识/一致性算法有两个最核心的约束:1) 安全性(Safety),2) 存活性(Liveness):

  • Safety:保证决议(Value)结果是对的,无歧义的,不会出现错误情况。

    • 只有是被提案者提出的提案才可能被最终批准;
    • 在一次执行中,只批准(chosen)一个最终决议。被多数接受(accept)的结果成为决议;
  • Liveness:保证决议过程能在有限时间内完成。

    • 决议总会产生,并且学习者最终能获得被批准的决议。

Paxos

Google Chubby 的作者 Mike Burrows 说过, there is only one consensus protocol, and that’s Paxos” – all other approaches are just broken versions of Paxos.

意即世上只有一种共识算法,那就是 Paxos,其他所有的共识算法都只是 Paxos 算法的残缺版本。虽然有点武断,但是自从 Paxos 问世以来,它便几乎成为了分布式共识算法的代名词,后来的许多应用广泛的分布式共识算法如 Raft、Zab 等的原理和思想都可以溯源至 Paxos 算法。

Paxos 是由 Leslie Lamport (LaTeX 发明者,图灵奖得主,分布式领域的世界级大师) 在 1990 年的论文《The PartTime Parliament》里提出的,Lamport 在论文中以一个古希腊的 Paxos 小岛上的议会制订法律的故事切入,引出了 Paxos 分布式共识算法。

Basic Paxos

业界一般将 Lamport 论文里最初提出分布式算法称之为 Basic Paxos,这是 Paxos 最基础的算法思想。

Basic Paxos 算法的最终目标是通过严谨和可靠的流程来使得集群基于某个提案(Proposal)达到最终的共识

基础概念

  • Value:提案值,是一个抽象的概念,在工程实践中可以是任何操作,如『更新数据库某一行的某一列』、『选择 xxx 服务器节点作为集群中的主节点』。
  • Number:提案编号,全局唯一,单调递增。
  • Proposal:集群需要达成共识的提案,提案 = 编号 + 值。

Proposal 中的 Value 就是在 Paxos 算法完成之后需要达成共识的值。

Paxos 算法中有三个核心角色:

  • Proposer:生成提案编号 n 和值 v,然后向 Acceptors 广播该提案,接收 Acceptors 的回复,如果有超过半数的 Acceptors 同意该提案,则选定该提案,否则放弃此次提案并生成更新的提案重新发起流程,提案被选定之后则通知所有 Learners 学习该最终选定的提案值(也可以由 Acceptor 来通知,看具体实现)。Basic Paxos 中允许有多个 Proposers。
  • Acceptor:接收 Proposer 的提案并参与提案决策过程,把各自的决定回复给 Proposer 进行统计。Acceptor 可以接受来自多个 proposers 的多个提案。
  • Learner:不参与决策过程,只学习最终选定的提案值。

在具体的工程实践中,一个节点往往会充当多种角色,比如一个节点可以既是 Proposer 又是 Acceptor,甚至还是 Learner。

算法流程

相较于直接给出 Paxos 算法的流程,我想沿袭 Lamport 大师的经典 Paxos 论文《Paxos Made Simple》中的思路:通过循序渐进的方式推导出 Paxos 算法。

首先需要了解 Paxos 算法中的两个重要的约束:

C1. 一个 Acceptor 必须接受它收到的第一个提案。

C2. 只有当超过半数的 Acceptors 接受某一个提案,才能最终选定该提案。

C2 其实有一个隐含的推论:一个 Acceptor 可以接受多个提案,这也是为什么我们需要给每一个提案生成一个编号的原因,用来给提案排序。

我们前面提到过 Paxos 的最终目标是通过严谨和可靠的流程来使得集群基于某个提案(Proposal)达到最终的共识,也就是说基于某一个提案发起的一次 Paxos 流程,最终目的是希望集群对该提案达成一致的意见,而为了实现并维持集群中的这种一致性,前提是 Paxos 算法必须具有幂等性:一旦提案(Proposal)中的值(Value)被选定(Chosen),那么只要还在此次 Paxos 流程中,就算不断按照 Paxos 的规则重复步骤,未来被 Chosen 的 Value 都会是同一个。如果不满足这种幂等性,将可能导致不一致的问题。

因此,我们可以把 Paxos 的基本命题提炼出来:

P1. 在一次 Paxos 流程中,如果一个值(Value)为 v 的提案(Proposal)被选定(Chosen)了,那么后续任何被最终选定的带有更大编号(Number)的提案中的 Value 也必须是 v

提案在被最终选定之前必须先被 Acceptor 接受,于是我们可以再进一步总结一个具有更强约束的命题:

P2. 在一次 Paxos 流程中,如果一个值(Value)为 v 的提案(Proposal)被选定(Chosen)了,那么后续任何被 Acceptor 接受的带有更大编号(Number)的提案中的 Value 也必须是 v

这还不是具备最强约束的命题,因为提案在被 Acceptor 接受之前必须先由 Proposer 提出,因此还可以继续强化命题:

P3. 在一次 Paxos 流程中,如果一个值(Value)为 v 的提案(Proposal)被选定(Chosen)了,那么后续任何 Proposer 提议的带有更大编号(Number)的提案中的 Value 也必须是 v

从上述的三个命题,我们可以很容易地看出来,P3 可以推导出 P2,进而推导出 P1,也就是说这是一个归约的过程,因此只要 P3 成立则 P1 成立,也就是 Paxos 算法的正确性得到保证。

那么要如何实现呢 P3 呢?只需满足如下约束:

C3. 对于一个被 Proposer 提议的提案中任意的 vn,存在一个数量超过半数 Acceptors 的集合 S,满足以下两个条件中的任意一个:

  • S 中的任何一个 Acceptor 都没有接受过编号小于 n 的提案。
  • S 中所有的 Acceptors 接受过的最大编号的提案的 Value 为 v

为了满足 C3 从而实现 P3,需要引入一条约束:Proposer 每次生成自己的 n 之后,发起提案之前,必须要先去『学习』那个已经被选定或者将要被选定的小于 n 的提案,如果有这个提案的话则把那个提案的 v 作为自己的此次提案的 Value,没有的话才可以自己指定一个 Value,这样的话 Proposer 侧就可以保证更高编号的提案的值只会是已选定的 v 了,但是 Acceptor 侧还无法保证,因为 Acceptor 有可能还会接受其他的 Proposers 的提案值,于是我们需要对 Acceptor 也加一条约束,让它承诺在收到编号为 nv 之后,不会再接受新的编号小于 n 的提案值。

所以我们可以得到一个 Paxos 在 Proposer 侧的算法流程:

  1. Proposer 生成一个新的提案编号 n 然后发送一个 prepare 请求给超过半数的 Acceptors 集合,要求集合中的每一个 Acceptor 做出如下响应:

    (a) 向 Proposer 承诺在收到该消息之后就不再接受编号小于 n 的提案。

    (b) 如果 Acceptor 在收到该消息之前已经接受过其他提案,则把当前接受的编号最大的提案回复给 Proposer。

  2. 如果 Proposer 收到了超过半数的 Acceptors 的回复,那么就可以生成 (n, v) 的提案,这里 v 是所有 Acceptors 回复中编号最大的那个提案里的值,如果所有 Acceptors 回复中都没有附带上提案的话,则可以由 Proposer 自己选择一个 v
  3. Proposer 将上面生成的提案通过一个 accept 请求发送给一个超过半数的 Acceptors 集合。(需要注意的是这个集合不一定和第二步中的那个集合是同一个。)

Paxos 在 Proposer 侧的算法流程已经确定了,接下来我们需要从 Acceptor 的视角来完成剩下的算法推导。前面我们提到过,Acceptor 是可以接受多个 Proposers 的多个提案的,但是在收到一个 Proposer 的 prepare 消息后会承诺不再接受编号小于 n 的新提案,也就是说 Acceptor 也是可以忽略掉其他 Proposers 消息(包括 prepareaccept)而不会破坏算法的安全性,当然了,在工程实践中也可以直接回复一个错误,让 Proposer 更早知道提案被拒绝然后生成提案重新开始流程。这里我们应该重点思考的场景是一个 Acceptor 接受一个提案请求的时候,根据前面 Proposer 要求 Acceptor 的承诺,我们可以给 Acceptor 设置一个这样的约束:

C4. 如果一个 Proposer 发出了带 nprepare 请求,只要 Acceptor 还没有回复过任何其他编号大于 nprepare 请求,则该 Acceptor 可以接受这个提案。

因为 Acceptor 需要对 Proposer 做出不接受编号小于 n 的提案的承诺,因此它需要做持久化记录,那么它就必须是有状态的,也因此每个 Acceptor 都需要利用可靠性存储(日志)来保存两个对象:

  1. Acceptor 接受过的编号最大的提案;
  2. Acceptor 回复过的最大的 prepare 请求提案编号。

以上这就是 Acceptor 侧的约束。接下来我们就可以得到 Paxos 的整个算法流程了。

Paxos 算法可以归纳为两大基本过程:

  1. 选择过程;
  2. 学习过程。
选择过程

选择过程分为两个阶段:

  • 阶段一(Phase 1):

    (a) Proposer 生成一个全局唯一且单调递增的提案编号 n,然后发送编号为 nprepare 请求(P1a msg)给超过半数的 Acceptors 集合。

    (b) 当一个 Acceptor 收到一个编号为 nprepare 请求,如果 n 比它此前接受过其他的提案编号(如果有)都要大的话,那么将这个提案编号 n 写入本地日志,这里记为 max_n,然后作出『两个承诺,一个回复』:

    • 两个承诺:

      • 不再接受编号小于等于 nprepare 请求
      • 不再接受编号小于等于 naccept 请求
    • 一个回复:

      • 在不违背以前作出的承诺下,回复消息(P1b msg),附带上自己已经接受过的提案中编号最大的那个提案的 vn,没有则返回空值。

    否则就忽略该 prepare 消息或者回复一个错误。

  • 阶段二(Phase 2):

    (a) 当 Proposer 收到超过半数的 Acceptors 回复它的编号为 nprepare 请求的响应,此时有两种可能:

    • Free:没有任何一个 Acceptor 的回复消息中附带已被接受的提案,意味着当前流程中还没有提案值被最终接受,此时 Proposer 可以自由地选择提案值 Value,最后发送一个包含 (n, v) 提案的 accept 请求消息(P2a msg)给 Acceptors 集合。
    • Forced:某些 Acceptors 的回复消息中附带已被接受的提案,那么 Proposer 必须强制使用这些回复消息中编号最大的提案 Value 作为自己的提案值,最后发送一个包含 (n, v) 提案的 accept 请求消息(P2a msg)给 Acceptors 集合。

    (b) 当 Acceptor 收到一个编号为 n 的提案的 accept 请求消息,需要分两种情况处理:

    • 如果 n >= max_n(通常情况下这两个值是相等的),则接受该提案并回复消息(P2b msg)。
    • 如果 n < max_n,则忽略该 accept 消息或者回复一个错误(P2b error)。
学习过程

选择过程结束之后,我们得到了一个提案值,接下来就是要让集群中的所有 Learner 『学习』到这个值了,以求达到集群的共识。

Learner 学习提案值的方式可以分成三种:

  1. 任意一个 Acceptor 接受了一个提案后就立刻将该提案发送给所有 Learner。优点:Learner 能实时学习到被 Paxos 流程选定的 Value;缺点:网络通信次数太多,如果有 N 个 Acceptors 和 M 个 Learner,则需要的网络通信是 N*M 次。
  2. 设置一个主 Learner,Acceptor 接受了一个提案后只将该提案发送给主 Learner,主 Learner 再转发给剩下的 Learners。优点:网络通信次数只需 N+M-1 次;缺点:主 Learner 有单点故障的风险。
  3. Acceptor 接受了一个提案后将该提案发送给一个 Learner 集合,由这个集合去通知剩下的 Learners。优点:用集合替代单点,可靠性更高;缺点:增加系统复杂度,需要维护一个 Learner 小集群。

至此,我们就推导出了整个 Paxos 算法的流程:

算法证明

这一节我们来证明 Paxos 算法的正确性。

上一节我们已经提炼出来了 Paxos 的基本命题 P1,并通过归约 P1 得到了约束性更强的另外两个命题 P2 和 P3,根据归约的原理,我们知道 P3 可以最终推导出 P1,也就是说如果要证明 Paxos 的基本命题 P1,只需要证明 P3 即可。为什么之前我们要不断强化 Paxos 的命题呢?因为从数学的层面来讲,一个具有更强约束(更多假设)的命题一般会更容易证明。

现在我们把 P1, P2 和 P3 用更严格的数学语言来描述:

P1. 在一次 Paxos 流程中,如果一个包含 (n, v) 的提案被选定(Chosen),那么存在未来被选定的提案 (k, v1),必然满足 k > n,v1 = v。

P2. 在一次 Paxos 流程中,如果一个包含 (n, v) 的提案被选定(Chosen),那么存在未来被超过半数的 Acceptors 接受的提案 (k, v1),必然满足 k > n,v1 = v。

P3. 在一次 Paxos 流程中,如果一个包含 (n, v) 的提案被选定(Chosen),那么存在未来由 Proposer 提议的提案 (k, v1),必然满足 k > n,v1 = v。

现在我们利用数学归纳法来证明 P3:

假设 k = m 时 P3 成立,由于 (n, v) 已经是被选定的提案,因此 Proposer 发起的从 n 到 k 的提案中的 Value 都会是 v,其中 m >= n,那么根据归约的原理可证 k = m 时 P1 也成立

现在令 k = m+1,Proposer 发送带编号 k 的 prepare 请求消息到 Acceptors 集合。

由于此前已经有了选定的提案,那么根据 Paxos 的约束 C2 可知参与这一个提案投票的 Acceptors 集合必定和上一个集合有重合。

根据 Acceptors 集合重叠和 Paxos 的 P1b 阶段可知,回复的消息中必定附带有已被大多数 Acceptors 接受的提案 (i, v0)。

然后根据 P2a 阶段,Proposer 提案 (k, v1),其中 v1 = v0。

还是根据 P1b,可知 i 是所有回复消息里编号最大的,可得 i >= m,又根据 P1a 可知 i < k,因此可以得出提案 (i, v0) 中有 v0 = v。

可知当 k = m+1 时,提案 (k, v1) 中的 v1 = v。

根据数学归纳法的原理,我们还需要找到一个特例来使得命题成立,然后由特例推广到普遍,我们这里选择 k = 1 作为特例,证明 k = 1 时 P3 成立:根据 Paxos 的约束 C1 易知在 n = 0,k = 1 的场景下,P3 成立。

因此可根据数学归纳法基于 k = 1 进行推广至 k = m(m 代表任意自然数),最后 P3 命题得证。

再由归约的原理可知,P3 可推导出 P2,最后 P2 推导出 P1。至此, Paxos 算法原理正确性的证明完成。

上述的证明只是一种比较简单且粗浅的证明方法,但是对于工程师理解 Paxos 原理来说已经足够了,如果希望进一步学习 Paxos 原理的严格数学证明,可以参阅 Leslie Lamport 的原始论文《The PartTime Parliament》,里面给出了 Paxos 算法的严格数学证明。

Multi-Paxos

自 Lamport 于 1990 年在论文《The PartTime Parliament》中提出 Paxos 算法之后,这个算法一直被评价为难以理解和实现,这篇论文中运用了大量的数学对 Paxos 的原理进行证明,而又由于 Lamport 在论文里用讲故事的形式解释 Paxos,进一步增大了人们彻底理解 Paxos 的难度,事实上 Lamport 的这篇论文也因此在发表过程中一波三折,这里不展开,有兴趣的读者可以自行去了解这段这段背景故事。

因为业界在理解 Paxos 算法上持续的怨声载道,Lamport 在 2001 年发表了论文《Paxos Made Simple》,对原论文进行精简,以更通俗易懂的语言和形式阐述 Paxos 算法,并在其中提出了更加具备工程实践性的 Multi-Paxos 的思想。

关于 Paxos 难以理解的问题上,我个人的一点愚见是:Paxos 算法的思想其实并不难理解,真正难的地方是:

  1. Paxos 背后那一套完整的数学原理和证明
  2. 在复杂分布式环境将 Paxos 进行工程落地

我个人建议的 Paxos 学习资料是:《Paxos Made Simple》《Paxos Made Live - An Engineering Perspective》以及 Paxos lecture (Raft user study)。第一篇论文可以说是 Lamport 1990 年那篇最初的论文的精简版,可读性提高了很多,论文里也没有使用任何数学公式,只需一点英文基础就可以通读,第二篇论文讲的则是 Google 内部基于 Multi-Paxos 实现的分布式锁机制和小文件存储系统,这是业界较早的实现了 Multi-Paxos 的大规模线上系统,十分具有参考性,最后的 Youtube 视频则是 Raft 的作者 Diego Ongaro 为了对比 Raft 和 Multi-Paxos 的学习的难易程度而做的,非常适合作为学习 Paxos 和 Raft 的入门资料。

从上一节可知 Basic Paxos 算法有几个天然缺陷:

  • 只能就单个值(Value)达成共识,不支持多值共识。在实际的工程实践中往往是需要对一系列的操作达成共识,比如分布式事务,由很多执行命令组成。
  • 至少需要 2 轮往返 4 次 prepareaccept 网络通信才能基于一项提案达成共识。对于一个分布式系统来说,网络通信是最影响性能的因素之一,过多的网络通信往往会导致系统的性能瓶颈。
  • 不限制 Proposer 数量导致非常容易发生提案冲突。极端情况下,多 Proposer 会导致系统出现『活锁』,破坏分布式共识算法的两大约束之一的活性(liveness)。

关于第三点,前文提到分布式共识算法必须满足两个最核心的约束:安全性(safety)和活性(liveness),从上一节我们可以看出 Basic Paxos 主要着重于 safety,而对 liveness 并没有进行强约束,让我们设想一种场景:两个 Proposers (记为 P1 和 P2) 轮替着发起提案,导致两个 Paxos 流程重叠了:

  1. 首先,P1 发送编号 N1 的 prepare 请求到 Acceptors 集合,收到了过半的回复,完成阶段一。
  2. 紧接着 P2 也进入阶段一,发送编号 N2 的 prepare 请求到过半的 Acceptors 集合,也收到了过半的回复,Acceptors 集合承诺不再接受编号小于 N2 的提案。
  3. 然后 P1 进入阶段二,发送编号 N1 的 accept 请求被 Acceptors 忽略,于是 P1 重新进入阶段一发送编号 N3 的 prepare 请求到 Acceptors 集合,Acceptors 又承诺不再接受编号小于 N3 的提案。
  4. 紧接着 P2 进入阶段二,发送编号 N2 的 accept 请求,又被 Acceptors 忽略。
  5. 不断重复上面的过程......

在极端情况下,这个过程会永远持续,导致所谓的『活锁』,永远无法选定一个提案,也就是 liveness 约束无法满足。

为了解决这些问题,Lamport 在《Paxos Made Simple》论文中提出了一种基于 Basic Paxos 的 Multi-Paxos 算法思想,并基于该算法引出了一个分布式银行系统状态机的实现方案,感兴趣的读者不妨看一下。

Multi-Paxos 算法在 Basic Paxos 的基础上做了两点改进:

  1. 多 Paxos 实例:针对每一个需要达成共识的单值都运行一次 Basic Paxos 算法的实例,并使用 Instance ID 做标识,最后汇总完成多值共识。
  2. 选举单一的 Leader Proposer:选举出一个 Leader Proposer,所有提案只能由 Leader Proposer 来发起并决策,Leader Proposer 作为 Paxos 算法流程中唯一的提案发起者,『活锁』将不复存在。此外,由于单一 Proposer 不存在提案竞争的问题,Paxos 算法流程中的阶段一中的 prepare 步骤也可以省略掉,从而将两阶段流程变成一阶段,大大减少网络通信次数。

关于多值共识的优化,如果每一个 Basic Paxos 算法实例都设置一个 Leader Proposer 来工作,还是会产生大量的网络通信开销,因此,多个 Paxos 实例可以共享同一个 Leader Proposer,这要求该 Leader Proposer 必须是稳定的,也即 Leader 不应该在 Paxos 流程中崩溃或改变。

由于 Lamport 在论文中提出的 Multi-Paxos 只是一种思想而非一个具体算法,因此关于 Multi-Paxos 的很多细节他并没有给出具体的实现方案,有些即便给出了方案也描述得不是很清楚,比如他在论文中最后一节提出的基于银行系统的状态机中的多 Paxos 实例处理,虽然给了具体的论述,但是在很多关键地方还是没有指明,这也导致了后续业界里的 Multi-Paxos 实现各不相同。

我们这里用 Google Chubby 的 Multi-Paxos 实现来分析这个算法。

首先,Chubby 通过引入 Master 节点,实现了 Lamport 在论文中提到的 single distinguished proposer,也就是 Leader Proposer,Leader Proposer 作为 Paxos 算法流程中唯一的提案发起者,规避了多 Proposers 同时发起提案的场景,也就不存在提案冲突的情况了,从而解决了『活锁』的问题,保证了算法的活性(liveness)。

Lamport 在论文中指出,选择 Leader Proposer 的过程必须是可靠的,那么具体如何选择一个 Leader Proposer 呢?在 Chubby 中,集群利用 Basic Paxos 算法的共识功能来完成对 Leader Proposer 的选举,这个实现是具有天然合理性的,因为 Basic Paxos 本身就是一个非常可靠而且经过严格数学证明的共识算法,用来作为选举算法再合适不过了,在 Multi-Paxos 流程期间,Master 会通过不断续租的方式来延长租期(Lease)。比如在实际场景中,一般在长达几天的时期内都是同一个服务器节点作为 Master。万一 Master 故障了,那么剩下的 Slaves 节点会重新发起 Paxos 流程票选出新的 Master,也就是说主节点是一直存在的,而且是唯一的。

此外,Lamport 在论文中提到的过一种优化网络通信的方法:“当 Leader Proposer 处于稳定状态时,可以跳过阶段一,直接进入阶段二”,在 Chubby 中也实现了这个优化机制,Leader Proposer 在为多个 Paxos 算法实例服务的时候直接跳过阶段一进入阶段二,只发送 accept 请求消息给 Acceptors 集合,将算法从两阶段优化成了一阶段,大大节省网络带宽和提升系统性能。

最后,Multi-Paxos 是一个"脑裂"容错的算法思想,就是说当 Multi-Paxos 流程中因为网络问题而出现多 Leaders 的情况下,该算法的安全性(safety )约束依然能得到保证,因为在这种情况下,Multi-Paxos 实际上是退化成了 Basic Paxos,而 Basic Paxos 天然就支持多 Proposers。

在分布式事务中,Paxos 算法能够提供比两阶段提交协议更加可靠的一致性提交:通过将提交/放弃事务的决定从原来两阶段协议中单一的协调者转移到一个由 Proposer + Acceptors 组成的集群中。Lamport 曾经发表过一篇《Consensus on Transaction Commit》的论文,通过将两阶段提交协议和基于 Paxos 实现的分布式提交协议做对比,对基于 Paxos 实现的提交协议有非常精彩的论述,感兴趣的读者不妨一读

Raft

Raft 算法实际上是 Multi-Paxos 的一个变种,通过新增两个约束:

  1. 追加日志约束:Raft 中追加节点的日志必须是串行连续的,而 Multi-Paxos 中则可以并发追加日志(实际上 Multi-Paxos 的并发也只是针对日志追加,最后应用到内部 State Machine 的时候还是必须保证顺序)。
  2. 选主限制:Raft 中只有那些拥有最新、最全日志的节点才能当选 Leader 节点,而 Multi-Paxos 由于允许并发写日志,因此无法确定一个拥有最新、最全日志的节点,因此可以选择任意一个节点作为 Leader,但是选主之后必须要把 Leader 节点的日志补全。

基于这两个限制,Raft 算法的实现比 Multi-Paxos 更加简单易懂,不过由于 Multi-Paxos 的并发度更高,因此从理论上来说 Multi-Paxos 的性能会更好一些,但是到现在为止业界也没有一份权威的测试报告来支撑这一观点。

对比一下 Multi-Paxos 和 Raft 下集群中可能存在的日志顺序:

可以看出,Raft 中永远满足这样一个约束:follower log 一定会是 leader log 的子集并且顺序一定是连续的,而 Multi-Paxos 则不一定满足这个约束,日志记录通常是乱序的。

由于 Raft 的核心思想源自 Multi-Paxos,在实现过程中做了很多改进优化,然而万变不离其宗,我相信理解了 Multi-Paxos 之后再去学习 Raft 会事半功倍(Raft 在诞生之初也是打着"容易理解"的旗号来对标 Paxos 的),由于前面已经深度剖析过 Paxos 算法的流程和原理了,碍于本文的篇幅所限,这里就不再对 Raft 算法的细节进行深入探讨了,如果有意深入学习 Raft,可以从 The Raft Consensus Algorithm 处找到相关的论文、源码等资料进行全面的学习。

最后有一些概念要澄清一下,Basic Paxos 是一个经过了严格数学证明的分布式共识算法,但是由于前文提到的 Basic Paxos 算法应用在实际工程落地中的种种问题,现实中几乎没有直接基于 Basic Paxos 算法实现的分布式系统,绝大多数都是基于 Multi-Paxos,然而 Multi-Basic 仅仅是一种对 Basic Paxos 的延伸思想而非一个具体算法,问题在于目前业界并没有一个统一的 Multi-Paxos 实现标准,因此 Multi-Paxos 的工程实现是建立在一个未经严格证明的前提之上的,工程实现最终的正确性只能靠实现方自己去验证,而 Raft 则是一个具有统一标准实现的、正确性已经过严格证明的具体算法,因此在分布式系统的工程实践中大多数人往往还是会选择 Raft 作为底层的共识算法。

算法类型

需要特别指出的一点是,根据解决的场景是否允许拜占庭(Byzantine)错误,共识算法可以分为 Crash Fault Tolerance (CFT) 和 Byzantine Fault Tolerance(BFT)两类。

对于非拜占庭错误的情况,已经存在不少经典的算法,包括 Paxos(1990 年)、Raft(2014 年)及其变种等。这类容错算法往往性能比较好,处理较快,容忍不超过一半的故障节点。

对于要能容忍拜占庭错误的情况,包括 PBFT(Practical Byzantine Fault Tolerance,1999 年)为代表的确定性系列算法、PoW(1997 年)为代表的概率算法等。确定性算法一旦达成共识就不可逆转,即共识是最终结果;而概率类算法的共识结果则是临时的,随着时间推移或某种强化,共识结果被推翻的概率越来越小,最终成为事实上结果。拜占庭类容错算法往往性能较差,容忍不超过 1/3 的故障节点。

本文主要讨论的分布式共识算法是 CFT 类算法,毕竟对于大多数分布式系统来说,集群节点和网络消息一般都是可控的,系统只会出现节点故障而不会出现像拜占庭错误那样伪造的、欺骗性的网络消息,在这种场景下,CFT 类算法更具有现实意义;BFT/PBFT 类算法更多是用在系统被恶意入侵,故意伪造网络消息的场景里。

并发控制

在分布式事务中,集群中的每个服务器节点要管理很多资源对象,每个节点必须保证在并发事务访问这些资源对象时,它们能够始终保持一致性。因此,每个服务器节点需要对自己的管理的资源对象应用一定的并发控制机制。分布式事务中需要所有服务器节点共同保证事务以串行等价的的方式执行。

也就是说,如果事务 T 对某一个服务器节点上的资源对象 S 的并发访问在事务 U 之前,那么我们需要保证在所有服务器节点上对 S 和其他资源对象的冲突访问,T 始终在 U 之前。

锁并发控制

在分布式事务中,某个对象的锁总是本地持有的(在同一个服务器节点上)。是否加锁是由本地锁管理器(Local Lock Manager,LLM)决定的。LLM 决定是满足客户端持锁的请求,还是阻塞客户端发起的分布式事务。但是,事务在所有服务器节点上被提交或者放弃之前,LLM 不能释放任何锁。在使用加锁机制的并发控制中,原子提交协议在进行的过程中资源对象始终被锁住,并且是排他锁,其他事务无法染指这些资源对象。但如果事务在两阶段提交协议的阶段一就被放弃,则互斥锁可以提前释放。

由于不同服务器节点上的 LLM 独立设置资源对象锁,因此,对于不同的事务,它们加锁的顺序也可能出现不一致。考虑一个场景:事务 T 和 U在服务器 X 和 Y 之间的交错执行:

  1. 事务 T 锁住了服务器节点 X 上的资源对象 A,做写入操作;
  2. 事务 U 锁住了服务器节点 Y 上的资源对象 B,做写入操作;
  3. 事务 T 试图读取服务器节点 Y 上的资源对象 B,此时 B 被事务 U 锁住,因此 T 等待锁释放;
  4. 事务 U 试图读取服务器节点 X 上的资源对象 A,此时 A 被事务 T 锁住,因此 U 等待锁释放。

在服务器节点 X 上,事务 T 在事务 U 之前;而在服务器节点 Y 上,事务 U 在事务 T 之前。这种不一致的事务次序导致了事务之间的循环依赖,从而引起分布式死锁。分布式死锁需要通过特定的方法/算法来检测并解除,一旦检测到死锁,则必须放弃其中的某个事务来解除死锁,然后通知事务协调者,它将会放弃该事务所涉及的所有参与者上的事务。

时间戳并发控制

对于单一服务器节点的事务来说,协调者在每个事务启动时会为其分配一个全局唯一的时间戳。通过按照访问资源对象的事务时间戳顺序提交资源对象的版本来强制保证以事务执行的串行等价性。在分布式事务中,协调者必须保证每个事务都会附带全局唯一的时间戳。全局唯一的时间戳由事务访问的第一个协调者发给客户端。如果任意一个服务器节点上的资源对象执行了事务中的一个操作,那么事务时间戳会被发送给该服务器节点上的协调者。

分布式事务中的所有服务器节点共同保证事务以串行等价的方式执行。例如,如果在某服务器节点上,由事务 U 访问的资源对象版本在事务 T 访问之后提交;而在另一个服务器节点上,事务 T 和事务 U 又访问了同一个资源对象,那么它们也必须按照相同的次序提交资源对象。为了保证所有服务器节点上的事务执行的相同顺序,协调者必须就时间戳排序达成一致。时间戳是一个二元组 < 本地时间戳,服务器 ID > 对。在时间戳的比较排序过程中,首先比较本地时间戳,然后再比较服务器 ID。

一个可靠的时间戳并发控制应该保证即使各个服务器节点之间的本地时间不同步,也能保证事务之间的相同顺序。但是考虑到效率,各个协调者之间的时间戳还是最好还是要求大致同步。这样的话,事务之间的顺序通常与它们实际开始的时间顺序相一致。可以利用一些本地物理时钟同步方法来保证时间戳的大致同步。

如果决定利用时间戳机制进行分布式事务的并发控制,那么还需要通过某些方法来解决事务冲突问题。如果为了解决冲突需要放弃某个事务时,相应的协调者会收到通知,并且它将在所有的参与者上放弃该事务。这样,如果事务能够坚持到客户端发起提交请求命令的那个时候,那么这个事务就总能被提交。因此在两阶段提交协议中,正常情况下参与者都会同意提交,唯一一种不同意提交的情况是参与者在事务执行过程中曾经崩溃过。

乐观并发控制

加锁机制这一类悲观并发控制有许多明显的缺陷:

  • 锁的维护带来了很多新的开销。这些开销在不支持对共享数据并发访问的系统中是不存在的。即使是只读事务(如查询),就算这一类事务不会改变数据的完整性,却仍然需要利用锁来保证数据在读取过程中不会被其他事务修改,然而锁却只在最极端的情况下才会发挥作用。
  • 锁机制非常容易引发死锁。预防死锁会严重降低并发度,因此必须利用超时或者死锁检测来解除死锁,但这些死锁解除方案对于交互式的程序来说并不是很理想。
  • 锁周期过长。为了避免事务的连锁(雪崩)放弃,锁必须保留到事务结束之时才能释放,这再一次严重降低了系统的并发度。

由于锁这一类的悲观并发控制有上述的种种弊端,因此研究者们提出了另一种乐观并发控制的机制,以求规避锁机制的天然缺陷,研究者们发现这样的一个现象:在大多数应用中两个客户端事务访问同一个资源对象的可能性其实很低,事务总是能够成功执行,就好像事务之间不存在冲突一样。

所以事务的乐观并发控制的基本思路就是:各个并发事务只有在执行完成之后并且发出 closeTransaction 请求时,再去检测是否有冲突,如果确实存在冲突,那么就放弃一些事务,然后让客户端重新启动这些事务进行重试。

在乐观并发控制中,每个事务在提交之前都必须进行验证。事务在验证开始时首先要附加一个事务号,事务的串行化就是根据这些事务号的顺序实现的。分布式事务的验证由一组独立的服务器节点共同完成,每个服务器节点验证访问自己资源对象的事务。这些验证在两阶段提交协议的第一个阶段进行。

关于分布式事务的并发控制就暂时介绍到这里,如果想要继续深入学习更多并发控制的细节,可以深入阅读《分布式系统:概念与设计》、《数据库系统实现》和《数据库系统概念》等书籍或者其他资料。

总结

本文通过讲解 BASE 原则两阶段原子提交协议三阶段原子提交协议Paxos/Multi-Paxos 分布式共识算法的原理与证明Raft 分布式共识算法分布式事务的并发控制等内容,为读者全面而又深入地讲解分析了分布式事务的底层核心原理,特别是通过对原子提交协议中的 2PC/3PC 的阐述和分析,以及对分布式共识算法 Paxos 的原理剖析和正确性的证明,最后还有对分布式事务中几种并发控制的介绍,相信能够让读者对分布式事务底层的一致性和并发控制原理有一个深刻的认知,对以后学习和理解分布式系统大有裨益。

本文不仅仅是简单地介绍分布式事务的底层原理,更是在介绍原理的同时,通过层层递进的方式引导读者去真正地理解分布式系统的底层原理和设计思路,而非让读者死记硬背一些概念,所以希望通过这篇抛砖引玉的文章,能够对本文读者在以后学习、操作甚至是设计分布式系统以及分布式事务时的思路有所开拓。

参考&延伸

查看原文

赞 0 收藏 0 评论 0

panjf2000 发布了文章 · 2020-12-28

Linux I/O 原理和 Zero-copy 技术全面揭秘

博客原文

Linux I/O 原理和 Zero-copy 技术全面揭秘

导言

如今的网络应用早已从 CPU 密集型转向了 I/O 密集型,网络服务器大多是基于 C-S 模型,也即 客户端 - 服务端 模型,客户端需要和服务端进行大量的网络通信,这也决定了现代网络应用的性能瓶颈:I/O。

传统的 Linux 操作系统的标准 I/O 接口是基于数据拷贝操作的,即 I/O 操作会导致数据在操作系统内核地址空间的缓冲区和用户进程地址空间定义的缓冲区之间进行传输。设置缓冲区最大的好处是可以减少磁盘 I/O 的操作,如果所请求的数据已经存放在操作系统的高速缓冲存储器中,那么就不需要再进行实际的物理磁盘 I/O 操作;然而传统的 Linux I/O 在数据传输过程中的数据拷贝操作深度依赖 CPU,也就是说 I/O 过程需要 CPU 去执行数据拷贝的操作,因此导致了极大的系统开销,限制了操作系统有效进行数据传输操作的能力。

I/O 是决定网络服务器性能瓶颈的关键,而传统的 Linux I/O 机制又会导致大量的数据拷贝操作,损耗性能,所以我们亟需一种新的技术来解决数据大量拷贝的问题,这个答案就是零拷贝(Zero-copy)。

计算机存储器

既然要分析 Linux I/O,就不能不了解计算机的各类存储器。

存储器是计算机的核心部件之一,在完全理想的状态下,存储器应该要同时具备以下三种特性:

  1. 速度足够快:存储器的存取速度应当快于 CPU 执行一条指令,这样 CPU 的效率才不会受限于存储器
  2. 容量足够大:容量能够存储计算机所需的全部数据
  3. 价格足够便宜:价格低廉,所有类型的计算机都能配备

但是现实往往是残酷的,我们目前的计算机技术无法同时满足上述的三个条件,于是现代计算机的存储器设计采用了一种分层次的结构:

从顶至底,现代计算机里的存储器类型分别有:寄存器、高速缓存、主存和磁盘,这些存储器的速度逐级递减而容量逐级递增。存取速度最快的是寄存器,因为寄存器的制作材料和 CPU 是相同的,所以速度和 CPU 一样快,CPU 访问寄存器是没有时延的,然而因为价格昂贵,因此容量也极小,一般 32 位的 CPU 配备的寄存器容量是 32✖️32 Bit,64 位的 CPU 则是 64✖️64 Bit,不管是 32 位还是 64 位,寄存器容量都小于 1 KB,且寄存器也必须通过软件自行管理。

第二层是高速缓存,也即我们平时了解的 CPU 高速缓存 L1、L2、L3,一般 L1 是每个 CPU 独享,L3 是全部 CPU 共享,而 L2 则根据不同的架构设计会被设计成独享或者共享两种模式之一,比如 Intel 的多核芯片采用的是共享 L2 模式而 AMD 的多核芯片则采用的是独享 L2 模式。

第三层则是主存,也即主内存,通常称作随机访问存储器(Random Access Memory, RAM)。是与 CPU 直接交换数据的内部存储器。它可以随时读写(刷新时除外),而且速度很快,通常作为操作系统或其他正在运行中的程序的临时资料存储介质。

最后则是磁盘,磁盘和主存相比,每个二进制位的成本低了两个数量级,因此容量比之会大得多,动辄上 GB、TB,而问题是访问速度则比主存慢了大概三个数量级。机械硬盘速度慢主要是因为机械臂需要不断在金属盘片之间移动,等待磁盘扇区旋转至磁头之下,然后才能进行读写操作,因此效率很低。

主内存是操作系统进行 I/O 操作的重中之重,绝大部分的工作都是在用户进程和内核的内存缓冲区里完成的,因此我们接下来需要提前学习一些主存的相关原理。

物理内存

我们平时一直提及的物理内存就是上文中对应的第三种计算机存储器,RAM 主存,它在计算机中以内存条的形式存在,嵌在主板的内存槽上,用来加载各式各样的程序与数据以供 CPU 直接运行和使用。

虚拟内存

在计算机领域有一句如同摩西十诫般神圣的哲言:"计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决",从内存管理、网络模型、并发调度甚至是硬件架构,都能看到这句哲言在闪烁着光芒,而虚拟内存则是这一哲言的完美实践之一。

虚拟内存是现代计算机中的一个非常重要的存储器抽象,主要是用来解决应用程序日益增长的内存使用需求:现代物理内存的容量增长已经非常快速了,然而还是跟不上应用程序对主存需求的增长速度,对于应用程序来说内存还是不够用,因此便需要一种方法来解决这两者之间的容量差矛盾。

计算机对多程序内存访问的管理经历了 静态重定位 --> 动态重定位 --> 交换(swapping)技术 --> 虚拟内存,最原始的多程序内存访问是直接访问绝对内存地址,这种方式几乎是完全不可用的方案,因为如果每一个程序都直接访问物理内存地址的话,比如两个程序并发执行以下指令的时候:

mov cx, 2
mov bx, 1000H
mov ds, bx
mov [0], cx

...

mov ax, [0]
add ax, ax

这一段汇编表示在地址 1000:0 处存入数值 2,然后在后面的逻辑中把该地址的值取出来乘以 2,最终存入 ax 寄存器的值就是 4,如果第二个程序存入 cx 寄存器里的值是 3,那么并发执行的时候,第一个程序最终从 ax 寄存器里得到的值就可能是 6,这就完全错误了,得到脏数据还顶多算程序结果错误,要是其他程序往特定的地址里写入一些危险的指令而被另一个程序取出来执行,还可能会导致整个系统的崩溃。所以,为了确保进程间互不干扰,每一个用户进程都需要实时知晓当前其他进程在使用哪些内存地址,这对于写程序的人来说无疑是一场噩梦。

因此,操作绝对内存地址是完全不可行的方案,那就只能用操作相对内存地址,我们知道每个进程都会有自己的进程地址,从 0 开始,可以通过相对地址来访问内存,但是这同样有问题,还是前面类似的问题,比如有两个大小为 16KB 的程序 A 和 B,现在它们都被加载进了内存,内存地址段分别是 0 ~ 16384,16384 ~ 32768。A 的第一条指令是 jmp 1024,而在地址 1024 处是一条 mov 指令,下一条指令是 add,基于前面的 mov 指令做加法运算,与此同时,B 的第一条指令是 jmp 1028,本来在 B 的相对地址 1028 处应该也是一条 mov 去操作自己的内存地址上的值,但是由于这两个程序共享了段寄存器,因此虽然他们使用了各自的相对地址,但是依然操作的还是绝对内存地址,于是 B 就会跳去执行 add 指令,这时候就会因为非法的内存操作而 crash。

有一种静态重定位的技术可以解决这个问题,它的工作原理非常简单粗暴:当 B 程序被加载到地址 16384 处之后,把 B 的所有相对内存地址都加上 16384,这样的话当 B 执行 jmp 1028 之时,其实执行的是 jmp 1028+16384,就可以跳转到正确的内存地址处去执行正确的指令了,但是这种技术并不通用,而且还会对程序装载进内存的性能有影响。

再往后,就发展出来了存储器抽象:地址空间,就好像进程是 CPU 的抽象,地址空间则是存储器的抽象,每个进程都会分配独享的地址空间,但是独享的地址空间又带来了新的问题:如何实现不同进程的相同相对地址指向不同的物理地址?最开始是使用动态重定位技术来实现,这是用一种相对简单的地址空间到物理内存的映射方法。基本原理就是为每一个 CPU 配备两个特殊的硬件寄存器:基址寄存器和界限寄存器,用来动态保存每一个程序的起始物理内存地址和长度,比如前文中的 A,B 两个程序,当 A 运行时基址寄存器和界限寄存器就会分别存入 0 和 16384,而当 B 运行时则两个寄存器又会分别存入 16384 和 32768。然后每次访问指定的内存地址时,CPU 会在把地址发往内存总线之前自动把基址寄存器里的值加到该内存地址上,得到一个真正的物理内存地址,同时还会根据界限寄存器里的值检查该地址是否溢出,若是,则产生错误中止程序,动态重定位技术解决了静态重定位技术造成的程序装载速度慢的问题,但是也有新问题:每次访问内存都需要进行加法和比较运算,比较运算本身可以很快,但是加法运算由于进位传递时间的问题,除非使用特殊的电路,否则会比较慢。

然后就是 交换(swapping)技术,这种技术简单来说就是动态地把程序在内存和磁盘之间进行交换保存,要运行一个进程的时候就把程序的代码段和数据段调入内存,然后再把程序封存,存入磁盘,如此反复。为什么要这么麻烦?因为前面那两种重定位技术的前提条件是计算机内存足够大,能够把所有要运行的进程地址空间都加载进主存,才能够并发运行这些进程,但是现实往往不是如此,内存的大小总是有限的,所有就需要另一类方法来处理内存超载的情况,第一种便是简单的交换技术:

先把进程 A 换入内存,然后启动进程 B 和 C,也换入内存,接着 A 被从内存交换到磁盘,然后又有新的进程 D 调入内存,用了 A 退出之后空出来的内存空间,最后 A 又被重新换入内存,由于内存布局已经发生了变化,所以 A 在换入内存之时会通过软件或者在运行期间通过硬件(基址寄存器和界限寄存器)对其内存地址进行重定位,多数情况下都是通过硬件。

另一种处理内存超载的技术就是虚拟内存技术了,它比交换(swapping)技术更复杂而又更高效,是目前最新应用最广泛的存储器抽象技术:

虚拟内存的核心原理是:为每个程序设置一段"连续"的虚拟地址空间,把这个地址空间分割成多个具有连续地址范围的页 (page),并把这些页和物理内存做映射,在程序运行期间动态映射到物理内存。当程序引用到一段在物理内存的地址空间时,由硬件立刻执行必要的映射;而当程序引用到一段不在物理内存中的地址空间时,由操作系统负责将缺失的部分装入物理内存并重新执行失败的指令:

虚拟地址空间按照固定大小划分成被称为页(page)的若干单元,物理内存中对应的则是页框(page frame)。这两者一般来说是一样的大小,如上图中的是 4KB,不过实际上计算机系统中一般是 512 字节到 1 GB,这就是虚拟内存的分页技术。因为是虚拟内存空间,每个进程分配的大小是 4GB (32 位架构),而实际上当然不可能给所有在运行中的进程都分配 4GB 的物理内存,所以虚拟内存技术还需要利用到前面介绍的交换(swapping)技术,在进程运行期间只分配映射当前使用到的内存,暂时不使用的数据则写回磁盘作为副本保存,需要用的时候再读入内存,动态地在磁盘和内存之间交换数据。

其实虚拟内存技术从某种角度来看的话,很像是糅合了基址寄存器和界限寄存器之后的新技术。它使得整个进程的地址空间可以通过较小的单元映射到物理内存,而不需要为程序的代码和数据地址进行重定位。

进程在运行期间产生的内存地址都是虚拟地址,如果计算机没有引入虚拟内存这种存储器抽象技术的话,则 CPU 会把这些地址直接发送到内存地址总线上,直接访问和虚拟地址相同值的物理地址;如果使用虚拟内存技术的话,CPU 则是把这些虚拟地址通过地址总线送到内存管理单元(Memory Management Unit,MMU),MMU 将虚拟地址映射为物理地址之后再通过内存总线去访问物理内存:

虚拟地址(比如 16 位地址 8196=0010 000000000100)分为两部分:虚拟页号(高位部分)和偏移量(低位部分),虚拟地址转换成物理地址是通过页表(page table)来实现的,页表由页表项构成,页表项中保存了页框号、修改位、访问位、保护位和 "在/不在" 位等信息,从数学角度来说页表就是一个函数,入参是虚拟页号,输出是物理页框号,得到物理页框号之后复制到寄存器的高三位中,最后直接把 12 位的偏移量复制到寄存器的末 12 位构成 15 位的物理地址,即可以把该寄存器的存储的物理内存地址发送到内存总线:

在 MMU 进行地址转换时,如果页表项的 "在/不在" 位是 0,则表示该页面并没有映射到真实的物理页框,则会引发一个缺页中断,CPU 陷入操作系统内核,接着操作系统就会通过页面置换算法选择一个页面将其换出 (swap),以便为即将调入的新页面腾出位置,如果要换出的页面的页表项里的修改位已经被设置过,也就是被更新过,则这是一个脏页 (dirty page),需要写回磁盘更新改页面在磁盘上的副本,如果该页面是"干净"的,也就是没有被修改过,则直接用调入的新页面覆盖掉被换出的旧页面即可。

最后,还需要了解的一个概念是转换检测缓冲器(Translation Lookaside Buffer,TLB),也叫快表,是用来加速虚拟地址映射的,因为虚拟内存的分页机制,页表一般是保存内存中的一块固定的存储区,导致进程通过 MMU 访问内存比直接访问内存多了一次内存访问,性能至少下降一半,因此需要引入加速机制,即 TLB 快表,TLB 可以简单地理解成页表的高速缓存,保存了最高频被访问的页表项,由于一般是硬件实现的,因此速度极快,MMU收到虚拟地址时一般会先通过硬件 TLB 查询对应的页表号,若命中且该页表项的访问操作合法,则直接从 TLB 取出对应的物理页框号返回,若不命中则穿透到内存页表里查询,并且会用这个从内存页表里查询到最新页表项替换到现有 TLB 里的其中一个,以备下次缓存命中。

至此,我们介绍完了包含虚拟内存在内的多项计算机存储器抽象技术,虚拟内存的其他内容比如针对大内存的多级页表、倒排页表,以及处理缺页中断的页面置换算法等等,以后有机会再单独写一篇文章介绍,或者各位读者也可以先行去查阅相关资料了解,这里就不再深入了。

用户态和内核态

一般来说,我们在编写程序操作 Linux I/O 之时十有八九是在用户空间和内核空间之间传输数据,因此有必要先了解一下 Linux 的用户态和内核态的概念。

首先是用户态和内核态:

从宏观上来看,Linux 操作系统的体系架构分为用户态和内核态(或者用户空间和内核)。内核从本质上看是一种软件 —— 控制计算机的硬件资源,并提供上层应用程序 (进程) 运行的环境。用户态即上层应用程序 (进程) 的运行空间,应用程序 (进程) 的执行必须依托于内核提供的资源,这其中包括但不限于 CPU 资源、存储资源、I/O 资源等等。

现代操作系统都是采用虚拟存储器,那么对 32 位操作系统而言,它的寻址空间(虚拟存储空间)为 2^32 B = 4G。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对 Linux 操作系统而言,将最高的 1G 字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF),供内核使用,称为内核空间,而将较低的 3G 字节(从虚拟地址 0x00000000 到 0xBFFFFFFF),供各个进程使用,称为用户空间。

因为操作系统的资源是有限的,如果访问资源的操作过多,必然会消耗过多的系统资源,而且如果不对这些操作加以区分,很可能造成资源访问的冲突。所以,为了减少有限资源的访问和使用冲突,Unix/Linux 的设计哲学之一就是:对不同的操作赋予不同的执行等级,就是所谓特权的概念。简单说就是有多大能力做多大的事,与系统相关的一些特别关键的操作必须由最高特权的程序来完成。Intel 的 x86 架构的 CPU 提供了 0 到 3 四个特权级,数字越小,特权越高,Linux 操作系统中主要采用了 0 和 3 两个特权级,分别对应的就是内核态和用户态。运行于用户态的进程可以执行的操作和访问的资源都会受到极大的限制,而运行在内核态的进程则可以执行任何操作并且在资源的使用上没有限制。很多程序开始时运行于用户态,但在执行的过程中,一些操作需要在内核权限下才能执行,这就涉及到一个从用户态切换到内核态的过程。比如 C 函数库中的内存分配函数 malloc(),它具体是使用 sbrk() 系统调用来分配内存,当 malloc 调用 sbrk() 的时候就涉及一次从用户态到内核态的切换,类似的函数还有 printf(),调用的是 wirte() 系统调用来输出字符串,等等。

用户进程在系统中运行时,大部分时间是处在用户态空间里的,在其需要操作系统帮助完成一些用户态没有特权和能力完成的操作时就需要切换到内核态。那么用户进程如何切换到内核态去使用那些内核资源呢?答案是:1) 系统调用(trap),2) 异常(exception)和 3) 中断(interrupt)。

  • 系统调用:用户进程主动发起的操作。用户态进程发起系统调用主动要求切换到内核态,陷入内核之后,由操作系统来操作系统资源,完成之后再返回到进程。
  • 异常:被动的操作,且用户进程无法预测其发生的时机。当用户进程在运行期间发生了异常(比如某条指令出了问题),这时会触发由当前运行进程切换到处理此异常的内核相关进程中,也即是切换到了内核态。异常包括程序运算引起的各种错误如除 0、缓冲区溢出、缺页等。
  • 中断:当外围设备完成用户请求的操作后,会向 CPU 发出相应的中断信号,这时 CPU 会暂停执行下一条即将要执行的指令而转到与中断信号对应的处理程序去执行,如果前面执行的指令是用户态下的程序,那么转换的过程自然就会是从用户态到内核态的切换。中断包括 I/O 中断、外部信号中断、各种定时器引起的时钟中断等。中断和异常类似,都是通过中断向量表来找到相应的处理程序进行处理。区别在于,中断来自处理器外部,不是由任何一条专门的指令造成,而异常是执行当前指令的结果。

通过上面的分析,我们可以得出 Linux 的内部层级可分为三大部分:

  1. 用户空间;
  2. 内核空间;
  3. 硬件。

Linux I/O

I/O 缓冲区

在 Linux 中,当程序调用各类文件操作函数后,用户数据(User Data)到达磁盘(Disk)的流程如上图所示。

图中描述了 Linux 中文件操作函数的层级关系和内存缓存层的存在位置,中间的黑色实线是用户态和内核态的分界线。

read(2)/write(2) 是 Linux 系统中最基本的 I/O 读写系统调用,我们开发操作 I/O 的程序时必定会接触到它们,而在这两个系统调用和真实的磁盘读写之间存在一层称为 Kernel buffer cache 的缓冲区缓存。在 Linux 中 I/O 缓存其实可以细分为两个:Page CacheBuffer Cache,这两个其实是一体两面,共同组成了 Linux 的内核缓冲区(Kernel Buffer Cache):

  • 读磁盘:内核会先检查 Page Cache 里是不是已经缓存了这个数据,若是,直接从这个内存缓冲区里读取返回,若否,则穿透到磁盘去读取,然后再缓存在 Page Cache 里,以备下次缓存命中;
  • 写磁盘:内核直接把数据写入 Page Cache,并把对应的页标记为 dirty,添加到 dirty list 里,然后就直接返回,内核会定期把 dirty list 的页缓存 flush 到磁盘,保证页缓存和磁盘的最终一致性。

Page Cache 会通过页面置换算法如 LRU 定期淘汰旧的页面,加载新的页面。可以看出,所谓 I/O 缓冲区缓存就是在内核和磁盘、网卡等外设之间的一层缓冲区,用来提升读写性能的。

在 Linux 还不支持虚拟内存技术之前,还没有页的概念,因此 Buffer Cache 是基于操作系统读写磁盘的最小单位 -- 块(block)来进行的,所有的磁盘块操作都是通过 Buffer Cache 来加速,Linux 引入虚拟内存的机制来管理内存后,页成为虚拟内存管理的最小单位,因此也引入了 Page Cache 来缓存 Linux 文件内容,主要用来作为文件系统上的文件数据的缓存,提升读写性能,常见的是针对文件的 read()/write() 操作,另外也包括了通过 mmap() 映射之后的块设备,也就是说,事实上 Page Cache 负责了大部分的块设备文件的缓存工作。而 Buffer Cache 用来在系统对块设备进行读写的时候,对块进行数据缓存的系统来使用,实际上负责所有对磁盘的 I/O 访问:

因为 Buffer Cache 是对粒度更细的设备块的缓存,而 Page Cache 是基于虚拟内存的页单元缓存,因此还是会基于 Buffer Cache,也就是说如果是缓存文件内容数据就会在内存里缓存两份相同的数据,这就会导致同一份文件保存了两份,冗余且低效。另外一个问题是,调用 write 后,有效数据是在 Buffer Cache 中,而非 Page Cache 中。这就导致 mmap 访问的文件数据可能存在不一致问题。为了规避这个问题,所有基于磁盘文件系统的 write,都需要调用 update_vm_cache() 函数,该操作会把调用 write 之后的 Buffer Cache 更新到 Page Cache 去。由于有这些设计上的弊端,因此在 Linux 2.4 版本之后,kernel 就将两者进行了统一,Buffer Cache 不再以独立的形式存在,而是以融合的方式存在于 Page Cache 中:

融合之后就可以统一操作 Page CacheBuffer Cache:处理文件 I/O 缓存交给 Page Cache,而当底层 RAW device 刷新数据时以 Buffer Cache 的块单位来实际处理。

I/O 模式

在 Linux 或者其他 Unix-like 操作系统里,I/O 模式一般有三种:

  1. 程序控制 I/O
  2. 中断驱动 I/O
  3. DMA I/O

下面我分别详细地讲解一下这三种 I/O 模式。

程序控制 I/O

这是最简单的一种 I/O 模式,也叫忙等待或者轮询:用户通过发起一个系统调用,陷入内核态,内核将系统调用翻译成一个对应设备驱动程序的过程调用,接着设备驱动程序会启动 I/O 不断循环去检查该设备,看看是否已经就绪,一般通过返回码来表示,I/O 结束之后,设备驱动程序会把数据送到指定的地方并返回,切回用户态。

比如发起系统调用 read()

中断驱动 I/O

第二种 I/O 模式是利用中断来实现的:

流程如下:

  1. 用户进程发起一个 read() 系统调用读取磁盘文件,陷入内核态并由其所在的 CPU 通过设备驱动程序向设备寄存器写入一个通知信号,告知设备控制器 (我们这里是磁盘控制器)要读取数据;
  2. 磁盘控制器启动磁盘读取的过程,把数据从磁盘拷贝到磁盘控制器缓冲区里;
  3. 完成拷贝之后磁盘控制器会通过总线发送一个中断信号到中断控制器,如果此时中断控制器手头还有正在处理的中断或者有一个和该中断信号同时到达的更高优先级的中断,则这个中断信号将被忽略,而磁盘控制器会在后面持续发送中断信号直至中断控制器受理;
  4. 中断控制器收到磁盘控制器的中断信号之后会通过地址总线存入一个磁盘设备的编号,表示这次中断需要关注的设备是磁盘;
  5. 中断控制器向 CPU 置起一个磁盘中断信号;
  6. CPU 收到中断信号之后停止当前的工作,把当前的 PC/PSW 等寄存器压入堆栈保存现场,然后从地址总线取出设备编号,通过编号找到中断向量所包含的中断服务的入口地址,压入 PC 寄存器,开始运行磁盘中断服务,把数据从磁盘控制器的缓冲区拷贝到主存里的内核缓冲区;
  7. 最后 CPU 再把数据从内核缓冲区拷贝到用户缓冲区,完成读取操作,read() 返回,切换回用户态。

DMA I/O

并发系统的性能高低究其根本,是取决于如何对 CPU 资源的高效调度和使用,而回头看前面的中断驱动 I/O 模式的流程,可以发现第 6、7 步的数据拷贝工作都是由 CPU 亲自完成的,也就是在这两次数据拷贝阶段中 CPU 是完全被占用而不能处理其他工作的,那么这里明显是有优化空间的;第 7 步的数据拷贝是从内核缓冲区到用户缓冲区,都是在主存里,所以这一步只能由 CPU 亲自完成,但是第 6 步的数据拷贝,是从磁盘控制器的缓冲区到主存,是两个设备之间的数据传输,这一步并非一定要 CPU 来完成,可以借助 DMA 来完成,减轻 CPU 的负担。

DMA 全称是 Direct Memory Access,也即直接存储器存取,是一种用来提供在外设和存储器之间或者存储器和存储器之间的高速数据传输。整个过程无须 CPU 参与,数据直接通过 DMA 控制器进行快速地移动拷贝,节省 CPU 的资源去做其他工作。

目前,大部分的计算机都配备了 DMA 控制器,而 DMA 技术也支持大部分的外设和存储器。借助于 DMA 机制,计算机的 I/O 过程就能更加高效:

DMA 控制器内部包含若干个可以被 CPU 读写的寄存器:一个主存地址寄存器 MAR(存放要交换数据的主存地址)、一个外设地址寄存器 ADR(存放 I/O 设备的设备码,或者是设备信息存储区的寻址信息)、一个字节数寄存器 WC(对传送数据的总字数进行统计)、和一个或多个控制寄存器。

  1. 用户进程发起一个 read() 系统调用读取磁盘文件,陷入内核态并由其所在的 CPU 通过设置 DMA 控制器的寄存器对它进行编程:把内核缓冲区和磁盘文件的地址分别写入 MAR 和 ADR 寄存器,然后把期望读取的字节数写入 WC 寄存器,启动 DMA 控制器;
  2. DMA 控制器根据 ADR 寄存器里的信息知道这次 I/O 需要读取的外设是磁盘的某个地址,便向磁盘控制器发出一个命令,通知它从磁盘读取数据到其内部的缓冲区里;
  3. 磁盘控制器启动磁盘读取的过程,把数据从磁盘拷贝到磁盘控制器缓冲区里,并对缓冲区内数据的校验和进行检验,如果数据是有效的,那么 DMA 就可以开始了;
  4. DMA 控制器通过总线向磁盘控制器发出一个读请求信号从而发起 DMA 传输,这个信号和前面的中断驱动 I/O 小节里 CPU 发给磁盘控制器的读请求是一样的,它并不知道或者并不关心这个读请求是来自 CPU 还是 DMA 控制器;
  5. 紧接着 DMA 控制器将引导磁盘控制器将数据传输到 MAR 寄存器里的地址,也就是内核缓冲区;
  6. 数据传输完成之后,返回一个 ack 给 DMA 控制器,WC 寄存器里的值会减去相应的数据长度,如果 WC 还不为 0,则重复第 4 步到第 6 步,一直到 WC 里的字节数等于 0;
  7. 收到 ack 信号的 DMA 控制器会通过总线发送一个中断信号到中断控制器,如果此时中断控制器手头还有正在处理的中断或者有一个和该中断信号同时到达的更高优先级的中断,则这个中断信号将被忽略,而 DMA 控制器会在后面持续发送中断信号直至中断控制器受理;
  8. 中断控制器收到磁盘控制器的中断信号之后会通过地址总线存入一个主存设备的编号,表示这次中断需要关注的设备是主存;
  9. 中断控制器向 CPU 置起一个 DMA 中断的信号;
  10. CPU 收到中断信号之后停止当前的工作,把当前的 PC/PSW 等寄存器压入堆栈保存现场,然后从地址总线取出设备编号,通过编号找到中断向量所包含的中断服务的入口地址,压入 PC 寄存器,开始运行 DMA 中断服务,把数据从内核缓冲区拷贝到用户缓冲区,完成读取操作,read() 返回,切换回用户态。

传统 I/O 读写模式

Linux 中传统的 I/O 读写是通过 read()/write() 系统调用完成的,read() 把数据从存储器 (磁盘、网卡等) 读取到用户缓冲区,write() 则是把数据从用户缓冲区写出到存储器:

#include <unistd.h>

ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);

一次完整的读磁盘文件然后写出到网卡的底层传输过程如下:

可以清楚看到这里一共触发了 4 次用户态和内核态的上下文切换,分别是 read()/write() 调用和返回时的切换,2 次 DMA 拷贝,2 次 CPU 拷贝,加起来一共 4 次拷贝操作。

通过引入 DMA,我们已经把 Linux 的 I/O 过程中的 CPU 拷贝次数从 4 次减少到了 2 次,但是 CPU 拷贝依然是代价很大的操作,对系统性能的影响还是很大,特别是那些频繁 I/O 的场景,更是会因为 CPU 拷贝而损失掉很多性能,我们需要进一步优化,降低、甚至是完全避免 CPU 拷贝。

零拷贝 (Zero-copy)

Zero-copy 是什么?

Wikipedia 的解释如下:

"Zero-copy" describes computer operations in which the CPU does not perform the task of copying data from one memory area to another. This is frequently used to save CPU cycles and memory bandwidth when transmitting a file over a network.

零拷贝技术是指计算机执行操作时,CPU不需要先将数据从某处内存复制到另一个特定区域。这种技术通常用于通过网络传输文件时节省CPU周期和内存带宽

Zero-copy 能做什么?

  • 减少甚至完全避免操作系统内核和用户应用程序地址空间这两者之间进行数据拷贝操作,从而减少用户态 -- 内核态上下文切换带来的系统开销。
  • 减少甚至完全避免操作系统内核缓冲区之间进行数据拷贝操作。
  • 帮助用户进程绕开操作系统内核空间直接访问硬件存储接口操作数据。
  • 利用 DMA 而非 CPU 来完成硬件接口和内核缓冲区之间的数据拷贝,从而解放 CPU,使之能去执行其他的任务,提升系统性能。

Zero-copy 的实现方式有哪些?

从 zero-copy 这个概念被提出以来,相关的实现技术便犹如雨后春笋,层出不穷。但是截至目前为止,并没有任何一种 zero-copy 技术能满足所有的场景需求,还是计算机领域那句无比经典的名言:"There is no silver bullet"!

而在 Linux 平台上,同样也有很多的 zero-copy 技术,新旧各不同,可能存在于不同的内核版本里,很多技术可能有了很大的改进或者被更新的实现方式所替代,这些不同的实现技术按照其核心思想可以归纳成大致的以下三类:

  • 减少甚至避免用户空间和内核空间之间的数据拷贝:在一些场景下,用户进程在数据传输过程中并不需要对数据进行访问和处理,那么数据在 Linux 的 Page Cache 和用户进程的缓冲区之间的传输就完全可以避免,让数据拷贝完全在内核里进行,甚至可以通过更巧妙的方式避免在内核里的数据拷贝。这一类实现一般是通过增加新的系统调用来完成的,比如 Linux 中的 mmap(),sendfile() 以及 splice() 等。
  • 绕过内核的直接 I/O:允许在用户态进程绕过内核直接和硬件进行数据传输,内核在传输过程中只负责一些管理和辅助的工作。这种方式其实和第一种有点类似,也是试图避免用户空间和内核空间之间的数据传输,只是第一种方式是把数据传输过程放在内核态完成,而这种方式则是直接绕过内核和硬件通信,效果类似但原理完全不同。
  • 内核缓冲区和用户缓冲区之间的传输优化:这种方式侧重于在用户进程的缓冲区和操作系统的页缓存之间的 CPU 拷贝的优化。这种方法延续了以往那种传统的通信方式,但更灵活。

减少甚至避免用户空间和内核空间之间的数据拷贝

mmap()
#include <sys/mman.h>

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);

一种简单的实现方案是在一次读写过程中用 Linux 的另一个系统调用 mmap() 替换原先的 read()mmap() 也即是内存映射(memory map):把用户进程空间的一段内存缓冲区(user buffer)映射到文件所在的内核缓冲区(kernel buffer)上。

利用 mmap() 替换 read(),配合 write() 调用的整个流程如下:

  1. 用户进程调用 mmap(),从用户态陷入内核态,将内核缓冲区映射到用户缓存区;
  2. DMA 控制器将数据从硬盘拷贝到内核缓冲区;
  3. mmap() 返回,上下文从内核态切换回用户态;
  4. 用户进程调用 write(),尝试把文件数据写到内核里的套接字缓冲区,再次陷入内核态;
  5. CPU 将内核缓冲区中的数据拷贝到的套接字缓冲区;
  6. DMA 控制器将数据从套接字缓冲区拷贝到网卡完成数据传输;
  7. write() 返回,上下文从内核态切换回用户态。

通过这种方式,有两个优点:一是节省内存空间,因为用户进程上的这一段内存是虚拟的,并不真正占据物理内存,只是映射到文件所在的内核缓冲区上,因此可以节省一半的内存占用;二是省去了一次 CPU 拷贝,对比传统的 Linux I/O 读写,数据不需要再经过用户进程进行转发了,而是直接在内核里就完成了拷贝。所以使用 mmap() 之后的拷贝次数是 2 次 DMA 拷贝,1 次 CPU 拷贝,加起来一共 3 次拷贝操作,比传统的 I/O 方式节省了一次 CPU 拷贝以及一半的内存,不过因为 mmap() 也是一个系统调用,因此用户态和内核态的切换还是 4 次。

mmap() 因为既节省 CPU 拷贝次数又节省内存,所以比较适合大文件传输的场景。虽然 mmap() 完全是符合 POSIX 标准的,但是它也不是完美的,因为它并不总是能达到理想的数据传输性能。首先是因为数据数据传输过程中依然需要一次 CPU 拷贝,其次是内存映射技术是一个开销很大的虚拟存储操作:这种操作需要修改页表以及用内核缓冲区里的文件数据汰换掉当前 TLB 里的缓存以维持虚拟内存映射的一致性。但是,因为内存映射通常针对的是相对较大的数据区域,所以对于相同大小的数据来说,内存映射所带来的开销远远低于 CPU 拷贝所带来的开销。此外,使用 mmap() 还可能会遇到一些需要值得关注的特殊情况,例如,在 mmap() --> write() 这两个系统调用的整个传输过程中,如果有其他的进程突然截断了这个文件,那么这时用户进程就会因为访问非法地址而被一个从总线传来的 SIGBUS 中断信号杀死并且产生一个 core dump。有两种解决办法:

  1. 设置一个信号处理器,专门用来处理 SIGBUS 信号,这个处理器直接返回, write() 就可以正常返回已写入的字节数而不会被 SIGBUS 中断,errno 错误码也会被设置成 success。然而这实际上是一个掩耳盗铃的解决方案,因为 BIGBUS 信号的带来的信息是系统发生了一些很严重的错误,而我们却选择忽略掉它,一般不建议采用这种方式。
  2. 通过内核的文件租借锁(这是 Linux 的叫法,Windows 上称之为机会锁)来解决这个问题,这种方法相对来说更好一些。我们可以通过内核对文件描述符上读/写的租借锁,当另外一个进程尝试对当前用户进程正在进行传输的文件进行截断的时候,内核会发送给用户一个实时信号:RT_SIGNAL_LEASE 信号,这个信号会告诉用户内核正在破坏你加在那个文件上的读/写租借锁,这时 write() 系统调用会被中断,并且当前用户进程会被 SIGBUS 信号杀死,返回值则是中断前写的字节数,errno 同样会被设置为 success。文件租借锁需要在对文件进行内存映射之前设置,最后在用户进程结束之前释放掉。
sendfile()

在 Linux 内核 2.1 版本中,引入了一个新的系统调用 sendfile()

#include <sys/sendfile.h>

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

从功能上来看,这个系统调用将 mmap() + write() 这两个系统调用合二为一,实现了一样效果的同时还简化了用户接口,其他的一些 Unix-like 的系统像 BSD、Solaris 和 AIX 等也有类似的实现,甚至 Windows 上也有一个功能类似的 API 函数 TransmitFile

out_fd 和 in_fd 分别代表了写入和读出的文件描述符,in_fd 必须是一个指向文件的文件描述符,且要能支持类 mmap() 内存映射,不能是 Socket 类型,而 out_fd 在 Linux 内核 2.6.33 版本之前只能是一个指向 Socket 的文件描述符,从 2.6.33 之后则可以是任意类型的文件描述符。off_t 是一个代表了 in_fd 偏移量的指针,指示 sendfile() 该从 in_fd 的哪个位置开始读取,函数返回后,这个指针会被更新成 sendfile() 最后读取的字节位置处,表明此次调用共读取了多少文件数据,最后的 count 参数则是此次调用需要传输的字节总数。

使用 sendfile() 完成一次数据读写的流程如下:

  1. 用户进程调用 sendfile() 从用户态陷入内核态;
  2. DMA 控制器将数据从硬盘拷贝到内核缓冲区;
  3. CPU 将内核缓冲区中的数据拷贝到套接字缓冲区;
  4. DMA 控制器将数据从套接字缓冲区拷贝到网卡完成数据传输;
  5. sendfile() 返回,上下文从内核态切换回用户态。

基于 sendfile(), 整个数据传输过程中共发生 2 次 DMA 拷贝和 1 次 CPU 拷贝,这个和 mmap() + write() 相同,但是因为 sendfile() 只是一次系统调用,因此比前者少了一次用户态和内核态的上下文切换开销。读到这里,聪明的读者应该会开始提问了:"sendfile() 会不会遇到和 mmap() + write() 相似的文件截断问题呢?",很不幸,答案是肯定的。sendfile() 一样会有文件截断的问题,但欣慰的是,sendfile() 不仅比 mmap() + write() 在接口使用上更加简洁,而且处理文件截断时也更加优雅:如果 sendfile() 过程中遭遇文件截断,则 sendfile() 系统调用会被中断杀死之前返回给用户进程其中断前所传输的字节数,errno 会被设置为 success,无需用户提前设置信号处理器,当然你要设置一个进行个性化处理也可以,也不需要像之前那样提前给文件描述符设置一个租借锁,因为最终结果还是一样的。

sendfile() 相较于 mmap() 的另一个优势在于数据在传输过程中始终没有越过用户态和内核态的边界,因此极大地减少了存储管理的开销。即便如此,sendfile() 依然是一个适用性很窄的技术,最适合的场景基本也就是一个静态文件服务器了。而且根据 Linus 在 2001 年和其他内核维护者的邮件列表内容,其实当初之所以决定在 Linux 上实现 sendfile() 仅仅是因为在其他操作系统平台上已经率先实现了,而且有大名鼎鼎的 Apache Web 服务器已经在使用了,为了兼容 Apache Web 服务器才决定在 Linux 上也实现这个技术,而且 sendfile() 实现上的简洁性也和 Linux 内核的其他部分集成得很好,所以 Linus 也就同意了这个提案。

然而 sendfile() 本身是有很大问题的,从不同的角度来看的话主要是:

  1. 首先一个是这个接口并没有进行标准化,导致 sendfile() 在 Linux 上的接口实现和其他类 Unix 系统的实现并不相同;
  2. 其次由于网络传输的异步性,很难在接收端实现和 sendfile() 对接的技术,因此接收端一直没有实现对应的这种技术;
  3. 最后从性能方面考量,因为 sendfile() 在把磁盘文件从内核缓冲区(page cache)传输到到套接字缓冲区的过程中依然需要 CPU 参与,这就很难避免 CPU 的高速缓存被传输的数据所污染。

此外,需要说明下,sendfile() 的最初设计并不是用来处理大文件的,因此如果需要处理很大的文件的话,可以使用另一个系统调用 sendfile64(),它支持对更大的文件内容进行寻址和偏移。

sendfile() with DMA Scatter/Gather Copy

上一小节介绍的 sendfile() 技术已经把一次数据读写过程中的 CPU 拷贝的降低至只有 1 次了,但是人永远是贪心和不知足的,现在如果想要把这仅有的一次 CPU 拷贝也去除掉,有没有办法呢?

当然有!通过引入一个新硬件上的支持,我们可以把这个仅剩的一次 CPU 拷贝也给抹掉:Linux 在内核 2.4 版本里引入了 DMA 的 scatter/gather -- 分散/收集功能,并修改了 sendfile() 的代码使之和 DMA 适配。scatter 使得 DMA 拷贝可以不再需要把数据存储在一片连续的内存空间上,而是允许离散存储,gather 则能够让 DMA 控制器根据少量的元信息:一个包含了内存地址和数据大小的缓冲区描述符,收集存储在各处的数据,最终还原成一个完整的网络包,直接拷贝到网卡而非套接字缓冲区,避免了最后一次的 CPU 拷贝:

sendfile() + DMA gather 的数据传输过程如下:

  1. 用户进程调用 sendfile(),从用户态陷入内核态;
  2. DMA 控制器使用 scatter 功能把数据从硬盘拷贝到内核缓冲区进行离散存储;
  3. CPU 把包含内存地址和数据长度的缓冲区描述符拷贝到套接字缓冲区,DMA 控制器能够根据这些信息生成网络包数据分组的报头和报尾
  4. DMA 控制器根据缓冲区描述符里的内存地址和数据大小,使用 scatter-gather 功能开始从内核缓冲区收集离散的数据并组包,最后直接把网络包数据拷贝到网卡完成数据传输;
  5. sendfile() 返回,上下文从内核态切换回用户态。

基于这种方案,我们就可以把这仅剩的唯一一次 CPU 拷贝也给去除了(严格来说还是会有一次,但是因为这次 CPU 拷贝的只是那些微乎其微的元信息,开销几乎可以忽略不计),理论上,数据传输过程就再也没有 CPU 的参与了,也因此 CPU 的高速缓存再不会被污染了,也不再需要 CPU 来计算数据校验和了,CPU 可以去执行其他的业务计算任务,同时和 DMA 的 I/O 任务并行,此举能极大地提升系统性能。

splice()

sendfile() + DMA Scatter/Gather 的零拷贝方案虽然高效,但是也有两个缺点:

  1. 这种方案需要引入新的硬件支持;
  2. 虽然 sendfile() 的输出文件描述符在 Linux kernel 2.6.33 版本之后已经可以支持任意类型的文件描述符,但是输入文件描述符依然只能指向文件。

这两个缺点限制了 sendfile() + DMA Scatter/Gather 方案的适用场景。为此,Linux 在 2.6.17 版本引入了一个新的系统调用 splice(),它在功能上和 sendfile() 非常相似,但是能够实现在任意类型的两个文件描述符时之间传输数据;而在底层实现上,splice()又比 sendfile() 少了一次 CPU 拷贝,也就是等同于 sendfile() + DMA Scatter/Gather,完全去除了数据传输过程中的 CPU 拷贝。

splice() 系统调用函数定义如下:

#include <fcntl.h>
#include <unistd.h>

int pipe(int pipefd[2]);
int pipe2(int pipefd[2], int flags);

ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);

fd_in 和 fd_out 也是分别代表了输入端和输出端的文件描述符,这两个文件描述符必须有一个是指向管道设备的,这也是一个不太友好的限制,虽然 Linux 内核开发的官方从这个系统调用推出之时就承诺未来可能会重构去掉这个限制,然而他们许下这个承诺之后就如同石沉大海,如今 14 年过去了,依旧杳无音讯...

off_in 和 off_out 则分别是 fd_in 和 fd_out 的偏移量指针,指示内核从哪里读取和写入数据,len 则指示了此次调用希望传输的字节数,最后的 flags 是系统调用的标记选项位掩码,用来设置系统调用的行为属性的,由以下 0 个或者多个值通过『或』操作组合而成:

  • SPLICE_F_MOVE:指示 splice() 尝试仅仅是移动内存页面而不是复制,设置了这个值不代表就一定不会复制内存页面,复制还是移动取决于内核能否从管道中移动内存页面,或者管道中的内存页面是否是完整的;这个标记的初始实现有很多 bug,所以从 Linux 2.6.21 版本开始就已经无效了,但还是保留了下来,因为在未来的版本里可能会重新被实现。
  • SPLICE_F_NONBLOCK:指示 splice() 不要阻塞 I/O,也就是使得 splice() 调用成为一个非阻塞调用,可以用来实现异步数据传输,不过需要注意的是,数据传输的两个文件描述符也最好是预先通过 O_NONBLOCK 标记成非阻塞 I/O,不然 splice() 调用还是有可能被阻塞。
  • SPLICE_F_MORE:通知内核下一个 splice() 系统调用将会有更多的数据传输过来,这个标记对于输出端是 socket 的场景非常有用。

splice() 是基于 Linux 的管道缓冲区 (pipe buffer) 机制实现的,所以 splice() 的两个入参文件描述符才要求必须有一个是管道设备,一个典型的 splice() 用法是:

int pfd[2];

pipe(pfd);

ssize_t bytes = splice(file_fd, NULL, pfd[1], NULL, 4096, SPLICE_F_MOVE);
assert(bytes != -1);

bytes = splice(pfd[0], NULL, socket_fd, NULL, bytes, SPLICE_F_MOVE | SPLICE_F_MORE);
assert(bytes != -1);

数据传输过程图:

使用 splice() 完成一次磁盘文件到网卡的读写过程如下:

  1. 用户进程调用 pipe(),从用户态陷入内核态,创建匿名单向管道,pipe() 返回,上下文从内核态切换回用户态;
  2. 用户进程调用 splice(),从用户态陷入内核态;
  3. DMA 控制器将数据从硬盘拷贝到内核缓冲区,从管道的写入端"拷贝"进管道,splice() 返回,上下文从内核态回到用户态;
  4. 用户进程再次调用 splice(),从用户态陷入内核态;
  5. 内核把数据从管道的读取端"拷贝"到套接字缓冲区,DMA 控制器将数据从套接字缓冲区拷贝到网卡;
  6. splice() 返回,上下文从内核态切换回用户态。

相信看完上面的读写流程之后,读者肯定会非常困惑:说好的 splice()sendfile() 的改进版呢?sendfile() 好歹只需要一次系统调用,splice() 居然需要三次,这也就罢了,居然中间还搞出来一个管道,而且还要在内核空间拷贝两次,这算个毛的改进啊?

我最开始了解 splice() 的时候,也是这个反应,但是深入学习它之后,才渐渐知晓个中奥妙,且听我细细道来:

先来了解一下 pipe buffer 管道,管道是 Linux 上用来供进程之间通信的信道,管道有两个端:写入端和读出端,从进程的视角来看,管道表现为一个 FIFO 字节流环形队列:

管道本质上是一个内存中的文件,也就是本质上还是基于 Linux 的 VFS,用户进程可以通过 pipe() 系统调用创建一个匿名管道,创建完成之后会有两个 VFS 的 file 结构体的 inode 分别指向其写入端和读出端,并返回对应的两个文件描述符,用户进程通过这两个文件描述符读写管道;管道的容量单位是一个虚拟内存的页,也就是 4KB,总大小一般是 16 个页,基于其环形结构,管道的页可以循环使用,提高内存利用率。 Linux 中以 pipe_buffer 结构体封装管道页,file 结构体里的 inode 字段里会保存一个 pipe_inode_info 结构体指代管道,其中会保存很多读写管道时所需的元信息,环形队列的头部指针页,读写时的同步机制如互斥锁、等待队列等:

struct pipe_buffer {
    struct page *page; // 内存页结构
    unsigned int offset, len; // 偏移量,长度
    const struct pipe_buf_operations *ops;
    unsigned int flags;
    unsigned long private;
};

struct pipe_inode_info {
    struct mutex mutex;
    wait_queue_head_t wait;
    unsigned int nrbufs, curbuf, buffers;
    unsigned int readers;
    unsigned int writers;
    unsigned int files;
    unsigned int waiting_writers;
    unsigned int r_counter;
    unsigned int w_counter;
    struct page *tmp_page;
    struct fasync_struct *fasync_readers;
    struct fasync_struct *fasync_writers;
    struct pipe_buffer *bufs;
    struct user_struct *user;
};

pipe_buffer 中保存了数据在内存中的页、偏移量和长度,以这三个值来定位数据,注意这里的页不是虚拟内存的页,而用的是物理内存的页框,因为管道时跨进程的信道,因此不能使用虚拟内存来表示,只能使用物理内存的页框定位数据;管道的正常读写操作是通过 pipe_write()/pipe_read() 来完成的,通过把数据读取/写入环形队列的 pipe_buffer 来完成数据传输。

splice() 是基于 pipe buffer 实现的,但是它在通过管道传输数据的时候却是零拷贝,因为它在写入读出时并没有使用 pipe_write()/pipe_read() 真正地在管道缓冲区写入读出数据,而是通过把数据在内存缓冲区中的物理内存页框指针、偏移量和长度赋值给前文提及的 pipe_buffer 中对应的三个字段来完成数据的"拷贝",也就是其实只拷贝了数据的内存地址等元信息。

splice() 在 Linux 内核源码中的内部实现是 do_splice() 函数,而写入读出管道则分别是通过 do_splice_to()do_splice_from(),这里我们重点来解析下写入管道的源码,也就是 do_splice_to(),我现在手头的 Linux 内核版本是 v4.8.17,我们就基于这个版本来分析,至于读出的源码函数 do_splice_from(),原理是相通的,大家举一反三即可。

splice() 写入数据到管道的调用链式:do_splice() --> do_splice_to() --> splice_read()

static long do_splice(struct file *in, loff_t __user *off_in,
              struct file *out, loff_t __user *off_out,
              size_t len, unsigned int flags)
{
...

    // 判断是写出 fd 是一个管道设备,则进入数据写入的逻辑
    if (opipe) {
        if (off_out)
            return -ESPIPE;
        if (off_in) {
            if (!(in->f_mode & FMODE_PREAD))
                return -EINVAL;
            if (copy_from_user(&offset, off_in, sizeof(loff_t)))
                return -EFAULT;
        } else {
            offset = in->f_pos;
        }

        // 调用 do_splice_to 把文件内容写入管道
        ret = do_splice_to(in, &offset, opipe, len, flags);

        if (!off_in)
            in->f_pos = offset;
        else if (copy_to_user(off_in, &offset, sizeof(loff_t)))
            ret = -EFAULT;

        return ret;
    }

    return -EINVAL;
}

进入 do_splice_to() 之后,再调用 splice_read()

static long do_splice_to(struct file *in, loff_t *ppos,
             struct pipe_inode_info *pipe, size_t len,
             unsigned int flags)
{
    ssize_t (*splice_read)(struct file *, loff_t *,
                   struct pipe_inode_info *, size_t, unsigned int);
    int ret;

    if (unlikely(!(in->f_mode & FMODE_READ)))
        return -EBADF;

    ret = rw_verify_area(READ, in, ppos, len);
    if (unlikely(ret < 0))
        return ret;

    if (unlikely(len > MAX_RW_COUNT))
        len = MAX_RW_COUNT;

    // 判断文件的文件的 file 结构体的 f_op 中有没有可供使用的、支持 splice 的 splice_read 函数指针
    // 因为是 splice() 调用,因此内核会提前给这个函数指针指派一个可用的函数
    if (in->f_op->splice_read)
        splice_read = in->f_op->splice_read;
    else
        splice_read = default_file_splice_read;

    return splice_read(in, ppos, pipe, len, flags);
}

in->f_op->splice_read 这个函数指针根据文件描述符的类型不同有不同的实现,比如这里的 in 是一个文件,因此是 generic_file_splice_read(),如果是 socket 的话,则是 sock_splice_read(),其他的类型也会有对应的实现,总之我们这里将使用的是 generic_file_splice_read() 函数,这个函数会继续调用内部函数 __generic_file_splice_read 完成以下工作:

  1. 在 page cache 页缓存里进行搜寻,看看我们要读取这个文件内容是否已经在缓存里了,如果是则直接用,否则如果不存在或者只有部分数据在缓存中,则分配一些新的内存页并进行读入数据操作,同时会增加页框的引用计数;
  2. 基于这些内存页,初始化 splice_pipe_desc 结构,这个结构保存会保存文件数据的地址元信息,包含有物理内存页框地址,偏移、数据长度,也就是 pipe_buffer 所需的三个定位数据的值;
  3. 最后,调用 splice_to_pipe(),splice_pipe_desc 结构体实例是函数入参。
ssize_t splice_to_pipe(struct pipe_inode_info *pipe, struct splice_pipe_desc *spd)
{
...

    for (;;) {
        if (!pipe->readers) {
            send_sig(SIGPIPE, current, 0);
            if (!ret)
                ret = -EPIPE;
            break;
        }

        if (pipe->nrbufs < pipe->buffers) {
            int newbuf = (pipe->curbuf + pipe->nrbufs) & (pipe->buffers - 1);
            struct pipe_buffer *buf = pipe->bufs + newbuf;

            // 写入数据到管道,没有真正拷贝数据,而是内存地址指针的移动,
            // 把物理页框、偏移量和数据长度赋值给 pipe_buffer 完成数据入队操作
            buf->page = spd->pages[page_nr];
            buf->offset = spd->partial[page_nr].offset;
            buf->len = spd->partial[page_nr].len;
            buf->private = spd->partial[page_nr].private;
            buf->ops = spd->ops;
            if (spd->flags & SPLICE_F_GIFT)
                buf->flags |= PIPE_BUF_FLAG_GIFT;

            pipe->nrbufs++;
            page_nr++;
            ret += buf->len;

            if (pipe->files)
                do_wakeup = 1;

            if (!--spd->nr_pages)
                break;
            if (pipe->nrbufs < pipe->buffers)
                continue;

            break;
        }

    ...
}

这里可以清楚地看到 splice() 所谓的写入数据到管道其实并没有真正地拷贝数据,而是玩了个 tricky 的操作:只进行内存地址指针的拷贝而不真正去拷贝数据。所以,数据 splice() 在内核中并没有进行真正的数据拷贝,因此 splice() 系统调用也是零拷贝。

还有一点需要注意,前面说过管道的容量是 16 个内存页,也就是 16 * 4KB = 64 KB,也就是说一次往管道里写数据的时候最好不要超过 64 KB,否则的话会 splice() 会阻塞住,除非在创建管道的时候使用的是 pipe2() 并通过传入 O_NONBLOCK 属性将管道设置为非阻塞。

即使 splice() 通过内存地址指针避免了真正的拷贝开销,但是算起来它还要使用额外的管道来完成数据传输,也就是比 sendfile() 多了两次系统调用,这不是又增加了上下文切换的开销吗?为什么不直接在内核创建管道并调用那两次 splice(),然后只暴露给用户一次系统调用呢?实际上因为 splice() 利用管道而非硬件来完成零拷贝的实现比 sendfile() + DMA Scatter/Gather 的门槛更低,因此后来的 sendfile() 的底层实现就已经替换成 splice() 了。

至于说 splice() 本身的 API 为什么还是这种使用模式,那是因为 Linux 内核开发团队一直想把基于管道的这个限制去掉,但不知道因为什么一直搁置,所以这个 API 也就一直没变化,只能等内核团队哪天想起来了这一茬,然后重构一下使之不再依赖管道,在那之前,使用 splice() 依然还是需要额外创建管道来作为中间缓冲,如果你的业务场景很适合使用 splice(),但又是性能敏感的,不想频繁地创建销毁 pipe buffer 管道缓冲区,那么可以参考一下 HAProxy 使用 splice() 时采用的优化方案:预先分配一个 pipe buffer pool 缓存管道,每次调用 spclie() 的时候去缓存池里取一个管道,用完就放回去,循环利用,提升性能。

send() with MSG_ZEROCOPY

Linux 内核在 2017 年的 v4.14 版本接受了来自 Google 工程师 Willem de Bruijn 在 TCP 网络报文的通用发送接口 send() 中实现的 zero-copy 功能 (MSG_ZEROCOPY) 的 patch,通过这个新功能,用户进程就能够把用户缓冲区的数据通过零拷贝的方式经过内核空间发送到网络套接字中去,这个新技术和前文介绍的几种零拷贝方式相比更加先进,因为前面几种零拷贝技术都是要求用户进程不能处理加工数据而是直接转发到目标文件描述符中去的。Willem de Bruijn 在他的论文里给出的压测数据是:采用 netperf 大包发送测试,性能提升 39%,而线上环境的数据发送性能则提升了 5%~8%,官方文档陈述说这个特性通常只在发送 10KB 左右大包的场景下才会有显著的性能提升。一开始这个特性只支持 TCP,到内核 v5.0 版本之后才支持 UDP。

这个功能的使用模式如下:

if (setsockopt(socket_fd, SOL_SOCKET, SO_ZEROCOPY, &one, sizeof(one)))
        error(1, errno, "setsockopt zerocopy");

ret = send(socket_fd, buffer, sizeof(buffer), MSG_ZEROCOPY);

首先第一步,先给要发送数据的 socket 设置一个 SOCK_ZEROCOPY option,然后在调用 send() 发送数据时再设置一个 MSG_ZEROCOPY option,其实理论上来说只需要调用 setsockopt() 或者 send() 时传递这个 zero-copy 的 option 即可,两者选其一,但是这里却要设置同一个 option 两次,官方的说法是为了兼容 send() API 以前的设计上的一个错误:send() 以前的实现会忽略掉未知的 option,为了兼容那些可能已经不小心设置了 MSG_ZEROCOPY option 的程序,故而设计成了两步设置。不过我猜还有一种可能:就是给使用者提供更灵活的使用模式,因为这个新功能只在大包场景下才可能会有显著的性能提升,但是现实场景是很复杂的,不仅仅是全部大包或者全部小包的场景,有可能是大包小包混合的场景,因此使用者可以先调用 setsockopt() 设置 SOCK_ZEROCOPY option,然后再根据实际业务场景中的网络包尺寸选择是否要在调用 send() 时使用 MSG_ZEROCOPY 进行 zero-copy 传输。

因为 send() 可能是异步发送数据,因此使用 MSG_ZEROCOPY 有一个需要特别注意的点是:调用 send() 之后不能立刻重用或释放 buffer,因为 buffer 中的数据不一定已经被内核读走了,所以还需要从 socket 关联的错误队列里读取一下通知消息,看看 buffer 中的数据是否已经被内核读走了:

pfd.fd = fd;
pfd.events = 0;
if (poll(&pfd, 1, -1) != 1 || pfd.revents & POLLERR == 0)
        error(1, errno, "poll");

ret = recvmsg(fd, &msg, MSG_ERRQUEUE);
if (ret == -1)
        error(1, errno, "recvmsg");

read_notification(msg);


uint32_t read_notification(struct msghdr *msg)
{
    struct sock_extended_err *serr;
    struct cmsghdr *cm;
    
    cm = CMSG_FIRSTHDR(msg);
    if (cm->cmsg_level != SOL_IP &&
        cm->cmsg_type != IP_RECVERR)
            error(1, 0, "cmsg");
    
    serr = (void *) CMSG_DATA(cm);
    if (serr->ee_errno != 0 ||
        serr->ee_origin != SO_EE_ORIGIN_ZEROCOPY)
            error(1, 0, "serr");
    
    return serr->ee _ data;
}

这个技术是基于 redhat 红帽在 2010 年给 Linux 内核提交的 virtio-net zero-copy 技术之上实现的,至于底层原理,简单来说就是通过 send() 把数据在用户缓冲区中的分段指针发送到 socket 中去,利用 page pinning 页锁定机制锁住用户缓冲区的内存页,然后利用 DMA 直接在用户缓冲区通过内存地址指针进行数据读取,实现零拷贝;具体的细节可以通过阅读 Willem de Bruijn 的论文 (PDF) 深入了解。

目前来说,这种技术的主要缺陷有:

  1. 只适用于大文件 (10KB 左右) 的场景,小文件场景因为 page pinning 页锁定和等待缓冲区释放的通知消息这些机制,甚至可能比直接 CPU 拷贝更耗时;
  2. 因为可能异步发送数据,需要额外调用 poll()recvmsg() 系统调用等待 buffer 被释放的通知消息,增加代码复杂度,以及会导致多次用户态和内核态的上下文切换;
  3. MSG_ZEROCOPY 目前只支持发送端,接收端暂不支持。

绕过内核的直接 I/O

可以看出,前面种种的 zero-copy 的方法,都是在想方设法地优化减少或者去掉用户态和内核态之间以及内核态和内核态之间的数据拷贝,为了实现避免这些拷贝可谓是八仙过海,各显神通,采用了各种各样的手段,那么如果我们换个思路:其实这么费劲地去消除这些拷贝不就是因为有内核在掺和吗?如果我们绕过内核直接进行 I/O 不就没有这些烦人的拷贝问题了吗?这就是绕过内核直接 I/O 技术:

这种方案有两种实现方式:

  1. 用户直接访问硬件
  2. 内核控制访问硬件
用户直接访问硬件

这种技术赋予用户进程直接访问硬件设备的权限,这让用户进程能有直接读写硬件设备,在数据传输过程中只需要内核做一些虚拟内存配置相关的工作。这种无需数据拷贝和内核干预的直接 I/O,理论上是最高效的数据传输技术,但是正如前面所说的那样,并不存在能解决一切问题的银弹,这种直接 I/O 技术虽然有可能非常高效,但是它的适用性也非常窄,目前只适用于诸如 MPI 高性能通信、丛集计算系统中的远程共享内存等有限的场景。

这种技术实际上破坏了现代计算机操作系统最重要的概念之一 —— 硬件抽象,我们之前提过,抽象是计算机领域最最核心的设计思路,正式由于有了抽象和分层,各个层级才能不必去关心很多底层细节从而专注于真正的工作,才使得系统的运作更加高效和快速。此外,网卡通常使用功能较弱的 CPU,例如只包含简单指令集的 MIPS 架构处理器(没有不必要的功能,如浮点数计算等),也没有太多的内存来容纳复杂的软件。因此,通常只有那些基于以太网之上的专用协议会使用这种技术,这些专用协议的设计要比远比 TCP/IP 简单得多,而且多用于局域网环境中,在这种环境中,数据包丢失和损坏很少发生,因此没有必要进行复杂的数据包确认和流量控制机制。而且这种技术还需要定制的网卡,所以它是高度依赖硬件的。

与传统的通信设计相比,直接硬件访问技术给程序设计带来了各种限制:由于设备之间的数据传输是通过 DMA 完成的,因此用户空间的数据缓冲区内存页必须进行 page pinning(页锁定),这是为了防止其物理页框地址被交换到磁盘或者被移动到新的地址而导致 DMA 去拷贝数据的时候在指定的地址找不到内存页从而引发缺页错误,而页锁定的开销并不比 CPU 拷贝小,所以为了避免频繁的页锁定系统调用,应用程序必须分配和注册一个持久的内存池,用于数据缓冲。

用户直接访问硬件的技术可以得到极高的 I/O 性能,但是其应用领域和适用场景也极其的有限,如集群或网络存储系统中的节点通信。它需要定制的硬件和专门设计的应用程序,但相应地对操作系统内核的改动比较小,可以很容易地以内核模块或设备驱动程序的形式实现出来。直接访问硬件还可能会带来严重的安全问题,因为用户进程拥有直接访问硬件的极高权限,所以如果你的程序设计没有做好的话,可能会消耗本来就有限的硬件资源或者进行非法地址访问,可能也会因此间接地影响其他正在使用同一设备的应用程序,而因为绕开了内核,所以也无法让内核替你去控制和管理。

内核控制访问硬件

相较于用户直接访问硬件技术,通过内核控制的直接访问硬件技术更加的安全,它比前者在数据传输过程中会多干预一点,但也仅仅是作为一个代理人这样的角色,不会参与到实际的数据传输过程,内核会控制 DMA 引擎去替用户进程做缓冲区的数据传输工作。同样的,这种方式也是高度依赖硬件的,比如一些集成了专有网络栈协议的网卡。这种技术的一个优势就是用户集成去 I/O 时的接口不会改变,就和普通的 read()/write() 系统调用那样使用即可,所有的脏活累活都在内核里完成,用户接口友好度很高,不过需要注意的是,使用这种技术的过程中如果发生了什么不可预知的意外从而导致无法使用这种技术进行数据传输的话,则内核会自动切换为最传统 I/O 模式,也就是性能最差的那种模式。

这种技术也有着和用户直接访问硬件技术一样的问题:DMA 传输数据的过程中,用户进程的缓冲区内存页必须进行 page pinning 页锁定,数据传输完成后才能解锁。CPU 高速缓存内保存的多个内存地址也会被冲刷掉以保证 DMA 传输前后的数据一致性。这些机制有可能会导致数据传输的性能变得更差,因为 read()/write() 系统调用的语义并不能提前通知 CPU 用户缓冲区要参与 DMA 数据传输传输,因此也就无法像内核缓冲区那样可依提前加载进高速缓存,提高性能。由于用户缓冲区的内存页可能分布在物理内存中的任意位置,因此一些实现不好的 DMA 控制器引擎可能会有寻址限制从而导致无法访问这些内存区域。一些技术比如 AMD64 架构中的 IOMMU,允许通过将 DMA 地址重新映射到内存中的物理地址来解决这些限制,但反过来又可能会导致可移植性问题,因为其他的处理器架构,甚至是 Intel 64 位 x86 架构的变种 EM64T 都不具备这样的特性单元。此外,还可能存在其他限制,比如 DMA 传输的数据对齐问题,又会导致无法访问用户进程指定的任意缓冲区内存地址。

内核缓冲区和用户缓冲区之间的传输优化

到目前为止,我们讨论的 zero-copy 技术都是基于减少甚至是避免用户空间和内核空间之间的 CPU 数据拷贝的,虽然有一些技术非常高效,但是大多都有适用性很窄的问题,比如 sendfile()splice() 这些,效率很高,但是都只适用于那些用户进程不需要直接处理数据的场景,比如静态文件服务器或者是直接转发数据的代理服务器。

现在我们已经知道,硬件设备之间的数据可以通过 DMA 进行传输,然而却并没有这样的传输机制可以应用于用户缓冲区和内核缓冲区之间的数据传输。不过另一方面,广泛应用在现代的 CPU 架构和操作系统上的虚拟内存机制表明,通过在不同的虚拟地址上重新映射页面可以实现在用户进程和内核之间虚拟复制和共享内存,尽管一次传输的内存颗粒度相对较大:4KB 或 8KB。

因此如果要在实现在用户进程内处理数据(这种场景比直接转发数据更加常见)之后再发送出去的话,用户空间和内核空间的数据传输就是不可避免的,既然避无可避,那就只能选择优化了,因此本章节我们要介绍两种优化用户空间和内核空间数据传输的技术:

  1. 动态重映射与写时拷贝 (Copy-on-Write)
  2. 缓冲区共享 (Buffer Sharing)
动态重映射与写时拷贝 (Copy-on-Write)

前面我们介绍过利用内存映射技术来减少数据在用户空间和内核空间之间的复制,通常简单模式下,用户进程是对共享的缓冲区进行同步阻塞读写的,这样不会有 data race 问题,但是这种模式下效率并不高,而提升效率的一种方法就是异步地对共享缓冲区进行读写,而这样的话就必须引入保护机制来避免数据冲突问题,写时复制 (Copy on Write) 就是这样的一种技术。

写入时复制Copy-on-writeCOW)是一种计算机程序设计领域的优化策略。其核心思想是,如果有多个调用者(callers)同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这过程对其他的调用者都是透明的。此作法主要的优点是如果调用者没有修改该资源,就不会有副本(private copy)被创建,因此多个调用者只是读取操作时可以共享同一份资源。

举一个例子,引入了 COW 技术之后,用户进程读取磁盘文件进行数据处理最后写到网卡,首先使用内存映射技术让用户缓冲区和内核缓冲区共享了一段内存地址并标记为只读 (read-only),避免数据拷贝,而当要把数据写到网卡的时候,用户进程选择了异步写的方式,系统调用会直接返回,数据传输就会在内核里异步进行,而用户进程就可以继续其他的工作,并且共享缓冲区的内容可以随时再进行读取,效率很高,但是如果该进程又尝试往共享缓冲区写入数据,则会产生一个 COW 事件,让试图写入数据的进程把数据复制到自己的缓冲区去修改,这里只需要复制要修改的内存页即可,无需所有数据都复制过去,而如果其他访问该共享内存的进程不需要修改数据则可以永远不需要进行数据拷贝。

COW 是一种建构在虚拟内存冲映射技术之上的技术,因此它需要 MMU 的硬件支持,MMU 会记录当前哪些内存页被标记成只读,当有进程尝试往这些内存页中写数据的时候,MMU 就会抛一个异常给操作系统内核,内核处理该异常时为该进程分配一份物理内存并复制数据到此内存地址,重新向 MMU 发出执行该进程的写操作。

COW 最大的优势是节省内存和减少数据拷贝,不过却是通过增加操作系统内核 I/O 过程复杂性作为代价的。当确定采用 COW 来复制页面时,重要的是注意空闲页面的分配位置。许多操作系统为这类请求提供了一个空闲的页面池。当进程的堆栈或堆要扩展时或有写时复制页面需要管理时,通常分配这些空闲页面。操作系统分配这些页面通常采用称为按需填零的技术。按需填零页面在需要分配之前先填零,因此会清除里面旧的内容。

局限性

COW 这种零拷贝技术比较适用于那种多读少写从而使得 COW 事件发生较少的场景,因为 COW 事件所带来的系统开销要远远高于一次 CPU 拷贝所产生的。此外,在实际应用的过程中,为了避免频繁的内存映射,可以重复使用同一段内存缓冲区,因此,你不需要在只用过一次共享缓冲区之后就解除掉内存页的映射关系,而是重复循环使用,从而提升性能,不过这种内存页映射的持久化并不会减少由于页表往返移动和 TLB 冲刷所带来的系统开销,因为每次接收到 COW 事件之后对内存页而进行加锁或者解锁的时候,页面的只读标志 (read-ony) 都要被更改为 (write-only)。

缓冲区共享 (Buffer Sharing)

从前面的介绍可以看出,传统的 Linux I/O接口,都是基于复制/拷贝的:数据需要在操作系统内核空间和用户空间的缓冲区之间进行拷贝。在进行 I/O 操作之前,用户进程需要预先分配好一个内存缓冲区,使用 read() 系统调用时,内核会将从存储器或者网卡等设备读入的数据拷贝到这个用户缓冲区里;而使用 write() 系统调用时,则是把用户内存缓冲区的数据拷贝至内核缓冲区。

为了实现这种传统的 I/O 模式,Linux 必须要在每一个 I/O 操作时都进行内存虚拟映射和解除。这种内存页重映射的机制的效率严重受限于缓存体系结构、MMU 地址转换速度和 TLB 命中率。如果能够避免处理 I/O 请求的虚拟地址转换和 TLB 刷新所带来的开销,则有可能极大地提升 I/O 性能。而缓冲区共享就是用来解决上述问题的一种技术。

最早支持 Buffer Sharing 的操作系统是 Solaris。后来,Linux 也逐步支持了这种 Buffer Sharing 的技术,但时至今日依然不够完整和成熟。

操作系统内核开发者们实现了一种叫 fbufs 的缓冲区共享的框架,也即快速缓冲区( Fast Buffers ),使用一个 fbuf 缓冲区作为数据传输的最小单位,使用这种技术需要调用新的操作系统 API,用户区和内核区、内核区之间的数据都必须严格地在 fbufs 这个体系下进行通信。fbufs 为每一个用户进程分配一个 buffer pool,里面会储存预分配 (也可以使用的时候再分配) 好的 buffers,这些 buffers 会被同时映射到用户内存空间和内核内存空间。fbufs 只需通过一次虚拟内存映射操作即可创建缓冲区,有效地消除那些由存储一致性维护所引发的大多数性能损耗。

传统的 Linux I/O 接口是通过把数据在用户缓冲区和内核缓冲区之间进行拷贝传输来完成的,这种数据传输过程中需要进行大量的数据拷贝,同时由于虚拟内存技术的存在,I/O 过程中还需要频繁地通过 MMU 进行虚拟内存地址到物理内存地址的转换,高速缓存的汰换以及 TLB 的刷新,这些操作均会导致性能的损耗。而如果利用 fbufs 框架来实现数据传输的话,首先可以把 buffers 都缓存到 pool 里循环利用,而不需要每次都去重新分配,而且缓存下来的不止有 buffers 本身,而且还会把虚拟内存地址到物理内存地址的映射关系也缓存下来,也就可以避免每次都进行地址转换,从发送接收数据的层面来说,用户进程和 I/O 子系统比如设备驱动程序、网卡等可以直接传输整个缓冲区本身而不是其中的数据内容,也可以理解成是传输内存地址指针,这样就就避免了大量的数据内容拷贝:用户进程/ IO 子系统通过发送一个个的 fbuf 写出数据到内核而非直接传递数据内容,相对应的,用户进程/ IO 子系统通过接收一个个的 fbuf 而从内核读入数据,这样就能减少传统的 read()/write() 系统调用带来的数据拷贝开销:

  1. 发送方用户进程调用 uf_allocate 从自己的 buffer pool 获取一个 fbuf 缓冲区,往其中填充内容之后调用 uf_write 向内核区发送指向 fbuf 的文件描述符;
  2. I/O 子系统接收到 fbuf 之后,调用 uf_allocb 从接收方用户进程的 buffer pool 获取一个 fubf 并用接收到的数据进行填充,然后向用户区发送指向 fbuf 的文件描述符;
  3. 接收方用户进程调用 uf_get 接收到 fbuf,读取数据进行处理,完成之后调用 uf_deallocate 把 fbuf 放回自己的 buffer pool。

fbufs 的缺陷

共享缓冲区技术的实现需要依赖于用户进程、操作系统内核、以及 I/O 子系统 (设备驱动程序,文件系统等)之间协同工作。比如,设计得不好的用户进程容易就会修改已经发送出去的 fbuf 从而污染数据,更要命的是这种问题很难 debug。虽然这个技术的设计方案非常精彩,但是它的门槛和限制却不比前面介绍的其他技术少:首先会对操作系统 API 造成变动,需要使用新的一些 API 调用,其次还需要设备驱动程序配合改动,还有由于是内存共享,内核需要很小心谨慎地实现对这部分共享的内存进行数据保护和同步的机制,而这种并发的同步机制是非常容易出 bug 的从而又增加了内核的代码复杂度,等等。因此这一类的技术还远远没有到发展成熟和广泛应用的阶段,目前大多数的实现都还处于实验阶段。

总结

本文中我主要讲解了 Linux I/O 底层原理,然后介绍并解析了 Linux 中的 Zero-copy 技术,并给出了 Linux 对 I/O 模块的优化和改进思路。

Linux 的 Zero-copy 技术可以归纳成以下三大类:

  • 减少甚至避免用户空间和内核空间之间的数据拷贝:在一些场景下,用户进程在数据传输过程中并不需要对数据进行访问和处理,那么数据在 Linux 的 Page Cache 和用户进程的缓冲区之间的传输就完全可以避免,让数据拷贝完全在内核里进行,甚至可以通过更巧妙的方式避免在内核里的数据拷贝。这一类实现一般是是通过增加新的系统调用来完成的,比如 Linux 中的 mmap(),sendfile() 以及 splice() 等。
  • 绕过内核的直接 I/O:允许在用户态进程绕过内核直接和硬件进行数据传输,内核在传输过程中只负责一些管理和辅助的工作。这种方式其实和第一种有点类似,也是试图避免用户空间和内核空间之间的数据传输,只是第一种方式是把数据传输过程放在内核态完成,而这种方式则是直接绕过内核和硬件通信,效果类似但原理完全不同。
  • 内核缓冲区和用户缓冲区之间的传输优化:这种方式侧重于在用户进程的缓冲区和操作系统的页缓存之间的 CPU 拷贝的优化。这种方法延续了以往那种传统的通信方式,但更灵活。

本文从虚拟内存、I/O 缓冲区,用户态&内核态以及 I/O 模式等等知识点全面而又详尽地剖析了 Linux 系统的 I/O 底层原理,分析了 Linux 传统的 I/O 模式的弊端,进而引入 Linux Zero-copy 零拷贝技术的介绍和原理解析,通过将零拷贝技术和传统的 I/O 模式进行区分和对比,带领读者经历了 Linux I/O 的演化历史,通过帮助读者理解 Linux 内核对 I/O 模块的优化改进思路,相信不仅仅是让读者了解 Linux 底层系统的设计原理,更能对读者们在以后优化改进自己的程序设计过程中能够有所启发。

参考&延伸阅读

查看原文

赞 22 收藏 11 评论 0

panjf2000 发布了文章 · 2020-12-28

Go netpoller 原生网络模型之源码全面揭秘

博客原文

Go netpoller 原生网络模型之源码全面揭秘

导言

Go 基于 I/O multiplexing 和 goroutine scheduler 构建了一个简洁而高性能的原生网络模型(基于 Go 的 I/O 多路复用 netpoller ),提供了 goroutine-per-connection 这样简单的网络编程模式。在这种模式下,开发者使用的是同步的模式去编写异步的逻辑,极大地降低了开发者编写网络应用时的心智负担,且借助于 Go runtime scheduler 对 goroutines 的高效调度,这个原生网络模型不论从适用性还是性能上都足以满足绝大部分的应用场景。

然而,在工程性上能做到如此高的普适性和兼容性,最终暴露给开发者提供接口/模式如此简洁,其底层必然是基于非常复杂的封装,做了很多取舍,也有可能放弃了一些追求极致性能的设计和理念。事实上 Go netpoller 底层就是基于 epoll/kqueue/iocp 这些 I/O 多路复用技术来做封装的,最终暴露出 goroutine-per-connection 这样的极简的开发模式给使用者。

Go netpoller 在不同的操作系统,其底层使用的 I/O 多路复用技术也不一样,可以从 Go 源码目录结构和对应代码文件了解 Go 在不同平台下的网络 I/O 模式的实现。比如,在 Linux 系统下基于 epoll,freeBSD 系统下基于 kqueue,以及 Windows 系统下基于 iocp。

本文将基于 Linux 平台来解析 Go netpoller 之 I/O 多路复用的底层是如何基于 epoll 封装实现的,从源码层层推进,全面而深度地解析 Go netpoller 的设计理念和实现原理,以及 Go 是如何利用 netpoller 来构建它的原生网络模型的。主要涉及到的一些概念:I/O 模型、用户/内核空间、epoll、Linux 源码、goroutine scheduler 等等,我会尽量简单地讲解,如果有对相关概念不熟悉的同学,还是希望能提前熟悉一下。

用户空间与内核空间

现代操作系统都是采用虚拟存储器,那么对 32 位操作系统而言,它的寻址空间(虚拟存储空间)为 4G(2 的 32 次方)。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对 Linux 操作系统而言,将最高的 1G 字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF),供内核使用,称为内核空间,而将较低的 3G 字节(从虚拟地址 0x00000000 到 0xBFFFFFFF),供各个进程使用,称为用户空间。

现代的网络服务的主流已经完成从 CPU 密集型到 IO 密集型的转变,所以服务端程序对 I/O 的处理必不可少,而一旦操作 I/O 则必定要在用户态和内核态之间来回切换。

I/O 模型

在神作《UNIX 网络编程》里,总结归纳了 5 种 I/O 模型,包括同步和异步 I/O:

  • 阻塞 I/O (Blocking I/O)
  • 非阻塞 I/O (Nonblocking I/O)
  • I/O 多路复用 (I/O multiplexing)
  • 信号驱动 I/O (Signal driven I/O)
  • 异步 I/O (Asynchronous I/O)

操作系统上的 I/O 是用户空间和内核空间的数据交互,因此 I/O 操作通常包含以下两个步骤:

  1. 等待网络数据到达网卡(读就绪)/等待网卡可写(写就绪) –> 读取/写入到内核缓冲区
  2. 从内核缓冲区复制数据 –> 用户空间(读)/从用户空间复制数据 -> 内核缓冲区(写)

而判定一个 I/O 模型是同步还是异步,主要看第二步:数据在用户和内核空间之间复制的时候是不是会阻塞当前进程,如果会,则是同步 I/O,否则,就是异步 I/O。基于这个原则,这 5 种 I/O 模型中只有一种异步 I/O 模型:Asynchronous I/O,其余都是同步 I/O 模型。

这 5 种 I/O 模型的对比如下:

Non-blocking I/O

什么叫非阻塞 I/O,顾名思义就是:所有 I/O 操作都是立刻返回而不会阻塞当前用户进程。I/O 多路复用通常情况下需要和非阻塞 I/O 搭配使用,否则可能会产生意想不到的问题。比如,epoll 的 ET(边缘触发) 模式下,如果不使用非阻塞 I/O,有极大的概率会导致阻塞 event-loop 线程,从而降低吞吐量,甚至导致 bug。

Linux 下,我们可以通过 fcntl 系统调用来设置 O_NONBLOCK 标志位,从而把 socket 设置成 Non-blocking。当对一个 Non-blocking socket 执行读操作时,流程是这个样子:

当用户进程发出 read 操作时,如果 kernel 中的数据还没有准备好,那么它并不会 block 用户进程,而是立刻返回一个 EAGAIN error。从用户进程角度讲 ,它发起一个 read 操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个 error 时,它就知道数据还没有准备好,于是它可以再次发送 read 操作。一旦 kernel 中的数据准备好了,并且又再次收到了用户进程的 system call,那么它马上就将数据拷贝到了用户内存,然后返回。

所以,Non-blocking I/O 的特点是用户进程需要不断的主动询问 kernel 数据好了没有。下一节我们要讲的 I/O 多路复用需要和 Non-blocking I/O 配合才能发挥出最大的威力!

I/O 多路复用

所谓 I/O 多路复用指的就是 select/poll/epoll 这一系列的多路选择器:支持单一线程同时监听多个文件描述符(I/O 事件),阻塞等待,并在其中某个文件描述符可读写时收到通知。 I/O 复用其实复用的不是 I/O 连接,而是复用线程,让一个 thread of control 能够处理多个连接(I/O 事件)。

select & poll

#include <sys/select.h>

/* According to earlier standards */
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

// 和 select 紧密结合的四个宏:
void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);

select 是 epoll 之前 Linux 使用的 I/O 事件驱动技术。

理解 select 的关键在于理解 fd_set,为说明方便,取 fd_set 长度为 1 字节,fd_set 中的每一 bit 可以对应一个文件描述符 fd,则 1 字节长的 fd_set 最大可以对应 8 个 fd。select 的调用过程如下:

  1. 执行 FD_ZERO(&set), 则 set 用位表示是 0000,0000
  2. 若 fd=5, 执行 FD_SET(fd, &set); 后 set 变为 0001,0000(第 5 位置为 1)
  3. 再加入 fd=2, fd=1,则 set 变为 0001,0011
  4. 执行 select(6, &set, 0, 0, 0) 阻塞等待
  5. 若 fd=1, fd=2 上都发生可读事件,则 select 返回,此时 set 变为 0000,0011 (注意:没有事件发生的 fd=5 被清空)

基于上面的调用过程,可以得出 select 的特点:

  • 可监控的文件描述符个数取决于 sizeof(fd_set) 的值。假设服务器上 sizeof(fd_set)=512,每 bit 表示一个文件描述符,则服务器上支持的最大文件描述符是 512*8=4096。fd_set 的大小调整可参考 【原创】技术系列之 网络模型(二) 中的模型 2,可以有效突破 select 可监控的文件描述符上限
  • 将 fd 加入 select 监控集的同时,还要再使用一个数据结构 array 保存放到 select 监控集中的 fd,一是用于在 select 返回后,array 作为源数据和 fd_set 进行 FD_ISSET 判断。二是 select 返回后会把以前加入的但并无事件发生的 fd 清空,则每次开始 select 前都要重新从 array 取得 fd 逐一加入(FD_ZERO 最先),扫描 array 的同时取得 fd 最大值 maxfd,用于 select 的第一个参数
  • 可见 select 模型必须在 select 前循环 array(加 fd,取 maxfd),select 返回后循环 array(FD_ISSET 判断是否有事件发生)

所以,select 有如下的缺点:

  1. 最大并发数限制:使用 32 个整数的 32 位,即 32*32=1024 来标识 fd,虽然可修改,但是有以下第 2, 3 点的瓶颈
  2. 每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大
  3. 性能衰减严重:每次 kernel 都需要线性扫描整个 fd_set,所以随着监控的描述符 fd 数量增长,其 I/O 性能会线性下降

poll 的实现和 select 非常相似,只是描述 fd 集合的方式不同,poll 使用 pollfd 结构而不是 select 的 fd_set 结构,poll 解决了最大文件描述符数量限制的问题,但是同样需要从用户态拷贝所有的 fd 到内核态,也需要线性遍历所有的 fd 集合,所以它和 select 只是实现细节上的区分,并没有本质上的区别。

epoll

epoll 是 Linux kernel 2.6 之后引入的新 I/O 事件驱动技术,I/O 多路复用的核心设计是 1 个线程处理所有连接的 等待消息准备好 I/O 事件,这一点上 epoll 和 select&poll 是大同小异的。但 select&poll 错误预估了一件事,当数十万并发连接存在时,可能每一毫秒只有数百个活跃的连接,同时其余数十万连接在这一毫秒是非活跃的。select&poll 的使用方法是这样的: 返回的活跃连接 == select(全部待监控的连接)

什么时候会调用 select&poll 呢?在你认为需要找出有报文到达的活跃连接时,就应该调用。所以,select&poll 在高并发时是会被频繁调用的。这样,这个频繁调用的方法就很有必要看看它是否有效率,因为,它的轻微效率损失都会被 高频 二字所放大。它有效率损失吗?显而易见,全部待监控连接是数以十万计的,返回的只是数百个活跃连接,这本身就是无效率的表现。被放大后就会发现,处理并发上万个连接时,select&poll 就完全力不从心了。这个时候就该 epoll 上场了,epoll 通过一些新的设计和优化,基本上解决了 select&poll 的问题。

epoll 的 API 非常简洁,涉及到的只有 3 个系统调用:

#include <sys/epoll.h>  
int epoll_create(int size); // int epoll_create1(int flags);
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_create 创建一个 epoll 实例并返回 epollfd;epoll_ctl 注册 file descriptor 等待的 I/O 事件(比如 EPOLLIN、EPOLLOUT 等) 到 epoll 实例上;epoll_wait 则是阻塞监听 epoll 实例上所有的 file descriptor 的 I/O 事件,它接收一个用户空间上的一块内存地址 (events 数组),kernel 会在有 I/O 事件发生的时候把文件描述符列表复制到这块内存地址上,然后 epoll_wait 解除阻塞并返回,最后用户空间上的程序就可以对相应的 fd 进行读写了:

#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);

epoll 的工作原理如下:

与 select&poll 相比,epoll 分清了高频调用和低频调用。例如,epoll_ctl 相对来说就是非频繁调用的,而 epoll_wait 则是会被高频调用的。所以 epoll 利用 epoll_ctl 来插入或者删除一个 fd,实现用户态到内核态的数据拷贝,这确保了每一个 fd 在其生命周期只需要被拷贝一次,而不是每次调用 epoll_wait 的时候都拷贝一次。 epoll_wait 则被设计成几乎没有入参的调用,相比 select&poll 需要把全部监听的 fd 集合从用户态拷贝至内核态的做法,epoll 的效率就高出了一大截。

在实现上 epoll 采用红黑树来存储所有监听的 fd,而红黑树本身插入和删除性能比较稳定,时间复杂度 O(logN)。通过 epoll_ctl 函数添加进来的 fd 都会被放在红黑树的某个节点内,所以,重复添加是没有用的。当把 fd 添加进来的时候时候会完成关键的一步:该 fd 会与相应的设备(网卡)驱动程序建立回调关系,也就是在内核中断处理程序为它注册一个回调函数,在 fd 相应的事件触发(中断)之后(设备就绪了),内核就会调用这个回调函数,该回调函数在内核中被称为: ep_poll_callback这个回调函数其实就是把这个 fd 添加到 rdllist 这个双向链表(就绪链表)中。epoll_wait 实际上就是去检查 rdllist 双向链表中是否有就绪的 fd,当 rdllist 为空(无就绪 fd)时挂起当前进程,直到 rdllist 非空时进程才被唤醒并返回。

相比于 select&poll 调用时会将全部监听的 fd 从用户态空间拷贝至内核态空间并线性扫描一遍找出就绪的 fd 再返回到用户态,epoll_wait 则是直接返回已就绪 fd,因此 epoll 的 I/O 性能不会像 select&poll 那样随着监听的 fd 数量增加而出现线性衰减,是一个非常高效的 I/O 事件驱动技术。

由于使用 epoll 的 I/O 多路复用需要用户进程自己负责 I/O 读写,从用户进程的角度看,读写过程是阻塞的,所以 select&poll&epoll 本质上都是同步 I/O 模型,而像 Windows 的 IOCP 这一类的异步 I/O,只需要在调用 WSARecv 或 WSASend 方法读写数据的时候把用户空间的内存 buffer 提交给 kernel,kernel 负责数据在用户空间和内核空间拷贝,完成之后就会通知用户进程,整个过程不需要用户进程参与,所以是真正的异步 I/O。

延伸

另外,我看到有些文章说 epoll 之所以性能高是因为利用了 Linux 的 mmap 内存映射让内核和用户进程共享了一片物理内存,用来存放就绪 fd 列表和它们的数据 buffer,所以用户进程在 epoll_wait 返回之后用户进程就可以直接从共享内存那里读取/写入数据了,这让我很疑惑,因为首先看 epoll_wait 的函数声明:

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

第二个参数:就绪事件列表,是需要在用户空间分配内存然后再传给 epoll_wait 的,如果内核会用 mmap 设置共享内存,直接传递一个指针进去就行了,根本不需要在用户态分配内存,多此一举。其次,内核和用户进程通过 mmap 共享内存是一件极度危险的事情,内核无法确定这块共享内存什么时候会被回收,而且这样也会赋予用户进程直接操作内核数据的权限和入口,非常容易出现大的系统漏洞,因此一般极少会这么做。所以我很怀疑 epoll 是不是真的在 Linux kernel 里用了 mmap,我就去看了下最新版本(5.3.9)的 Linux kernel 源码:

/*
 * Implement the event wait interface for the eventpoll file. It is the kernel
 * part of the user space epoll_wait(2).
 */
static int do_epoll_wait(int epfd, struct epoll_event __user *events,
             int maxevents, int timeout)
{
    ...
  
    /* Time to fish for events ... */
    error = ep_poll(ep, events, maxevents, timeout);
}

// 如果 epoll_wait 入参时设定 timeout == 0, 那么直接通过 ep_events_available 判断当前是否有用户感兴趣的事件发生,如果有则通过 ep_send_events 进行处理
// 如果设置 timeout > 0,并且当前没有用户关注的事件发生,则进行休眠,并添加到 ep->wq 等待队列的头部;对等待事件描述符设置 WQ_FLAG_EXCLUSIVE 标志
// ep_poll 被事件唤醒后会重新检查是否有关注事件,如果对应的事件已经被抢走,那么 ep_poll 会继续休眠等待
static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events, int maxevents, long timeout)
{
    ...
  
    send_events:
    /*
     * Try to transfer events to user space. In case we get 0 events and
     * there's still timeout left over, we go trying again in search of
     * more luck.
     */
  
    // 如果一切正常, 有 event 发生, 就开始准备数据 copy 给用户空间了
    // 如果有就绪的事件发生,那么就调用 ep_send_events 将就绪的事件 copy 到用户态内存中,
    // 然后返回到用户态,否则判断是否超时,如果没有超时就继续等待就绪事件发生,如果超时就返回用户态。
    // 从 ep_poll 函数的实现可以看到,如果有就绪事件发生,则调用 ep_send_events 函数做进一步处理
    if (!res && eavail &&
            !(res = ep_send_events(ep, events, maxevents)) && !timed_out)
        goto fetch_events;
  
    ...
}

// ep_send_events 函数是用来向用户空间拷贝就绪 fd 列表的,它将用户传入的就绪 fd 列表内存简单封装到
// ep_send_events_data 结构中,然后调用 ep_scan_ready_list 将就绪队列中的事件写入用户空间的内存;
// 用户进程就可以访问到这些数据进行处理
static int ep_send_events(struct eventpoll *ep,
                struct epoll_event __user *events, int maxevents)
{
    struct ep_send_events_data esed;

    esed.maxevents = maxevents;
    esed.events = events;
    // 调用 ep_scan_ready_list 函数检查 epoll 实例 eventpoll 中的 rdllist 就绪链表,
    // 并注册一个回调函数 ep_send_events_proc,如果有就绪 fd,则调用 ep_send_events_proc 进行处理
    ep_scan_ready_list(ep, ep_send_events_proc, &esed, 0, false);
    return esed.res;
}

// 调用 ep_scan_ready_list 的时候会传递指向 ep_send_events_proc 函数的函数指针作为回调函数,
// 一旦有就绪 fd,就会调用 ep_send_events_proc 函数
static __poll_t ep_send_events_proc(struct eventpoll *ep, struct list_head *head, void *priv)
{
    ...
  
    /*
     * If the event mask intersect the caller-requested one,
     * deliver the event to userspace. Again, ep_scan_ready_list()
     * is holding ep->mtx, so no operations coming from userspace
     * can change the item.
     */
    revents = ep_item_poll(epi, &pt, 1);
    // 如果 revents 为 0,说明没有就绪的事件,跳过,否则就将就绪事件拷贝到用户态内存中
    if (!revents)
        continue;
    // 将当前就绪的事件和用户进程传入的数据都通过 __put_user 拷贝回用户空间,
    // 也就是调用 epoll_wait 之时用户进程传入的 fd 列表的内存
    if (__put_user(revents, &uevent->events) || __put_user(epi->event.data, &uevent->data)) {
        list_add(&epi->rdllink, head);
        ep_pm_stay_awake(epi);
        if (!esed->res)
            esed->res = -EFAULT;
        return 0;
    }
  
    ...
}

do_epoll_wait 开始层层跳转,我们可以很清楚地看到最后内核是通过 __put_user 函数把就绪 fd 列表和事件返回到用户空间,而 __put_user 正是内核用来拷贝数据到用户空间的标准函数。此外,我并没有在 Linux kernel 的源码中和 epoll 相关的代码里找到 mmap 系统调用做内存映射的逻辑,所以基本可以得出结论:epoll 在 Linux kernel 里并没有使用 mmap 来做用户空间和内核空间的内存共享,所以那些说 epoll 使用了 mmap 的文章都是误解。

Go netpoller 核心

Go netpoller 基本原理

Go netpoller 通过在底层对 epoll/kqueue/iocp 的封装,从而实现了使用同步编程模式达到异步执行的效果。总结来说,所有的网络操作都以网络描述符 netFD 为中心实现。netFD 与底层 PollDesc 结构绑定,当在一个 netFD 上读写遇到 EAGAIN 错误时,就将当前 goroutine 存储到这个 netFD 对应的 PollDesc 中,同时调用 gopark 把当前 goroutine 给 park 住,直到这个 netFD 上再次发生读写事件,才将此 goroutine 给 ready 激活重新运行。显然,在底层通知 goroutine 再次发生读写等事件的方式就是 epoll/kqueue/iocp 等事件驱动机制。

总所周知,Go 是一门跨平台的编程语言,而不同平台针对特定的功能有不用的实现,这当然也包括了 I/O 多路复用技术,比如 Linux 里的 I/O 多路复用有 selectpollepoll,而 freeBSD 或者 MacOS 里则是 kqueue,而 Windows 里则是基于异步 I/O 实现的 iocp,等等;因此,Go 为了实现底层 I/O 多路复用的跨平台,分别基于上述的这些不同平台的系统调用实现了多版本的 netpollers,具体的源码路径如下:

本文的解析基于 epoll 版本,如果读者对其他平台的 netpoller 底层实现感兴趣,可以在阅读完本文后自行翻阅其他 netpoller 源码,所有实现版本的机制和原理基本类似,所以了解了 epoll 版本的实现后再去学习其他版本实现应该没什么障碍。

接下来让我们通过分析最新的 Go 源码(v1.15.3),全面剖析一下整个 Go netpoller 的运行机制和流程。

数据结构

netFD

net.Listen("tcp", ":8888") 方法返回了一个 TCPListener,它是一个实现了 net.Listener 接口的 struct,而通过 listener.Accept() 接收的新连接 TCPConn 则是一个实现了 net.Conn 接口的 struct,它内嵌了 net.conn struct。仔细阅读上面的源码可以发现,不管是 Listener 的 Accept 还是 Conn 的 Read/Write 方法,都是基于一个 netFD 的数据结构的操作, netFD 是一个网络描述符,类似于 Linux 的文件描述符的概念,netFD 中包含一个 poll.FD 数据结构,而 poll.FD 中包含两个重要的数据结构 Sysfd 和 pollDesc,前者是真正的系统文件描述符,后者对是底层事件驱动的封装,所有的读写超时等操作都是通过调用后者的对应方法实现的。

netFDpoll.FD 的源码:

// Network file descriptor.
type netFD struct {
    pfd poll.FD

    // immutable until Close
    family      int
    sotype      int
    isConnected bool // handshake completed or use of association with peer
    net         string
    laddr       Addr
    raddr       Addr
}

// FD is a file descriptor. The net and os packages use this type as a
// field of a larger type representing a network connection or OS file.
type FD struct {
    // Lock sysfd and serialize access to Read and Write methods.
    fdmu fdMutex

    // System file descriptor. Immutable until Close.
    Sysfd int

    // I/O poller.
    pd pollDesc

    // Writev cache.
    iovecs *[]syscall.Iovec

    // Semaphore signaled when file is closed.
    csema uint32

    // Non-zero if this file has been set to blocking mode.
    isBlocking uint32

    // Whether this is a streaming descriptor, as opposed to a
    // packet-based descriptor like a UDP socket. Immutable.
    IsStream bool

    // Whether a zero byte read indicates EOF. This is false for a
    // message based socket connection.
    ZeroReadIsEOF bool

    // Whether this is a file rather than a network socket.
    isFile bool
}

pollDesc

前面提到了 pollDesc 是底层事件驱动的封装,netFD 通过它来完成各种 I/O 相关的操作,它的定义如下:

type pollDesc struct {
    runtimeCtx uintptr
}

这里的 struct 只包含了一个指针,而通过 pollDesc 的 init 方法,我们可以找到它具体的定义是在 runtime.pollDesc 这里:

func (pd *pollDesc) init(fd *FD) error {
    serverInit.Do(runtime_pollServerInit)
    ctx, errno := runtime_pollOpen(uintptr(fd.Sysfd))
    if errno != 0 {
        if ctx != 0 {
            runtime_pollUnblock(ctx)
            runtime_pollClose(ctx)
        }
        return syscall.Errno(errno)
    }
    pd.runtimeCtx = ctx
    return nil
}

// Network poller descriptor.
//
// No heap pointers.
//
//go:notinheap
type pollDesc struct {
    link *pollDesc // in pollcache, protected by pollcache.lock

    // The lock protects pollOpen, pollSetDeadline, pollUnblock and deadlineimpl operations.
    // This fully covers seq, rt and wt variables. fd is constant throughout the PollDesc lifetime.
    // pollReset, pollWait, pollWaitCanceled and runtime·netpollready (IO readiness notification)
    // proceed w/o taking the lock. So closing, everr, rg, rd, wg and wd are manipulated
    // in a lock-free way by all operations.
    // NOTE(dvyukov): the following code uses uintptr to store *g (rg/wg),
    // that will blow up when GC starts moving objects.
    lock    mutex // protects the following fields
    fd      uintptr
    closing bool
    everr   bool    // marks event scanning error happened
    user    uint32  // user settable cookie
    rseq    uintptr // protects from stale read timers
    rg      uintptr // pdReady, pdWait, G waiting for read or nil
    rt      timer   // read deadline timer (set if rt.f != nil)
    rd      int64   // read deadline
    wseq    uintptr // protects from stale write timers
    wg      uintptr // pdReady, pdWait, G waiting for write or nil
    wt      timer   // write deadline timer
    wd      int64   // write deadline
}

这里重点关注里面的 rgwg,这里两个 uintptr "万能指针"类型,取值分别可能是 pdReadypdWait、等待 file descriptor 就绪的 goroutine 也就是 g 数据结构以及 nil,它们是实现唤醒 goroutine 的关键。

runtime.pollDesc 包含自身类型的一个指针,用来保存下一个 runtime.pollDesc 的地址,以此来实现链表,可以减少数据结构的大小,所有的 runtime.pollDesc 保存在 runtime.pollCache 结构中,定义如下:

type pollCache struct {
   lock  mutex
   first *pollDesc
   // PollDesc objects must be type-stable,
   // because we can get ready notification from epoll/kqueue
   // after the descriptor is closed/reused.
   // Stale notifications are detected using seq variable,
   // seq is incremented when deadlines are changed or descriptor is reused.
}

因为 runtime.pollCache 是一个在 runtime 包里的全局变量,因此需要用一个互斥锁来避免 data race 问题,从它的名字也能看出这是一个用于缓存的数据结构,也就是用来提高性能的,具体如何实现呢?

const pollBlockSize = 4 * 1024

func (c *pollCache) alloc() *pollDesc {
    lock(&c.lock)
    if c.first == nil {
        const pdSize = unsafe.Sizeof(pollDesc{})
        n := pollBlockSize / pdSize
        if n == 0 {
            n = 1
        }
        // Must be in non-GC memory because can be referenced
        // only from epoll/kqueue internals.
        mem := persistentalloc(n*pdSize, 0, &memstats.other_sys)
        for i := uintptr(0); i < n; i++ {
            pd := (*pollDesc)(add(mem, i*pdSize))
            pd.link = c.first
            c.first = pd
        }
    }
    pd := c.first
    c.first = pd.link
    lockInit(&pd.lock, lockRankPollDesc)
    unlock(&c.lock)
    return pd
}

Go runtime 会在调用 poll_runtime_pollOpen 往 epoll 实例注册 fd 之时首次调用 runtime.pollCache.alloc方法时批量初始化大小 4KB 的 runtime.pollDesc 结构体的链表,初始化过程中会调用 runtime.persistentalloc 来为这些数据结构分配不会被 GC 回收的内存,确保这些数据结构只能被 epollkqueue 在内核空间去引用。

再往后每次调用这个方法则会先判断链表头是否已经分配过值了,若是,则直接返回表头这个 pollDesc,这种批量初始化数据进行缓存而后每次都直接从缓存取数据的方式是一种很常见的性能优化手段,在这里这种方式可以有效地提升 netpoller 的吞吐量。

Go runtime 会在关闭 pollDesc 之时调用 runtime.pollCache.free 释放内存:

func (c *pollCache) free(pd *pollDesc) {
    lock(&c.lock)
    pd.link = c.first
    c.first = pd
    unlock(&c.lock)
}

实现原理

使用 Go 编写一个典型的 TCP echo server:

package main

import (
    "log"
    "net"
)

func main() {
    listen, err := net.Listen("tcp", ":8888")
    if err != nil {
        log.Println("listen error: ", err)
        return
    }

    for {
        conn, err := listen.Accept()
        if err != nil {
            log.Println("accept error: ", err)
            break
        }

        // start a new goroutine to handle the new connection.
        go HandleConn(conn)
    }
}

func HandleConn(conn net.Conn) {
    defer conn.Close()
    packet := make([]byte, 1024)
    for {
        // block here if socket is not available for reading data.
        n, err := conn.Read(packet)
        if err != nil {
            log.Println("read socket error: ", err)
            return
        }

        // same as above, block here if socket is not available for writing.
        _, _ = conn.Write(packet[:n])
    }
}

上面是一个基于 Go 原生网络模型(基于 netpoller)编写的一个 TCP server,模式是 goroutine-per-connection ,在这种模式下,开发者使用的是同步的模式去编写异步的逻辑而且对于开发者来说 I/O 是否阻塞是无感知的,也就是说开发者无需考虑 goroutines 甚至更底层的线程、进程的调度和上下文切换。而 Go netpoller 最底层的事件驱动技术肯定是基于 epoll/kqueue/iocp 这一类的 I/O 事件驱动技术,只不过是把这些调度和上下文切换的工作转移到了 runtime 的 Go scheduler,让它来负责调度 goroutines,从而极大地降低了程序员的心智负担!

Go 的这种同步模式的网络服务器的基本架构通常如下:

上面的示例代码中相关的在源码里的几个数据结构和方法:

// TCPListener is a TCP network listener. Clients should typically
// use variables of type Listener instead of assuming TCP.
type TCPListener struct {
    fd *netFD
    lc ListenConfig
}

// Accept implements the Accept method in the Listener interface; it
// waits for the next call and returns a generic Conn.
func (l *TCPListener) Accept() (Conn, error) {
    if !l.ok() {
        return nil, syscall.EINVAL
    }
    c, err := l.accept()
    if err != nil {
        return nil, &OpError{Op: "accept", Net: l.fd.net, Source: nil, Addr: l.fd.laddr, Err: err}
    }
    return c, nil
}

func (ln *TCPListener) accept() (*TCPConn, error) {
    fd, err := ln.fd.accept()
    if err != nil {
        return nil, err
    }
    tc := newTCPConn(fd)
    if ln.lc.KeepAlive >= 0 {
        setKeepAlive(fd, true)
        ka := ln.lc.KeepAlive
        if ln.lc.KeepAlive == 0 {
            ka = defaultTCPKeepAlive
        }
        setKeepAlivePeriod(fd, ka)
    }
    return tc, nil
}

// TCPConn is an implementation of the Conn interface for TCP network
// connections.
type TCPConn struct {
    conn
}

// Conn
type conn struct {
    fd *netFD
}

type conn struct {
    fd *netFD
}

func (c *conn) ok() bool { return c != nil && c.fd != nil }

// Implementation of the Conn interface.

// Read implements the Conn Read method.
func (c *conn) Read(b []byte) (int, error) {
    if !c.ok() {
        return 0, syscall.EINVAL
    }
    n, err := c.fd.Read(b)
    if err != nil && err != io.EOF {
        err = &OpError{Op: "read", Net: c.fd.net, Source: c.fd.laddr, Addr: c.fd.raddr, Err: err}
    }
    return n, err
}

// Write implements the Conn Write method.
func (c *conn) Write(b []byte) (int, error) {
    if !c.ok() {
        return 0, syscall.EINVAL
    }
    n, err := c.fd.Write(b)
    if err != nil {
        err = &OpError{Op: "write", Net: c.fd.net, Source: c.fd.laddr, Addr: c.fd.raddr, Err: err}
    }
    return n, err
}

net.Listen

调用 net.Listen 之后,底层会通过 Linux 的系统调用 socket 方法创建一个 fd 分配给 listener,并用以来初始化 listener 的 netFD ,接着调用 netFD 的 listenStream 方法完成对 socket 的 bind&listen 操作以及对 netFD 的初始化(主要是对 netFD 里的 pollDesc 的初始化),调用链是 runtime.runtime_pollServerInit --> runtime.poll_runtime_pollServerInit --> runtime.netpollGenericInit,主要做的事情是:

  1. 调用 epollcreate1 创建一个 epoll 实例 epfd,作为整个 runtime 的唯一 event-loop 使用;
  2. 调用 runtime.nonblockingPipe 创建一个用于和 epoll 实例通信的管道,这里为什么不用更新且更轻量的 eventfd 呢?我个人猜测是为了兼容更多以及更老的系统版本;
  3. netpollBreakRd 通知信号量封装成 epollevent 事件结构体注册进 epoll 实例。

相关源码如下:

// 调用 linux 系统调用 socket 创建 listener fd 并设置为为阻塞 I/O
s, err := socketFunc(family, sotype|syscall.SOCK_NONBLOCK|syscall.SOCK_CLOEXEC, proto)
// On Linux the SOCK_NONBLOCK and SOCK_CLOEXEC flags were
// introduced in 2.6.27 kernel and on FreeBSD both flags were
// introduced in 10 kernel. If we get an EINVAL error on Linux
// or EPROTONOSUPPORT error on FreeBSD, fall back to using
// socket without them.

socketFunc        func(int, int, int) (int, error)  = syscall.Socket

// 用上面创建的 listener fd 初始化 listener netFD
if fd, err = newFD(s, family, sotype, net); err != nil {
    poll.CloseFunc(s)
    return nil, err
}

// 对 listener fd 进行 bind&listen 操作,并且调用 init 方法完成初始化
func (fd *netFD) listenStream(laddr sockaddr, backlog int, ctrlFn func(string, string, syscall.RawConn) error) error {
    ...
  
    // 完成绑定操作
    if err = syscall.Bind(fd.pfd.Sysfd, lsa); err != nil {
        return os.NewSyscallError("bind", err)
    }
  
    // 完成监听操作
    if err = listenFunc(fd.pfd.Sysfd, backlog); err != nil {
        return os.NewSyscallError("listen", err)
    }
  
    // 调用 init,内部会调用 poll.FD.Init,最后调用 pollDesc.init
    if err = fd.init(); err != nil {
        return err
    }
    lsa, _ = syscall.Getsockname(fd.pfd.Sysfd)
    fd.setAddr(fd.addrFunc()(lsa), nil)
    return nil
}

// 使用 sync.Once 来确保一个 listener 只持有一个 epoll 实例
var serverInit sync.Once

// netFD.init 会调用 poll.FD.Init 并最终调用到 pollDesc.init,
// 它会创建 epoll 实例并把 listener fd 加入监听队列
func (pd *pollDesc) init(fd *FD) error {
    // runtime_pollServerInit 通过 `go:linkname` 链接到具体的实现函数 poll_runtime_pollServerInit,
    // 接着再调用 netpollGenericInit,然后会根据不同的系统平台去调用特定的 netpollinit 来创建 epoll 实例
    serverInit.Do(runtime_pollServerInit)
  
    // runtime_pollOpen 内部调用了 netpollopen 来将 listener fd 注册到 
    // epoll 实例中,另外,它会初始化一个 pollDesc 并返回
    ctx, errno := runtime_pollOpen(uintptr(fd.Sysfd))
    if errno != 0 {
        if ctx != 0 {
            runtime_pollUnblock(ctx)
            runtime_pollClose(ctx)
        }
        return syscall.Errno(errno)
    }
    // 把真正初始化完成的 pollDesc 实例赋值给当前的 pollDesc 代表自身的指针,
    // 后续使用直接通过该指针操作
    pd.runtimeCtx = ctx
    return nil
}

var (
    // 全局唯一的 epoll fd,只在 listener fd 初始化之时被指定一次
    epfd int32 = -1 // epoll descriptor
)

// netpollinit 会创建一个 epoll 实例,然后把 epoll fd 赋值给 epfd,
// 后续 listener 以及它 accept 的所有 sockets 有关 epoll 的操作都是基于这个全局的 epfd
func netpollinit() {
    epfd = epollcreate1(_EPOLL_CLOEXEC)
    if epfd < 0 {
        epfd = epollcreate(1024)
        if epfd < 0 {
            println("runtime: epollcreate failed with", -epfd)
            throw("runtime: netpollinit failed")
        }
        closeonexec(epfd)
    }
    r, w, errno := nonblockingPipe()
    if errno != 0 {
        println("runtime: pipe failed with", -errno)
        throw("runtime: pipe failed")
    }
    ev := epollevent{
        events: _EPOLLIN,
    }
    *(**uintptr)(unsafe.Pointer(&ev.data)) = &netpollBreakRd
    errno = epollctl(epfd, _EPOLL_CTL_ADD, r, &ev)
    if errno != 0 {
        println("runtime: epollctl failed with", -errno)
        throw("runtime: epollctl failed")
    }
    netpollBreakRd = uintptr(r)
    netpollBreakWr = uintptr(w)
}

// netpollopen 会被 runtime_pollOpen 调用,注册 fd 到 epoll 实例,
// 注意这里使用的是 epoll 的 ET 模式,同时会利用万能指针把 pollDesc 保存到 epollevent 的一个 8 位的字节数组 data 里
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)
}

我们前面提到的 epoll 的三个基本调用,Go 在源码里实现了对那三个调用的封装:

#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);

// Go 对上面三个调用的封装
func netpollinit()
func netpollopen(fd uintptr, pd *pollDesc) int32
func netpoll(block bool) gList

netFD 就是通过这三个封装来对 epoll 进行创建实例、注册 fd 和等待事件操作的。

Listener.Accept()

netpoll accept socket 的工作流程如下:

  1. 服务端的 netFD 在 listen 时会创建 epoll 的实例,并将 listenerFD 加入 epoll 的事件队列
  2. netFD 在 accept 时将返回的 connFD 也加入 epoll 的事件队列
  3. netFD 在读写时出现 syscall.EAGAIN 错误,通过 pollDesc 的 waitRead 方法将当前的 goroutine park 住,直到 ready,从 pollDesc 的 waitRead 中返回

Listener.Accept() 接收来自客户端的新连接,具体还是调用 netFD.accept 方法来完成这个功能:

// Accept implements the Accept method in the Listener interface; it
// waits for the next call and returns a generic Conn.
func (l *TCPListener) Accept() (Conn, error) {
    if !l.ok() {
        return nil, syscall.EINVAL
    }
    c, err := l.accept()
    if err != nil {
        return nil, &OpError{Op: "accept", Net: l.fd.net, Source: nil, Addr: l.fd.laddr, Err: err}
    }
    return c, nil
}

func (ln *TCPListener) accept() (*TCPConn, error) {
    fd, err := ln.fd.accept()
    if err != nil {
        return nil, err
    }
    tc := newTCPConn(fd)
    if ln.lc.KeepAlive >= 0 {
        setKeepAlive(fd, true)
        ka := ln.lc.KeepAlive
        if ln.lc.KeepAlive == 0 {
            ka = defaultTCPKeepAlive
        }
        setKeepAlivePeriod(fd, ka)
    }
    return tc, nil
}

func (fd *netFD) accept() (netfd *netFD, err error) {
    // 调用 poll.FD 的 Accept 方法接受新的 socket 连接,返回 socket 的 fd
    d, rsa, errcall, err := fd.pfd.Accept()
    if err != nil {
        if errcall != "" {
            err = wrapSyscallError(errcall, err)
        }
        return nil, err
    }
    // 以 socket fd 构造一个新的 netFD,代表这个新的 socket
    if netfd, err = newFD(d, fd.family, fd.sotype, fd.net); err != nil {
        poll.CloseFunc(d)
        return nil, err
    }
    // 调用 netFD 的 init 方法完成初始化
    if err = netfd.init(); err != nil {
        fd.Close()
        return nil, err
    }
    lsa, _ := syscall.Getsockname(netfd.pfd.Sysfd)
    netfd.setAddr(netfd.addrFunc()(lsa), netfd.addrFunc()(rsa))
    return netfd, nil
}

netFD.accept 方法里会再调用 poll.FD.Accept ,最后会使用 Linux 的系统调用 accept 来完成新连接的接收,并且会把 accept 的 socket 设置成非阻塞 I/O 模式:

// Accept wraps the accept network call.
func (fd *FD) Accept() (int, syscall.Sockaddr, string, error) {
    if err := fd.readLock(); err != nil {
        return -1, nil, "", err
    }
    defer fd.readUnlock()

    if err := fd.pd.prepareRead(fd.isFile); err != nil {
        return -1, nil, "", err
    }
    for {
        // 使用 linux 系统调用 accept 接收新连接,创建对应的 socket
        s, rsa, errcall, err := accept(fd.Sysfd)
        // 因为 listener fd 在创建的时候已经设置成非阻塞的了,
        // 所以 accept 方法会直接返回,不管有没有新连接到来;如果 err == nil 则表示正常建立新连接,直接返回
        if err == nil {
            return s, rsa, "", err
        }
        // 如果 err != nil,则判断 err == syscall.EAGAIN,符合条件则进入 pollDesc.waitRead 方法
        switch err {
        case syscall.EAGAIN:
            if fd.pd.pollable() {
                // 如果当前没有发生期待的 I/O 事件,那么 waitRead 会通过 park goroutine 让逻辑 block 在这里
                if err = fd.pd.waitRead(fd.isFile); err == nil {
                    continue
                }
            }
        case syscall.ECONNABORTED:
            // This means that a socket on the listen
            // queue was closed before we Accept()ed it;
            // it's a silly error, so try again.
            continue
        }
        return -1, nil, errcall, err
    }
}

// 使用 linux 的 accept 系统调用接收新连接并把这个 socket fd 设置成非阻塞 I/O
ns, sa, err := Accept4Func(s, syscall.SOCK_NONBLOCK|syscall.SOCK_CLOEXEC)
// On Linux the accept4 system call was introduced in 2.6.28
// kernel and on FreeBSD it was introduced in 10 kernel. If we
// get an ENOSYS error on both Linux and FreeBSD, or EINVAL
// error on Linux, fall back to using accept.

// Accept4Func is used to hook the accept4 call.
var Accept4Func func(int, int) (int, syscall.Sockaddr, error) = syscall.Accept4

pollDesc.waitRead 方法主要负责检测当前这个 pollDesc 的上层 netFD 对应的 fd 是否有『期待的』I/O 事件发生,如果有就直接返回,否则就 park 住当前的 goroutine 并持续等待直至对应的 fd 上发生可读/可写或者其他『期待的』I/O 事件为止,然后它就会返回到外层的 for 循环,让 goroutine 继续执行逻辑。

poll.FD.Accept() 返回之后,会构造一个对应这个新 socket 的 netFD,然后调用 init() 方法完成初始化,这个 init 过程和前面 net.Listen() 是一样的,调用链:netFD.init() --> poll.FD.Init() --> poll.pollDesc.init(),最终又会走到这里:

var serverInit sync.Once

func (pd *pollDesc) init(fd *FD) error {
    serverInit.Do(runtime_pollServerInit)
    ctx, errno := runtime_pollOpen(uintptr(fd.Sysfd))
    if errno != 0 {
        if ctx != 0 {
            runtime_pollUnblock(ctx)
            runtime_pollClose(ctx)
        }
        return syscall.Errno(errno)
    }
    pd.runtimeCtx = ctx
    return nil
}

然后把这个 socket fd 注册到 listener 的 epoll 实例的事件队列中去,等待 I/O 事件。

Conn.Read/Conn.Write

我们先来看看 Conn.Read 方法是如何实现的,原理其实和 Listener.Accept 是一样的,具体调用链还是首先调用 conn 的 netFD.Read ,然后内部再调用 poll.FD.Read ,最后使用 Linux 的系统调用 read: syscall.Read 完成数据读取:

// Implementation of the Conn interface.

// Read implements the Conn Read method.
func (c *conn) Read(b []byte) (int, error) {
    if !c.ok() {
        return 0, syscall.EINVAL
    }
    n, err := c.fd.Read(b)
    if err != nil && err != io.EOF {
        err = &OpError{Op: "read", Net: c.fd.net, Source: c.fd.laddr, Addr: c.fd.raddr, Err: err}
    }
    return n, err
}

func (fd *netFD) Read(p []byte) (n int, err error) {
    n, err = fd.pfd.Read(p)
    runtime.KeepAlive(fd)
    return n, wrapSyscallError("read", err)
}

// Read implements io.Reader.
func (fd *FD) Read(p []byte) (int, error) {
    if err := fd.readLock(); err != nil {
        return 0, err
    }
    defer fd.readUnlock()
    if len(p) == 0 {
        // If the caller wanted a zero byte read, return immediately
        // without trying (but after acquiring the readLock).
        // Otherwise syscall.Read returns 0, nil which looks like
        // io.EOF.
        // TODO(bradfitz): make it wait for readability? (Issue 15735)
        return 0, nil
    }
    if err := fd.pd.prepareRead(fd.isFile); err != nil {
        return 0, err
    }
    if fd.IsStream && len(p) > maxRW {
        p = p[:maxRW]
    }
    for {
        // 尝试从该 socket 读取数据,因为 socket 在被 listener accept 的时候设置成
        // 了非阻塞 I/O,所以这里同样也是直接返回,不管有没有可读的数据
        n, err := syscall.Read(fd.Sysfd, p)
        if err != nil {
            n = 0
            // err == syscall.EAGAIN 表示当前没有期待的 I/O 事件发生,也就是 socket 不可读
            if err == syscall.EAGAIN && fd.pd.pollable() {
                // 如果当前没有发生期待的 I/O 事件,那么 waitRead 
                // 会通过 park goroutine 让逻辑 block 在这里
                if err = fd.pd.waitRead(fd.isFile); err == nil {
                    continue
                }
            }

            // On MacOS we can see EINTR here if the user
            // pressed ^Z.  See issue #22838.
            if runtime.GOOS == "darwin" && err == syscall.EINTR {
                continue
            }
        }
        err = fd.eofError(n, err)
        return n, err
    }
}

conn.Writeconn.Read 的原理是一致的,它也是通过类似 pollDesc.waitReadpollDesc.waitWrite 来 park 住 goroutine 直至期待的 I/O 事件发生才返回恢复执行。

pollDesc.waitRead/pollDesc.waitWrite

pollDesc.waitRead 内部调用了 poll.runtime_pollWait --> runtime.poll_runtime_pollWait 来达成无 I/O 事件时 park 住 goroutine 的目的:

//go:linkname poll_runtime_pollWait internal/poll.runtime_pollWait
func poll_runtime_pollWait(pd *pollDesc, mode int) int {
    err := netpollcheckerr(pd, int32(mode))
    if err != pollNoError {
        return err
    }
    // As for now only Solaris, illumos, and AIX use level-triggered IO.
    if GOOS == "solaris" || GOOS == "illumos" || GOOS == "aix" {
        netpollarm(pd, mode)
    }
    // 进入 netpollblock 并且判断是否有期待的 I/O 事件发生,
    // 这里的 for 循环是为了一直等到 io ready
    for !netpollblock(pd, int32(mode), false) {
        err = netpollcheckerr(pd, int32(mode))
        if err != 0 {
            return err
        }
        // Can happen if timeout has fired and unblocked us,
        // but before we had a chance to run, timeout has been reset.
        // Pretend it has not happened and retry.
    }
    return 0
}

// 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 {
    // gpp 保存的是 goroutine 的数据结构 g,这里会根据 mode 的值决定是 rg 还是 wg,
  // 前面提到过,rg 和 wg 是用来保存等待 I/O 就绪的 gorouine 的,后面调用 gopark 之后,
  // 会把当前的 goroutine 的抽象数据结构 g 存入 gpp 这个指针,也就是 rg 或者 wg
    gpp := &pd.rg
    if mode == 'w' {
        gpp = &pd.wg
    }

    // set the gpp semaphore to WAIT
    // 这个 for 循环是为了等待 io ready 或者 io wait
    for {
        old := *gpp
        // gpp == pdReady 表示此时已有期待的 I/O 事件发生,
        // 可以直接返回 unblock 当前 goroutine 并执行响应的 I/O 操作
        if old == pdReady {
            *gpp = 0
            return true
        }
        if old != 0 {
            throw("runtime: double wait")
        }
        // 如果没有期待的 I/O 事件发生,则通过原子操作把 gpp 的值置为 pdWait 并退出 for 循环
        if atomic.Casuintptr(gpp, 0, pdWait) {
            break
        }
    }

    // need to recheck error states after setting gpp to WAIT
    // this is necessary because runtime_pollUnblock/runtime_pollSetDeadline/deadlineimpl
    // do the opposite: store to closing/rd/wd, membarrier, load of rg/wg
  
    // waitio 此时是 false,netpollcheckerr 方法会检查当前 pollDesc 对应的 fd 是否是正常的,
    // 通常来说  netpollcheckerr(pd, mode) == 0 是成立的,所以这里会执行 gopark 
    // 把当前 goroutine 给 park 住,直至对应的 fd 上发生可读/可写或者其他『期待的』I/O 事件为止,
    // 然后 unpark 返回,在 gopark 内部会把当前 goroutine 的抽象数据结构 g 存入
    // gpp(pollDesc.rg/pollDesc.wg) 指针里,以便在后面的 netpoll 函数取出 pollDesc 之后,
    // 把 g 添加到链表里返回,接着重新调度 goroutine
    if waitio || netpollcheckerr(pd, mode) == 0 {
        // 注册 netpollblockcommit 回调给 gopark,在 gopark 内部会执行它,保存当前 goroutine 到 gpp
        gopark(netpollblockcommit, unsafe.Pointer(gpp), waitReasonIOWait, traceEvGoBlockNet, 5)
    }
    // be careful to not lose concurrent READY notification
    old := atomic.Xchguintptr(gpp, 0)
    if old > pdWait {
        throw("runtime: corrupted polldesc")
    }
    return old == pdReady
}

// gopark 会停住当前的 goroutine 并且调用传递进来的回调函数 unlockf,从上面的源码我们可以知道这个函数是
// netpollblockcommit
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {
    if reason != waitReasonSleep {
        checkTimeouts() // timeouts may expire while two goroutines keep the scheduler busy
    }
    mp := acquirem()
    gp := mp.curg
    status := readgstatus(gp)
    if status != _Grunning && status != _Gscanrunning {
        throw("gopark: bad g status")
    }
    mp.waitlock = lock
    mp.waitunlockf = unlockf
    gp.waitreason = reason
    mp.waittraceev = traceEv
    mp.waittraceskip = traceskip
    releasem(mp)
    // can't do anything that might move the G between Ms here.
  // gopark 最终会调用 park_m,在这个函数内部会调用 unlockf,也就是 netpollblockcommit,
    // 然后会把当前的 goroutine,也就是 g 数据结构保存到 pollDesc 的 rg 或者 wg 指针里
    mcall(park_m)
}

// park continuation on g0.
func park_m(gp *g) {
    _g_ := getg()

    if trace.enabled {
        traceGoPark(_g_.m.waittraceev, _g_.m.waittraceskip)
    }

    casgstatus(gp, _Grunning, _Gwaiting)
    dropg()

    if fn := _g_.m.waitunlockf; fn != nil {
        // 调用 netpollblockcommit,把当前的 goroutine,
        // 也就是 g 数据结构保存到 pollDesc 的 rg 或者 wg 指针里
        ok := fn(gp, _g_.m.waitlock)
        _g_.m.waitunlockf = nil
        _g_.m.waitlock = nil
        if !ok {
            if trace.enabled {
                traceGoUnpark(gp, 2)
            }
            casgstatus(gp, _Gwaiting, _Grunnable)
            execute(gp, true) // Schedule it back, never returns.
        }
    }
    schedule()
}

// netpollblockcommit 在 gopark 函数里被调用
func netpollblockcommit(gp *g, gpp unsafe.Pointer) bool {
    // 通过原子操作把当前 goroutine 抽象的数据结构 g,也就是这里的参数 gp 存入 gpp 指针,
    // 此时 gpp 的值是 pollDesc 的 rg 或者 wg 指针
    r := atomic.Casuintptr((*uintptr)(gpp), pdWait, uintptr(unsafe.Pointer(gp)))
    if r {
        // Bump the count of goroutines waiting for the poller.
        // The scheduler uses this to decide whether to block
        // waiting for the poller if there is nothing else to do.
        atomic.Xadd(&netpollWaiters, 1)
    }
    return r
}

pollDesc.waitWrite 的内部实现原理和 pollDesc.waitRead 是一样的,都是基于 poll.runtime_pollWait --> runtime.poll_runtime_pollWait,这里就不再赘述。

netpoll

前面已经从源码的层面分析完了 netpoll 是如何通过 park goroutine 从而达到阻塞 Accept/Read/Write 的效果,而通过调用 gopark,goroutine 会被放置在某个等待队列中,这里是放到了 epoll 的 "interest list" 里,底层数据结构是由红黑树实现的 eventpoll.rbr,此时 G 的状态由 _Grunning_Gwaitting ,因此 G 必须被手动唤醒(通过 goready ),否则会丢失任务,应用层阻塞通常使用这种方式。

所以我们现在可以来从整体的层面来概括 Go 的网络业务 goroutine 是如何被规划调度的了:

首先,client 连接 server 的时候,listener 通过 accept 调用接收新 connection,每一个新 connection 都启动一个 goroutine 处理,accept 调用会把该 connection 的 fd 连带所在的 goroutine 上下文信息封装注册到 epoll 的监听列表里去,当 goroutine 调用 conn.Read 或者 conn.Write 等需要阻塞等待的函数时,会被 gopark 给封存起来并使之休眠,让 P 去执行本地调度队列里的下一个可执行的 goroutine,往后 Go scheduler 会在循环调度的 runtime.schedule() 函数以及 sysmon 监控线程中调用 runtime.nepoll 以获取可运行的 goroutine 列表并通过调用 injectglist 把剩下的 g 放入全局调度队列或者当前 P 本地调度队列去重新执行。

那么当 I/O 事件发生之后,netpoller 是通过什么方式唤醒那些在 I/O wait 的 goroutine 的?答案是通过 runtime.netpoll

runtime.netpoll 的核心逻辑是:

  1. 根据调用方的入参 delay,设置对应的调用 epollwait 的 timeout 值;
  2. 调用 epollwait 等待发生了可读/可写事件的 fd;
  3. 循环 epollwait 返回的事件列表,处理对应的事件类型, 组装可运行的 goroutine 链表并返回。
// netpoll checks for ready network connections.
// Returns list of goroutines that become runnable.
// delay < 0: blocks indefinitely
// delay == 0: does not block, just polls
// delay > 0: block for up to that many nanoseconds
func netpoll(delay int64) gList {
    if epfd == -1 {
        return gList{}
    }

    // 根据特定的规则把 delay 值转换为 epollwait 的 timeout 值
    var waitms int32
    if delay < 0 {
        waitms = -1
    } else if delay == 0 {
        waitms = 0
    } else if delay < 1e6 {
        waitms = 1
    } else if delay < 1e15 {
        waitms = int32(delay / 1e6)
    } else {
        // An arbitrary cap on how long to wait for a timer.
        // 1e9 ms == ~11.5 days.
        waitms = 1e9
    }
    var events [128]epollevent
retry:
    // 超时等待就绪的 fd 读写事件
    n := epollwait(epfd, &events[0], int32(len(events)), waitms)
    if n < 0 {
        if n != -_EINTR {
            println("runtime: epollwait on fd", epfd, "failed with", -n)
            throw("runtime: netpoll failed")
        }
        // If a timed sleep was interrupted, just return to
        // recalculate how long we should sleep now.
        if waitms > 0 {
            return gList{}
        }
        goto retry
    }

    // toRun 是一个 g 的链表,存储要恢复的 goroutines,最后返回给调用方
    var toRun gList
    for i := int32(0); i < n; i++ {
        ev := &events[i]
        if ev.events == 0 {
            continue
        }

        // Go scheduler 在调用 findrunnable() 寻找 goroutine 去执行的时候,
        // 在调用 netpoll 之时会检查当前是否有其他线程同步阻塞在 netpoll,
        // 若是,则调用 netpollBreak 来唤醒那个线程,避免它长时间阻塞
        if *(**uintptr)(unsafe.Pointer(&ev.data)) == &netpollBreakRd {
            if ev.events != _EPOLLIN {
                println("runtime: netpoll: break fd ready for", ev.events)
                throw("runtime: netpoll: break fd ready for something unexpected")
            }
            if delay != 0 {
                // netpollBreak could be picked up by a
                // nonblocking poll. Only read the byte
                // if blocking.
                var tmp [16]byte
                read(int32(netpollBreakRd), noescape(unsafe.Pointer(&tmp[0])), int32(len(tmp)))
                atomic.Store(&netpollWakeSig, 0)
            }
            continue
        }

        // 判断发生的事件类型,读类型或者写类型等,然后给 mode 复制相应的值,
    // mode 用来决定从 pollDesc 里的 rg 还是 wg 里取出 goroutine
        var mode int32
        if ev.events&(_EPOLLIN|_EPOLLRDHUP|_EPOLLHUP|_EPOLLERR) != 0 {
            mode += 'r'
        }
        if ev.events&(_EPOLLOUT|_EPOLLHUP|_EPOLLERR) != 0 {
            mode += 'w'
        }
        if mode != 0 {
            // 取出保存在 epollevent 里的 pollDesc
            pd := *(**pollDesc)(unsafe.Pointer(&ev.data))
            pd.everr = false
            if ev.events == _EPOLLERR {
                pd.everr = true
            }
            // 调用 netpollready,传入就绪 fd 的 pollDesc,
            // 把 fd 对应的 goroutine 添加到链表 toRun 中
            netpollready(&toRun, pd, mode)
        }
    }
    return toRun
}

// netpollready 调用 netpollunblock 返回就绪 fd 对应的 goroutine 的抽象数据结构 g
func netpollready(toRun *gList, pd *pollDesc, mode int32) {
    var rg, wg *g
    if mode == 'r' || mode == 'r'+'w' {
        rg = netpollunblock(pd, 'r', true)
    }
    if mode == 'w' || mode == 'r'+'w' {
        wg = netpollunblock(pd, 'w', true)
    }
    if rg != nil {
        toRun.push(rg)
    }
    if wg != nil {
        toRun.push(wg)
    }
}

// netpollunblock 会依据传入的 mode 决定从 pollDesc 的 rg 或者 wg 取出当时 gopark 之时存入的
// goroutine 抽象数据结构 g 并返回
func netpollunblock(pd *pollDesc, mode int32, ioready bool) *g {
    // mode == 'r' 代表当时 gopark 是为了等待读事件,而 mode == 'w' 则代表是等待写事件
    gpp := &pd.rg
    if mode == 'w' {
        gpp = &pd.wg
    }

    for {
        // 取出 gpp 存储的 g
        old := *gpp
        if old == pdReady {
            return nil
        }
        if old == 0 && !ioready {
            // Only set READY for ioready. runtime_pollWait
            // will check for timeout/cancel before waiting.
            return nil
        }
        var new uintptr
        if ioready {
            new = pdReady
        }
        // 重置 pollDesc 的 rg 或者 wg
        if atomic.Casuintptr(gpp, old, new) {
      // 如果该 goroutine 还是必须等待,则返回 nil
            if old == pdWait {
                old = 0
            }
            // 通过万能指针还原成 g 并返回
            return (*g)(unsafe.Pointer(old))
        }
    }
}

// netpollBreak 往通信管道里写入信号去唤醒 epollwait
func netpollBreak() {
    // 通过 CAS 避免重复的唤醒信号被写入管道,
    // 从而减少系统调用并节省一些系统资源
    if atomic.Cas(&netpollWakeSig, 0, 1) {
        for {
            var b byte
            n := write(netpollBreakWr, unsafe.Pointer(&b), 1)
            if n == 1 {
                break
            }
            if n == -_EINTR {
                continue
            }
            if n == -_EAGAIN {
                return
            }
            println("runtime: netpollBreak write failed with", -n)
            throw("runtime: netpollBreak write failed")
        }
    }
}

Go 在多种场景下都可能会调用 netpoll 检查文件描述符状态,netpoll 里会调用 epoll_wait 从 epoll 的 eventpoll.rdllist 就绪双向链表返回,从而得到 I/O 就绪的 socket fd 列表,并根据取出最初调用 epoll_ctl 时保存的上下文信息,恢复 g。所以执行完netpoll 之后,会返回一个就绪 fd 列表对应的 goroutine 链表,接下来将就绪的 goroutine 通过调用 injectglist 加入到全局调度队列或者 P 的本地调度队列中,启动 M 绑定 P 去执行。

具体调用 netpoll 的地方,首先在 Go runtime scheduler 循环调度 goroutines 之时就有可能会调用 netpoll 获取到已就绪的 fd 对应的 goroutine 来调度执行。

首先 Go scheduler 的核心方法 runtime.schedule() 里会调用一个叫 runtime.findrunable() 的方法获取可运行的 goroutine 来执行,而在 runtime.findrunable() 方法里就调用了 runtime.netpoll 获取已就绪的 fd 列表对应的 goroutine 列表:

// One round of scheduler: find a runnable goroutine and execute it.
// Never returns.
func schedule() {
    ...
  
  if gp == nil {
        gp, inheritTime = findrunnable() // blocks until work is available
    }
  
    ...
}

// Finds a runnable goroutine to execute.
// Tries to steal from other P's, get g from global queue, poll network.
func findrunnable() (gp *g, inheritTime bool) {
  ...
  
  // Poll network.
    if netpollinited() && (atomic.Load(&netpollWaiters) > 0 || pollUntil != 0) && atomic.Xchg64(&sched.lastpoll, 0) != 0 {
        atomic.Store64(&sched.pollUntil, uint64(pollUntil))
        if _g_.m.p != 0 {
            throw("findrunnable: netpoll with p")
        }
        if _g_.m.spinning {
            throw("findrunnable: netpoll with spinning")
        }
        if faketime != 0 {
            // When using fake time, just poll.
            delta = 0
        }
        list := netpoll(delta) // 同步阻塞调用 netpoll,直至有可用的 goroutine
        atomic.Store64(&sched.pollUntil, 0)
        atomic.Store64(&sched.lastpoll, uint64(nanotime()))
        if faketime != 0 && list.empty() {
            // Using fake time and nothing is ready; stop M.
            // When all M's stop, checkdead will call timejump.
            stopm()
            goto top
        }
        lock(&sched.lock)
        _p_ = pidleget() // 查找是否有空闲的 P 可以来就绪的 goroutine
        unlock(&sched.lock)
        if _p_ == nil {
            injectglist(&list) // 如果当前没有空闲的 P,则把就绪的 goroutine 放入全局调度队列等待被执行
        } else {
            // 如果当前有空闲的 P,则 pop 出一个 g,返回给调度器去执行,
            // 并通过调用 injectglist 把剩下的 g 放入全局调度队列或者当前 P 本地调度队列
            acquirep(_p_)
            if !list.empty() {
                gp := list.pop()
                injectglist(&list)
                casgstatus(gp, _Gwaiting, _Grunnable)
                if trace.enabled {
                    traceGoUnpark(gp, 0)
                }
                return gp, false
            }
            if wasSpinning {
                _g_.m.spinning = true
                atomic.Xadd(&sched.nmspinning, 1)
            }
            goto top
        }
    } else if pollUntil != 0 && netpollinited() {
        pollerPollUntil := int64(atomic.Load64(&sched.pollUntil))
        if pollerPollUntil == 0 || pollerPollUntil > pollUntil {
            netpollBreak()
        }
    }
    stopm()
    goto top
}

另外, sysmon 监控线程会在循环过程中检查距离上一次 runtime.netpoll 被调用是否超过了 10ms,若是则会去调用它拿到可运行的 goroutine 列表并通过调用 injectglist 把 g 列表放入全局调度队列或者当前 P 本地调度队列等待被执行:

// Always runs without a P, so write barriers are not allowed.
//
//go:nowritebarrierrec
func sysmon() {
        ...
  
        // poll network if not polled for more than 10ms
        lastpoll := int64(atomic.Load64(&sched.lastpoll))
        if netpollinited() && lastpoll != 0 && lastpoll+10*1000*1000 < now {
            atomic.Cas64(&sched.lastpoll, uint64(lastpoll), uint64(now))
            list := netpoll(0) // non-blocking - returns list of goroutines
            if !list.empty() {
                // Need to decrement number of idle locked M's
                // (pretending that one more is running) before injectglist.
                // Otherwise it can lead to the following situation:
                // injectglist grabs all P's but before it starts M's to run the P's,
                // another M returns from syscall, finishes running its G,
                // observes that there is no work to do and no other running M's
                // and reports deadlock.
                incidlelocked(-1)
                injectglist(&list)
                incidlelocked(1)
            }
        }
  
  ...
}

Go runtime 在程序启动的时候会创建一个独立的 M 作为监控线程,叫 sysmon ,这个线程为系统级的 daemon 线程,无需 P 即可运行, sysmon 每 20us~10ms 运行一次。 sysmon 中以轮询的方式执行以下操作(如上面的代码所示):

  1. 以非阻塞的方式调用 runtime.netpoll ,从中找出能从网络 I/O 中唤醒的 g 列表,并通过调用 injectglist 把 g 列表放入全局调度队列或者当前 P 本地调度队列等待被执行,调度触发时,有可能从这个全局 runnable 调度队列获取 g。然后再循环调用 startm ,直到所有 P 都不处于 _Pidle 状态。
  2. 调用 retake ,抢占长时间处于 _Psyscall 状态的 P。

综上,Go 借助于 epoll/kqueue/iocp 和 runtime scheduler 等的帮助,设计出了自己的 I/O 多路复用 netpoller,成功地让 Listener.Accept / conn.Read / conn.Write 等方法从开发者的角度看来是同步模式。

Go netpoller 的价值

通过前面对源码的分析,我们现在知道 Go netpoller 依托于 runtime scheduler,为开发者提供了一种强大的同步网络编程模式;然而,Go netpoller 存在的意义却远不止于此,Go netpoller I/O 多路复用搭配 Non-blocking I/O 而打造出来的这个原生网络模型,它最大的价值是把网络 I/O 的控制权牢牢掌握在 Go 自己的 runtime 里,关于这一点我们需要从 Go 的 runtime scheduler 说起,Go 的 G-P-M 调度模型如下:

G 在运行过程中如果被阻塞在某个 system call 操作上,那么不光 G 会阻塞,执行该 G 的 M 也会解绑 P(实质是被 sysmon 抢走了),与 G 一起进入 sleep 状态。如果此时有 idle 的 M,则 P 与其绑定继续执行其他 G;如果没有 idle M,但仍然有其他 G 要去执行,那么就会创建一个新的 M。当阻塞在 system call 上的 G 完成 syscall 调用后,G 会去尝试获取一个可用的 P,如果没有可用的 P,那么 G 会被标记为 _Grunnable 并把它放入全局的 runqueue 中等待调度,之前的那个 sleep 的 M 将再次进入 sleep。

现在清楚为什么 netpoll 为什么一定要使用非阻塞 I/O 了吧?就是为了避免让操作网络 I/O 的 goroutine 陷入到系统调用从而进入内核态,因为一旦进入内核态,整个程序的控制权就会发生转移(到内核),不再属于用户进程了,那么也就无法借助于 Go 强大的 runtime scheduler 来调度业务程序的并发了;而有了 netpoll 之后,借助于非阻塞 I/O ,G 就再也不会因为系统调用的读写而 (长时间) 陷入内核态,当 G 被阻塞在某个 network I/O 操作上时,实际上它不是因为陷入内核态被阻塞住了,而是被 Go runtime 调用 gopark 给 park 住了,此时 G 会被放置到某个 wait queue 中,而 M 会尝试运行下一个 _Grunnable 的 G,如果此时没有 _Grunnable 的 G 供 M 运行,那么 M 将解绑 P,并进入 sleep 状态。当 I/O available,在 epoll 的 eventpoll.rdr 中等待的 G 会被放到 eventpoll.rdllist 链表里并通过 netpoll 中的 epoll_wait 系统调用返回放置到全局调度队列或者 P 的本地调度队列,标记为 _Grunnable ,等待 P 绑定 M 恢复执行。

Goroutine 的调度

这一小节主要是讲处理网络 I/O 的 goroutines 阻塞之后,Go scheduler 具体是如何像前面几个章节所说的那样,避免让操作网络 I/O 的 goroutine 陷入到系统调用从而进入内核态的,而是封存 goroutine 然后让出 CPU 的使用权从而令 P 可以去调度本地调度队列里的下一个 goroutine 的。

温馨提示:这一小节属于延伸阅读,涉及到的知识点更偏系统底层,需要有一定的汇编语言基础才能通读,另外,这一节对 Go scheduler 的讲解仅仅涉及核心的一部分,不会把整个调度器都讲一遍(事实上如果真要解析 Go scheduler 的话恐怕重开一篇几万字的文章才能基本讲清楚。。。),所以也要求读者对 Go 的并发调度器有足够的了解,因此这一节可能会稍显深奥。当然这一节也可选择不读,因为通过前面的整个解析,我相信读者应该已经能够基本掌握 Go netpoller 处理网络 I/O 的核心细节了,以及能从宏观层面了解 netpoller 对业务 goroutines 的基本调度了。而这一节主要是通过对 goroutines 调度细节的剖析,能够加深读者对整个 Go netpoller 的彻底理解,接上前面几个章节,形成一个完整的闭环。如果对调度的底层细节没兴趣的话这也可以直接跳过这一节,对理解 Go netpoller 的基本原理影响不大,不过还是建议有条件的读者可以看看。

从源码可知,Go scheduler 的调度 goroutine 过程中所调用的核心函数链如下:

runtime.schedule --> runtime.execute --> runtime.gogo --> goroutine code --> runtime.goexit --> runtime.goexit1 --> runtime.mcall --> runtime.goexit0 --> runtime.schedule
Go scheduler 会不断循环调用 runtime.schedule() 去调度 goroutines,而每个 goroutine 执行完成并退出之后,会再次调用 runtime.schedule(),使得调度器回到调度循环去执行其他的 goroutine,不断循环,永不停歇。

当我们使用 go 关键字启动一个新 goroutine 时,最终会调用 runtime.newproc --> runtime.newproc1,来得到 g,runtime.newproc1 会先从 P 的 gfree 缓存链表中查找可用的 g,若缓存未生效,则会新创建 g 给当前的业务函数,最后这个 g 会被传给 runtime.gogo 去真正执行。

这里首先需要了解一个 gobuf 的结构体,它用来保存 goroutine 的调度信息,是 runtime.gogo 的入参:

// gobuf 存储 goroutine 调度上下文信息的结构体
type gobuf struct {
    // The offsets of sp, pc, and g are known to (hard-coded in) libmach.
    //
    // ctxt is unusual with respect to GC: it may be a
    // heap-allocated funcval, so GC needs to track it, but it
    // needs to be set and cleared from assembly, where it's
    // difficult to have write barriers. However, ctxt is really a
    // saved, live register, and we only ever exchange it between
    // the real register and the gobuf. Hence, we treat it as a
    // root during stack scanning, which means assembly that saves
    // and restores it doesn't need write barriers. It's still
    // typed as a pointer so that any other writes from Go get
    // write barriers.
    sp   uintptr // Stack Pointer 栈指针
    pc   uintptr // Program Counter 程序计数器
    g    guintptr // 持有当前 gobuf 的 goroutine
    ctxt unsafe.Pointer
    ret  sys.Uintreg
    lr   uintptr
    bp   uintptr // for GOEXPERIMENT=framepointer
}

执行 runtime.execute(),进而调用 runtime.gogo

func execute(gp *g, inheritTime bool) {
    _g_ := getg()

    // Assign gp.m before entering _Grunning so running Gs have an
    // M.
    _g_.m.curg = gp
    gp.m = _g_.m
    casgstatus(gp, _Grunnable, _Grunning)
    gp.waitsince = 0
    gp.preempt = false
    gp.stackguard0 = gp.stack.lo + _StackGuard
    if !inheritTime {
        _g_.m.p.ptr().schedtick++
    }

    // Check whether the profiler needs to be turned on or off.
    hz := sched.profilehz
    if _g_.m.profilehz != hz {
        setThreadCPUProfiler(hz)
    }

    if trace.enabled {
        // GoSysExit has to happen when we have a P, but before GoStart.
        // So we emit it here.
        if gp.syscallsp != 0 && gp.sysblocktraced {
            traceGoSysExit(gp.sysexitticks)
        }
        traceGoStart()
    }
    // gp.sched 就是 gobuf
    gogo(&gp.sched)
}

这里还需要了解一个概念:g0,Go G-P-M 调度模型中,g 代表 goroutine,而实际上一共有三种 g:

  1. 执行用户代码的 g;
  2. 执行调度器代码的 g,也即是 g0;
  3. 执行 runtime.main 初始化工作的 main goroutine;

第一种 g 就是使用 go 关键字启动的 goroutine,也是我们接触最多的一类 g;第三种 g 是调度器启动之后用来执行的一系列初始化工作的,包括但不限于启动 sysmon 监控线程、内存初始化和启动 GC 等等工作;第二种 g 叫 g0,用来执行调度器代码,g0 在底层和其他 g 是一样的数据结构,但是性质上有很大的区别,首先 g0 的栈大小是固定的,比如在 Linux 或者其他 Unix-like 的系统上一般是固定 8MB,不能动态伸缩,而普通的 g 初始栈大小是 2KB,可按需扩展,g0 其实就是线程栈,我们知道每个线程被创建出来之时都需要操作系统为之分配一个初始固定的线程栈,就是前面说的 8MB 大小的栈,g0 栈就代表了这个线程栈,因此每一个 m 都需要绑定一个 g0 来执行调度器代码,然后跳转到执行用户代码的地方。

runtime.gogo 是真正去执行 goroutine 代码的函数,这个函数由汇编实现,为什么需要用汇编?因为 gogo 的工作是完成线程 M 上的堆栈切换:从系统堆栈 g0 切换成 goroutine gp,也就是 CPU 使用权和堆栈的切换,这种切换本质上是对 CPU 的 PC、SP 等寄存器和堆栈指针的更新,而这一类精度的底层操作别说是 Go,就算是最贴近底层的 C 也无法做到,这种程度的操作已超出所有高级语言的范畴,因此只能借助于汇编来实现。

runtime.gogo 在不同的 CPU 架构平台上的实现各不相同,但是核心原理殊途同归,我们这里选用 amd64 架构的汇编实现来分析,我会在关键的地方加上解释:

// func gogo(buf *gobuf)
// restore state from Gobuf; longjmp
TEXT runtime·gogo(SB), NOSPLIT, $16-8
    // 将第一个 FP 伪寄存器所指向的 gobuf 的第一个参数存入 BX 寄存器, 
    // gobuf 的一个参数即是 SP 指针
    MOVQ    buf+0(FP), BX
    MOVQ    gobuf_g(BX), DX  // 将 gp.sched.g 保存到 DX 寄存器
    MOVQ    0(DX), CX        // make sure g != nil

    // 将 tls (thread local storage) 保存到 CX 寄存器,然后把 gp.sched.g 放到 tls[0],
    // 这样以后调用 getg() 之时就可以通过 TLS 直接获取到当前 goroutine 的 g 结构体实例,
    // 进而可以得到 g 所在的 m 和 p,TLS 里一开始存储的是系统堆栈 g0 的地址
    get_tls(CX)
    MOVQ    DX, g(CX)

    // 下面的指令则是对函数栈的 BP/SP 寄存器(指针)的存取,
    // 最后进入到指定的代码区域,执行函数栈帧
    MOVQ    gobuf_sp(BX), SP    // restore SP
    MOVQ    gobuf_ret(BX), AX
    MOVQ    gobuf_ctxt(BX), DX
    MOVQ    gobuf_bp(BX), BP

    // 这里是在清空 gp.sched,因为前面已经把 gobuf 里的字段值都存入了寄存器,
    // 所以 gp.sched 就可以提前清空了,不需要等到后面 GC 来回收,减轻 GC 的负担
    MOVQ    $0, gobuf_sp(BX)    // clear to help garbage collector
    MOVQ    $0, gobuf_ret(BX)
    MOVQ    $0, gobuf_ctxt(BX)
    MOVQ    $0, gobuf_bp(BX)

    // 把 gp.sched.pc 值放入 BX 寄存器
    // PC 指针指向 gogo 退出时需要执行的函数地址
    MOVQ    gobuf_pc(BX), BX
    // 用 BX 寄存器里的值去修改 CPU 的 IP 寄存器,
    // 这样就可以根据 CS:IP 寄存器的段地址+偏移量跳转到 BX 寄存器里的地址,也就是 gp.sched.pc
    JMP    BX

runtime.gogo 函数接收 gp.sched 这个 gobuf 结构体实例,其中保存了函数栈寄存器 SP/PC/BP,如果熟悉操作系统原理的话可以知道这些寄存器是 CPU 进行函数调用和返回时切换对应的函数栈帧所需的寄存器,而 goroutine 的执行和函数调用的原理是一致的,也是 CPU 寄存器的切换过程,所以这里的几个寄存器当前存的就是 G 的函数执行栈,当 goroutine 在处理网络 I/O 之时,如果恰好处于 I/O 就绪的状态的话,则正常完成 runtime.gogo,并在最后跳转到特定的地址,那么这个地址是哪里呢?

我们知道 CPU 执行函数的时候需要知道函数在内存里的代码段地址和偏移量,然后才能去取来函数栈执行,而典型的提供代码段地址和偏移量的寄存器就是 CS 和 IP 寄存器,而 JMP BX 指令则是用 BX 寄存器去更新 IP 寄存器,而 BX 寄存器里的值是 gp.sched.pc,那么这个 PC 指针究竟是指向哪里呢?让我们来看另一处源码。

众所周知,启动一个新的 goroutine 是通过 go 关键字来完成的,而 go compiler 会在编译期间利用 cmd/compile/internal/gc.state.stmtcmd/compile/internal/gc.state.call 这两个函数将 go 关键字翻译成 runtime.newproc 函数调用,而 runtime.newproc 接收了函数指针和其大小之后,会获取 goroutine 和调用处的程序计数器,接着再调用 runtime.newproc1

// Create a new g in state _Grunnable, starting at fn, with narg bytes
// of arguments starting at argp. callerpc is the address of the go
// statement that created this. The caller is responsible for adding
// the new g to the scheduler.
//
// This must run on the system stack because it's the continuation of
// newproc, which cannot split the stack.
//
//go:systemstack
func newproc1(fn *funcval, argp unsafe.Pointer, narg int32, callergp *g, callerpc uintptr) *g {
  ...
  
  memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched))
    newg.sched.sp = sp
    newg.stktopsp = sp
    // 把 goexit 函数地址存入 gobuf 的 PC 指针里
    newg.sched.pc = funcPC(goexit) + sys.PCQuantum // +PCQuantum so that previous instruction is in same function
    newg.sched.g = guintptr(unsafe.Pointer(newg))
    gostartcallfn(&newg.sched, fn)
    newg.gopc = callerpc
    newg.ancestors = saveAncestors(callergp)
    newg.startpc = fn.fn
    if _g_.m.curg != nil {
        newg.labels = _g_.m.curg.labels
    }
    if isSystemGoroutine(newg, false) {
        atomic.Xadd(&sched.ngsys, +1)
    }
    casgstatus(newg, _Gdead, _Grunnable)
  
  ...
}

这里可以看到,newg.sched.pc 被设置了 runtime.goexit 的函数地址,newg 就是后面 runtime.gogo 执行的 goroutine,因此 runtime.gogo 最后的汇编指令 JMP BX是跳转到了 runtime.goexit,让我们来继续看看这个函数做了什么:

// The top-most function running on a goroutine
// returns to goexit+PCQuantum. Defined as ABIInternal
// so as to make it identifiable to traceback (this
// function it used as a sentinel; traceback wants to
// see the func PC, not a wrapper PC).
TEXT runtime·goexit<ABIInternal>(SB),NOSPLIT,$0-0
    BYTE    $0x90    // NOP
    CALL    runtime·goexit1(SB)    // does not return
    // traceback from goexit1 must hit code range of goexit
    BYTE    $0x90    // NOP

这个函数也是汇编实现的,但是非常简单,就是直接调用 runtime·goexit1

// Finishes execution of the current goroutine.
func goexit1() {
    if raceenabled {
        racegoend()
    }
    if trace.enabled {
        traceGoEnd()
    }
    mcall(goexit0)
}

调用 runtime.mcall函数:

// func mcall(fn func(*g))
// Switch to m->g0's stack, call fn(g).
// Fn must never return. It should gogo(&g->sched)
// to keep running g.

// 切换回 g0 的系统堆栈,执行 fn(g)
TEXT runtime·mcall(SB), NOSPLIT, $0-8
    // 取入参 funcval 对象的指针存入 DI 寄存器,此时 fn.fn 是 goexit0 的地址
    MOVQ    fn+0(FP), DI

    get_tls(CX)
    MOVQ    g(CX), AX    // save state in g->sched
    MOVQ    0(SP), BX    // caller's PC
    MOVQ    BX, (g_sched+gobuf_pc)(AX)
    LEAQ    fn+0(FP), BX    // caller's SP
    MOVQ    BX, (g_sched+gobuf_sp)(AX)
    MOVQ    AX, (g_sched+gobuf_g)(AX)
    MOVQ    BP, (g_sched+gobuf_bp)(AX)

    // switch to m->g0 & its stack, call fn
    MOVQ    g(CX), BX
    MOVQ    g_m(BX), BX

    // 把 g0 的栈指针存入 SI 寄存器,后面需要用到
    MOVQ    m_g0(BX), SI
    CMPQ    SI, AX    // if g == m->g0 call badmcall
    JNE    3(PC)
    MOVQ    $runtime·badmcall(SB), AX
    JMP    AX

    // 这两个指令是把 g0 地址存入到 TLS 里,
    // 然后从 SI 寄存器取出 g0 的栈指针,
    // 替换掉 SP 寄存器里存的当前 g 的栈指针
    MOVQ    SI, g(CX)    // g = m->g0
    MOVQ    (g_sched+gobuf_sp)(SI), SP    // sp = m->g0->sched.sp

    PUSHQ    AX
    MOVQ    DI, DX

    // 入口处的第一个指令已经把 funcval 实例对象的指针存入了 DI 寄存器,
    // 0(DI) 表示取出 DI 的第一个成员,即 goexit0 函数地址,再存入 DI
    MOVQ    0(DI), DI
    CALL    DI // 调用 DI 寄存器里的地址,即 goexit0
    POPQ    AX
    MOVQ    $runtime·badmcall2(SB), AX
    JMP    AX
    RET

可以看到 runtime.mcall 函数的主要逻辑是从当前 goroutine 切换回 g0 的系统堆栈,然后调用 fn(g),此处的 g 即是当前运行的 goroutine,这个方法会保存当前运行的 G 的 PC/SP 到 g->sched 里,以便该 G 可以在以后被重新恢复执行,因为也涉及到寄存器和堆栈指针的操作,所以也需要使用汇编实现,该函数最后会在 g0 系统堆栈下执行 runtime.goexit0:

func goexit0(gp *g) {
    _g_ := getg()

    casgstatus(gp, _Grunning, _Gdead)
    if isSystemGoroutine(gp, false) {
        atomic.Xadd(&sched.ngsys, -1)
    }
    gp.m = nil
    locked := gp.lockedm != 0
    gp.lockedm = 0
    _g_.m.lockedg = 0
    gp.preemptStop = false
    gp.paniconfault = false
    gp._defer = nil // should be true already but just in case.
    gp._panic = nil // non-nil for Goexit during panic. points at stack-allocated data.
    gp.writebuf = nil
    gp.waitreason = 0
    gp.param = nil
    gp.labels = nil
    gp.timer = nil

    if gcBlackenEnabled != 0 && gp.gcAssistBytes > 0 {
        // Flush assist credit to the global pool. This gives
        // better information to pacing if the application is
        // rapidly creating an exiting goroutines.
        scanCredit := int64(gcController.assistWorkPerByte * float64(gp.gcAssistBytes))
        atomic.Xaddint64(&gcController.bgScanCredit, scanCredit)
        gp.gcAssistBytes = 0
    }

    dropg()

    if GOARCH == "wasm" { // no threads yet on wasm
        gfput(_g_.m.p.ptr(), gp)
        schedule() // never returns
    }

    if _g_.m.lockedInt != 0 {
        print("invalid m->lockedInt = ", _g_.m.lockedInt, "\n")
        throw("internal lockOSThread error")
    }
    gfput(_g_.m.p.ptr(), gp)
    if locked {
        // The goroutine may have locked this thread because
        // it put it in an unusual kernel state. Kill it
        // rather than returning it to the thread pool.

        // Return to mstart, which will release the P and exit
        // the thread.
        if GOOS != "plan9" { // See golang.org/issue/22227.
            gogo(&_g_.m.g0.sched)
        } else {
            // Clear lockedExt on plan9 since we may end up re-using
            // this thread.
            _g_.m.lockedExt = 0
        }
    }
    schedule()
}

runtime.goexit0 的主要工作是就是

  1. 利用 CAS 操作把 g 的状态从 _Grunning 更新为 _Gdead
  2. 对 g 做一些清理操作,把一些字段值置空;
  3. 调用 runtime.dropg 解绑 g 和 m;
  4. 把 g 放入 p 存储 g 的 gfree 链表作为缓存,后续如果需要启动新的 goroutine 则可以直接从链表里取而不用重新初始化分配内存。
  5. 最后,调用 runtime.schedule() 再次进入调度循环去调度新的 goroutines,永不停歇。

另一方面,如果 goroutine 处于 I/O 不可用状态,我们前面已经分析过 netpoller 利用非阻塞 I/O + I/O 多路复用避免了陷入系统调用,所以此时会调用 runtime.gopark 并把 goroutine 暂时封存在用户态空间,并休眠当前的 goroutine,因此不会阻塞 runtime.gogo 的汇编执行,而是通过 runtime.mcall 调用 runtime.park_m

func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {
    if reason != waitReasonSleep {
        checkTimeouts() // timeouts may expire while two goroutines keep the scheduler busy
    }
    mp := acquirem()
    gp := mp.curg
    status := readgstatus(gp)
    if status != _Grunning && status != _Gscanrunning {
        throw("gopark: bad g status")
    }
    mp.waitlock = lock
    mp.waitunlockf = unlockf
    gp.waitreason = reason
    mp.waittraceev = traceEv
    mp.waittraceskip = traceskip
    releasem(mp)
    // can't do anything that might move the G between Ms here.
    mcall(park_m)
}

func park_m(gp *g) {
    _g_ := getg()

    if trace.enabled {
        traceGoPark(_g_.m.waittraceev, _g_.m.waittraceskip)
    }

    casgstatus(gp, _Grunning, _Gwaiting)
    dropg()

    if fn := _g_.m.waitunlockf; fn != nil {
        ok := fn(gp, _g_.m.waitlock)
        _g_.m.waitunlockf = nil
        _g_.m.waitlock = nil
        if !ok {
            if trace.enabled {
                traceGoUnpark(gp, 2)
            }
            casgstatus(gp, _Gwaiting, _Grunnable)
            execute(gp, true) // Schedule it back, never returns.
        }
    }
    schedule()
}

runtime.mcall 方法我们在前面已经介绍过,它主要的工作就是是从当前 goroutine 切换回 g0 的系统堆栈,然后调用 fn(g),而此时 runtime.mcall 调用执行的是 runtime.park_m,这个方法里会利用 CAS 把当前运行的 goroutine -- gp 的状态 从 _Grunning 切换到 _Gwaiting,表明该 goroutine 已进入到等待唤醒状态,此时封存和休眠 G 的操作就完成了,只需等待就绪之后被重新唤醒执行即可。最后调用 runtime.schedule() 再次进入调度循环,去执行下一个 goroutine,充分利用 CPU。

至此,我们完成了对 Go netpoller 原理剖析的整个闭环。

Go netpoller 的问题

Go netpoller 的设计不可谓不精巧、性能也不可谓不高,配合 goroutine 开发网络应用的时候就一个字:爽。因此 Go 的网络编程模式是及其简洁高效的,然而,没有任何一种设计和架构是完美的, goroutine-per-connection 这种模式虽然简单高效,但是在某些极端的场景下也会暴露出问题:goroutine 虽然非常轻量,它的自定义栈内存初始值仅为 2KB,后面按需扩容;海量连接的业务场景下, goroutine-per-connection ,此时 goroutine 数量以及消耗的资源就会呈线性趋势暴涨,虽然 Go scheduler 内部做了 g 的缓存链表,可以一定程度上缓解高频创建销毁 goroutine 的压力,但是对于瞬时性暴涨的长连接场景就无能为力了,大量的 goroutines 会被不断创建出来,从而对 Go runtime scheduler 造成极大的调度压力和侵占系统资源,然后资源被侵占又反过来影响 Go scheduler 的调度,进而导致性能下降。

Reactor 网络模型

目前 Linux 平台上主流的高性能网络库/框架中,大都采用 Reactor 模式,比如 netty、libevent、libev、ACE,POE(Perl)、Twisted(Python)等。

Reactor 模式本质上指的是使用 I/O 多路复用(I/O multiplexing) + 非阻塞 I/O(non-blocking I/O) 的模式。

通常设置一个主线程负责做 event-loop 事件循环和 I/O 读写,通过 select/poll/epoll_wait 等系统调用监听 I/O 事件,业务逻辑提交给其他工作线程去做。而所谓『非阻塞 I/O』的核心思想是指避免阻塞在 read() 或者 write() 或者其他的 I/O 系统调用上,这样可以最大限度的复用 event-loop 线程,让一个线程能服务于多个 sockets。在 Reactor 模式中,I/O 线程只能阻塞在 I/O multiplexing 函数上(select/poll/epoll_wait)。

Reactor 模式的基本工作流程如下:

  • Server 端完成在 bind&listen 之后,将 listenfd 注册到 epollfd 中,最后进入 event-loop 事件循环。循环过程中会调用 select/poll/epoll_wait 阻塞等待,若有在 listenfd 上的新连接事件则解除阻塞返回,并调用 socket.accept 接收新连接 connfd,并将 connfd 加入到 epollfd 的 I/O 复用(监听)队列。
  • 当 connfd 上发生可读/可写事件也会解除 select/poll/epoll_wait 的阻塞等待,然后进行 I/O 读写操作,这里读写 I/O 都是非阻塞 I/O,这样才不会阻塞 event-loop 的下一个循环。然而,这样容易割裂业务逻辑,不易理解和维护。
  • 调用 read 读取数据之后进行解码并放入队列中,等待工作线程处理。
  • 工作线程处理完数据之后,返回到 event-loop 线程,由这个线程负责调用 write 把数据写回 client。

accept 连接以及 conn 上的读写操作若是在主线程完成,则要求是非阻塞 I/O,因为 Reactor 模式一条最重要的原则就是:I/O 操作不能阻塞 event-loop 事件循环。实际上 event loop 可能也可以是多线程的,只是一个线程里只有一个 select/poll/epoll_wait

上面提到了 Go netpoller 在某些场景下可能因为创建太多的 goroutine 而过多地消耗系统资源,而在现实世界的网络业务中,服务器持有的海量连接中在极短的时间窗口内只有极少数是 active 而大多数则是 idle,就像这样(非真实数据,仅仅是为了比喻):

那么为每一个连接指派一个 goroutine 就显得太过奢侈了,而 Reactor 模式这种利用 I/O 多路复用进而只需要使用少量线程即可管理海量连接的设计就可以在这样网络业务中大显身手了:

MultiReactors.png

在绝大部分应用场景下,我推荐大家还是遵循 Go 的 best practices,使用原生的 Go 网络库来构建自己的网络应用。然而,在某些极度追求性能、压榨系统资源以及技术栈必须是原生 Go (不考虑 C/C++ 写中间层而 Go 写业务层)的业务场景下,我们可以考虑自己构建 Reactor 网络模型。

gnet

gnet 是一个基于事件驱动的高性能和轻量级网络框架。它直接使用 epollkqueue 系统调用而非标准 Go 网络包:net 来构建网络应用,它的工作原理类似两个开源的网络库:nettylibuv,这也使得gnet 达到了一个远超 Go net 的性能表现。

gnet 设计开发的初衷不是为了取代 Go 的标准网络库:net,而是为了创造出一个类似于 RedisHaproxy 能高效处理网络包的 Go 语言网络服务器框架。

gnet 的卖点在于它是一个高性能、轻量级、非阻塞的纯 Go 实现的传输层(TCP/UDP/Unix Domain Socket)网络框架,开发者可以使用 gnet 来实现自己的应用层网络协议(HTTP、RPC、Redis、WebSocket 等等),从而构建出自己的应用层网络应用:比如在 gnet 上实现 HTTP 协议就可以创建出一个 HTTP 服务器 或者 Web 开发框架,实现 Redis 协议就可以创建出自己的 Redis 服务器等等。

gnet,在某些极端的网络业务场景,比如海量连接、高频短连接、网络小包等等场景,gnet 在性能和资源占用上都远超 Go 原生的 net 包(基于 netpoller)。

gnet 已经实现了 Multi-ReactorsMulti-Reactors + Goroutine Pool 两种网络模型,也得益于这些网络模型,使得 gnet 成为一个高性能和低损耗的 Go 网络框架:

MultiReactors.png

multireactorsthreadpool.png

🚀 功能

  • [x] 高性能 的基于多线程/Go程网络模型的 event-loop 事件驱动
  • [x] 内置 goroutine 池,由开源库 ants 提供支持
  • [x] 内置 bytes 内存池,由开源库 bytebufferpool 提供支持
  • [x] 整个生命周期是无锁的
  • [x] 简单易用的 APIs
  • [x] 基于 Ring-Buffer 的高效且可重用的内存 buffer
  • [x] 支持多种网络协议/IPC 机制:TCPUDPUnix Domain Socket
  • [x] 支持多种负载均衡算法:Round-Robin(轮询)Source-Addr-Hash(源地址哈希)Least-Connections(最少连接数)
  • [x] 支持两种事件驱动机制:Linux 里的 epoll 以及 FreeBSD/DragonFly/Darwin 里的 kqueue
  • [x] 支持异步写操作
  • [x] 灵活的事件定时器
  • [x] SO_REUSEPORT 端口重用
  • [x] 内置多种编解码器,支持对 TCP 数据流分包:LineBasedFrameCodec, DelimiterBasedFrameCodec, FixedLengthFrameCodec 和 LengthFieldBasedFrameCodec,参考自 netty codec,而且支持自定制编解码器
  • [x] 支持 Windows 平台,基于 IOCP 事件驱动机制 Go 标准网络库
  • [ ] 实现 gnet 客户端

参考&延伸阅读

查看原文

赞 2 收藏 0 评论 0

panjf2000 发布了文章 · 2020-06-09

【译】为什么 Kafka 这么快?

博客原文

https://taohuawu.club/why-kaf...

为什么 Kafka 如此地快

探究是哪些精妙的设计决策使得 Kafka 成为了现如今的性能强者。

软件体系结构在过去的几年间发生了巨大的变化。单体应用程序或甚至几个粗粒度的服务共享一个公共数据存储的理念,在全世界的软件从业者的头脑中早已不复存在了。自主微服务、事件驱动架构和职责分离 (CQRS) 模式是构建以业务为中心的现代应用程序的主要工具。除此之外,设备连接物联网、移动和可穿戴设备的普及,正在对系统在接近实时的情况下必须处理的事件数量造成越来越大的压力。

我们首先要接受一个共识:术语『快』是一个多义的、复杂的甚至是模糊不清的词。延迟、吞吐量和抖动,这些指标会影响人们对这个术语的理解。它还具有内在的上下文关系:行业和应用领域本身就设置了关于性能的规范和期望。某个东西是否『快』很大程度上取决于一个人的参照系。

Apache Kafka 以延迟和抖动为代价对吞吐量进行了优化,同时保留了其他必须的功能特性,比如持久化、严格的日志记录顺序和至少交付一次的语义。当有人说 "Kafka 很快",并且假定他们至少是有资格说这话的,那么我们可以认为他们指的是 Kafka 在短时间内安全地积累和分发大量日志记录的能力。

从历史上看,Kafka 诞生于 LinkedIn 的业务需求:高效地移动大量的消息,每小时的数据量达数 TB 。因为时间的可变性,单个消息的传播延迟被认为是次要的。毕竟,LinkedIn 不是从事高频交易的金融机构,也不是需要在确定的时限内完成指定操作的工业控制系统。Kafka 可用于实现近实时(或称为软实时)的系统。

注意:对于不熟悉这个术语的人,这里必须说明一下,实时并不等同于快速,它仅仅意味着 "可预测"。具体点说,实时意味着完成一个指定操作所需的硬性时间上限,或称为截止时间。如果系统作为一个整体不能每次都满足这个时限(内完成操作),它就不能被归类为实时。能够在具有小概率超时容错性的时限范围内完成操作的系统被称为近实时系统。就吞吐量而言,实时系统通常比近实时或非实时的系统要慢。

Kafka 的高性能主要得益于两个要素,这两个要素需要分开来讨论。第一个与客户端 (Client) 和 代理 (Broker) 实现上的底层效率有关。第二个则来自于流数据处理的机会性并行。

Broker 性能

日志结构的持久性

Kafka 利用了一种分段式的、只追加 (Append-Only) 的日志,基本上把自身的读写操作限制为顺序 I/O,也就使得它在各种存储介质上能有很快的速度。一直以来,有一种广泛的误解认为磁盘很慢。实际上,存储介质 (特别是旋转式的机械硬盘) 的性能很大程度依赖于访问模式。在一个 7200 转/分钟的 SATA 机械硬盘上,随机 I/O 的性能比顺序 I/O 低了大概 3 到 4 个数量级。此外,一般来说现代的操作系统都会提供预读和延迟写技术:以大数据块的倍数预先载入数据,以及合并多个小的逻辑写操作成一个大的物理写操作。正因为如此,顺序 I/O 和随机 I/O 之间的性能差距在 flash 和其他固态非易失性存储介质中仍然很明显,尽管它远没有旋转式的存储介质那么明显。

日志记录批处理

顺序 I/O 在大多数的存储介质上都非常快,几乎可以和网络 I/O 的峰值性能相媲美。在实践中,这意味着一个设计良好的日志结构的持久层将可以紧随网络流量的速度。事实上,Kafka 的瓶颈通常是网络而非磁盘。因此,除了由操作系统提供的底层批处理能力之外,Kafka 的 Clients 和 Brokers 会把多条读写的日志记录合并成一个批次,然后才通过网络发送出去。日志记录的批处理通过使用更大的包以及提高带宽效率来摊薄网络往返的开销。

批量压缩

当启用压缩功能时,批处理的影响尤为明显,因为压缩效率通常会随着数据量大小的增加而变得更高。特别是当使用 JSON 等基于文本的数据格式时,压缩效果会非常显著,压缩比通常能达到 5 到 7 倍。此外,日志记录批处理在很大程度上是作为 Client 侧的操作完成的,此举把负载转移到 Client 上,不仅对网络带宽效率、而且对 Brokers 的磁盘 I/O 利用率也有很大的提升。

廉价的 Consumers

与传统 MQ 风格的 Brokers 在消费点删除消息 (导致随机 I/O 损耗) 不同,Kafka 不会在消息被消费后删除消息 —— 相反,它独立地跟踪每个 Consumer Group 级别的偏移量。偏移量本身的进度被发布到一个名为 __consumer_offsets 的内部 Kafka Topic 上了。同样的,因为是只追加 (Append-Only) 的操作,所以这个过程非常快。这个 Topic 的内容会在后台被进一步缩减 (利用了 Kafka 的压缩特性) ,只为任意给定的 Consumer Group 保留最后的已知偏移量。

将此模型与更传统的消息 Brokers 进行比较,后者通常提供几种不同的消息分布拓扑。一方面,是一个消息队列 —— 一种提供点对点消息传递而不具备点对多点功能的持久化传输机制。另一方面,一个发布-订阅主题允许点对多点消息传输,但是这样带来的代价是牺牲持久性。在传统 MQ 中实现持久的点对多点消息传递模型需要为每个有状态的 Consumer 维护一个专有的消息队列。这将同时产生读和写操作的扩增。一方面,发布者被迫往多个队列写数据。另一种情况是,扇出中继可能从一个队列里消费日志记录并将其写入其他几个队列,但这只会延迟读写扩增到来的时间,治标不治本。另一方面,一些 Consumers 在 Broker 上产生负载 —— 混合了读和写 I/O,既有顺序的,也有随机的。

Kafka 里的 Consumers 是 "廉价的",只要他们不修改日志文件 (只有 Producer 或者是 Kafka 的内部进程有权限修改)。这意味着大量的 Consumers 可以并发地读取同一个 Topic,而不会压垮集群。不过,新增一个 Consumer 仍然需要一些成本,但是它主要是顺序读操作,顺序写操作占的比率很低。因此,一个单一的 Topic 在不同的 Consumer 生态系统中被共享是相当正常的。

非强制刷新缓冲写操作

另一个助力 Kafka 高性能、同时也是一个值得更进一步去探究的底层原因:Kafka 在确认写成功之前的磁盘写操作不会真正调用 fsync 命令;通常只需要确保日志记录被写入到 I/O Buffer 里就可以给 Client 回复 ACK 信号。这是一个鲜为人知却至关重要的事实:事实上,这正是让 Kafka 能表现得如同一个内存型消息队列的原因 —— 因为 Kafka 是一个基于磁盘的内存型消息队列 (受缓冲区/页面缓存大小的限制)。

另一方面,这种形式的写入是不安全的,因为副本的写失败可能会导致数据丢失,即使日志记录似乎已经被确认成功。换句话说,与关系型数据库不同,确认一个写操作成功并不等同于持久化成功。真正使得 Kafka 具备持久化能力的是运行多个同步的副本的设计;即便有一个副本写失败了,其他的副本 (假设有多个) 仍然可以保持可用状态,前提是写失败是不相关的 (例如,多个副本由于一个共同的上游故障而同时写失败)。因此,不使用 fsync 的 I/O 非阻塞方法和冗余同步副本的结合,使得 Kafka 同时具备了高吞吐量、持久性和可用性。

Client 侧的优化

大多数数据库、队列和其他形式的持久化中间件都是围绕重量级 Server (或 Server 集群) 和轻量级 (极度简单的) Client —— 通过知名的有线协议与 Server(s) 通信这一组合模式来设计的。 Client 的实现通常被认为要比服务端简单得多。在这种模式下,服务端将承担大部分的负载,而 Client 仅仅是充当应用程序代码和 Server 之间的接口。

Kafka 对 Client 采取了一种独具一格的设计理念。在将日志记录发送到服务端之前, Client 需要先完成大量的工作。这其中包括日志记录在累加器中的分段,散列日志记录的键值以到达正确的分区 (Partition) 索引,对日志记录进行校验和计算以及压缩日志记录批次。 Client 能够感知集群元数据,并定期刷新该元数据,以同步 Broker 拓扑结构的任何变更。这也让 Client 可以做一些低层次的转发决策;生产者 Clients 不会盲目地向集群发送一条日志记录并依靠集群将其转发到适当的 Broker 节点,而是直接将写操作转发到分区主节点。类似地, 消费者 Clients 在搜寻日志记录时能够做出明智的决策, 它们可能会去访问 (在地理意义上) 距离发出『读取查询』更近的副本数据。(该特性是 Kafka 最近添加的,从 2.4.0 版本开始提供。)

零拷贝

导致应用程序效率低下的一个典型根源是缓冲区之间的字节数据拷贝。Kafka 使用由 Producer、Broker 和 Consumer 多方共享的二进制消息格式,因此数据块即便是处于压缩状态也可以在不被修改的情况下在端到端之间流动。虽然消除通信各方之间的结构化差异是非常重要的一步,但它本身并不能避免数据的拷贝。

Kafka 通过利用 Java 的 NIO 框架,尤其是 java.nio.channels.FileChannel 里的 transferTo 这个方法,解决了前面提到的在 Linux 等类 UNIX 系统上的数据拷贝问题。此方法能够在不借助作为传输中介的应用程序的情况下,将字节数据从源通道直接传输到接收通道。要了解 NIO 的带来的改进,请考虑传统方式下作为两个单独的操作:源通道中的数据被读入字节缓冲区,接着写入接收通道:

File.read(fileDesc, buf, len);
Socket.send(socket, buf, len);

通过图表来说明,这个过程可以被描述如下:

img

尽管上面的过程看起来已经足够简单,但是在内部仍需要 4 次用户态和内核态的上下文切换来完成拷贝操作,而且需要拷贝 4 次数据才能完成这个操作。下面的示意图概述了每一个步骤中的上下文切换。

img

让我们来更详细地看一下细节:

  1. 初始的 read() 调用导致了一次用户态到内核态的上下文切换。DMA (Direct Memory Access 直接内存访问) 引擎读取文件,并将其内容复制到内核地址空间中的缓冲区中。这个缓冲区和上面的代码片段里使用的并非同一个。
  2. 在从 read() 返回之前,内核缓冲区的数据会被拷贝到用户态的缓冲区。此时,我们的程序可以读取文件的内容。
  3. 接下来的 send() 方法会切换回内核态,拷贝用户态的缓冲区数据到内核地址空间 —— 这一次是拷贝到一个关联着目标套接字的不同缓冲区。在后台,DMA 引擎会接手这一操作,异步地把数据从内核缓冲区拷贝到协议堆栈。send() 方法在返回之前不等待此操作。
  4. send() 调用返回,切换回用户态。

尽管模式切换的效率很低,而且需要进行额外的拷贝,但在许多情况下,中间内核缓冲区的性能实际上可以进一步提高。比如它可以作为一个预读缓存,异步预载入数据块,从而可以在应用程序前端运行请求。但是,当请求的数据量极大地超过内核缓冲区大小时,内核缓冲区就会成为性能瓶颈。它不会直接拷贝数据,而是迫使系统在用户态和内核态之间摇摆,直到所有数据都被传输完成。

相比之下,零拷贝方式能在单个操作中处理完成。前面示例中的代码片段现在能重写为一行程序:

fileDesc.transferTo(offset, len, socket);

零拷贝方式可以用下图来说明:

img

在这种模式下,上下文的切换次数被缩减至一次。具体来说,transferTo() 方法指示数据块设备通过 DMA 引擎将数据读入读缓冲区,然后这个缓冲区的数据拷贝到另一个内核缓冲区中,分阶段写入套接字。最后,DMA 将套接字缓冲区的数据拷贝到 NIC 缓冲区中。

img

最终结果,我们已经把拷贝的次数从 4 降到了 3,而且其中只有一次拷贝占用了 CPU 资源。我们也已经把上下文切换的次数从 4 降到了 2。

这是一个巨大的提升,不过还没有实现完全 "零拷贝"。不过我们可以通过利用 Linux 内核 2.4 或更高版本以及支持 gather 操作的网卡来做进一步的优化从而实现真正的 "零拷贝"。下面的示意图可以说明:

img

调用 transferTo() 方法会致使设备通过 DMA 引擎将数据读入内核读缓冲区,就像前面的例子那样。然而,通过 gather 操作,读缓冲区和套接字缓冲区之间的数据拷贝将不复存在。相反地,NIC 被赋予一个指向读缓冲区的指针,连同偏移量和长度,所有数据都将通过 DMA 抽取干净并拷贝到 NIC 缓冲区。在这个过程中,在缓冲区间拷贝数据将无需占用任何 CPU 资源。

传统的方式和零拷贝方式在 MB 字节到 GB 字节的文件大小范围内的性能对比显示,零拷贝方式相较于传统方式的性能提升幅度在 2 到 3 倍。但更令人印象深刻的是,Kafka 仅仅是在一个纯 JVM 虚拟机下、没有使用本地库或 JNI 代码,就实现了这一点。

规避 GC

对通道 (Channel)、本地缓冲区 (Native Buffer) 和页面缓存 (Page Cache) 的大量使用还有一个额外的好处 —— 即减少垃圾收集器 (GC) 的负载。举个例子,在一台 32 GB 内存的机器上运行 Kafka 会产生 28-30 GB 的可用页面缓存,这完全超出了 GC 的作用范围。其实最终吞吐量的差异很小 —— 只有几个百分点 —— 因为经过正确的参数调优之后的 GC 的吞吐量可能相当高,特别是在处理寿命较短的对象时。真正的收益是抖动的减少;通过规避 GC,Brokers 不太可能出现那种导致日志记录端到端传播延迟增大、从而影响 Client 的暂停。

有一说一,与当时构想出 Kafka 的难度相比,现在规避 GC 已经不是什么问题了。像 Shenandoah 和 ZGC 这样的现代垃圾回收器可以扩展到巨大的、TB 级的堆,并且有可调的最坏情况下的暂停时间,可以把该时间优化到个位数毫秒级别。目前,基于 JVM 的应用程序在使用基于堆的大型缓存之后的性能优于堆外设计的情况并不少见。

流数据并行

日志结构 I/O 的效率是影响性能的一个关键因素,主要影响写操作;Kafka 在对 Topic 结构和 Consumer 生态系统的并行处理是其读性能的基础。这种组合产生了非常高的端到端消息传递总体吞吐量。并发性根深蒂固地存在于 Kafka 的分区方案和 Consumer Groups 的操作中,这是 Kafka 中一种有效的负载均衡机制 —— 把数据分区 (Partition) 近似均匀地分配给组内的各个 Consumer 实例。将此与更传统的 MQ 进行比较:在 RabbitMQ 的等效设置中,多个并发的 Consumers 可能以轮询的方式从队列读取数据,然而这样做,就会失去消息消费的顺序性。

分区机制也使得 Kafka Brokers 可以水平扩展。每个分区都有一个专门的 Leader;因此,任何重要的主题 Topic (具有多个分区) 都可以利用整个 Broker 集群进行写操作,这是 Kafka 和消息队列之间的另一个区别;后者利用集群来获得可用性,而 Kafka 将真正地在 Brokers 之间负载均衡,以获得可用性、持久性和吞吐量。

生产者在发布日志记录之时指定分区,假设你正在发布消息到一个有多个分区的 Topic 上。(也可能有单一分区的 Topic, 这种情况下将不成问题。) 这可以通过直接指定分区索引来完成,或者间接通过日志记录的键值来完成,该键值能被确定性地哈希到一个一致的 (即每次都相同) 分区索引。拥有相同哈希值的日志记录将会被存储到同一个分区中。假设一个 Topic 有多个分区,那些不同哈希值的日志记录将很可能最后被存储到不同的分区里。但是,由于哈希碰撞的缘故,不同哈希值的日志记录也可能最后被存储到相同的分区里。这是哈希的本质,如果你理解哈希表的原理,那应该是显而易见的。

日志记录的实际处理是由一个在 (可选的) Consumer Group 中的 Consumer 操作完成。Kafka 确保一个分区最多只能分配给它的 Consumer Group 中的一个 Consumer 。(我们说 "最多" 是因为考虑到一种全部 Consumer 都离线的情况。) 当第一个 Consumer Group 里的 Consumer 订阅了 Topic,它将消费这个 Topic 下的所有分区的数据。当第二个 Consumer 紧随其后加入订阅时,它将大致获得这个 Topic 的一半分区,减轻第一个 Consumer 先前负荷的一半。这使得你能够并行处理事件流,并根据需要增加 Consumer (理想情况下,使用自动伸缩机制),前提是你已经对事件流进行了合理的分区。

日志记录吞吐量的控制一般通过以下两种方式来达成:

  1. Topic 的分区方案。应该对 Topics 进行分区,以最大限度地增加独立子事件流的数量。换句话说,日志记录的顺序应该只保留在绝对必要的地方。如果任意两个日志记录在某种意义上没有合理的关联,那它们就不应该被绑定到同一个分区。这暗示你要使用不同的键值,因为 Kafka 将使用日志记录的键值作为一个散列源来派生其一致的分区映射。
  2. 一个组里的 Consumers 数量。你可以增加 Consumer Group 里的 Consumer 数量来均衡入站的日志记录的负载,这个数量的上限是 Topic 的分区数量。(如果你愿意的话,你当然可以增加更多的 Consumers ,不过分区计数将会设置一个上限来确保每一个活跃的 Consumer 至少被指派到一个分区,多出来的 Consumers 将会一直保持在一个空闲的状态。) 请注意, Consumer 可以是进程或线程。依据 Consumer 执行的工作负载类型,你可以在线程池中使用多个独立的 Consumer 线程或进程记录。

如果你之前一直想知道 Kafka 是否很快、它是如何拥有其现如今公认的高性能标签,或者它是否可以满足你的使用场景,那么相信你现在应该有了所需的答案。

为了让事情足够清楚,必须说明 Kafka 并不是最快的 (也就是说,具有最大吞吐量能力的) 消息传递中间件,还有其他具有更大吞吐量的平台 —— 有些是基于软件的 —— 有些是在硬件中实现的。Apache Pulsar 是一项极具前景的技术,它具备可扩展性,在提供相同的消息顺序性和持久性保证的同时,还能实现更好的吞吐量-延迟效果。使用 Kafka 的根本原因是,它作为一个完整的生态系统仍然是无与伦比的。它展示了卓越的性能,同时提供了一个丰富和成熟而且还在不断进化的环境,尽管 Kafka 的规模已经相当庞大了,但仍以一种令人羡慕的速度在成长。

Kafka 的设计者和维护者们在创造一个以性能导向为核心的解决方案这方面做得非常出色。它的大多数设计/理念元素都是早期就构思完成、几乎没有什么是事后才想到的,也没有什么是附加的。从把工作负载分摊到 Client 到 Broker 上的日志结构持久性,批处理、压缩、零拷贝 I/O 和流数据级并行 —— Kafka 向几乎所有其他面向消息的中间件 (商业的或开源的) 发起了挑战。而且最令人叹为观止的是,它做到这些事情的同时竟然没有牺牲掉持久性、日志记录顺序性和至少交付一次的语义等特性。

Kafka 不是最简单的消息传输平台,所以有很多东西可以学习。一个人必须先掌握整体/部分顺序性、主题 (Topic)、分区 (Partition)、 消费者 (Consumer) 和 消费者组 (Consumer Group) 等基本概念之后,才有可能轻松地设计和构建高性能的事件驱动系统。虽然学习曲线是陡峭的,但最终的结果肯定是值得的。如果你热衷于服用《黑客帝国》中的 "红色药丸",可以阅读 Introduction to Event Streaming with Kafka and Kafdrop

这篇文章是否对你有所裨益?我很想听到你的反馈,所以别藏着掖着了。如果你对 Kafka、Kubernetes、微服务或者事件流处理,甚至于说仅仅只是想提问题,欢迎在 Twitter 上关注我。我同时还是开源项目 Kafdrop 的维护者以及 Effective Kafka 一书的作者。

查看原文

赞 1 收藏 0 评论 0

panjf2000 发布了文章 · 2020-05-29

【译】CPU 高速缓存原理和应用

博客原文: 【译】CPU 高速缓存原理和应用

曾三次获得 F1 世界冠军的杰基•斯图尔特 (Jackie Stewart) 表示,了解汽车的工作原理让他成为了一名更好的驾驶员。

"你并不需要先成为一个工程师才能去做一个赛车手,但是你得有一种机械同感 (Mechanical Sympathy)"

Martin Thompson (高性能消息库 LMAX Disruptor 的设计者) 就一直都把机械同感的理念应用到编程中。简而言之,了解计算机底层硬件能让我们作为一个更优秀的开发者去设计算法、数据结构等等。

在这篇文章中,我们会深入钻研计算机处理器然后看看了解它的一些概念是如何帮助我们去优化程序的。

基本原理

现代计算机处理器是基于一种叫对称多处理 (symmetric multiprocessing, SMP) 的概念。在一个 SMP 系统里,处理器的设计使两个或多个核心连接到一片共享内存 (也叫做主存,RAM)。另外,为了加速内存访问,处理器有着不同级别的缓存,分别是 L1、L2 和 L3。确切的体系结构可能因供应商、处理器模型等等而异。然而,目前最流行的模型是把 L1 和 L2 缓存内嵌在 CPU 核心本地,而把 L3 缓存设计成跨核心共享:

越靠近 CPU 核心的缓存,容量就越小,同时访问延迟就越低 (越快):

CacheLatencyCPU cyclesSize
L1 access~1.2 ns~4Between 32 KB and 512 KB
L2 access~3 ns~10Between 128 KB and 24 MB
L3 access~12 ns~40Between 2 MB and 32 MB

同样的,这些具体的数字因不同的处理器模型而异。不过,我们可以做一个粗略的估算:假设 CPU 访问主存需要耗费 60 ns,那么访问 L1 缓存会快上 50 倍。

在处理器的世界里,有一个很重要的概念叫访问局部性 (locality of reference),当处理器访问某个特定的内存地址时,有很大的概率会发生下面的情况:

  • CPU 在不久的将来会去访问相同的地址:这叫时间局部性 (temporal locality)原则。
  • CPU 会访问特定地址附近的内存地址:这叫空间局部性 (spatial locality)原则。

之所以会有 CPU 缓存,时间局部性是其中一个重要的原因。不过,我们到底应该怎么利用处理器的空间局部性呢?比起拷贝一个单独的内存地址到 CPU 缓存里,拷贝一个缓存行 (Cache Line) 是更好的实现。一个缓存行是一个连续的内存段。

缓存行的大小取决于缓存的级别 (同样的,具体还是取决于处理器模型)。举个例子,这是我的电脑的 L1 缓存行的大小:

$ sysctl -a | grep cacheline
hw.cachelinesize: 64

处理器会拷贝一段连续的 64 字节的内存段到 L1 缓存里,而不是仅仅拷贝一个单独的变量。举个例子,当处理器要拷贝一个由 int64 类型组成 Go 的切片到 CPU 缓存里的时候,它会一起拷贝 8 个元素,而不是单单拷贝 1 个。

一个具体的应用缓存行的 Go 程序

让我们来看一个具体的例子,这个例子将会给我们展示利用 CPU 缓存带来的好处。下面的代码完成的功能是合并两个由 int64 类型组成的方形矩阵:

func BenchmarkMatrixCombination(b *testing.B) {
    matrixA := createMatrix(matrixLength)
    matrixB := createMatrix(matrixLength)

    for n := 0; n < b.N; n++ {
        for i := 0; i < matrixLength; i++ {
            for j := 0; j < matrixLength; j++ {
                matrixA[i][j] = matrixA[i][j] + matrixB[i][j]
            }
        }
    }
}

给定的 matrixLength 变量值设为 64k,压测结果如下:

BenchmarkMatrixSimpleCombination-64000                     8  130724158 ns/op

现在,我们把加 matrixB[i][j] 的操作换成 matrixB[j][i]

func BenchmarkMatrixReversedCombination(b *testing.B) {
    matrixA := createMatrix(matrixLength)
    matrixB := createMatrix(matrixLength)

    for n := 0; n < b.N; n++ {
        for i := 0; i < matrixLength; i++ {
            for j := 0; j < matrixLength; j++ {
                matrixA[i][j] = matrixA[i][j] + matrixB[j][i]
            }
        }
    }
}

改动之后对压测结果的影响有多大呢?

BenchmarkMatrixCombination-64000                           8  130724158 ns/op
BenchmarkMatrixReversedCombination-64000                   2  573121540 ns/op

性能大幅下降!那该怎么解释这个结果呢?

让我们画几幅图来更直观地描述一下中间到底发生了什么,蓝色圆圈代表第一个矩阵的当前指针而粉红色圆圈代表了第二个矩阵的指针。由于程序的操作是 matrixA[i][j] = matrixA[i][j] + matrixB[j][i] ,所以当蓝色指针处于坐标 (4,0) 之时,粉红色指针对应的坐标就是 (0,4):

在上面的图解中,我们用横坐标纵坐标来表示矩阵,(0,0) 代表顶上最左的方块。从计算机原理的角度,一个矩阵所有的行将会被分配到一片连续的内存上,不过为了更直观地表示,我们还是按照数学的表示方法。

此外,接下来的例子里,矩阵的大小是缓存行大小的倍数。因此,一个缓存行不会在下一个矩阵行溢出。

程序会怎么遍历矩阵?蓝色指针会一直向右移动直到最后一列,然后移到下一行,到达坐标 (5,0),以此类推。相反地,粉红色指针会一直往下移动直到最后一行,然后移到下一列。

当粉红色指针在坐标 (0,4) 之时,处理器会缓存指针所在那一行 (在这个示意图里,我们假设缓存行的大小是 4 个元素):

因此,当粉红色指针到达坐标 (0,5) 之时,我们可能会假定这个变量已经在 L1 缓存里了对不对?实际上这取决于矩阵的大小

  • 如果矩阵足够小从而所有的缓存行都能被容纳在 L1 里,那答案就是肯定的。
  • 否则的话,该缓存行就会在指针达到 (0,5) 之前就被清出 L1。因此,将会产生一个缓存缺失,然后处理器就不得不通过别的方式访问该变量 (比如从 L2 里去取)。此时,程序的状态将会是这样的:

那么矩阵的容量应该达到多小才能从 L1 缓存中获益呢?让我们做个简单的计算:首先,我们需要知道 L1 缓存的容量有多大:

$ sysctl hw.l1icachesize
hw.l1icachesize: 32768

在我的机器上,L1 缓存的大小是 32768 字节而缓存行的大小是 64 字节。因此,我最多能存 512 个缓存行到 L1 里。那么如果我们把上面的程序里的矩阵的大小改成 512 之后再跑一下压测,结果会怎样?

BenchmarkMatrixCombination-512                1404     718594 ns/op
BenchmarkMatrixReversedCombination-512        1363     850141 ns/opp

尽管我们已经把两个测试用例的性能差距缩小了很多 (用 64k 大小的矩阵测的时候,第二个要慢了大约 300%),我们还是可以看到会有细微的差距。到底是哪里出了问题?在压测过程中,我们使用了两个矩阵,因此 CPU 需要储存这两个矩阵的所有缓存行。在一个完全理想的状态下 (比如压测过程中没有其他程序在运行,而这几乎是不可能的),L1 缓存会用 50% 的容量来存第一个矩阵而用另外的 50% 的容量来存第二个矩阵。那我们就再进一步缩小两个矩阵的大小,缩减到 256 个元素:

BenchmarkMatrixCombination-256                5712     176415 ns/op
BenchmarkMatrixReversedCombination-256        6470     164720 ns/op

现在我们终于得到了一个近乎相等的压测结果了。

关于为什么第二个测试用例还要略微地比第一个快,这点差别看起来不是很容易察觉而且应该和 Go 编译器生成的汇编代码有关。在第二个测试用例里,第二个矩阵上的指针区别于第一个矩阵指针的管理方式,使用的是 LEA (Load Effective Address) 汇编指令。因为操作系统的虚拟内存机制,当一个处理器访问一个内存地址时,需要做一个虚拟内存到物理真实内存的转换。使用 LEA 指令允许你不经过虚拟内存的转换直接得到内存地址。举个例子,如果我们维护一个由 int64 类型元素组成的切片,我们已经知道了切片里第一个元素的地址,我们就能使用 LEA 指令简单地往后移动 8 个字节得到第二个元素的地址。在我们的例子里,这可能就是为什么第二个测试更快的原因。不过,因为我不是汇编方面的专家,所以如果觉得我的分析有问题的话欢迎提出异议。我已经把第一个函数第二个函数 (反向相加)的汇编代码上传到 GitHub 了,有兴趣的话可以看看。

好了,那我们现在怎么才能在处理一个大容量矩阵时减少处理器缓存缺失带来的影响呢?这里介绍一种叫嵌套循环最优化 (Loop Nest Optimization) 的技巧:我们遍历矩阵的时候,每次都以一个指定大小的矩阵块为单位来遍历,以此来最大化利用 CPU 缓存。

在上面的例子里定义一个包含 4 * 4 大小的矩阵块。在第一个矩阵里,我们从 (4,0) 到 (4,3) 遍历一次,然后切换到下一行。相应的,我们在第二个矩阵里就是从 (0,4) 到 (3,4) 遍历一次,然后切换到下一列。

当粉红色指针遍历完第一列之后,处理器就会把相应的的所有缓存行都储存到 L1 里了,因此,遍历剩下的那些元素的时候就都是从 L1 里访问了,这样就能加快速度了:

让我们把上述的思路用 Go 实现出来,不过我们得谨慎地选择矩阵块的大小;在之前的例子里,矩阵块的边长等于缓存行的大小,这个值不能设置得再小了,否则的话,缓存行里就会有空余,浪费空间。在我们的 Go 压测程序里,矩阵的元素是 int64 类型 (8 个字节),而缓存行是 64 字节,可以储存 8 个元素,那么矩阵块的边长就至少要是 8:

func BenchmarkMatrixReversedCombinationPerBlock(b *testing.B) {
    matrixA := createMatrix(matrixLength)
    matrixB := createMatrix(matrixLength)
    blockSize := 8

    for n := 0; n < b.N; n++ {
        for i := 0; i < matrixLength; i += blockSize {
            for j := 0; j < matrixLength; j += blockSize {
                for ii := i; ii < i+blockSize; ii++ {
                    for jj := j; jj < j+blockSize; jj++ {
                        matrixA[ii][jj] = matrixA[ii][jj] + matrixB[jj][ii]
                    }
                }
            }
        }
    }
}

现在用这个最新的代码实现去跑压测,结果要比直接遍历整个矩阵的实现快 67%:

BenchmarkMatrixReversedCombination-64000          2  573121540 ns/op
BenchmarkMatrixReversedCombinationPerBlock-64000  6  185375690 ns/op

这就是用来展示对 CPU 缓存的了解可以如何潜在地帮助我们设计更高效算法的第一个例子。

伪共享 (False Sharing)

经过上面的分析,我们现在应该对处理器如何管理内部缓存有一个比较清晰的理解了;再来快速回顾一下:

  • 因为空间局部性原则,处理器会储存缓存行而不是一个单独内存地址。
  • L1 缓存是内嵌在指定的 CPU 核心本地的。

现在,让我们通过一个例子来讨论一下 L1 缓存一致性和伪共享的问题。假设现在有两个变量: var1var2 被储存在主存里,一个在 core1 里的线程访问 var1 ,而另一个 core2 里的线程访问 var2 。假设这两个变量在内存中的位置是相邻的 (或者是非常靠近的),那么最后就会导致 var2 存在于两个核心的同一个 L1 缓存行里:

如果第一个线程更新了它所在 CPU 的缓存行会发生什么?这更新操作可能会更新任何包含 var2 的缓存行。接着,当第二个线程尝试去读 var2 的时候,它的值可能已经和之前不一致了。

处理器是如何保持缓存的一致性的?如果两个缓存行共享了一些内存地址,处理器将会把他们标记成 Shared 状态。如果一个线程修改了其中一个 Shared 状态的缓存行,那么两个缓存行都会被标记成 Modified。为了保证缓存一致性,需要引入在多核之间引入一种协调机制,而这种机制可能会导致应用程序的性能大幅度下降。这个问题就被成为伪共享 (Fasle Sharing)

我们来看一个具体的 Go 程序。在这个例子里,我们相继地实例化了两个结构体,一个紧挨着另一个;因此,这两个结构体应该会被分配在一片连续的内存上;然后,我们再创建两个 goroutines,分别去访问对应的结构体 (变量 M 的值等于 100 万):

type SimpleStruct struct {
    n int
}

func BenchmarkStructureFalseSharing(b *testing.B) {
    structA := SimpleStruct{}
    structB := SimpleStruct{}
    wg := sync.WaitGroup{}

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        wg.Add(2)
        go func() {
            for j := 0; j < M; j++ {
                structA.n += j
            }
            wg.Done()
        }()
        go func() {
            for j := 0; j < M; j++ {
                structB.n += j
            }
            wg.Done()
        }()
        wg.Wait()
    }
}

在这个例子里,第二个结构体里的变量 n 只会被第二个 goroutine 访问,然而,因为两个结构体在内存上的地址是连续的, n 将会存在于两个 CPU 缓存行中 (这里假设两个 goroutine 会被分配到不同核心上调度,当然,这通常不是必须的),这是压测结果:

BenchmarkStructureFalseSharing         514    2641990 ns/op

那么我们如何才能规避这种伪共享呢?有一个解决办法是使用内存填充 (Memory Padding)。这种方案的原理是在两个变量之间填充足够多的空间,以确保它们会储存在不同的 CPU 缓存行里。

首先,让我们创建一个替代之前那个结构体的新结构体,在变量声明之后填充足够的内存:

type PaddedStruct struct {
    n int
    _ CacheLinePad
}

type CacheLinePad struct {
    _ [CacheLinePadSize]byte
}

const CacheLinePadSize = 64

接着,我们再初始化这两个结构体而且和之前一样通过单独的 goroutine 分别去访问这两个变量:

func BenchmarkStructurePadding(b *testing.B) {
    structA := PaddedStruct{}
    structB := SimpleStruct{}
    wg := sync.WaitGroup{}

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        wg.Add(2)
        go func() {
            for j := 0; j < M; j++ {
                structA.n += j
            }
            wg.Done()
        }()
        go func() {
            for j := 0; j < M; j++ {
                structB.n += j
            }
            wg.Done()
        }()
        wg.Wait()
    }
}

内存智能化,这个例子里的内存分布应该看起来像下图这样,两个变量之间留有足够多的内存填充,从而导致它们最后只会分别存在于不同核心的缓存行上:

让我们来看下最新的压测结果:

BenchmarkStructureFalseSharing         514    2641990 ns/op
BenchmarkStructurePadding              735    1622886 ns/op

使用了内存填充之后的第二个例子要比最初的那个快了差不多 40% 🎉,虽然不是没有代价的。内存填充的确能加快执行时间,不过代价是会导致更多的内存分配和浪费。

机械同感 (Mechanical Sympathy) 在程序优化方面是一个重要的概念。在这篇文章中,我们已经通过相关的例子展示了对 CPU 处理器的了解是如何帮助我们优化/降低程序执行时间的。

这里我要感谢 Inanc GumusVal Deleplace,正是因为和他们二位在 Twitter 上进行了一番有趣的探讨之后,才让我萌生了写这篇博客的想法。你们也应该去看看他们写的博客,因为他们输出了很多优质的内容。

延伸阅读

go-cpu-caches

Numbers Every Programmer Should Know By Year

False Sharing

Loop Optimizations Where Blocks are Required

从Java视角理解伪共享(False Sharing)

关于 Fasle Sharing 的补充

由于本文关于 False Sharing 那一章节对于该知识点的阐述过于简略以及分析不够准确,所以在这里译者补充一下我个人对 False Sharing 的分析

要真正理解伪共享,首先要了解 MESI 协议及 RFO 请求

从前面的内容我们可以知道,每个核心都有自己私有的 L1、L2 缓存。那么多线程编程时, 另外一个核的线程想要访问当前核内 L1、L2 缓存行的数据, 该怎么做呢?

有人说可以通过第 2 个核直接访问第 1 个核的缓存行,这是当然是可行的,但这种方法不够快。跨核访问需要通过 Memory Controller (内存控制器,是计算机系统内部控制内存并且通过内存控制器使内存与 CPU 之间交换数据的重要组成部分),典型的情况是第 2 个核经常访问第 1 个核的这条数据,那么每次都有跨核的消耗。更糟的情况是,有可能第 2 个核与第 1 个核不在一个插槽内,况且 Memory Controller 的总线带宽是有限的,扛不住这么多数据传输。所以,CPU 设计者们更偏向于另一种办法:如果第 2 个核需要这份数据,由第 1 个核直接把数据内容发过去,数据只需要传一次。

那么什么时候会发生缓存行的传输呢?答案很简单:当一个核需要读取另外一个核的脏缓存行时发生。但是前者怎么判断后者的缓存行已经被弄脏(写)了呢?

下面将详细地解答以上问题。 首先我们需要谈到一个协议—— MESI 协议。现在主流的处理器都是用它来保证缓存的相干性和内存的相干性。M、E、S 和 I 代表使用 MESI 协议时缓存行所处的四个状态:

M(修改,Modified):本地处理器已经修改缓存行,即是脏行,它的内容与内存中的内容不一样,并且此 cache 只有本地一个拷贝(专有);
E(专有,Exclusive):缓存行内容和内存中的一样,而且其它处理器都没有这行数据;
S(共享,Shared):缓存行内容和内存中的一样, 有可能其它处理器也存在此缓存行的拷贝;
I(无效,Invalid):缓存行失效, 不能使用。

下面说明这四个状态是如何转换的:

初始:一开始时,缓存行没有加载任何数据,所以它处于 I 状态。

本地写(Local Write):如果本地处理器写数据至处于 I 状态的缓存行,则缓存行的状态变成 M。

本地读(Local Read):如果本地处理器读取处于 I 状态的缓存行,很明显此缓存没有数据给它。此时分两种情况:(1)其它处理器的缓存里也没有此行数据,则从内存加载数据到此缓存行后,再将它设成 E 状态,表示只有我一家有这条数据,其它处理器都没有;(2)其它处理器的缓存有此行数据,则将此缓存行的状态设为 S 状态。(备注:如果处于M状态的缓存行,再由本地处理器写入/读出,状态是不会改变的)

远程读(Remote Read):假设我们有两个处理器 c1 和 c2,如果 c2 需要读另外一个处理器 c1 的缓存行内容,c1 需要把它缓存行的内容通过内存控制器 (Memory Controller) 发送给 c2,c2 接到后将相应的缓存行状态设为 S。在设置之前,内存也得从总线上得到这份数据并保存。

远程写(Remote Write):其实确切地说不是远程写,而是 c2 得到 c1 的数据后,不是为了读,而是为了写。也算是本地写,只是 c1 也拥有这份数据的拷贝,这该怎么办呢?c2 将发出一个 RFO (Request For Owner) 请求,它需要拥有这行数据的权限,其它处理器的相应缓存行设为 I,除了它自已,谁不能动这行数据。这保证了数据的安全,同时处理 RFO 请求以及设置I的过程将给写操作带来很大的性能消耗。

下面添加一个简单的 MESI 状态转换图:

现在,让我们通过一个例子来讨论一下 L1 缓存一致性和伪共享的问题。假设现在有两个变量: var1var2 被储存在主存里,一个在 core1 里的线程访问 var1 ,而另一个 core2 里的线程访问 var2 。假设这两个变量在内存中的位置是相邻的 (或者是非常靠近的),那么最后就会导致 var2 存在于两个核心的同一个 L1 缓存行里:

上图中 thread1 位于 core1 ,而 thread2 位于 core2 ,二者均想更新彼此独立的两个变量,但是由于两个变量位于不同核心中的同一个 L1 缓存行中,此时可知的是两个缓存行的状态应该都是 Shared ,而对于同一个缓存行的操作,不同的 core 间必须通过发送 RFO 消息来争夺所有权 (ownership) ,如果 core1 抢到了, thread1 因此去更新该缓存行,把状态变成 Modified ,那就会导致 core2 中对应的缓存行失效变成 Invalid ,当 thread2 取得所有权之后再去更新该缓存行时必须先让 core1 把对应的缓存行刷回 L3 缓存/主存,然后它再从 L3 缓存/主存中加载该缓存行进 L1 之后才能进行修改。然而,这个过程又会导致 core1 对应的缓存行失效变成 Invalid ,这个过程将会一直循环发生,从而导致 L1 高速缓存并未起到应有的作用,反而会降低性能;轮番夺取拥有权不但带来大量的 RFO 消息,而且如果某个线程需要读此行数据时,L1 和 L2 缓存上都是失效数据,只有 L3 缓存上是同步好的数据,而从前面的内容可以知道,L3 的读取速度相比 L1/L2 要慢了数十倍,性能下降很大;更坏的情况是跨槽读取,L3 都不能命中,只能从主存上加载,那就更慢了。

请记住,CPU 缓存的最小的处理单位永远是缓存行 (Cache Line),所以当某个核心发送 RFO 消息请求把其他核心对应的缓存行设置成 Invalid 从而使得 var1 缓存失效的同时,也会导致同在一个缓存行里的 var2 失效,反之亦然。

Medium 英文原文

Go and CPU Caches

查看原文

赞 2 收藏 1 评论 0

panjf2000 发布了文章 · 2020-04-07

【译】Go 语言项目源码贡献官方指导文档

Golang.png

以前给 Go 语言项目源码提交过一些 commits,期间阅读他们的官方指导文档的时候觉得这篇指导文档可以作为绝佳的关于大型软件项目的规范管理的参考,因为最近又提交了几个 commits,就又把这篇文档再看了一遍,有感于 Go 团队在项目管理和工程实践上的一些宝贵经验,就把文档翻译成了中文;一来为了更加深入地理解 Go 语言团队的项目工程最佳实践,二来则是为了给其他有意给 Go 语言源码提交贡献的开发者提供一点参考。

导言

Go 语言项目欢迎所有的代码贡献者。

这是一份指导你完成向 Go 语言项目贡献代码整个流程的文档,会略微跟其他开源项目所使用的指导文档有所不同。我们假设阅读者已经对 Git 和 Go 有基本的理解以及具备相关的基础知识。

除了这里所介绍的信息,Go 语言社区也维护了一份关于代码评审的 wiki 页面。在你学习评审流程期间,欢迎随时给这份 wiki 贡献、补充新内容。

请注意, gccgo 前端的文档在另一处;看这里:Contributing to gccgo

成为一个代码贡献者

概述

第一步需要注册成为一个 Go contributor 以及配置你的环境。这里有一份包含了所需步骤的清单:

  • 步骤 0: 准备好一个你将用来给 Go 语言贡献代码的 Google 账号。在后面所有的步骤中都要使用这个账号,还有确保你的 git 已经正确配置了这个账号的邮箱地址,以便后续提交 commits。
  • 步骤 1: 签署以及提交一个 CLA(贡献者证书协议)。
  • 步骤 2: 给 Go Git 仓库配置好权限凭证。访问 go.googlesource.com,点击右上角的齿轮图标,接着点击 "Obtain password",然后跟着指引操作即可。
  • 步骤 3: 在这个页面注册一个 Gerrit 账号,它是 Go 语言团队使用的代码评审工具。CLA 的申请和 Gerrit 的注册只需要在你的账号上做一次就可以了
  • 步骤 4: 运行 go get -u golang.org/x/review/git-codereview 命令安装 git-codereview 工具。

如果你图省事的话,可以直接用自动化工具帮你完成上面的全部步骤,只需运行:

$ go get -u golang.org/x/tools/cmd/go-contrib-init
$ cd /code/to/edit
$ go-contrib-init

这个章节的后面部分将会更加详尽地阐述上面的每一个步骤。如果你已经完成上面的所有步骤(不管是手动还是通过自动化工具),可以直接跳到贡献代码之前部分。

步骤 0: 选择一个 Google 账号

每一个提交到 Go 语言的代码贡献都是通过一个绑定了特定邮箱地址的 Google 账号来完成的。请确保你在整个流程中自始至终使用的都是同一个账号,当然,后续你提交的所有代码贡献也是如此。你可能需要想好使用哪一种邮箱,个人的还是企业的。邮箱类型的选择将决定谁拥有你编写和提交的代码的版权。在决定使用哪个账户之前,你大概要和你的雇主商议一下。

Google 账号可以是 Gmail 邮箱账号、G Suite 组织账号,或者是那些绑定了外部邮箱的账号。例如,如果你想要使用一个已存在且并不属于 G Suite 的企业邮箱,你可以创建一个绑定了外部邮箱的 Google 账号

你还需要确保你的 Git 工具已经正确配置好你之前选定的邮箱地址,用来提交代码。你可以通过 Git 命令来进行全局配置(所有项目都将默认使用这个配置)或者只进行本地配置(只指定某个特定的项目使用)。可以通过以下的命令来检查当前的配置情况:

$ git config --global user.email  # check current global config
$ git config user.email           # check current local config

修改配置好的邮箱地址:

$ git config --global user.email name@example.com   # change global config
$ git config user.email name@example.com            # change local config

步骤 1: 贡献者证书协议

在你发送第一个代码变更到 Go 语言项目(进行评审)之前,你必须先签署下面两种证书协议的其中之一。最后的代码版权归属于谁,将决定你应该签署哪一种协议。

你可以在 Google Developers Contributor License Agreements 网站上检查当前已签署的协议以及再签署新的协议。如果你代码的版权持有方之前已经在其他的 Google 开源项目上签署过这些协议了,那么就不需要再重复签署了。

如果你代码的版权持有方更改了--例如,如果你开始代表新的公司来贡献代码--请发送邮件到 golang-dev 邮件组。这样我们可以知悉情况,接着准备一份新的协议文件以及更新 作者 文件。

步骤 2: 配置 Git 认证信息

Go 语言的主仓库位于 go.googlesource.com,这是一个 Google 自建的 Git 服务器。Web 服务器上的认证信息是通过你的 Google 帐户生成的,不过你还是需要在你的个人电脑上安装配置 git 来访问它。按照以下的步骤进行:

  1. 访问 go.googlesource.com 然后点击页面右上角菜单条上的 "Generate Password" 按钮。接着你会被重定向到 accounts.google.com 去登陆。
  2. 登陆之后,你会被引导到一个标题为 "Configure Git" 的网页。这个网页包含了一段个性化的脚本代码,运行这个脚本之后会自动生成身份认证的密钥并配置到 Git 里面去。这个密钥是和另一个在远端 Server 生成并存储的密钥成对的,类似于 SSH 密钥对的工作原理。
  3. 复制这段脚本并在你的个人电脑上的终端运行一下,你的密钥认证 token 就会被保存到一个 .gitcookies 的文件里。如果你使用的是 Windows 电脑,那你应该复制并运行黄色方格里的脚本,而不是下面那个通用的脚本。

步骤 3: 创建一个 Gerrit 账号

Gerrit 是 Go 语言团队所使用的一个开源工具,用来进行讨论和代码评审。

要注册一个你自己的 Gerrit 账号,访问 go-review.googlesource.com/login/ 然后使用你上面的 Google 账号登陆一次,然后就自动注册成功了。

步骤 4: 安装 git-codereview 命令行工具

无论是谁,提交到 Go 语言源码的代码变更在被接受合并之前,必须要经过代码评审。Go 官方提供了一个叫 git-codereview 的定制化 git 命令行工具,它可以简化与 Gerrit 的交互流程。

运行下面的命令安装 git-codereview 命令行工具:

$ go get -u golang.org/x/review/git-codereview

确保 git-codereview 被正确安装到你的终端路径里,这样 git 命令才可以找到它,检查一下:

git codereview help

正确打印出帮助信息,而且没有任何错误。如果发现有错误,确保环境变量 $PATH 里有 $GOPATH/bin 这个值。

在 Windows 系统上,当使用 git-bash 的时候你必须确保 git-codereview.exe 已经存在于你的 git exec-path 上了。可以运行 git --exec-path 来找到正确的位置然后创建一个软链接指向它或者直接从 $GOPATH/bin 目录下拷贝这个可执行文件到 exec-path。

贡献代码之前

Go 语言项目欢迎提交代码补丁,但是为了确保很好地进行协调,你应该在开始提交重大代码变更之前进行必要的讨论。我们建议你把自己的意图或问题要不先提交到一个新的 GitHub issue,要不找到一个和你的问题相同或类似的 issue 跟进查看。

检查 issue 列表

不管你是已经明确了要提交什么代码,还是你正在搜寻一个想法,你都应该先到 issue 列表 看一下。所有 Issues 已经被分门别类以及被用来管理 Go 开发的工作流。

大多数 issues 会被标记上以下众多的工作流标签中的其中一个:

  • NeedsInvestigation: 该 issue 并不能被完全清晰地解读,需要更多的分析去找到问题的根源
  • NeedsDecision: 该 issue 已经在相当程度上被解读,但是 Go 团队还没有得出一个最好的方法去解决它。最好等 Go 团队得出了最终的结论之后才开始写代码修复它。如果你对解决这个 issue 感兴趣,而且这个 issue 已经过了很久都没得出最终结论,随时可以在该 issue 下面发表评论去"催促"维护者。
  • NeedsFix: 该 issue 可以被完全清晰地解读而且可以开始写代码修复它。

你可以使用 GitHub 的搜索功能去搜寻一个 issue 然后搭把手帮忙解决它。例子:

新开一个关于任何新问题的 issue

除了一些很琐碎的变更之外,所有的代码贡献都应该关联到一个已有的 issue。你随时可以新开一个 issue 来讨论你的相关计划。这个流程可以让所有人都能够参与验证代码的设计,同时帮忙减少一些重复的工作,以及确保这个想法是符合这门语言和相关工具的目标和理念的。还有就是能在真正开始写代码之前就检查这个代码设计是否合理;代码评审工具不是用来讨论高层次问题的。

在规划你的代码变更工作的时候,请知悉 Go 语言项目遵循的是 6 个月开发周期。在每一个 6 个月周期的后半部分是长达 3 个月的新功能特性冻结期:这期间我们只接受 bug 修复和文档更新相关的变更。在冻结期内还是可以提交新的变更的,但是这些变更的代码在冻结期结束之前不会被合并入主分支。

那些针对语言、标准库或者工具的重大变更必须经过变更提议流程才能被接受。

敏感性的安全相关的 issues 只能上报到 security@golang.org 邮箱!

通过 GitHub 提交一个变更

我们鼓励那些初次提交代码并且已经相当熟悉 GitHub 工作流的贡献者通过标准的 GitHub 工作流给 Go 提交代码。尽管 Go 的维护者们是使用 Gerrit 来进行代码评审,但是不用担心,会有一个叫 Gopherbot 的机器人专门把 GitHub PR 同步到 Gerrit 上。

就像你以往那样新建一个 pull request,Gopherbot 会创建一个对应的 Gerrit 变更页面然后把指向该 Gerrit 变更页面的链接发布在 GitHub PR 里面;所有 GitHub PR 的更新都会被同步更新到 Gerrit 里。当有人在 Gerrit 的代码变更页面里发表评论的时候,这些评论也会被同步更新回 GitHub PR 里,因此 PR owner 将会收到一个通知。

需要谨记于心的东西:

  • 如果要在 GitHub PR 里进行代码更新的话,只需要把你最新的代码推送到对应的分支;你可以添加更多的 commits、或者做 rebase 和 force-push 操作(无论哪种方式都是可以接受的)。
  • 一旦 GitHub PR 被接受,所有的 commits 将会被合并成一条,而且最终的 commit 信息将由 PR 的标题和描述联结而成。那些单独的 commit 描述将会被丢弃掉。查看写好 Commits 信息获取更多的建议。
  • Gopherbot 无法逐字逐句地把代码评审的信息同步回 Github: 仅仅是(未经格式化的)全部评论的内容会被同步过去。请记住,你总是可以访问 Gerrit 去查看更细粒度和格式化的内容。

通过 Gerrit 提交一个变更

一般来说,我们基本不可能在 Gerrit 和 GitHub 之间完整地同步所有信息,至少在现阶段来说是这样,所以我们推荐你去学习一下 Gerrit。它是不同于 GitHub 却同样强大的工具,而且熟悉它能帮助你更好地理解我们的工作流。

概述

这是一个关于整个流程的概述:

  • 步骤 1: 从 go.googlesource.com 克隆 Go 的源码下来,然后通过编译和测试一次确保这份源码是完整和稳定的:

    $ git clone https://go.googlesource.com/go
    $ cd go/src
    $ ./all.bash                                # compile and test
  • 步骤 2: 从 master 分支上拉出一条新分支并在这个分支上准备好你的代码变更。使用 git codereview change 来提交代码变更;这将会在这个分支上新建或者 amend 一条单独的 commit。

    $ git checkout -b mybranch
    $ [edit files...]
    $ git add [files...]
    $ git codereview change   # create commit in the branch
    $ [edit again...]
    $ git add [files...]
    $ git codereview change   # amend the existing commit with new changes
    $ [etc.]
  • 步骤 3: 重跑 all.bash 脚本,测试你的代码变更。

    $ ./all.bash    # recompile and test
  • 步骤 4: 使用 git codereview mail 命令发送你的代码变更到 Gerrit 进行代码评审(这个过程并不使用 e-mail,请忽略这个奇葩名字)。

    $ git codereview mail     # send changes to Gerrit
  • 步骤 5: 经过一轮代码评审之后,把你新的代码变更依附在同一个单独 commit 上然后再次使用 mail 命令发送到 Gerrit:

    $ [edit files...]
    $ git add [files...]
    $ git codereview change   # update same commit
    $ git codereview mail     # send to Gerrit again

这个章节剩下的内容将会把上面的步骤进行详细的讲解。

步骤 1: 克隆 Go 语言的源码

除了你近期安装的 Go 版本,你还需要有一份从正确的远程仓库克隆下来的本地拷贝。你可以克隆 Go 语言源码到你的本地文件系统上的任意路径下,除了你的 GOPATH 环境变量对应的目录。从 go.googlesource.com 克隆下来 (不是从 Github):

$ git clone https://go.googlesource.com/go
$ cd go

步骤 2: 在新分支上准备好代码变更

每一次代码变更都必须在一条从 master 拉出来的独立分支上开发。你可以使用正常的 git 命令来新建一条分支然后把代码变更添加到暂存区:

$ git checkout -b mybranch
$ [edit files...]
$ git add [files...]

使用 git codereview change 而不是 git commit 命令来提交变更。

$ git codereview change
(open $EDITOR)

你可以像往常一样在你最喜欢的编辑器里编辑 commit 的描述信息。 git codereview change 命令会自动在靠近底部的地方添加一个唯一的 Change-Id 行。那一行是被 Gerrit 用来匹配归属于同一个变更的多次连续的上传。不要编辑或者是删除这一行。一个典型的 Change-Id 一般长的像下面这样:

Change-Id: I2fbdbffb3aab626c4b6f56348861b7909e3e8990

这个工具还会检查你是否有使用 go fmt 命令对代码进行格式化,以及你的 commit 信息是否遵循建议的格式

如果你需要再次编辑这些文件,你可以把新的代码变更暂存到暂存区然后重跑 git codereview change : 后续每一次运行都会 amend 到现存的上一条 commit 上,同时保留同一个 Change-Id。

确保在每一条分支上都只存在一个单独的 commit,如果你不小心添加了多条 commits,你可以使用 git rebase把它们合并成一条

步骤 3: 测试你的代码变更

此时,你已经写好并测试好你的代码了,但是在提交你的代码去进行代码评审之前,你还需要对整个目录树运行所有的测试来确保你的代码变更没有对其他的包或者程序造成影响/破坏:

$ cd go/src
$ ./all.bash

(如果是在 Windows 下构建,使用 all.bat ;还需要在保存 Go 语言源码树的目录下为引导编译器设置环境变量 GOROOT_BOOTSTRAP。)

在运行和打印测试输出一段时间后,这个命令在结束前打印的最后一行应该是:

ALL TESTS PASSED

你可以使用 make.bash 而不是 all.bash 来构建编译器以及标准库而不用运行整个测试套件。一旦 go 工具构建完成,一个 bin/go 可执行程序会被安装在你前面克隆下来的 Go 语言源码的根目录下,然后你可以在那个目录下直接运行那个程序。可以查看快速测试你的代码变更这个章节。

步骤 4: 提交代码变更进行代码评审

一旦代码变更准备好了而且通过完整的测试了,就可以发送代码变更去进行代码评审了。这个步骤可以通过 mail 子命令完成,当然它并没有发送任何邮件;只是把代码变更发送到 Gerrit 上面去了:

git codereview mail

Gerrit 会给你的变更分配一个数字和 URL,通过 git codereview mail 打印出来,类似于下面的:

remote: New Changes:
remote:   https://go-review.googlesource.com/99999 math: improved Sin, Cos and Tan precision for very large arguments

如果有错误,查看 mail 命令错误大全和故障排除

如果你的代码变更关联到一个现存的 GitHub issue 而且你也已经遵循了建议的 commit 信息格式,机器人将会在几分钟更新那个 issue:在评论区添加 Gerrit 变更页面的链接。

步骤 5: 代码评审之后修正变更

Go 语言的维护者们会在 Gerrit 上对你的代码进行 review,然后你会收到一堆邮件通知。你可以在 Gerrit 上查看详情以及发表评论,如果你更倾向于直接使用邮件回复,也没问题。

如果你需要在一轮代码评审之后更新代码,直接在你之前创建的同一条分支上编辑代码文件,接着添加这些文件进 Git 暂存区,最后通过 git codereview change amend 到上一条 commit:

$ git codereview change     # amend current commit
(open $EDITOR)
$ git codereview mail       # send new changes to Gerrit

要是你不需要更改 commit 描述信息,可以直接在编辑器保存然后退出。记得不要去碰那一行特殊的 Change-Id。

再次确保你在每一条分支上只保留了一个单独的 commit,如果你不小心添加了多条 commits,你可以使用 git rebase把它们合并成一条

良好的 commit 信息

Go 语言的 commit 信息遵循一系列特定的惯例,我们将在这一章节讨论。

这是一个良好的 commit 信息的例子:

math: improve Sin, Cos and Tan precision for very large arguments

The existing implementation has poor numerical properties for
large arguments, so use the McGillicutty algorithm to improve
accuracy above 1e10.

The algorithm is described at https://wikipedia.org/wiki/McGillicutty_Algorithm

Fixes #159

首行

变更信息的第一行照惯例一般是一短行关于代码变更的概述,前缀是此次代码变更影响的主要的包名。

作为经验之谈,这一行是作为 "此次变更对 Go 的 _ 部分进行了改动" 这一个句子的补全信息,也就是说这一行并不是一个完整的句子,因此并不需要首字母大写,仅仅只是对于代码变更的归纳总结。

紧随第一行之后的是一个空行。

主干内容

描述信息中剩下的内容会进行详尽地阐述以及会提供关于此次变更的上下文信息,而且还要解释这个变更具体做了什么。请用完整的句子以及正确的标点符号来表达,就像你在 Go 代码里的注释那样。不要使用 HTML、Markdown 或者任何其他的标记语言。

添加相关的信息,比如,如果是性能相关的改动就需要添加对应的压测数据。照惯例会使用 benchstat 工具来对压测数据进行格式化处理,以便写入变更信息里。

引用 issues

接下来那个特殊的表示法 "Fixes #12345" 把代码变更关联到了 Go issue tracker 列表里的 issue 12345。当这个代码变更最终实施之后 (也就是合入主干),issue tracker 将会自动标记那个 issue 为"已解决"并关闭它。

如果这个代码变更只是部分解决了这个 issue 的话,请使用 "Updates #12345",这样的话就会在那个 issue 的评论区里留下一个评论把它链接回 Gerrit 上的变更页面,但是在该代码变更被实施之后并不会关闭掉 issue。

如果你是针对一个子仓库发送的代码变更,你必须使用 GitHub 支持的完全形式的语法来确保这个代码变更是链接到主仓库的 issue 上去的,而非子仓库。主仓库的 issue tracker 会追踪所有的 issues,正确的格式是 "Fixes golang/go#159"。

代码评审流程

这个章节是对代码评审流程的详细介绍以及如何在一个变更被发送之后处理反馈。

常见的新手错误

当一个变更被发送到 Gerrit 之后,通常来说它会在几天内被分门别类。接着一个维护者将会查看并提供一些初始的评审,对于初次提交代码贡献者来说,这些评审通常集中在基本的修饰和常见的错误上。

内容包括诸如:

  • Commit 信息没有遵循建议的格式
  • 没有链接到对应的 GitHub issue。大部分代码变更需要链接到对应的 GitHub issue,说明这次变更修复的 bug 或者实现的功能特性,而且在开始这个变更之前,issue 里应该已经达成了一致的意见。Gerrit 评审不会讨论代码变更的价值,仅仅是讨论它的具体实现。
  • 变更如果是在开发周期的冻结阶段被发送到 Gerrit 上的,也就是说彼时 Go 代码树是不接受一般的变更的,这种情况下,一个维护者可能会在评审代码时留下一行这样的评论:R=go.1.12,意思是这个代码变更将会在下一个开发窗口期打开 Go 代码树的时候再进行评审。如果你知道那不是这个代码变更应该被评审的正确的时间范围,你可以自己加上这样的评论:R=go1.XX 来更正。

Trybots

在第一次审查完你的代码变更之后,维护者会启动一些 trybots,这是一个会在不同 CPU 架构的机器上运行完整测试套件的服务器集群。大部分 trybots 会在几分钟内执行完成,之后会有一个可以查看具体结果的链接出现在 Gerrit 变更页面上。

如果 trybot 最后执行失败了,点击链接然后查看完整的日志,看看是在哪个平台上测试失败了。尽量尝试去弄明白失败的原因,然后更新你的代码去修复它,最后重新上传你的新代码。维护者会重新启动一个新的 trybot 再跑一遍,看看问题是不是已经解决了。

有时候,Go 代码树会在某些平台上有长达数小时的执行失败;如果 trybot 上报的失败的问题看起来和你的这次代码变更无关的话,到构建面板上去查看近期内的其他 commits 在相同的平台上是不是有出现过这种一样的失败。如果有的话,你就在 Gerrit 变更页面的评论区里说明一下这个失败和你的代码变更无关,以此让维护者知悉这种情况。

评审

Go 语言社区非常重视全面的评审。你要把每一条评审的评论的当成一张罚单:你必须通过某种方式把它"关掉",或者是你把评论里评审人建议/要求的改动实现一下,或者是你说服维护者那部分不需要修改。

在你更新了你的代码之后,过一遍评审页面的所有评论,确保你已经全部回复了。你可以点击 "Done" 按钮回复,这表示你已经实现了评审人建议的修改,否则的话,点击 "Reply" 按钮然后解释一下你为什么还没修改、或者是你已经做了其他地方的修改并覆盖了这一部分。

一般来说,代码评审里会经历多轮的评审,期间会有一个或者多个评审人不断地发表新的代码审查评论然后等待提交者修改更新代码之后继续评审,这是很正常的。甚至一些经验老到的代码贡献者也会经历这种循环,所以不要因此而被打击到。

投票规则

在评审人们差不多要得出结论之时,他们会对你的此次代码变更进行"投票"。Gerrit 的投票系统包含了一个在[-2, 2]区间的整数:

  • +2: 同意此次代码变更被合入到主分支。只有 Go 语言的维护者们才有权限投 +2 的票。
  • +1: 这个代码变更看起来没什么问题,不过要么是因为评审人还要求对代码做一些小的改动、要么是因为该评审人不是一个维护者而无法直接批准这个变更,但是该评审人支持批准这个变更。
  • -1: 这个代码变更并不是很合理但可能有机会做进一步的修改。如果你得到了一个 -1 票,那一定会有一个明确的解释告诉你为什么。
  • -2: 一个维护者否决了这个代码变更并且不同意合入主干。同样的,会有一个明确的解释来说明原因。

提交一个核准的变更

在一个代码变更被投了一个 +2 票之后,投下这票的核准人将会使用 Gerrit 的用户界面来将代码合并入主干,这个操作被称为"提交变更"。

之所以把核准和提交拆分成两步,是因为有些时候维护者们可能并不想把刚刚批准的代码变更立刻合入主干,比如,彼时可能正处于 Go 代码树的暂时冻结期。

提交一个变更将会把代码合入主仓库,代码变更的描述信息里会包含一个指向对应代码评审页面的链接,而具体代码评审页面处也会更新一个链接指向仓库里的此次代码变更 commit。把代码变更合入主干时使用的是 Git 的 "Cherry Pick" 命令,因此在主仓库里的关于此次代码变更的 commit 哈希 ID 会被这个提交操作更改。

如果你的变更已经被批准了好几天了,但是一直没有被提交到主仓库,你可以在 Gerrit 写个评论要求合入。

更多信息

除了这里的信息,Go 语言社区还维护了一个代码评审的 wiki 页面。随时欢迎你在学习相关的评审流程之时为这个页面贡献、补充新内容。

其他主题

这个章节收集了一些除了 issue/edit/code review/submit 流程之外的注解信息。

版权标头

Go 语言仓库里的文件不会保存一份作者列表,既是为了避免杂乱也是为了避免需要实时更新这份列表。相反的,你的名字将会出现在变更日志贡献者文件里,也可能会出现在作者文件里。这些文件是定期从 commit 日志上自动生成的。作者文件定义了哪些人是 “Go 语言作者” - 版权持有者。

如果你在提交变更的时候有新添加的文件,那么应该使用标准的版权头:

// Copyright 2020 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

(如果你此刻是在 2021 年或者往后的时间阅读这份文档,请使用你当前的年份。)仓库里的文件版权生效于被添加进去的当年,不要在你变更的文件里更改版权信息里的年份。

mail 命令错误大全和故障排除

git codereview mail 命令失败的最常见原因是因为你的邮件地址和你在注册流程中使用的邮件地址不匹配。

如果你看到这样的输出信息:

remote: Processing changes: refs: 1, done
remote:
remote: ERROR:  In commit ab13517fa29487dcf8b0d48916c51639426c5ee9
remote: ERROR:  author email address XXXXXXXXXXXXXXXXXXX
remote: ERROR:  does not match your user account.

你需要在这个仓库下把 Git 用户邮箱配置为你一开始注册好的那个邮箱。更正邮箱地址以确保不会再发生这个错误:

$ git config user.email email@address.com

然后通过以下命令修改你的 commit 信息,更正里面的用户名和邮箱:

$ git commit --amend --author="Author Name <email@address.com>"

最后运行一下的命令再重试一次:

$ git codereview mail

快速测试你的代码变更

如果每一次单独的代码变更都对整个代码树运行 all.bash 脚本的话太费劲了,尽管我们极力建议你在发送代码变更之前跑一下这个脚本,然而在开发的期间你可能只想要编译和测试那些你涉及到的包。

  • 通常来说,你可以运行 make.bash 而不是 all.bash 来只构建 Go 工具链,而不需要运行整个测试套件。或者你可以运行 run.bash 来运行整个测试套件而不构建 Go 工具链。你可以把 all.bash 看成是依次执行 make.bashrun.bash
  • 在这个章节,我们会把你存放 Go 语言仓库的目录称为 $GODIRmake.bash 脚本构建的 go 工具会被安装到 $GODIR/bin/go 然后你就可以调用它来测试你的代码了。例如,如果你修改了编译器而且你想要测试看看会对你自己项目里的测试套件造成怎样的影响,直接用它运行 go test

    $ cd <MYPROJECTDIR>
    $ $GODIR/bin/go test
  • 如果你正在修改标准库,你可能不需要重新构建编译器:你可以直接在你正在修改的包里跑一下测试代码就可以了。你可以使用平时用的 Go 版本或者从克隆下来的源码构建而成的编译器(有时候这个是必须的因为你正在修改的标准库代码可能会需要一个比你已经安装的稳定版更新版本的编译器)来做这件事。

    $ cd $GODIR/src/hash/sha1
    $ [make changes...]
    $ $GODIR/bin/go test .
  • 如果你正在修改编译器本身,你可以直接重新编译 编译 工具(这是一个使用 go build 命令编译每一个单独的包之时会调用到的一个内部的二进制文件)。完成之后,你会想要编译或者运行一些代码来测试一下:

    $ cd $GODIR/src
    $ [make changes...]
    $ $GODIR/bin/go install cmd/compile
    $ $GODIR/bin/go build [something...]   # test the new compiler
    $ $GODIR/bin/go run [something...]     # test the new compiler
    $ $GODIR/bin/go test [something...]    # test the new compiler

    同样的操作可以应用到 Go 工具链里的其他内部工具,像是 asmcoverlink 等等。直接重新编译然后使用 go install cmd/<TOOL> 命令安装,最后使用构建出来的 Go 二进制文件测试一下。

  • 除了标准的逐包测试,在 $GODIR/test 目录下有一个顶级的测试套件,里面包含了多种黑盒和回归测试。这个测试套件是包含在 all.bash 脚本里运行的,不过你也可以手动运行它:

    $ cd $GODIR/test
    $ $GODIR/bin/go run run.go

向子仓库提交贡献 (golang.org/x/...)

如果你正在向一个子仓库提交贡献,你需要使用 go get 来获取对应的 Go 包。例如,如果要向 golang.org/x/oauth2 包贡献代码,你可以通过运行以下的命令来获取代码:

$ go get -d golang.org/x/oauth2/...

紧接着,进入到包的源目录($GOPATH/src/golang.org/x/oauth2),然后按照正常的代码贡献流程走就行了。

指定一个评审人/抄送其他人

除非有明确的说明,比如在你提交代码变更之前的讨论中,否则的话最好不要自己指定评审人。所有的代码变更都会自动抄送给 golang-codereviews@googlegroups.com 邮件组。如果这是你的第一次提交代码变更,在它出现在邮件列表之前可能会有一个审核延迟,主要是为了过滤垃圾邮件。

你可以指定一个评审人或者使用 -r/-cc 选项抄送有关各方。这两种方式都接受逗号分隔的邮件地址列表:

$ git codereview mail -r joe@golang.org -cc mabel@example.com,math-nuts@swtch.com

同步你的客户端

在你做代码变更期间,可能有其他人的变更已经先你一步被提交到主仓库里,那么为了保持你的本地分支更新,运行:

git codereview sync

(这个命令背后运行的是 git pull -r .)

其他人评审代码

评审人作为评审流程的一部分可以直接提交代码到你的变更里(就像是在 GitHub 工作流里有其他人把 commits 依附到你的 PR 上了)。你可以导入这些他人提交的变更到你的本地 Git 分支上。在 Gerrit 的评审页面,点击右上角的 "Download ▼" 链接,复制 "Checkout" 命令然后在你的本地 Git 仓库下运行它。这个命令类似如下的格式:

$ git fetch https://go.googlesource.com/review refs/changes/21/13245/1 && git checkout FETCH_HEAD

如果要撤销,切换回你之前在开发的那个分支即可。

设置 Git 别名

git codereview 相关的命令可以直接在终端键入对应的选项运行,例如:

$ git codereview sync

不过给 git codereview 子命令命令设置别名会更方便使用,上面的命令可以替换成:

$ git sync

git codereview 的子命令的名字是排除了 Git 本身的命令关键字而挑选出来的,所以不用担心设置了这些别名会和 Git 本身的命令冲突。要设置这些别名,复制下面的文本到你的 Git 配置文件里(通常是在 home 路径下的 .gitconfig 文件):

[alias]
    change = codereview change
    gofmt = codereview gofmt
    mail = codereview mail
    pending = codereview pending
    submit = codereview submit
    sync = codereview sync

发送多个依赖的变更

老司机用户可能会想要把相关的 commits 叠加到一个单独的分支上。Gerrit 允许多个代码变更之间相互依赖,形成这样的依赖链。每一个变更需要被单独地核准和提交,但是依赖对于评审人来说是可见的。

要发送一组依赖的代码更改,请将每个变更作为不同的 commit 保存在同一分支下,然后运行:

$ git codereview mail HEAD

要确保显示地指定 HEAD ,不过这在单个变更的场景里通常是不需要指定的。

英文原文地址

https://golang.org/doc/contribute.html

查看原文

赞 1 收藏 1 评论 0

panjf2000 发布了文章 · 2020-04-07

最快的 Go 网络框架 gnet 来啦!

gnet 是什么?

gnet 是一个基于事件驱动的高性能且轻量级的网络框架。它直接使用 epollkqueue 系统调用而非标准 Golang 网络包:net 来构建网络应用,它的工作原理类似两个开源的网络库:nettylibuv

gnet 设计开发的初衷不是为了取代 Go 的标准网络库:net,而是为了创造出一个类似于 RedisHaproxy 能高效处理网络包的 Go 语言网络服务器框架。

gnet 的亮点在于它是一个高性能、轻量级、非阻塞的纯 Go 实现的传输层(TCP/UDP/Unix Domain Socket)网络框架,开发者可以使用 gnet 来实现自己的应用层网络协议(HTTP、RPC、Redis、WebSocket 等等),从而构建出自己的应用层网络应用:比如在 gnet 上实现 HTTP 协议就可以创建出一个 HTTP 服务器 或者 Web 开发框架,实现 Redis 协议就可以创建出自己的 Redis 服务器等等。

开源地址:https://github.com/panjf2000/gnet

v1.0.0 正式版本

从 2019 年 9 月份开放源码到 GitHub,经过半年多的新功能开发、bug 修复、架构设计重构以及性能优化,Go 语言网络框架 gnet 现在终于发布了第一个正式的 v1 稳定版本!具体的 release 列表可以到 https://github.com/panjf2000/gnet/releases 查看。往后还会持续不断地进行开发、修复、优化甚至重构,如果 gnet 的用户在使用的过程中发现 bug,随时到 gnetGithub Issue 页 给我提 issue。

目前,gnet 具备了如下的功能特性:

  • [X] 高性能 的基于多线程/Go 程网络模型的 event-loop 事件驱动
  • [X] 内置 goroutine 池,由开源库 ants 提供支持
  • [X] 内置 bytes 内存池,由开源库 bytebufferpool 提供支持
  • [X] 简洁的 APIs
  • [X] 基于 Ring-Buffer 的高效内存利用
  • [X] 支持多种网络协议/IPC 机制:TCP、UDP 和 Unix Domain Socket
  • [X] 支持多种负载均衡算法:Round-Robin(轮询)、Source Addr Hash(源地址哈希)和 Least-Connections(最少连接数)
  • [X] 支持两种事件驱动机制:Linux 里的 epoll 以及 FreeBSD 里的 kqueue
  • [X] 支持异步写操作
  • [X] 灵活的事件定时器
  • [X] SO_REUSEPORT 端口重用
  • [X] 内置多种编解码器,支持对 TCP 数据流分包:LineBasedFrameCodec, DelimiterBasedFrameCodec, FixedLengthFrameCodec 和 LengthFieldBasedFrameCodec,参考自 netty codec,而且支持自定制编解码器
  • [X] 支持 Windows 平台,基于 IOCP 事件驱动机制 Go 标准网络库
  • [ ] 实现 gnet 客户端

上面列表中除了一些最基本的功能特性,后来的新功能都是由 gnet 的用户提出、我开发实现的,在此感谢这些同学的贡献!列表中还有几个计划中的新功能特性在考察和开发阶段,我会对用户提出的新功能需求进行合理性和必要性的评估,然后进行适当的取舍,因此计划中的功能特性列表可能会随时发生变化。另外,也欢迎对 gnet 源码感兴趣且想为 gnet 增添新功能或者修复 bug 的同学给我提 PR 贡献代码,谢谢!

gnet 的自我定位是高性能且轻量级的 Go 语言网络框架,暴露极简的接口的同时又能提供丰富的功能,性能远超 Go 语言原生网络库,如果你的追求极致的性能,那 gnet 绝对是你的绝佳选择。

性能测试

上面提到 gnet 作为一个 Go 语言网络框架主打的是高性能,当然,不能只凭我一张嘴说说就证明了 gnet 的高性能,毕竟空口无凭嘛!所以,在这里让我引用改编一下程序员撕逼界著名的一句话:Talk is cheap, show me your benchmark!

提到框架性能测试,熟悉这方面的同学不会没听过 TechEmpower,这是全球 Web 框架权威性能测试:

This is a performance comparison of many Web application frameworks executing fundamental tasks such as JSON serialization, database access, and server-side template composition. Each framework is operating in a realistic production configuration. Results are captured on cloud instances and on physical hardware. The test implementations are largely community-contributed and all source is available at the GitHub repository.

TechEmpower 测试有源代码,硬件配置全部公开,而且很多框架是作者自己或资深爱好者提交的,他们各自肯定知道该如何极致地优化基于这些框架的 Server,而且这些结果都是可重现的,谁觉得不服可以自己跑跑看,源代码和需求页面提供了每种测试的执行细节以及其它相关信息,各种 Web 框架性能对比页面提供了更多有关如何进行测试的细节与测试基准的概况。TechEmpower 测试的主要目的是将目前流行的 Web 开发框架从多个维度来进行测试,这些测试的场景主要是针对这些 Web 框架执行的基本任务,比如数据库访问、JSON 序列化和服务端模板的组合等等场景,整体得分非常具有借鉴价值。

目前已提交 TechEmpower 测试的框架有将近 700 个,其中包括 Netty、Vert.x、Spring、Actix、FastHTTP、Swoole、Nginx 等业界知名的框架/平台,囊括了 C/C++、Java、C#、Rust、Go、PHP、Ruby、Python 等一众主流编程语言,是目前业界最权威的 Web 框架性能测试。

目前,TechEmpower 提供了 2 种硬件环境:云主机 Microsoft Azure D3v2 instances; switched gigabit Ethernet 和物理机 Dell R440 servers each equipped with an Intel Xeon Gold 5120 CPU, 32 GB of memory, and an enterprise SSD. Dedicated Cisco 10-gigabit Ethernet switch。

测试内容包括 Plaintext、Single Database Query、Multiple Database Queries、Fortunes、JSON Serialization 等等(全部的测试 cases 可以查看 Project Information Framework Tests Overview),得出了一系列的 Web 框架的性能基准,对于程序员来说,这是一份极具参考价值的 Web 框架评估选型的 benchmark 数据。

gnet 的性能数据将借助于 TechEmpower 展现,由于 TechEmpower 的测试是基于 HTTP 协议的,因此需要基于 gnet 实现一个简单的 HTTP Server 并提交 TechEmpower 测试,目前 gnet 参与测试的只有 Plaintext 这一项,这也是最能直接体现出框架网络处理性能的一项测试。

下面是最新一轮的 TechEmpower Benchmark 性能测试结果:

# Hardware
CPU: 28 HT Cores Intel(R) Xeon(R) Gold 5120 CPU @ 2.20GHz
Mem: 32GB RAM
OS : Ubuntu 18.04.3 4.15.0-88-generic #88-Ubuntu
Net: Switched 10-gigabit ethernet
Go : go1.14.x linux/amd64

所有语言框架

这是包含全部编程语言框架的性能排名 top 50 的结果,总榜单包含了全世界共计 382 个框架(Plaintext 测试), gnet 位列第 5, gnet 也是唯一进入前十的 Go 语言框架。其中,一些业界比较知名的框架/平台的排名:Netty 排名 36、Nginx 排名 66、Vert.x 排名 40、Spring 排名 238,等等。

Go 语言框架

这是 Go 语言分类下的性能排名, gnet 位列第 1。

完整的排行可以通过 view all benchmark results 查看。

基于上面的 TechEmpower 性能测试结果, gnet 在全世界的框架/平台的竞争中名列第 5,中二点说法就是天下第五,它的高性能定位应该可以说是毋庸置疑了。

P.S. 需要说明的是,因为 gnet 并不是一个 Web/HTTP 框架而是一个更加底层的网络框架,所以我给 gnet 裸写了一个简单的 HTTP Parser,其对于 HTTP 协议的解析是不完备的,跳过了一些(对于这个测试不需要的)繁杂解析步骤,可以说是针对性的优化。相较于其他真正的 Web/HTTP 框架, gnet 在这方面占了一点便宜,不过,Plaintext Benchmarks 主要测试的是框架最基础/核心request-routing 性能,所以最终的测试结果对于评估一个框架的网络处理性能还是极具参考价值和现实意义的。

总结

gnet 作为一个高性能且轻量级的网络框架,适用于追求极致性能的网络场景,性能表现远超 Go 语言原生网络库,就算是在全球权威性能测试 TechEmpower 排行榜上的表现也很耀眼:全部编程语言总排行第 5,Go 语言分类排行第 1。如果你正在用 Go 开发网络应用且追求极致的性能, gnet 将会是你的绝佳选择,欢迎试用!

开源地址:https://github.com/panjf2000/gnet

查看原文

赞 3 收藏 1 评论 0

panjf2000 发布了文章 · 2019-10-15

【发布】高性能 Go 网络库 gnet 发布 v1 版

Github 主页

https://github.com/panjf2000/...

欢迎大家围观~~,目前还在持续更新,感兴趣的话可以 star 一下暗中观察哦。

简介

gnet 是一个基于事件驱动的高性能和轻量级网络框架。它直接使用 epollkqueue 系统调用而非标准 Golang 网络包:net 来构建网络应用,它的工作原理类似两个开源的网络库:nettylibuv

这个项目存在的价值是提供一个在网络包处理方面能和 RedisHaproxy 这两个项目具有相近性能的 Go 语言网络服务器框架。

gnet 的亮点在于它是一个高性能、轻量级、非阻塞的纯 Go 实现的传输层(TCP/UDP/Unix-Socket)网络框架,开发者可以使用 gnet 来实现自己的应用层网络协议,从而构建出自己的应用层网络应用:比如在 gnet 上实现 HTTP 协议就可以创建出一个 HTTP 服务器 或者 Web 开发框架,实现 Redis 协议就可以创建出自己的 Redis 服务器等等。

gnet 衍生自另一个项目:evio,但性能远胜之。

功能

  • 高性能 的基于多线程/Go程模型的 event-loop 事件驱动
  • 内置 Round-Robin 轮询负载均衡算法
  • 内置 goroutine 池,由开源库 ants 提供支持
  • 内置 bytes 内存池,由开源库 pool 提供支持
  • 简洁的 APIs
  • 基于 Ring-Buffer 的高效内存利用
  • 支持多种网络协议:TCP、UDP、Unix Sockets
  • 支持两种事件驱动机制:Linux 里的 epoll 以及 FreeBSD 里的 kqueue
  • 支持异步写操作
  • 灵活的事件定时器
  • SO_REUSEPORT 端口重用

核心设计

多线程/Go程模型

主从多 Reactors 模型

gnet 重新设计开发了一个新内置的多线程/Go程模型:『主从多 Reactors』,这也是 netty 默认的线程模型,下面是这个模型的原理图:

它的运行流程如下面的时序图:

主从多 Reactors + 线程/Go程池

你可能会问一个问题:如果我的业务逻辑是阻塞的,那么在 EventHandler.React 注册方法里的逻辑也会阻塞,从而导致阻塞 event-loop 线程,这时候怎么办?

正如你所知,基于 gnet 编写你的网络服务器有一条最重要的原则:永远不能让你业务逻辑(一般写在 EventHandler.React 里)阻塞 event-loop 线程,否则的话将会极大地降低服务器的吞吐量,这也是 netty 的一条最重要的原则。

我的回答是,基于gnet 的另一种多线程/Go程模型:『带线程/Go程池的主从多 Reactors』可以解决阻塞问题,这个新网络模型通过引入一个 worker pool 来解决业务逻辑阻塞的问题:它会在启动的时候初始化一个 worker pool,然后在把 EventHandler.React里面的阻塞代码放到 worker pool 里执行,从而避免阻塞 event-loop 线程,

模型的架构图如下所示:

它的运行流程如下面的时序图:

gnet 通过利用 ants goroutine 池(一个基于 Go 开发的高性能的 goroutine 池 ,实现了对大规模 goroutines 的调度管理、goroutines 复用)来实现『主从多 Reactors + 线程/Go程池』网络模型。关于 ants 的全部功能和使用,可以在 ants 文档 里找到。

gnet 内部集成了 ants 以及提供了 pool.NewWorkerPool 方法来初始化一个 ants goroutine 池,然后你可以把 EventHandler.React 中阻塞的业务逻辑提交到 goroutine 池里执行,最后在 goroutine 池里的代码调用 gnet.Conn.AsyncWrite 方法把处理完阻塞逻辑之后得到的输出数据异步写回客户端,这样就可以避免阻塞 event-loop 线程。

有关在 gnet 里使用 ants goroutine 池的细节可以到这里进一步了解。

自动扩容的 Ring-Buffer

gnet 利用 Ring-Buffer 来缓冲网络数据以及管理内存。

开始使用

前提

gnet 需要 Go 版本 >= 1.9。

安装

go get -u github.com/panjf2000/gnet

gnet 支持作为一个 Go module 被导入,基于 Go 1.11 Modules (Go 1.11+),只需要在你的项目里直接 import "github.com/panjf2000/gnet",然后运行 go [build|run|test] 自动下载和构建需要的依赖包。

使用示例

详细的文档在这里: gnet 接口文档,不过下面我们先来了解下使用 gnet 的简略方法。

gnet 来构建网络服务器是非常简单的,只需要实现 gnet.EventHandler接口然后把你关心的事件函数注册到里面,最后把它连同监听地址一起传递给 gnet.Serve 函数就完成了。在服务器开始工作之后,每一条到来的网络连接会在各个事件之间传递,如果你想在某个事件中关闭某条连接或者关掉整个服务器的话,直接把 gnet.Action 设置成 Cosed 或者 Shutdown就行了。

Echo 服务器是一种最简单网络服务器,把它作为 gnet 的入门例子在再合适不过了,下面是一个最简单的 echo server,它监听了 9000 端口:

不带阻塞逻辑的 echo 服务器

package main

import (
    "log"

    "github.com/panjf2000/gnet"
)

type echoServer struct {
    *gnet.EventServer
}

func (es *echoServer) React(c gnet.Conn) (out []byte, action gnet.Action) {
    out = c.Read()
    c.ResetBuffer()
    return
}

func main() {
    echo := new(echoServer)
    log.Fatal(gnet.Serve(echo, "tcp://:9000", gnet.WithMulticore(true)))
}

正如你所见,上面的例子里 gnet 实例只注册了一个 React 事件。一般来说,主要的业务逻辑代码会写在这个事件方法里,这个方法会在服务器接收到客户端写过来的数据之时被调用,然后处理输入数据(这里只是把数据 echo 回去)并且在处理完之后把需要输出的数据赋值给 out 变量然后返回,之后你就不用管了,gnet 会帮你把数据写回客户端的。

带阻塞逻辑的 echo 服务器

package main

import (
    "log"
    "time"

    "github.com/panjf2000/gnet"
    "github.com/panjf2000/gnet/pool"
)

type echoServer struct {
    *gnet.EventServer
    pool *pool.WorkerPool
}

func (es *echoServer) React(c gnet.Conn) (out []byte, action gnet.Action) {
    data := append([]byte{}, c.Read()...)
    c.ResetBuffer()

    // Use ants pool to unblock the event-loop.
    _ = es.pool.Submit(func() {
        time.Sleep(1 * time.Second)
        c.AsyncWrite(data)
    })

    return
}

func main() {
    p := pool.NewWorkerPool()
    defer p.Release()
    
    echo := &echoServer{pool: p}
    log.Fatal(gnet.Serve(echo, "tcp://:9000", gnet.WithMulticore(true)))
}

正如我在『主从多 Reactors + 线程/Go程池』那一节所说的那样,如果你的业务逻辑里包含阻塞代码,那么你应该把这些阻塞代码变成非阻塞的,比如通过把这部分代码通过 goroutine 去运行,但是要注意一点,如果你的服务器处理的流量足够的大,那么这种做法将会导致创建大量的 goroutines 极大地消耗系统资源,所以我一般建议你用 goroutine pool 来做 goroutines 的复用和管理,以及节省系统资源。

I/O 事件

gnet 目前支持的 I/O 事件如下:

  • EventHandler.OnInitComplete 当 server 初始化完成之后调用。
  • EventHandler.OnOpened 当连接被打开的时候调用。
  • EventHandler.OnClosed 当连接被关闭的时候调用。
  • EventHandler.React 当 server 端接收到从 client 端发送来的数据的时候调用。(你的核心业务代码一般是写在这个方法里)
  • EventHandler.Tick 服务器启动的时候会调用一次,之后就以给定的时间间隔定时调用一次,是一个定时器方法。
  • EventHandler.PreWrite 预先写数据方法,在 server 端写数据回 client 端之前调用。

定时器

Tick 会每隔一段时间触发一次,间隔时间你可以自己控制,设定返回的 delay 变量就行。

定时器的第一次触发是在 gnet.Serving 事件之后。

events.Tick = func() (delay time.Duration, action Action){
    log.Printf("tick")
    delay = time.Second
    return
}

UDP 支持

gnet 支持 UDP 协议,在 gnet.Serve 里绑定 UDP 地址即可,gnet 的 UDP 支持有如下的特性:

  • 数据进入服务器之后立刻写回客户端,不做缓存。
  • OnOpenedOnClosed 这两个事件在 UDP 下不可用,唯一可用的事件是 React

使用多核

gnet.WithMulticore(true) 参数指定了 gnet 是否会使用多核来进行服务,如果是 true 的话就会使用多核,否则就是单核运行,利用的核心数一般是机器的 CPU 数量。

负载均衡

gnet 目前内置的负载均衡算法是轮询调度 Round-Robin,暂时不支持自定制。

SO_REUSEPORT 端口复用

服务器支持 SO_REUSEPORT 端口复用特性,允许多个 sockets 监听同一个端口,然后内核会帮你做好负载均衡,每次只唤醒一个 socket 来处理 accept 请求,避免惊群效应。

开启这个功能也很简单,使用 functional options 设置一下即可:

gnet.Serve(events, "tcp://:9000", gnet.WithMulticore(true), gnet.WithReusePort(true)))

性能测试

Linux (epoll)

系统参数

# Machine information
        OS : Ubuntu 18.04/x86_64
       CPU : 8 Virtual CPUs
    Memory : 16.0 GiB

# Go version and configurations
Go Version : go1.12.9 linux/amd64
GOMAXPROCS=8

同类型的网络库性能对比:

Echo Server

echolinux.png

HTTP Server

httplinux.png

FreeBSD (kqueue)

系统参数

# Machine information
        OS : macOS Mojave 10.14.6/x86_64
       CPU : 4 CPUs
    Memory : 8.0 GiB

# Go version and configurations
Go Version : go version go1.12.9 darwin/amd64
GOMAXPROCS=4

Echo Server

echomac.png

HTTP Server

httpmac.png

证书

gnet 的源码允许用户在遵循 MIT 开源证书 规则的前提下使用。

致谢

相关文章

查看原文

赞 0 收藏 0 评论 1

panjf2000 发布了文章 · 2019-09-16

gnet: 轻量级且高性能的 Golang 网络库

博客

https://taohuawu.club/go-even...

项目主页

https://github.com/panjf2000/...

欢迎大家围观~~,目前还在持续更新,感兴趣的话可以 star 一下暗中观察哦。

简介

gnet 是一个基于 Event-Loop 事件驱动的高性能和轻量级网络库。这个库直接使用 epollkqueue 系统调用而非标准 Golang 网络包:net 来构建网络应用,它的工作原理类似两个开源的网络库:libuvlibevent

这个项目存在的价值是提供一个在网络包处理方面能和 RedisHaproxy 这两个项目具有相近性能的Go 语言网络服务器框架。

gnet 的亮点在于它是一个高性能、轻量级、非阻塞的纯 Go 实现的传输层(TCP/UDP/Unix-Socket)网络库,开发者可以使用 gnet 来实现自己的应用层网络协议,从而构建出自己的应用层网络应用:比如在 gnet 上实现 HTTP 协议就可以创建出一个 HTTP 服务器 或者 Web 开发框架,实现 Redis 协议就可以创建出自己的 Redis 服务器等等。

gnet 衍生自另一个项目:evio,但是性能更好。

功能

  • 高性能 的基于多线程模型的 Event-Loop 事件驱动
  • 内置 Round-Robin 轮询负载均衡算法
  • 简洁的 APIs
  • 基于 Ring-Buffer 的高效内存利用
  • 支持多种网络协议:TCP、UDP、Unix Sockets
  • 支持两种事件驱动机制:Linux 里的 epoll 以及 FreeBSD 里的 kqueue
  • 支持异步写操作
  • 允许多个网络监听地址绑定在一个 Event-Loop 上
  • 灵活的事件定时器
  • SO_REUSEPORT 端口重用

核心设计

多线程模型

gnet 重新设计开发了一个新内置的多线程模型:『主从 Reactor 多线程』,这也是 netty 默认的线程模型,下面是这个模型的原理图:

它的运行流程如下面的时序图:

现在我正在 gnet 里开发一个新的多线程模型:『带线程/go程池的主从 Reactors 多线程』,并且很快就能完成,这个模型的架构图如下所示:

它的运行流程如下面的时序图:

通信机制

gnet 的『主从 Reactors 多线程』模型是基于 Golang 里的 Goroutines的,一个 Reactor 挂载在一个 Goroutine 上,所以在 gnet 的这个网络模型里主 Reactor/Goroutine 与从 Reactors/Goroutines 有海量通信的需求,因此 gnet 里必须要有一个能在 Goroutines 之间进行高效率的通信的机制,我没有选择 Golang 里的主流方案:基于 Channel 的 CSP 模型,而是选择了性能更好、基于 Ring-Buffer 的 Disruptor 方案。

所以我最终选择了 go-disruptor:高性能消息分发队列 LMAX Disruptor 的 Golang 实现。

自动扩容的 Ring-Buffer

gnet 利用 Ring-Buffer 来缓存 TCP 流数据以及管理内存使用。

开始使用

安装

$ go get -u github.com/panjf2000/gnet

使用示例

// ======================== Echo Server implemented with gnet ===========================

package main

import (
    "flag"
    "fmt"
    "log"
    "strings"

    "github.com/panjf2000/gnet"
    "github.com/panjf2000/gnet/ringbuffer"
)

func main() {
    var port int
    var loops int
    var udp bool
    var trace bool
    var reuseport bool

    flag.IntVar(&port, "port", 5000, "server port")
    flag.BoolVar(&udp, "udp", false, "listen on udp")
    flag.BoolVar(&reuseport, "reuseport", false, "reuseport (SO_REUSEPORT)")
    flag.BoolVar(&trace, "trace", false, "print packets to console")
    flag.IntVar(&loops, "loops", 0, "num loops")
    flag.Parse()

    var events gnet.Events
    events.NumLoops = loops
    events.OnInitComplete = func(srv gnet.Server) (action gnet.Action) {
        log.Printf("echo server started on port %d (loops: %d)", port, srv.NumLoops)
        if reuseport {
            log.Printf("reuseport")
        }
        return
    }
    events.React = func(c gnet.Conn, inBuf *ringbuffer.RingBuffer) (out []byte, action gnet.Action) {
        top, tail := inBuf.PreReadAll()
        out = append(top, tail...)
        inBuf.Reset()

        if trace {
            log.Printf("%s", strings.TrimSpace(string(top)+string(tail)))
        }
        return
    }
    scheme := "tcp"
    if udp {
        scheme = "udp"
    }
    log.Fatal(gnet.Serve(events, fmt.Sprintf("%s://:%d", scheme, port)))
}

I/O 事件

gnet 目前支持的 I/O 事件如下:

  • OnInitComplete 当 server 初始化完成之后调用。
  • OnOpened 当连接被打开的时候调用。
  • OnClosed 当连接被关闭的时候调用。
  • OnDetached 当主动摘除连接的时候的调用。
  • React 当 server 端接收到从 client 端发送来的数据的时候调用。(你的核心业务代码一般是写在这个方法里)
  • Tick 服务器启动的时候会调用一次,之后就以给定的时间间隔定时调用一次,是一个定时器方法。
  • PreWrite 预先写数据方法,在 server 端写数据回 client 端之前调用。

性能测试

Linux (epoll)

系统参数

Go Version: go1.12.9 linux/amd64
OS:         Ubuntu 18.04
CPU:        8 Virtual CPUs
Memory:     16.0 GiB

Echo Server

echolinux.png

HTTP Server

httplinux.png

FreeBSD (kqueue)

系统参数

Go Version: go version go1.12.9 darwin/amd64
OS:         macOS Mojave 10.14.6
CPU:        4 CPUs
Memory:     8.0 GiB

Echo Server

echomac.png

HTTP Server

httpmac.png

证书

gnet 的源码允许用户在遵循 MIT 开源证书 规则的前提下使用。

待做事项

gnet 还在持续开发的过程中,所以这个仓库的代码和文档会一直持续更新,如果你对 gnet 感兴趣的话,欢迎给这个开源库贡献你的代码~~
查看原文

赞 7 收藏 4 评论 0

认证与成就

  • 获得 127 次点赞
  • 获得 0 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 0 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

  • gnet

    一个轻量级且高性能的网络库,基于 event-loop 和 multi-reactors 网络模型。

  • ants

    ants是一个高性能的协程池,实现了对大规模goroutine的调度管理、goroutine复用,允许使用者在开发并发程序的时候限制协程数量,复用资源,达到更高效执行任务的效果。

  • goproxy

    goproxy是使用golang实现的一个基本的负载均衡服务器,支持缓存(使用redis);反向代理,目前支持随机分发和IP HASH两种模式,另外,对转发的请求有较大的控制度,可以控制代理特定的请求,屏蔽特定的请求,甚至可以重写特定的请求。 另外,有时候项目需要用到第三方的服务并对返回的数据进行自定义修改,调用第三方的API,利用proxy server可以很容易的控制第三方API返回的数据并进行自定义修改。

注册于 2015-11-09
个人主页被 1.9k 人浏览