阅读视图

发现新文章,点击刷新页面。
🔲 ☆

GPU 计算的起源

本文永久链接 – https://tonybai.com/2026/04/17/the-origins-of-gpu-computing

大家好,我是Tony Bai。

在今天的人工智能时代,GPU 已成为数据中心的核心算力引擎,但它的崛起并非一夜之间的奇迹。ACM通讯文章《The Origins of GPU Computing》回溯了 GPU 计算的三十年发展史,揭示了从并行计算、图形系统到流处理等关键技术如何在政府资助的学术研究中逐步成熟,并最终汇聚成推动深度学习革命的基础设施。文章不仅梳理了技术脉络,也展示了学界与产业之间如何通过人才与思想的流动,共同塑造了现代 GPU 计算的格局。

本文是这篇文章的译文,供大家学习参考(格式有调整,更适合公众号阅读)。


政府资助的并行计算、流处理、实时着色语言和可编程图形处理单元(GPU)的学术研究直接推动了 GPU 计算的发展。GPU 被广泛应用于现代数据中心,并促成了当前的人工智能(AI)革命。生产 GPU 的英伟达(Nvidia)现已成为世界上最有价值的公司。这种计算变革及其产生的经济价值,得益于超过 30 年的政府资助研究。政府资助不仅有助于发展许多关键的技术创新,还培养了大量将这些技术带入行业的学生。

本文追溯了 GPU 计算的起源。我们首先描述了 GPU 计算所构建的技术(并行计算、并行图形系统、可编程着色器(shaders)和流处理)的发展,然后详细介绍了这些技术是如何转移到英伟达和其他公司,并最终应用于现代机器学习的。

赋能技术

GPU 计算建立在并行计算、并行图形系统和流处理的早期工作基础上。这些技术是通过超过 30 年的政府资助学术研究发展而来的。

并行计算

当你学习计算时,你了解到的是中央处理器(CPU)按顺序执行一系列指令。

实际上,芯片包含数十亿个并行切换并由导线连接的晶体管。开关和导线是物理计算机的基本构建块,它们同时运行。

此外,晶体管切换消耗的能量很少,而沿导线的通信消耗的能量要多得多。

通信需要功率来将信号从一点发送到另一点;功率随着距离的增加而增加,如果是在芯片之间进行信号传输,功率消耗将非常巨大。

虽然顺序计算机可能比并行计算机更容易理解,但顺序计算机必须通过同时切换的晶体管和同时传输信息的导线来实现。顺序计算机使用许多晶体管并行计算结果,然后仔细地以与顺序执行一致的方式组装这些结果。

创建这种执行是顺序的“幻觉”,在功率和性能上都是低效的。随着可用晶体管数量的增加,这种低效性也随之增加。在现代半导体技术中构建计算机的自然方式是设计并行计算机。GPU 比 CPU 更高效,因为它们是大规模并行计算机。

GPU 计算建立在并行计算的早期工作之上。与所有并行计算机一样,在 GPU 上运行的并行任务或线程必须相互同步和通信。

线程需要通信来使用由另一个线程产生的数据。同步是必要的,以在数据可用时发出信号,确保消耗的是正确的值。

并行计算、同步和通信的许多基础知识是由政府资助的学术研究开发的。由加州理工学院 Chuck Seitz 领导的 DARPA 资助的“宇宙立方”(Cosmic Cube)项目发展了并行计算的许多基础知识。在该项目上开发的硬件是英特尔 iPSC、Delta 和 Paragon 机器的蓝图,以及几台早期的能源部 ASCI 机器。“Cosmic-C”编程语言引入了异步消息传递和集合通信,后来以消息传递接口(MPI)的形式成为编程大型并行机器的标准。

麻省理工学院(MIT)的 DARPA 资助的 J-Machine 和 M-Machine 项目开发了用于通信和同步的低开销机制,以及现代互连网络的许多关键方面。这些机制使得并行性可以在非常细的粒度上被利用,最少只需 10 或 20 条指令即可作为一个可调度的工作单元。J-Machine 的许多特性被 Cray T3D 和 T3E 计算机直接采用。

并行计算有着超越这一特定历史分支的丰富历史。由于篇幅有限,我们无法进行完整的综述。Culler 等人的文章提供了一个很好的回顾。

GPU 计算与所有高性能计算一样,深受这一遗产的影响。它使用 MPI 进行节点间的通信,使用互连网络连接这些节点,并且在此研究过程中开发的许多通信和同步机制被用于协调并行计算。

并行图形系统

虽然不如传统的并行计算和超级计算机广为人知,但并行图形和成像计算机有着悠久的历史。

处理和生成图像需要巨大的计算量。例如,如果一台每秒处理一百万条指令的计算机(1MIPS)对百万像素图像的每个像素应用一次算术运算,计算机需要一秒钟来处理一张图像。

渲染电影和游戏中的 3D 虚拟世界比图像处理每像素需要的计算量大几个数量级。例如,为现代电影生成的图像每个像素需要大约十亿次浮点运算。因此,为了在实践中有用,图形和成像需要高性能的并行超级计算机。这些计算机在大规模数据集合上并行计算。

一个早期的 DARPA 资助研究项目是吉姆·克拉克(Jim Clark)在斯坦福大学领导的几何引擎(Geometry Engine)。

几何引擎促成了硅谷图形公司(Silicon Graphics)的成立,该公司率先开发了 3D 图形工作站。SGI 硬件架构和 OpenGL 软件库定义了现代 GPU 架构。

另一个值得注意的政府资助研究项目是亨利·福克斯(Henry Fuchs)及其合作者在北卡罗来纳大学领导的 Pixel Planes 系列高性能图形系统。事实上,Pixel Planes 5 是一台相当通用的单指令多数据(SIMD)计算机,它在 128 x 128 图像上运行并行计算。其他早期并行图形和图像计算机的例子包括 NASA 的大规模并行处理器(MPP)、Ikonas 图形系统和 Pixar 图像计算机。

早期 GPU 实现了类似于早期 SGI 工作站的固定功能图形流水线。当整个 OpenGL 图形流水线可以在单个芯片上实现时,英伟达引入了“GPU”一词。1999 年推出的英伟达 Geforce 256 由 1700 万个晶体管组成,是第一款商用 GPU。

在此之前,在皮克斯(Pixar)工作期间,Hanrahan 开发了 RenderMan,这是一个生成照片级逼真图像的系统。该系统彻底改变了电影行业,因为它能够生成可以与相机拍摄的实景无缝结合的图像。RenderMan 的一个关键组件是着色语言,它使用户能够扩展系统以模拟复杂的材质和光照。

虽然最初的 GPU 实现了固定功能流水线,但它们是由可编程组件构成的。不幸的是,这些处理单元因系统而异,因代而异。需要的是一种可移植的编程模型。由于 GPU 的主要应用是电脑游戏,因此将 RenderMan 着色语言适配到 GPU,以便游戏开发者可以创造新的光照和着色效果似乎是自然而然的。

在斯坦福大学的一个 DARPA 资助项目下,为当时的 GPU 设计并实现了一种实时着色语言(RTSL)。着色语言程序现在被称为着色器(shaders)。博士后学者 Bill Mark 领导了斯坦福 RTSL 的设计,后来加入了英伟达。他与另一位前斯坦福研究生 Kurt Akeley 一起增强了该技术,并创建了 Cg 着色语言。Cg 导致了微软 HLSL 和 OpenGL GLSL 的开发。

人们很快意识到,这些早期的着色语言足够灵活,可以实现科学计算中的许多算法。研究人员采用了诸如矩阵乘法、线性求解器、流体动力学求解器和分子动力学等算法在着色器上运行。这导致了 GPGPU(通用 GPU)计算运动的兴起。

流处理

DARPA 和 DOE 在斯坦福大学资助的关于 Imagine 流处理器和 Merrimac 流式超级计算机的工作发展了流处理,这是一种导致算术强度(计算与带宽之比)增加的并行计算形式。

如前所述,处理器消耗的大部分功率是在通信上。在芯片之间发送信号尤其耗电。芯片外通信也比芯片内通信慢得多。

流处理包含两个减少内存带宽需求的主要思想。

第一个是利用生产者-消费者局部性,使得一个阶段(生产者)将其结果转发给下一个阶段(消费者),而无需写入和读取内存。

第二个主要思想是将计算组织成称为内核(kernels)的函数。每个内核获取一个数据包,对该包执行函数,并输出另一个数据包。函数中的算术运算数量大于对内存的读写次数。这两种技术显著减少了内存访问次数,并提高了流处理架构的效率。

在流处理器中,计算被组织成产生和消耗数据流的内核。产生内核会将输出流写入流寄存器文件(SRF)。消费内核会从 SRF 读取输入,而数据无需写入或从内存中读取。通过适当的调度来匹配流的批处理大小与 SRF 的容量,这种组织使得应用程序能够维持非常高的算术强度(算术与内存带宽之比)。

一个设计和构建 Imagine 流处理器的 DARPA 资助项目于 1997 年在 MIT 启动,并于同年晚些时候转移到斯坦福大学。Imagine 是一台用于信号和图像处理工作负载的图形和媒体处理器。它由许多带有本地寄存器文件的并行算术单元、一个中央流寄存器文件和一个内存系统组成。内核从流寄存器文件读取流,通过本地寄存器文件传递中间结果,并将输出流写回流寄存器文件,供下一个内核读取。

Stream-C 编程语言被开发用于编程 Imagine。它扩展了 C 编程语言,增加了描述内核和流的构造。开发了众多的图形、信号处理和图像处理应用程序来调整和评估该架构。它在纹理映射光栅图形上的性能与当时的固定功能 GPU 相当。

在一次 DARPA 主要研究人员会议上,本文作者意识到这项技术可以应用于高性能计算,并构思了 Merrimac 项目。斯坦福 DOE ASCI 中心的计算机科学(CS)部分被重定向以追求这种高性能计算方法。该中心的年度报告提供了流处理发展史的详实记录。

Merrimac 架构被定义为将流处理适配到科学应用。与 Imagine 相比,主要变化是增加了科学计算所需的数据类型(如 FP64),将架构扩展到通过互连网络连接的多个节点以处理大规模问题,并增加了许多弹性特征,以支持在具有合理故障率的情况下进行大规模计算。

Stream-C 编程语言演变成了 Brook。Brook 背后的关键思想是将流编程的想法与更传统的数据并行计算合并。内核函数成为保持高算术强度的关键处理原语。

Brook 被适配以针对 2000 年代初的 GPU。这些 GPU 运行可编程顶点和片段着色器。着色器实现了内核,但指令数量有限且寄存器很少。常见的数据并行编程原语(如 map、reduce/scan、filter、gather 和 scatter)是通过在低级图形着色器之上构建虚拟数据并行计算机来实现的。这种抽象使得大量现有的并行算法可以在 GPU 上运行,并且早期着色器的局限性逐渐被消除。

早期利用内核执行高算术强度计算的一个很好的例子是稠密矩阵-矩阵乘法,它是现代神经网络算法的基础。在执行矩阵-矩阵乘法时,需要读取两个 n×n 矩阵并写入一个 n×n 矩阵。矩阵乘法需要 n³ 次乘加运算。因此,算术强度为 O(n)。这一事实众所周知,并导致了针对带有缓存的 CPU 进行矩阵乘法分块的有效方法。分块在 GPU 上运行时也非常有效。

斯坦福 ASCI 中心的数值科学家将几种科学代码移植到 Brook,以便在 Merrimac 模拟器上运行。这些代码包括计算流体动力学、磁流体动力学和 n 体模拟。n 体模拟是高效 GPU 应用的一个很好的例子。原子对之间的力由天体物理模拟中的引力定律给出,但非结合原子之间的相互作用由 Lennard-Jones 势(甚至更复杂的经验势)近似。这些函数需要许多算术运算。对于这些模拟,相邻原子存储在“邻居列表”中。分子动力学模拟立即成为 GPU 的主要应用。

GPU 和流处理器的一个关键特征是它们具有多种形式的硬件并行性。

每个 GPU 由许多核心组成。每个核心包含一个 SIMD 处理单元(通常为 32 宽)。

此外,每个核心都是多线程的。

回想一下,GPU 是为图形应用程序开发的,其性能取决于将纹理应用于三角形的效率。

纹理映射涉及计算三角形内每个像素片段的纹理坐标,然后使用这些坐标从图像中获取。这些纹理获取具有空间局部性,但时间局部性很小。空间局部性可以通过小型缓存来处理,但由于缺乏一致性,缓存无法处理时间局部性。

高效的纹理映射要求 GPU 隐藏这些纹理获取的延迟。早期 GPU 通过让片段请求纹理、挂起该片段的执行,并立即切换到处理另一个片段来实现这一点。这是多线程的简化版本,这意味着 GPU 需要有许多并行线程同时运行。任务总数是核心数乘以 SIMD 算术单元数(称为 warp)乘以线程数。Blackwell B200 GPU 拥有 384 个流多处理器(SMs)。每个 SM 有 64 个驻留 warp,每个 warp 有 32 个线程。因此,该 GPU 上有 786,432 个任务同时执行。

技术转移

流处理架构和编程系统通过人员流动从斯坦福转移到了英伟达。英伟达的一位架构师 John Nickolls 听说过流处理,并招募了 Bill Dally 在 2003 年为英伟达的 NV50 架构提供咨询。(NV50 于 2006 年作为 G80 发布)。流处理器的许多特性被合并到了该架构中。NV50 的“共享内存”发挥了 Imagine 和 Merrimac 中 SRF 的作用。

Ian Buck(Merrimac 项目的研究生和 Brook 的主要开发人员)于 2004 年加入英伟达。Ian 与 John Nickolls 合作将 Brook 演进为 CUDA。CUDA 合并了 Brook 和 Cg(一种图形着色语言)的最佳特性,并采纳了 Brook 程序员的反馈。关于该技术如何从斯坦福转移到英伟达的故事在一篇演示文稿中进行了描述。Mike Houston(该项目的另一位研究生)加入了 AMD,并直接使用 Brook 作为其 GPU 的编程语言。G80(NV50)和 CUDA 于 2006 年在超级计算大会上发布。

当 CUDA 于 2006 年发布时,很少有人了解并行编程,更不用说 GPU 流编程了。为了克服这一劳动力短缺,Wen-Mei Hwu 和 David Kirk 通过为教授讲授 CUDA 编程课程来推广 GPU 计算。参加这些课程的教师随后教授了成千上万的学生使用 CUDA 进行并行编程。从 Cosmic Cube、J-Machine 和 M-Machine 借来的并行计算技术既被应用于 GPU 内部(以协调多个 SM),也被应用于跨 GPU(构建多节点 GPU 系统以解决大型问题)。

赋能 AI

现代机器学习依赖于三个关键要素——海量数据集、具有许多层和权重的庞大模型,以及优化权重的计算能力。核心算法(深度神经网络、卷积网络、使用反向传播的训练和随机梯度下降)自 20 世纪 80 年代或更早以来就一直存在。大型标注数据集,例如 PASCAL 和 Imagenet,出现在 21 世纪初。最近的进展,例如将文本嵌入到向量空间中,使得自然语言深度学习成为可能。Transformers(“注意力就是你所需要的”)用带有历史记录的易于训练的神经网络取代了难以训练的循环神经网络。GPU 计算使得大规模数据集的网络训练在经济上变得可行。一旦展示了这种能力(Alexnet, GPT),AI 的能力就得到了迅速提升。AI 的快速采用为改进 GPU 计算系统提供了更大的动力。

英伟达的机器学习也得益于学术界与产业界的协同效应。2010 年,作者之一(Dally)与吴恩达(Andrew Ng)的一次早餐交谈促成了一个英伟达与斯坦福之间的联合项目,旨在 GPU 上构建深度神经网络。Bryan Catanzaro 领导了该项目的英伟达部分。在此项目中开发的软件成为了 CuDNN,它为英伟达 GPU 上的深度学习提供了一个现成的库——从而推动了深度学习的普及。

结论

GPU 计算背后的技术(已促成了现代机器学习)主要归功于 30 年的政府资助学术研究。

并行计算、并行图形系统和流处理的研究为 GPU 计算奠定了基础。在这些研究项目中培养的许多学生后来进入行业,转移了这些技术并利用其开发了创新产品。

从斯坦福流处理项目到 GPU 计算的转移非常直接,学术上的 Brook 语言演变为 CUDA,流处理器的功能被整合到 G80 GPU 中。

GPU 提供的高效、易于编程且性能极高的计算平台,通过计算着色器促成了当前的机器学习革命——提供了缺失的成分,以补充早已可用但一直缺乏计算能力的算法和数据。

资料链接:https://cacm.acm.org/federal-funding-of-academic-research/the-origins-of-gpu-computing/

关于作者

威廉·J·达利是美国加利福尼亚州圣克拉拉英伟达公司首席科学家兼高级副总裁,同时也是斯坦福大学电气工程与计算机科学的兼职教授。

帕特·汉拉汉是美国加利福尼亚州斯坦福大学电气工程与计算机科学的佳能荣休教授。


还在为“复制粘贴喂AI”而烦恼?我的新专栏 AI原生开发工作流实战 将带你:

  • 告别低效,重塑开发范式
  • 驾驭AI Agent(Claude Code),实现工作流自动化
  • 从“AI使用者”进化为规范驱动开发的“工作流指挥家”

扫描下方二维码,开启你的AI原生开发之旅。


你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?

  • 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
  • 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
  • 想打造生产级的Go服务,却在工程化实践中屡屡受挫?

继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!

我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。

目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

© 2026, bigwhite. 版权所有.

🔲 ☆

降低 74% 的 P99 尾延迟:揭秘 Go HTTP 客户端的“请求对冲”魔法

本文永久链接 – https://tonybai.com/2026/03/30/reduced-p99-latency-by-request-hedging-in-go

大家好,我是Tony Bai。

在微服务和分布式系统的世界里,我们常常会遇到一个令人头疼的现象:服务在大部分时间(如 P50 或 P90 指标)表现得非常丝滑,但总有那么一小撮请求(P99 甚至 P99.9 指标)慢得令人发指。

近日,在 Reddit 的 r/golang 社区中,一位开发者分享了他将 Go 服务的 P99 延迟降低了 74% 的经验。令人惊讶的是,他所使用的绝招并非升级硬件或重构业务逻辑,而是引入了一个名为 Request Hedging(请求对冲) 的策略。

面对高延迟,我们本能的反应是“重试(Retry)”。但正如这位开发者所发现的:单纯的重试不仅无助于解决长尾延迟,反而可能在系统高负载时雪上加霜。真正有效的方法是处理“落后者”,而不是“失败者”。

本文将带你重温 Google 关于分布式系统的经典论文,深入剖析 Request Hedging 的原理,并手把手教你如何仅使用 Go 标准库,为你的 HTTP 客户端插上“对冲”的翅膀。

尾延迟的诅咒:为什么重试不是万能药?

在深入 Hedging 之前,我们必须先理解什么是尾延迟(Tail Latency)

2013 年,Google 的两位大神 Jeffrey Dean 和 Luiz André Barroso 在《Communications of the ACM》上发表了一篇神级论文:《The Tail at Scale》。在这篇Paper中,他们详细阐述了在大规模分布式系统中,为什么长尾延迟是不可避免的。

哪怕你拥有世界上最优秀的工程师,底层硬件的物理特性(如 CPU 降频、网络拥塞)、操作系统的后台任务(如 IO 调度)、以及语言运行时的特性(如 Go 的 GC 停顿),都会导致某些请求的处理时间远高于平均值。

当你的服务需要并行调用多个下游服务时,这种局部的延迟波动会被急剧放大。 假设一个服务需要调用 100 个叶子节点,如果单个节点响应时间超过 1 秒的概率是 1%,那么整个请求超过 1 秒的概率将飙升至 63%!

注:节点总数 n = 100 ,已知单个节点响应时间超过 1 秒的概率 为1%。单个节点响应时间不超过 1 秒(即正常响应)的概率为1-1% = 99% = 0.99。由于 100 个请求是并行的且相互独立,整个请求“正常”的前提是所有 100 个节点都必须在 1 秒内返回。这种概率为0.99^100=0.366。这样只要这 100 个节点中有任何一个掉链子,整个请求(作为整体)的耗时就会超过 1 秒。其概率为1-0.366≈0.63=63%。


图:来自《The Tail at Scale》

这张图直观地展示了随着服务器数量(Fan-out)增加,哪怕单机变慢的概率极低,整体响应时间变慢的概率也会陡峭上升。

面对超时的请求,传统的做法是实施超时重试(Timeout & Retry)。但重试存在致命缺陷:

  1. 你必须等待超时发生。 如果超时设置为 1 秒,那么重试的请求至少要经历 1 秒的延迟,这根本无法改善 P99 延迟。
  2. 加剧雪崩。 当下游服务因为负载过高而变慢时,大量的重试请求会瞬间淹没下游,导致系统彻底崩溃。

Request Hedging:优雅地跑赢时间

为了解决长尾延迟,Google 论文中提出了一种极具工程智慧的策略:Hedged Requests(请求对冲/对冲请求)

其核心思想非常简单直白:

客户端首先向目标服务器发送一个请求。如果该请求在预期的时间(即“对冲延迟阈值”,Hedging Delay)内没有返回,客户端不会等待其超时或失败,而是立即向另一个副本(或者同一个负载均衡器后的其他实例)发送一模一样的备份请求。客户端将使用最先返回的那个成功响应,并主动取消其余的未决请求。

这种方法之所以有效,是因为导致请求变慢的因素通常是瞬时的且与特定机器相关的(如某台机器刚好在做 GC,或者刚好被一个大查询阻塞了队列)。第二个请求很大概率会被路由到一台健康的、空闲的机器上,从而快速返回。

Hedging 与 Retry 的本质区别:

  • Retry:针对的是失败(Failure)。必须等第一个请求彻底失败或超时,才发起第二个。
  • Hedging:针对的是慢(Slowness)。第一个请求还在运行(没报错),第二个请求就已经出发了。它们是并行竞争的关系。

虽然这听起来像是在浪费服务器资源,但 Google 的实践证明,如果将 Hedging Delay 设置为 P95 延迟(即 95% 的请求都能在这个时间内完成),那么只有 5% 的请求会触发对冲。这仅仅增加了 5% 的系统负载,却能将 P99 或 P99.9 的长尾延迟削减大半!

在现代微服务生态中,gRPC 已经在 Service Config 中原生支持了 Hedging 策略,但对于广泛使用的 HTTP/REST 接口,我们通常需要自己实现。

实战:构建可压测的 Hedging HTTP Client

为了验证 Hedging 的威力,我们将使用 Go 原生标准库,从零实现一个带有对冲机制的 http.RoundTripper,并构建一个完整的压测实验环境。

项目布局

首先,创建一个新的 Go 项目:

mkdir go-hedging-demo
cd go-hedging-demo
go mod init hedging-demo

我们将创建三个文件:

  • hedge.go:包含核心的 Hedging 逻辑实现。
  • server.go:一个模拟真实分布式环境、带有随机高延迟的测试服务器。
  • main.go:客户端压测入口,用于对比普通请求和 Hedging 请求的性能差异。
go-hedging-demo/
├── go.mod
├── hedge.go
├── server.go
└── main.go

核心实现:hedge.go

我们将通过实现 http.RoundTripper 接口,优雅地将对冲逻辑无缝注入到 Go 标准库的 http.Client 中。

// hedge.go
package main

import (
    "context"
    "errors"
    "net/http"
    "sync"
    "time"
)

// HedgedTransport 实现了 http.RoundTripper 接口
type HedgedTransport struct {
    Transport   http.RoundTripper // 底层真正的 Transport
    MaxAttempts int               // 最大并发请求数(包括最初的1次)
    HedgeDelay  time.Duration     // 触发对冲的延迟时间
}

func (ht *HedgedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
    // 如果没有设置,使用默认行为
    transport := ht.Transport
    if transport == nil {
        transport = http.DefaultTransport
    }
    attempts := ht.MaxAttempts
    if attempts <= 0 {
        attempts = 1
    }

    // 使用带有取消功能的 context 控制整个对冲生命周期
    ctx, cancel := context.WithCancel(req.Context())
    defer cancel()

    // 结果通道,用于接收第一个成功的响应或错误
    type result struct {
        resp *http.Response
        err  error
    }
    resCh := make(chan result, attempts)
    var wg sync.WaitGroup

    // 启动一个请求的闭包函数
    doRequest := func() {
        wg.Add(1)
        go func() {
            defer wg.Done()
            // 克隆请求,防止并发修改
            cloneReq := req.Clone(ctx)
            resp, err := transport.RoundTrip(cloneReq)

            // 只有当请求不是因为 context 取消而失败时,才尝试写入结果
            if !errors.Is(err, context.Canceled) {
                select {
                case resCh <- result{resp: resp, err: err}:
                default:
                    // 通道已满或已不再需要,直接丢弃(如果 resp 不为空,需要关闭 Body 以防泄露)
                    if resp != nil && resp.Body != nil {
                        resp.Body.Close()
                    }
                }
            }
        }()
    }

    // 1. 发起第一个请求
    doRequest()

    // 2. 控制对冲的定时器和尝试次数
    timer := time.NewTimer(ht.HedgeDelay)
    defer timer.Stop()

    errs := make([]error, 0, attempts)
    requestsSent := 1

    for {
        select {
        case res := <-resCh:
            // 收到结果
            if res.err == nil {
                // 成功!立即取消其他还在飞行的请求
                cancel()
                // 等待后台 goroutine 清理完成 (可选,这里为了简单不阻塞)
                return res.resp, nil
            }
            // 如果这个请求失败了,记录错误
            errs = append(errs, res.err)
            // 如果所有发出的请求都失败了,且已经达到最大尝试次数,返回错误
            if len(errs) == attempts {
                return nil, errors.Join(errs...)
            }

            // 如果一个请求失败了,且还没达到最大尝试次数,我们不应该死等 Timer,
            // 而应该立刻触发下一个对冲请求(这里为了简化逻辑,依然依赖下一次 Timer 或失败循环)
            // 实际生产级实现可以在这里直接触发 doRequest()

        case <-timer.C:
            // 对冲延迟到达
            if requestsSent < attempts {
                // 触发对冲请求
                doRequest()
                requestsSent++
                // 重置定时器,准备下一次可能的对冲
                timer.Reset(ht.HedgeDelay)
            }

        case <-ctx.Done():
            // 整个请求超时或被调用方取消
            return nil, ctx.Err()
        }
    }
}

这里,我们使用了 req.Clone(ctx) 来复制请求,确保并发安全。通过 context.WithCancel 控制所有的下游请求,一旦有一个请求成功返回(res.err == nil),立即调用 cancel() 取消其余正在运行(in-flight)的请求。

测试服务器:模拟“长尾效应” server.go

为了看到效果,我们编写一个简单的 HTTP 服务。它在 90% 的情况下在 50ms 内快速响应,但在 10% 的情况下会遇到长达 500ms 到 1s 的长尾延迟。

// server.go
package main

import (
    "fmt"
    "math/rand"
    "net/http"
    "time"
)

func startServer() {
    http.HandleFunc("/data", func(w http.ResponseWriter, r *http.Request) {
        // 模拟 10% 的长尾延迟
        if rand.Float32() < 0.1 {
            // 长尾延迟:500ms - 1000ms
            delay := 500 + rand.Intn(500)
            time.Sleep(time.Duration(delay) * time.Millisecond)
        } else {
            // 正常响应:10ms - 50ms
            delay := 10 + rand.Intn(40)
            time.Sleep(time.Duration(delay) * time.Millisecond)
        }

        fmt.Fprintln(w, "OK")
    })

    go func() {
        err := http.ListenAndServe(":8080", nil)
        if err != nil {
            panic(err)
        }
    }()
    time.Sleep(100 * time.Millisecond) // 等待服务器启动
}

压测入口:对比见真章 main.go

最后,我们编写压测代码,分别使用普通 Client 和 Hedged Client 发送 1000 个并发请求,并统计 P99 延迟。

// main.go
package main

import (
    "fmt"
    "io"
    "net/http"
    "sort"
    "sync"
    "time"
)

const RequestCount = 1000

func main() {
    startServer()

    fmt.Println("开始压测普通 HTTP Client...")
    normalClient := &http.Client{
        Timeout: 2 * time.Second,
    }
    normalLatencies := runBenchmark(normalClient)

    fmt.Println("\n开始压测 Hedged HTTP Client...")
    hedgedClient := &http.Client{
        Timeout: 2 * time.Second,
        Transport: &HedgedTransport{
            Transport:   http.DefaultTransport,
            MaxAttempts: 3,                 // 最多发送3个请求
            HedgeDelay:  80 * time.Millisecond, // P95 延迟设为触发点(我们服务器正常响应 < 50ms)
        },
    }
    hedgedLatencies := runBenchmark(hedgedClient)

    // 打印统计结果
    printStats("Normal Client", normalLatencies)
    printStats("Hedged Client", hedgedLatencies)
}

func runBenchmark(client *http.Client) []time.Duration {
    var wg sync.WaitGroup
    latencies := make([]time.Duration, RequestCount)

    for i := 0; i < RequestCount; i++ {
        wg.Add(1)
        go func(index int) {
            defer wg.Done()

            start := time.Now()
            resp, err := client.Get("http://localhost:8080/data")
            if err != nil {
                fmt.Printf("Request failed: %v\n", err)
                return
            }
            io.Copy(io.Discard, resp.Body)
            resp.Body.Close()

            latencies[index] = time.Since(start)
        }(i)
    }

    wg.Wait()
    return latencies
}

func printStats(name string, latencies []time.Duration) {
    // 去除可能的失败请求(0值)
    valid := make([]time.Duration, 0, len(latencies))
    for _, l := range latencies {
        if l > 0 {
            valid = append(valid, l)
        }
    }

    sort.Slice(valid, func(i, j int) bool {
        return valid[i] < valid[j]
    })

    if len(valid) == 0 {
        fmt.Printf("No valid responses for %s\n", name)
        return
    }

    p50 := valid[len(valid)/2]
    p95 := valid[int(float64(len(valid))*0.95)]
    p99 := valid[int(float64(len(valid))*0.99)]

    fmt.Printf("\n=== %s 统计 ===\n", name)
    fmt.Printf("请求总数: %d\n", len(valid))
    fmt.Printf("P50 延迟: %v\n", p50)
    fmt.Printf("P95 延迟: %v\n", p95)
    fmt.Printf("P99 延迟: %v\n", p99)
}

运行与验证

在本地 MacBook Pro 的终端上执行 go run .,我得到了以下真实的性能对决:

$go run .
开始压测普通 HTTP Client...

开始压测 Hedged HTTP Client...

=== Normal Client 统计 ===
请求总数: 1000
P50 延迟: 115.226929ms
P95 延迟: 850.768537ms <-- 注意看这里
P99 延迟: 1.045720114s <-- 长尾效应严重

=== Hedged Client 统计 ===
请求总数: 1000
P50 延迟: 138.930108ms <-- P50 轻微损耗
P95 延迟: 360.607686ms <-- 巨大的改善!
P99 延迟: 376.98949ms  <-- P99 降低了将近 70%!

正如你所见:

  • P99 巨幅改善:对冲机制成功将 P99 延迟降低了 64%。原本需要 1 秒以上的极端慢请求,现在被控制在了 400ms 以内。
  • P50 轻微损耗:由于请求克隆、Context 管理以及本地 CPU 调度多出一倍请求的竞争,P50 上升了约 23ms。

结论:在典型的分布式系统中,这种权衡是极度划算的。我们用极小的平均延迟上升,换取了尾部延迟的高稳定性。

生产环境的避坑指南

Request Hedging 虽好,但绝非能随意滥用的“银弹”。在将其部署到生产环境之前,你必须考虑以下几个核心约束:

  1. 绝对的幂等性(Idempotency):对冲意味着同一笔请求可能同时发送给后端的两个节点。如果这是个 POST 扣款请求,而你的后端没有做好幂等性控制,这将会是一场灾难。Hedging 最好只用于幂等的只读请求(如 GET),或者有严格全局事务 ID 兜底的写入操作。
  2. Hedge Delay 的设定:这是最考验架构师的参数。设得太短,所有的请求都会变成双倍发送,瞬间打挂后端(这叫放大攻击);设得太长,起不到降低长尾的作用。最佳实践是通过 Prometheus 等监控工具,计算出该接口过去的 P95 响应时间,将其作为 Hedging Delay 的基准值。
  3. 熔断与限流(Throttling):如果下游服务整体宕机,所有的请求都会变慢,此时触发所有的对冲请求只会加速死亡。因此,正如 gRPC 规范中要求的,Hedging 必须与限流(Throttling)结合。例如,计算一个“对冲令牌池”,只有当成功请求大于失败请求达到一定比例时,才允许发送对冲请求。

小结

软件工程是一门关于权衡的艺术。在追求极致性能的道路上,我们往往将目光局限于优化数据库索引、压缩 JSON 序列化,却忽视了分布式系统固有的宏观不确定性。

Request Hedging 是从宏观架构层面给出的一记漂亮的防守反击。通过上面几百行的 Go 代码,我们成功复现了 Google 级别的架构优化。下一次,当你的监控大盘上 P99 曲线再次异常抖动时,不妨收起单纯的“超时重试”,尝试给你的 Go 客户端加一点“对冲”的魔法吧。

本文中涉及的代码可以在这里下载。https://github.com/bigwhite/experiments/tree/master/go-hedging-demo

资料链接:

  • https://www.reddit.com/r/golang/comments/1s4mb10/reduced_p99_latency_by_74_in_go_learned_something/
  • https://grpc.io/docs/guides/request-hedging/
  • https://research.google/pubs/the-tail-at-scale/

你的 P99 达标了吗?

尾延迟是分布式系统中最难缠的对手。在你的项目中,主要的长尾延迟来源是什么?你会为了降低那 1% 的极端慢请求,而接受 5% 的额外系统负载吗?

欢迎在评论区分享你的性能调优“必杀技”!


还在为“复制粘贴喂AI”而烦恼?我的新专栏 AI原生开发工作流实战 将带你:

  • 告别低效,重塑开发范式
  • 驾驭AI Agent(Claude Code),实现工作流自动化
  • 从“AI使用者”进化为规范驱动开发的“工作流指挥家”

扫描下方二维码,开启你的AI原生开发之旅。


你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?

  • 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
  • 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
  • 想打造生产级的Go服务,却在工程化实践中屡屡受挫?

继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!

我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。

目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

© 2026, bigwhite. 版权所有.

🔲 ☆

OpenAI 创始人盛赞 Rust,却遭开发者反驳:Go 才是大模型眼里的“香饽饽”!

本文永久链接 – https://tonybai.com/2026/03/23/go-is-the-best-programming-language-for-llm

大家好,我是Tony Bai。

在这个大模型重塑编程范式的当下,如果你想开发一个自主运行的智能体(Agent),或者想让大模型(LLM)帮你生成上万行的核心业务代码,你会选择哪门编程语言?

如果你去问 OpenAI 的总裁兼联合创始人 Greg Brockman,他的答案非常直接:

“Rust is a perfect language for agents, given that if it compiles it’s ~correct.”
(Rust 是开发 Agent 的完美语言,因为只要它能编译通过,它就基本是正确的。)

这句话听起来极其硬核且有道理。Rust 引以为傲的所有权模型和严苛的编译器,就像一个极度刻薄的审查员。既然大模型经常“胡言乱语”,那不如交给 Rust 编译器来兜底。

但有趣的是,Greg 的这番高论,最近在推特(X)上遭到了不少一线资深开发者的强烈反驳。其中,一条阅读量近 7 万的推文直指核心痛点,甚至抛出了一个让无数 Gopher(Go 开发者)极度舒适的反直觉结论:

“别吹 Rust 了,在大模型眼里,语法简单、风格统一的 Go 才是真正的‘香饽饽’!”

今天,我们就来扒一扒这场顶级“语言战争”背后的神仙打架,看看为什么 Go 语言身上那些曾经被全网群嘲的“缺点”,如今却成了大模型时代最无敌的护城河。

大模型写 Rust,真的安全吗?

发起反驳的开发者 Emil Privér 一针见血地指出了用大模型写 Rust 的最大陷阱:“逃课”心理

Greg Brockman 认为 Rust 编译器能阻止大模型犯错。但这有一个前提:大模型必须老老实实地去解生命周期(Lifetime)和所有权(Ownership)的方程。

然而现实是,大模型也是会“偷懒”的。

Emil 敏锐地指出,当现代 LLM 在生成复杂的 Rust 业务逻辑,且实在绕不过编译器的各种借用检查报错时,它们会极其鸡贼地使出大招:直接套上一层 unsafe {} 块,或者无脑使用 .unwrap() 来强行绕过编译器的安全审查!

你在指望编译器兜底,大模型却在底下悄悄开了“后门”。

就像评论区一位开发者吐槽的那样:“当你看到大模型为了图省事,把一段关键操作包在 unsafe 里,并且依然能顺利编译通过时,你还敢说它‘只要编译通过就基本正确’吗?”

虽然有开发者反驳说,可以通过配置强制禁止 unsafe。但大模型的“逃课手段”防不胜防,比如疯狂滥用 RefCell 导致运行时 Panic,这在编译器眼里是合法的,但在生产环境下却是灾难。

Go 的“无趣”,成了最顶级的生产力

既然 Rust 太“聪明”导致大模型容易弄巧成拙,那大模型到底喜欢什么样的语言?

Emil 给出的答案是:Go。

他的底层逻辑非常硬核。

他认为,大模型(LLMs)的本质是基于大量预训练语料进行下一个 Token 的概率预测。对于这种预测机制来说,一段代码的上下文看起来越“同质化(Looks the same)”,大模型生成的准确率就越高。

这就牵扯到了 Go 语言一个常年被群嘲的“缺点”:啰嗦、缺乏表现力、没有花里胡哨的语法糖。

在 Go 里,如果你想写一个循环,你只有一种办法:for 循环。

没有 while,没有 do-while,没有 foreach,更没有各种炫技的函数式流处理。

而在 Rust 或者 JavaScript 等语言里,你想遍历一个数组,至少有 5 种写法。甚至在不同的开源库里,大家的编码风格都千奇百怪。

在人类看来,Go 语言简直“无趣”到了极点。但在大模型这种无情的“概率预测机器”眼里,Go 简直就是天堂!

因为 Go 语言有着近乎暴君般的强制格式化工具 gofmt,以及全宇宙最少、最没有歧义的语法关键字。无论你是 Google 的顶级工程师,还是刚入门三个月的新手,写出来的 Go 代码结构几乎是一模一样的。

这种极度“收敛”和“无聊”的代码风格,恰恰完美契合了大模型的预测机制。

当所有的 Go 项目看起来都像是一个模子里刻出来的,大模型在生成上下文时就不需要去猜测“这个项目的主人喜欢用哪种流派”。它闭着眼睛往下预测,准确率就能轻易碾压其他语言。

Go,这种“一眼望到底”的特性,让它成为了大模型眼里的头号“香饽饽”。

AI 时代的软件工程师,该选什么语言?

推特评论区里,争论依然在继续。

但透过这场口水战,我们作为一线的软件工程师,应该看透一个更深层次的时代演进:

在过去十年,程序员们热衷于发明各种奇技淫巧,比拼谁的代码写得更短、更具“魔法”;但在未来,当 80%以上 的代码都将由 AI Agent 自动生成时,“可读性”与“无歧义”将成为一门编程语言最核心的生产力。

Go 语言的联合缔造者 Rob Pike 当年顶着巨大的压力,坚持不给 Go 加各种复杂的特性。很多人觉得他固执、老派。但在十多年后的今天,当大模型海啸席卷而来时,我们才突然惊觉:

Go 语言那种“强迫你用最笨、最直白的方式写代码”的设计哲学,不仅让它在微服务时代大杀四方,更让它在 AGI 时代,成为了大模型最忠实、最可靠的合作伙伴。

当大模型吐出一段复杂的 Rust 代码,你可能还要花十分钟去审查它有没有隐藏的逻辑陷阱;

但当大模型吐出一段 Go 代码,那满屏极其直白的 if err != nil,让人类工程师一眼就能看穿它的核心逻辑。

没有魔法,才是大模型时代最强的防御。

资料链接:https://x.com/emil_priver/status/2034971247348535399


今日互动探讨:

在日常开发中,你让 ChatGPT/Claude 帮你写过哪种语言的代码?你觉得它写 Go、Python 还是 Rust 时的准确率最高?

欢迎在评论区分享你的实战感受!


还在为“复制粘贴喂AI”而烦恼?我的新专栏 AI原生开发工作流实战 将带你:

  • 告别低效,重塑开发范式
  • 驾驭AI Agent(Claude Code),实现工作流自动化
  • 从“AI使用者”进化为规范驱动开发的“工作流指挥家”

扫描下方二维码,开启你的AI原生开发之旅。


你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?

  • 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
  • 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
  • 想打造生产级的Go服务,却在工程化实践中屡屡受挫?

继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!

我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。

目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!


原「Gopher部落」已重装升级为「Go & AI 精进营」知识星球,快来加入星球,开启你的技术跃迁之旅吧!

我们致力于打造一个高品质的 Go 语言深度学习AI 应用探索 平台。在这里,你将获得:

  • 体系化 Go 核心进阶内容: 深入「Go原理课」、「Go进阶课」、「Go避坑课」等独家深度专栏,夯实你的 Go 内功。
  • 前沿 Go+AI 实战赋能: 紧跟时代步伐,学习「Go+AI应用实战」、「Agent开发实战课」、「Agentic软件工程课」、「Claude Code开发工作流实战课」、「OpenClaw实战分享」等,掌握 AI 时代新技能。
  • 星主 Tony Bai 亲自答疑: 遇到难题?星主第一时间为你深度解析,扫清学习障碍。
  • 高活跃 Gopher 交流圈: 与众多优秀 Gopher 分享心得、讨论技术,碰撞思想火花。
  • 独家资源与内容首发: 技术文章、课程更新、精选资源,第一时间触达。

衷心希望「Go & AI 精进营」能成为你学习、进步、交流的港湾。让我们在此相聚,享受技术精进的快乐!欢迎你的加入!

img{512x368}


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

© 2026, bigwhite. 版权所有.

🔲 ☆

别再像 2015 年那样写 Go 了:Modern Go 终极进化指南

本文永久链接 – https://tonybai.com/2026/03/02/modern-go-evolution-guide-1-0-to-1-26

大家好,我是Tony Bai。

Go 语言在业界最著名的标签之一就是“向后兼容承诺(Go 1 Compatibility Promise)”。一份 10 年前写下的 Go 1.4 代码,在今天的 Go 1.26 编译器下依然能完美编译并运行。

但这带来了一个副作用:许多 Go 开发者的思维和编码习惯,也停留在过去的时代。

我们依然能看到满天飞的 interface{}、冗长易错的 for 循环切片查找、为了获取指针而被迫抽离的辅助函数,以及在并发测试中繁琐的 Context 初始化。

近日,JetBrains 开源了一个名为 use-modern-go 的 AI Coding Agent Skill。这份Skill文件通过精准的 Prompt,强迫 AI 智能体在生成 Go 代码时,必须根据项目 go.mod 的版本,使用该版本支持的最现代化、最优雅的语法和标准库

这份文件简直是一座宝库!它不仅是给 AI 看的指令,更是给每一位 Gopher 的“代码现代化”体检表。

本文将以这份资料为基础,全面盘点从 Go 1.0 一路演进到 Go 1.26 的 Modern Go 特性。我们将通过清晰的 Before / After 对比示例,带你洗礼一遍 Go 语言的现代化之美。

第一阶段:早期的代码净化(Go 1.0 – Go 1.19)

虽然是早期版本,但这些 API 的引入确立了 Go 代码“少即是多”的审美基调。

时间的优雅流逝 (time.Since / time.Until)

在计算耗时或剩余时间时,不要再手动做减法了。

❌ Before (Legacy):

start := time.Now()
// do work
elapsed := time.Now().Sub(start)

deadline := time.Now().Add(5 * time.Second)
remaining := deadline.Sub(time.Now())

✅ After (Modern – Go 1.0/1.8):

start := time.Now()
// do work
elapsed := time.Since(start)

deadline := time.Now().Add(5 * time.Second)
remaining := time.Until(deadline)

错误处理的革命 (errors.Is)

Go 1.13 引入了错误包装(Error Wrapping)。使用 == 判断错误已经不再安全。

❌ Before (Legacy):

if err == sql.ErrNoRows {
    // 无法捕获 fmt.Errorf("query failed: %w", sql.ErrNoRows) 包装后的错误
}

✅ After (Modern – Go 1.13):

if errors.Is(err, sql.ErrNoRows) {
    // 即使被多层 %w 包装,依然能准确识别
}

告别 interface{} (any)

Go 1.18 引入了泛型,同时带来了一个赏心悦目的类型别名 any。

❌ Before (Legacy):

func PrintAll(vals[]interface{}) { ... }

✅ After (Modern – Go 1.18):

func PrintAll(vals[]any) { ... }

字符串无损切割 (strings.Cut / bytes.Cut)

解析键值对是最常见的操作。过去我们需要 strings.Index 配合切片操作,极易引发 panic: slice bounds out of range。

❌ Before (Legacy):

idx := strings.Index(header, ":")
if idx != -1 {
    key := header[:idx]
    value := header[idx+1:]
}

✅ After (Modern – Go 1.18):

if key, value, found := strings.Cut(header, ":"); found {
    // 安全、直观、一次调用
}

高性能字符串追加 (fmt.Appendf)

Go 1.19 引入了直接向字节切片追加格式化字符串的能力,避免了 fmt.Sprintf 带来的隐式内存分配。

❌ Before (Legacy):

buf := []byte("Prefix: ")
buf = append(buf,[]byte(fmt.Sprintf("user_id=%d", id))...) // 发生堆分配

✅ After (Modern – Go 1.19):

buf :=[]byte("Prefix: ")
buf = fmt.Appendf(buf, "user_id=%d", id) // 零分配(如果 buf 容量充足)

类型安全的原子操作 (atomic.Bool/Int64/Pointer)

放弃 atomic.Value 和难记的 atomic.StoreInt32 吧,Go 1.19 的泛型原子类型既安全又易读。

❌ Before (Legacy):

var flag int32 // 0: false, 1: true
atomic.StoreInt32(&flag, 1)
if atomic.LoadInt32(&flag) == 1 { ... }

✅ After (Modern – Go 1.19):

var flag atomic.Bool
flag.Store(true)
if flag.Load() { ... }

var cfg atomic.Pointer[Config]
cfg.Store(&Config{})

第二阶段:标准库的泛型文艺复兴(Go 1.20 – Go 1.21)

在这个阶段,经过两个大版本打磨的 Go 泛型,彻底释放了泛型的潜力,引入了大量期待已久的内置函数和集合操作库。

明确的克隆 (strings.Clone / bytes.Clone)

当你想持有一个大字符串/字节切片的极小一部分,又不想让垃圾回收器保留整个底层大数组时,你需要 Clone。

❌ Before (Legacy):

// 丑陋的黑魔法来强制复制字符串
copiedStr := string([]byte(hugeString[:10]))

✅ After (Modern – Go 1.20):

copiedStr := strings.Clone(hugeString[:10])

溯源 Context 取消原因 (context.WithCancelCause)

Context 被取消了,但究竟是因为超时、主动取消,还是底层的网络错误?Go 1.20 让你能够携带取消原因。

❌ Before (Legacy):

ctx, cancel := context.WithCancel(parent)
// 发生错误时
cancel()
// 其他协程只知道 ctx.Err() == context.Canceled

✅ After (Modern – Go 1.20):

ctx, cancel := context.WithCancelCause(parent)
// 发生错误时
cancel(fmt.Errorf("db connection lost"))

// 消费端可以查明真凶
err := context.Cause(ctx) // 返回 "db connection lost"

(注:Go 1.21 还补充了 context.WithTimeoutCause)

内置的魔法:min, max, clear

这是 Go 1.21 最受欢迎的内置函数。

❌ Before (Legacy):

// 求最大值(非浮点数只能自己写 if/else)
m := a
if b > m { m = b }

// 清空 Map
for k := range myMap { delete(myMap, k) }

✅ After (Modern – Go 1.21):

m := max(a, b) // 支持所有可比较类型
clear(myMap)   // 高效清空 map,保留底层容量

强大的 slices 和 maps 库

告别手动写 for 循环查找元素的日子。

❌ Before (Legacy):

func contains(list[]string, target string) bool {
    for _, v := range list {
        if v == target { return true }
    }
    return false
}

✅ After (Modern – Go 1.21):

import "slices"
import "maps"

// 查找
found := slices.Contains(items, target)
idx := slices.IndexFunc(users, func(u User) bool { return u.ID == 42 })

// 排序 (原 sort.Slice 需要写繁琐的 Less 函数)
slices.Sort(ints)
slices.SortFunc(users, func(a, b User) int { return cmp.Compare(a.Age, b.Age) })

// 紧凑与裁剪
items = slices.Compact(items) // 移除连续重复元素
items = slices.Clip(items)    // 移除切片多余的 capacity

// 字典操作
clonedMap := maps.Clone(originalMap)
maps.DeleteFunc(m, func(k string, v int) bool { return v < 0 })

更聪明的单次执行 (sync.OnceFunc / OnceValue)

sync.Once 很好用,但如果我们想只初始化一次并返回一个值,过去需要闭包外变量和额外的锁。

❌ Before (Legacy):

var once sync.Once
var config *Config
func GetConfig() *Config {
    once.Do(func() { config = loadConfig() })
    return config
}

✅ After (Modern – Go 1.21):

// 声明即完成包装,线程安全且优雅
var GetConfig = sync.OnceValue(func() *Config {
    return loadConfig()
})

// 使用
cfg := GetConfig()

第三阶段:语法细节与 Web 路由的飞跃(Go 1.22)

整数范围循环 (for i := range n)

❌ Before: for i := 0; i < 10; i++ { … }
✅ After: for i := range 10 { … }

默认值救星 (cmp.Or)

返回第一个非零值,简直是读取环境变量的神器。

❌ Before (Legacy):

port := os.Getenv("PORT")
if port == "" {
    port = "8080"
}

✅ After (Modern – Go 1.22):

port := cmp.Or(os.Getenv("PORT"), "8080")

史诗级加强的 http.ServeMux

标准库路由器终于支持 HTTP 方法和路径参数了,很多小项目再也不需要引入 gin 或 chi。

✅ Modern Go 1.22:

mux := http.NewServeMux()
mux.HandleFunc("POST /api/users/{id}", func(w http.ResponseWriter, r *http.Request) {
    userID := r.PathValue("id")
    // ...
})

第四阶段:迭代器时代的黎明(Go 1.23 – Go 1.24)

Go 1.23 引入了 iter.Seq(迭代器),这是自泛型以来最大的范式转变。它统一了所有“序列”的遍历方式。

迭代器与切片/字典的梦幻联动

提取 map 的所有 key 并排序,过去需要手动 append 加 sort。

✅ Modern Go 1.23:

// 获取字典的 keys 迭代器 -> 收集为切片 -> 返回新切片
keys := slices.Collect(maps.Keys(m))

// 收集并一步排序
sortedKeys := slices.Sorted(maps.Keys(m))

测试和基准测试的现代化 (t.Context(), b.Loop())

Go 1.24 对 testing 库进行了大规模重构,代码更加精简防错。

❌ Before (Legacy Testing):

func TestFoo(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    doSomething(ctx)
}

func BenchmarkBar(b *testing.B) {
    for i := 0; i < b.N; i++ {
        doWork() // 编译器可能会过度优化这里的代码
    }
}

✅ After (Modern Go 1.24):

func TestFoo(t *testing.T) {
    // 自动随测试结束而取消的 Context
    ctx := t.Context()
    doSomething(ctx)
}

func BenchmarkBar(b *testing.B) {
    // 防止编译器优化掉内部逻辑的全新循环方式
    for b.Loop() {
        doWork()
    }
}

JSON 标签终极补丁 (omitzero)

长久以来,JSON 的 omitempty 标签对 time.Time 和嵌套 struct 这种“非空即零”的类型无效(因为它们永远不是 nil)。Go 1.24 终于引入了 omitzero。

✅ Modern Go 1.24:

type User struct {
    // 以前:即使时间是 0001-01-01 也会被序列化输出
    // 现在:只要是零值,就忽略
    CreatedAt time.Time json:"created_at,omitzero"
}

零分配迭代分割 (strings.SplitSeq)

当你只需要遍历分割后的字符串,而不需要将其存入切片时,迭代器能帮你省下所有内存分配。

  • ❌ Before (Allocates memory): for _, part := range strings.Split(s, “,”) { … }
  • ✅ After (Zero allocation): for part := range strings.SplitSeq(s, “,”) { … }

第五阶段:属于现在的未来(Go 1.25 – Go 1.26)

让我们来看看最近两个发布版本实装的黑科技。

拯救 WaitGroup (wg.Go())

Go 1.25 消除了并发控制中最常见的 Bug:忘记写 wg.Add(1) 或者忘记 defer wg.Done()。

❌ Before (Legacy):

var wg sync.WaitGroup
for _, item := range items {
    wg.Add(1)
    go func(i Item) {
        defer wg.Done()
        process(i)
    }(item)
}
wg.Wait()

✅ After (Modern Go 1.25):

var wg sync.WaitGroup
for _, item := range items {
    // 自动处理 Add(1) 和内部的 Done(),连闭包变量捕获问题都不用再担心
    wg.Go(func() {
        process(item)
    })
}
wg.Wait()

指针获取的终极解法 (new(expr))

在 Go 1.26 中,为了给结构体的指针字段(常见于 Protobuf/JSON 生成的代码)赋值,你再也不需要写恶心的辅助函数了。new() 终于支持了表达式。

❌ Before (Legacy):

timeout := 30
debug := true
cfg := Config{
    Timeout: &timeout, // 必须先单独声明变量
    Debug:   &debug,
}

✅ After (Modern Go 1.26):

cfg := Config{
    Timeout: new(30),   // 推断为 *int
    Debug:   new(true), // 推断为 *bool
    Role:    new("admin"), // *string
}

警告:请直接写 new(30),千万不要写 new(int(30)) 这种脱裤子放屁的类型转换,编译器足够聪明。

泛型安全类型断言 (errors.AsType)

处理自定义错误时,errors.As 极易用错,因为它需要传入一个指针的指针,如果传入非指针会在运行时直接 Panic。Go 1.26 用泛型完美解决了它。

❌ Before (Unsafe):

var pathErr *os.PathError
// 极易漏写 & 导致 panic
if errors.As(err, &pathErr) {
    handle(pathErr)
}

✅ After (Modern Go 1.26):

// 编译期类型安全,返回具体的实例
if pathErr, ok := errors.AsType[*os.PathError](err); ok {
    handle(pathErr)
}

小结:让 AI 成为代码现代化的推手

回顾这从 Go 1.0 到 1.26 的演进史,我们看到了一条清晰的脉络:Go 官方正在极力消除样板代码(Boilerplate),同时坚定地维持着语言的简单与直白。

JetBrains 开源的这个 use-modern-go Skill 给了我们一个绝佳的启示:在 AI 编程时代,不要让大模型去学习网上那些陈旧的、十年前的 StackOverflow 答案。 通过系统性的 Prompt 引导,我们可以强迫 AI 写出最符合当前语言版本的、最高效的 Modern Code。

作为 Gopher,是时候给你的脑海中的“Go 语言编译器”升个级了。下一次敲下代码时,问问自己:“这是 2015 年的写法,还是 2026 年的写法?”


你的代码里还有“老古董”吗?

哪怕 Go 1.26 已经发布,很多人的 go.mod 依然停留在 1.16 甚至更早。在这些 Modern 特性中,哪一个最让你感到“相见恨晚”?你在重构老代码时,遇到过哪些由于“兼容性思维”导致的阻碍?

欢迎在评论区分享你的 Modern Go 实践!


还在为“复制粘贴喂AI”而烦恼?我的新专栏 AI原生开发工作流实战 将带你:

  • 告别低效,重塑开发范式
  • 驾驭AI Agent(Claude Code),实现工作流自动化
  • 从“AI使用者”进化为规范驱动开发的“工作流指挥家”

扫描下方二维码,开启你的AI原生开发之旅。


你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?

  • 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
  • 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
  • 想打造生产级的Go服务,却在工程化实践中屡屡受挫?

继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!

我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。

目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!


想系统学习Go,构建扎实的知识体系?

我的新书《Go语言第一课》是你的首选。源自2.4万人好评的极客时间专栏,内容全面升级,同步至Go 1.24。首发期有专属五折优惠,不到40元即可入手,扫码即可拥有这本300页的Go语言入门宝典,即刻开启你的Go语言高效学习之旅!


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

© 2026, bigwhite. 版权所有.

🔲 ☆

Kelsey Hightower 退休后的冷思考:为什么 10 年过去了,我们还在谈论容器?

本文永久链接 – https://tonybai.com/2026/01/22/why-are-we-still-talking-about-containers-in-ai-age

大家好,我是Tony Bai。

“如果你在 2014 年告诉我,十年后我们还在讨论容器,我会觉得你疯了。但现在是 2025 年,我们依然在这里,谈论着同一个话题。”

在去年中旬举行的 ContainerDays Hamburg 2025 上,早已宣布“退休”的云原生传奇人物 Kelsey Hightower 发表了一场发人深省的主题演讲。在这个 AI 狂热席卷全球的时刻,他没有随波逐流地去谈论大模型,而是回过头来,向所有技术人抛出了一个灵魂拷问:

为什么我们总是在追逐下一个热点,却从来没有真正完成过手头的工作?

烂尾工程的诅咒——技术圈的“海啸”循环

Kelsey 首先回顾了他职业生涯中经历的三次技术浪潮:Linux 取代 Unix(AIX、Solaris等)、DevOps 的兴起、以及 Docker/Kubernetes 的容器革命。

他敏锐地指出,技术圈似乎陷入了一个无休止的“海啸循环”:

  1. 热点爆发:一个新的技术(如 Docker)出现,VC 资金涌入,所有人都在谈论它。
  2. 疯狂追逐:为了抢占市场,大家都只做“足够发布”的工作,追求速度而非完美。
  3. 未竟而散:还没等这项技术真正成熟、稳定、标准化,下一个热点(如 AI)就来了。于是,半数工程师跳船去追新热点,留下一地鸡毛。

“我们就像一群踢足球的孩子,看到球滚到哪里,所有人就一窝蜂地冲过去,连守门员都离开了球门。结果是,球门大开,后方空虚。”

这就是为什么 10 年过去了,我们还在谈论容器。因为我们当年并没有真正“完成”它。我们留下了无数的复杂性、不兼容和“企业级发行版”,却忘了初衷。

Apple 的“非性感”工作——这才是未来

在演讲中,Kelsey 分享了他最近的一个惊人发现:Apple 正在 macOS 中原生集成容器运行时。

这不是 Docker Desktop,也不是虚拟机套娃,而是操作系统级别的原生支持。这就是 GitHub 上的一个名为 apple/container 的 Apple 开源项目:

Kelsey 提到 contributors 中有 Docker 元老 Michael Crosby ,Michael Crosby 正在 Apple 做着这件“不性感”但极其重要的事情。

Kelsey 认为,这才是容器技术的终局

  • 标准化:容器运行时将成为像 TCP/IP 协议栈一样的操作系统标配,无论你是 Linux、macOS 还是 Windows。
  • 隐形化:你不再需要安装 Docker,不再需要关心运行时。它就在那里,像水和电一样自然。
  • 应用商店的重构:未来,App Store 分发的可能就是容器镜像,彻底解决依赖冲突和安全沙箱问题。

这正是那些没有去追逐 AI 热点,而是选择留在“球门”前的人,正在默默完成的伟大工程。

关于 AI——不要做“盲目的复制者”

作为 Google 前员工,Kelsey 对 AI 并不陌生。但他对当前的 LLM 热潮保持着清醒的警惕。

他现场演示了一个有趣的实验:询问一个本地运行的 LLM “FreeBSD Service Jails 需要什么版本?”
* AI 的回答:FreeBSD 13(一本正经的胡说八道)。
* 真相:FreeBSD 15(尚未发布)。

Kelsey 指出,现在的 AI 就像一个热心但糊涂的路人,它不懂装懂,只想取悦你。

他的建议是

  1. 不要迷信生成:不要因为 AI 生成了代码就直接用,就像你不会盲目复制 Stack Overflow 的代码一样。
  2. 上下文为王:AI 不是魔法,它只是一个强大的搜索引擎。如果你想得到正确答案,你必须先给它提供正确的上下文(Context)
  3. 先训练自己,再训练模型:在成为“提示词工程师”之前,先成为一名合格的工程师。只有当你自己深刻理解了问题,你才能判断 AI 的回答是天才还是垃圾。

给技术人的最后忠告

演讲的最后,Kelsey 回答了关于开源、职业发展和未来的提问。他的几条忠告,值得每一位技术人铭记:

  • 关于职业:“你的职业生涯不应该是一场马拉松,而应该是一场接力赛。当你到达巅峰时,想的应该是如何把接力棒交给下一个人,而不是霸占着位置直到倒下。”
  • 关于开源:“不要被商业公司的许可证游戏迷惑。如果代码是公开的,你可以 fork,可以学习。真正的开源精神在于分享和协作,而不在于谁拥有控制权。”
  • 关于专注:像那家只做钳子的德国公司(Knipex)一样,专注做好一件事。技术圈不缺追风者,缺的是能够沉下心来,把一项技术打磨到极致、直到它变得“无聊”和“隐形”的工匠。

小结

Kelsey Hightower 的这场演讲,是对当前浮躁技术圈的一剂清醒剂。

他提醒我们,技术的真正价值,不在于它有多新、多热,而在于它是否真正解决了问题,是否被完整地交付了。在所有人都在谈论 AI 的今天,或许我们更应该关注那些被遗忘的“球门”,去完成那些尚未完成的伟大工程。

资料链接:https://www.youtube.com/watch?v=x1t2GPChhX8


你的“烂尾”故事

Kelsey 的“海啸循环”论断让人深思。在你的职业生涯中,是否也经历过这种“还没做完旧技术,就被迫去追新热点”的无奈?你认为在这个 AI 时代,我们该如何保持“工匠精神”?

欢迎在评论区分享你的经历或思考!让我们一起在喧嚣中寻找内心的宁静。

如果这篇文章让你停下来思考了片刻,别忘了点个【赞】和【在看】,并转发给那些还在焦虑中奔跑的同行!


还在为“复制粘贴喂AI”而烦恼?我的新专栏 AI原生开发工作流实战 将带你:

  • 告别低效,重塑开发范式
  • 驾驭AI Agent(Claude Code),实现工作流自动化
  • 从“AI使用者”进化为规范驱动开发的“工作流指挥家”

扫描下方二维码,开启你的AI原生开发之旅。


你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?

  • 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
  • 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
  • 想打造生产级的Go服务,却在工程化实践中屡屡受挫?

继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!

我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。

目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

© 2026, bigwhite. 版权所有.

🔲 ☆

凌晨3点的警报:一个导致 50000 多个 Goroutine 泄漏的 Bug 分析

本文永久链接 – https://tonybai.com/2026/01/22/a-bug-cause-50000-goroutine-leak

大家好,我是Tony Bai。

内存占用 47GB,响应时间飙升至 32秒,Goroutine 数量达到惊人的 50847 个。

这是一个周六凌晨 3 点,发生在核心 API 服务上的真实噩梦。运维正准备重启服务止损,但 Serge Skoredin 敏锐地意识到:这不是普通的内存泄漏,而是一场已经潜伏了 6 周、呈指数级增长的 Goroutine 泄漏

导致这场灾难的代码,曾通过了三位资深工程师的 Code Review,看起来“完美无缺”。今天,让我们跟随 Serge 的视角,层层剥开这个隐蔽 Bug 的伪装,学习如何避免同样的悲剧发生在你身上。

img{512x368}

看似“无辜”的代码

问题的核心出在一个 WebSocket 通知服务中。让我们看看这段“看起来很合理”的代码:

func (s *NotificationService) Subscribe(userID string, ws *websocket.Conn) {
    // 1. 创建带取消功能的 Context
    ctx, cancel := context.WithCancel(context.Background())

    sub := &subscription{
        userID: userID,
        ws:     ws,
        cancel: cancel, // 保存 cancel 函数以便后续调用
    }
    s.subscribers[userID] = sub

    // 2. 启动消息处理和心跳
    go s.pumpMessages(ctx, sub)
    go s.heartbeat(ctx, sub)
}

这看起来非常标准:使用了 context.WithCancel 来管理生命周期,将 cancel 存入结构体以便连接断开时调用。然而,魔鬼就藏在细节里。

泄漏的“三重奏”

经过排查,Serge 发现了导致泄漏的三个致命错误,它们环环相扣,最终酿成了大祸。

Bug #1:无人调用的 cancel

// 预期:连接断开时调用 s.Unsubscribe -> sub.cancel()
// 现实:WebSocket 断开连接时,根本没有人通知 Service 去执行清理逻辑!

当 WebSocket 连接意外断开(如用户直接关掉浏览器),如果没有显式地监听关闭事件并调用清理函数,s.subscribers 中不仅残留了无效的订阅对象,更重要的是,ctx 永远不会被取消。这意味着所有依赖该 ctx 的 Goroutine 将永生。

Bug #2:永不停歇的 Ticker

func (s *NotificationService) heartbeat(ctx context.Context, sub *subscription) {
    ticker := time.NewTicker(30 * time.Second)
    // 致命错误:缺少 defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return // Goroutine 退出了,但 Ticker 还在!
        case <-ticker.C:
            // ...
        }
    }
}

即便 ctx 被取消,Goroutine 退出了,但 time.NewTicker 创建的计时器是由 Go 运行时全局管理的。如果不显式调用 Stop(),Ticker 将永远存在,持续消耗内存和 CPU 资源。 50,000 个泄漏的 Ticker,足以让 Go 运行时崩溃。

Bug #3:阻塞的 Channel

type subscription struct {
    messages chan Message // 无缓冲 Channel(或者缓冲区满了)
    // ...
}

func (s *NotificationService) pumpMessages(...) {
    // ...
    case msg := <-sub.messages:
        sub.ws.WriteJSON(msg)
}

如果写入端还在不断尝试发送消息(因为不知道连接已断开),而读取端(pumpMessages)因为网络阻塞或已退出而不再读取,那么写入端的 Goroutine 就会被永久阻塞在 channel 发送操作上,形成另一种泄漏。

修复与预防:构建防漏体系

修复后的代码不仅加上了必要的清理逻辑,更引入了一套完整的防御体系。

修复:确保生命周期的闭环

  • 监听关闭事件:利用 ws.SetCloseHandler 确保在连接断开时主动调用 Unsubscribe。
  • 停止 Ticker:永远使用 defer ticker.Stop()。
  • 关闭 Channel:在清理时关闭 sub.messages,解除写入端的阻塞。

注:关闭 channel务必由写入者goroutine进行,如果写入者goroutine阻塞在channel写上,此时由其他goroutine close channel,会导致panic on send on closed channel的问题。

预防:Goleak 与监控

Serge 强烈推荐使用 Uber 开源的 goleak 库进行单元测试。

func TestNoGoroutineLeaks(t *testing.T) {
    defer goleak.VerifyNone(t) // 测试结束时检查是否有泄漏的 Goroutine

    // ... 运行测试逻辑 ...
}

此外,在生产环境中,必须监控 runtime.NumGoroutine()。设置合理的告警阈值(例如:当 Goroutine 数量超过正常峰值的 1.5 倍时告警),能在灾难发生前 6 周就发现端倪,而不是等到凌晨 3 点。

注:Go 1.26已经吸收了uber的goleak项目思想,并原生支持goroutine leak检测!此特性可在编译时通过设置GOEXPERIMENT=goroutineleakprofile开启。

小结:经验教训

这次事故给所有 Go 开发者敲响了警钟:

  1. Goroutine 必须有明确的退出策略:每当你写下 go func() 时,必须清楚地知道它将在何时、何种条件下退出。
  2. Context 是生命线:正确传播和取消 Context 是管理并发生命周期的核心。
  3. 资源必须显式释放:Ticker、Channel、Timer 等资源不会自动被垃圾回收,必须手动关闭。
  4. 测试是最后一道防线:不要只测试逻辑正确性,还要测试资源清理的正确性。

Goroutine 泄漏是“沉默的杀手”,它不报错、不崩溃,只是悄悄地吞噬你的系统。保持警惕,定期体检,别让它成为你凌晨 3 点的噩梦。

资料链接:https://skoredin.pro/blog/golang/goroutine-leak-debugging


你的“惊魂时刻”

50000 个 Goroutine 的泄漏听起来很吓人,但它可能就潜伏在我们看似正常的代码里。在你的开发生涯中,是否也遇到过类似的内存泄漏或资源耗尽的“惊魂时刻”?你最后是如何定位并解决的?

欢迎在评论区分享你的排查故事或避坑心得!让我们一起把 Bug 扼杀在摇篮里。

如果这篇文章让你对 Goroutine 的生命周期有了更深的敬畏,别忘了点个【赞】和【在看】,并转发给你的团队,今晚睡个好觉!


还在为“复制粘贴喂AI”而烦恼?我的新专栏 AI原生开发工作流实战 将带你:

  • 告别低效,重塑开发范式
  • 驾驭AI Agent(Claude Code),实现工作流自动化
  • 从“AI使用者”进化为规范驱动开发的“工作流指挥家”

扫描下方二维码,开启你的AI原生开发之旅。


你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?

  • 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
  • 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
  • 想打造生产级的Go服务,却在工程化实践中屡屡受挫?

继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!

我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。

目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

© 2026, bigwhite. 版权所有.

🔲 ☆

Go 语言的“魔法”时刻:如何用 -toolexec 实现零侵入式自动插桩?

本文永久链接 – https://tonybai.com/2026/01/19/unleashing-the-go-toolchain

大家好,我是Tony Bai。

“Go 语言以简洁著称,但在可观测性(Observability)领域,这种简洁有时却是一种负担。手动埋点、繁琐的初始化代码、版本升级带来的破坏性变更……这些都让 Gopher 们痛苦不已。


可观测性的三大支柱

相比之下,Java 和 Python 开发者享受着“零代码修改”的自动插桩福利。Go 开发者能否拥有同样的体验?

在 GopherCon UK 2025 上,来自 DataDog 的资深工程师 Kemal Akkoyun 给出了肯定的答案。他通过挖掘 Go 工具链中一个鲜为人知的特性,不仅实现了这一目标,还将其开源为一个名为 Orchestrion 的工具。今天,就让我们一起揭秘这背后的“黑魔法”。

痛点:Go 语言的“反自动化”体质

在 Go 中集成分布式追踪(如 OpenTelemetry),通常意味着你需要:

  • 手动修改代码:在 main 函数中初始化 Tracer Provider。
  • 到处传递 Context:在每个函数签名中添加 ctx context.Context。

  • OpenTelemetry Go SDK难于集成。

  • 样板代码爆炸:在每个关键路径上通过 defer span.End() 开启和结束 Span。

这种手动方式不仅效率低下,而且容易出错。如果有遗漏,追踪链路就会断裂;如果库升级,你可能需要重写大量代码。

与 Java Agent 的字节码注入或 Python 的动态装饰器不同,Go 是静态编译语言,运行时极其简单,没有虚拟机层面的“后门”可走。这似乎是一个死局。

Gopher强烈希望 Go 也能像其他语言那样,轻松实现插桩从而注入追踪(trace)能力:

破局:编译时“大挪移”

Kemal 及其团队发现,Go 虽然没有运行时魔法,但在编译时却留了一扇窗:-toolexec 标志

$go help build|grep -A6 toolexec
    -toolexec 'cmd args'
        a program to use to invoke toolchain programs like vet and asm.
        For example, instead of running asm, the go command will run
        'cmd args /path/to/asm <arguments for asm>'.
        The TOOLEXEC_IMPORTPATH environment variable will be set,
        matching 'go list -f {{.ImportPath}}' for the package being built.

这是一个鲜为人知的 go build 参数。它允许你指定一个程序,拦截并包装构建过程中的每一个工具调用(如 compile、link、asm 等),让你可以在真正的compile、link 等之前对Go源码文件 (以compile等命令行工具的命令行参数形式传入) 做点什么。

为了让大家直观感受 -toolexec 的作用,我们先来看一个最简单的“拦截器”示例。

假设我们写了一个名为 mytool 的小程序,它的作用仅仅是打印出它接收到的命令,然后再原样执行该命令:

// mytool.go
package main

import (
    "fmt"
    "os"
    "os/exec"
)

func main() {
    // 注意:将日志打印到 Stderr,避免干扰 go build 读取工具的标准输出(如 Build ID)
    fmt.Fprintf(os.Stderr, "[Interceptor] Running: %v\n", os.Args[1:])

    // 原样执行被拦截的命令
    cmd := exec.Command(os.Args[1], os.Args[2:]...)
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    if err := cmd.Run(); err != nil {
        os.Exit(1)
    }
}

现在,当我们使用 -toolexec 参数来编译一个普通的 Go 程序时:

# 先编译我们的拦截器
go build -o mytool mytool.go

# 使用拦截器来编译目标程序
go build -toolexec="./mytool" main.go  // 这里的main.go只是一个"hello, world"的Go程序

你会看到类似这样的输出:

[Interceptor] Running: /usr/local/go/pkg/tool/darwin_amd64/compile -o ...
[Interceptor] Running: /usr/local/go/pkg/tool/darwin_amd64/link -o ...

看到了吗?go build 并没有直接调用编译器,而是先调用了我们的 mytool,并将真正的编译器路径和参数作为参数传给了它。之后再调用回原命令,在上面示例执行完go build -toolexec=”./mytool” main.go后,我们同样看到了编译成功后的可执行二进制文件main。

这就给了我们一个惊人的机会:既然我们拦截了编译指令,我们当然可以修改它,甚至修改它即将编译的源文件!

但是,仅仅打印几个日志、拦截一下命令,离真正的“自动插桩”还有很远的距离。要在真实复杂的 Go 项目中,安全、准确地修改成千上万行代码,同时还要处理依赖管理、缓存失效、语法兼容等棘手问题,绝非易事。

这正是 Orchestrion 登场的时刻。它不仅将 -toolexec 的潜力发挥到了极致,更将这套复杂的流程封装成了一个开箱即用的产品。

深度解构:Orchestrion 的“编译时手术”

Orchestrion 是什么?

简单来说,它是 DataDog 开源的一个编译时自动插桩工具。它的名字来源于一种模仿管弦乐队声音的机械乐器(Orchestrion),寓意它能像指挥家一样,协调并增强你的代码,而无需你亲自演奏每一个音符。

有了 -toolexec 这把钥匙,Orchestrion 就开启了一场编译时的“精密手术”。这不仅仅是简单的拦截,而是一场与 Go 编译器配合默契的“双人舞”。

安装下面图片中步骤,你就可以自动完成对你的go程序的插桩:

Kemal 在演讲中展示了一个复杂的时序图,Orchestrion 的工作流远比我们想象的要精细:

  1. 精准拦截:
    当 go build 启动时,Orchestrion 守在门口。它并不关心链接器(linker)或汇编器(asm),它的目光紧紧锁定在 compile 命令上。每当 Go 编译器准备编译一个包(Package),Orchestrion 就会叫停。

  2. AST 级解析与“无损”操作:
    它读取即将被编译的 .go 源文件,将其解析为 AST(抽象语法树)。

  3. 手术式注入 (Injection):
    根据预定义的规则(YAML 配置),Orchestrion 开始在 AST 上动刀:

    • 添加 Import:自动引入 dd-trace-go 等依赖包。
    • 函数入口插桩:在函数体的第一行插入 span, ctx := tracer.Start(…)。
    • 函数出口兜底:利用 defer span.End() 确保追踪闭环。
      甚至,它还能识别 database/sql 的调用,自动将其替换为带有追踪功能的 Wrapper。
  4. 狸猫换太子:
    手术完成后,Orchestrion 将修改后的 AST 重新生成为 .go 文件,保存在一个临时目录中。
    最后,它修改传递给编译器的参数,将原始源文件的路径替换为这些临时文件的路径。

  5. 透明编译:
    真正的 Go 编译器(compile)被唤醒,它毫不知情地编译了这些被“加料”的代码。

最终生成的二进制文件,包含了完整的、生产级的可观测性代码,而你的源代码仓库里,依然是那份清清爽爽、没有任何第三方依赖的业务逻辑。


Orchestrion:将“魔法”产品化

Orchestrion 不仅仅是一个概念验证,它是 DataDog 已经在生产环境中使用的成熟工具(现已捐赠给 OpenTelemetry 社区)。它解决了一系列工程难题:

1. 像 AOP 一样思考

Orchestrion 引入了类似 AOP(面向切面编程) 的概念。通过 YAML 配置文件,你可以定义“切入点”(Join Points)和“建议”(Advice)。

例如,你可以定义一条规则:
* 切入点:所有调用 database/sql 包 Query 方法的地方。
* 建议:在调用前后包裹一段计时和记录代码。


2. 解决 Context 丢失的终极“黑魔法”

Go 的许多老旧库或设计不规范的代码并没有在参数中传递 context.Context。为了在这些地方也能传递追踪 ID,Orchestrion 做了一件极其硬核的事情:它修改了 Go 的运行时(Runtime)!

通过修改 runtime.g 结构体,它引入了类似 GLS (Goroutine Local Storage) 的机制。这允许在同一个 Goroutine 的不同函数调用栈之间隐式传递上下文,彻底解决了 Context 断链的问题。虽然这听起来很危险,但在受控的编译时注入环境下,它变得可行且强大。

3. 零依赖与容器化友好

Orchestrion 支持通过环境变量注入。这意味着平台工程师可以构建一个包含 Orchestrion 的基础镜像,只需要在 CI/CD 流水线中设置几个环境变量,就可以让所有基于该镜像构建的 Go 应用自动获得可观测性能力,而无需应用开发者修改一行代码。

未来:社区驱动的标准

DataDog 已将 Orchestrion 捐赠给 OpenTelemetry,并与阿里巴巴(其有类似的 Go 自动插桩工具)合作,共同在 OpenTelemetry Go SIG 下推进这一技术的标准化。

这意味着,未来 Go 开发者可能只需要执行类似 otel-go-instrument my-app 的命令,就能获得与 Java/Python 同等便捷的监控体验。

小结:工具链的无限可能

Kemal 的演讲不仅展示了一个工具,更展示了一种思维方式:当语言本身的特性限制了你时,不妨向下看一层,去挖掘工具链本身的潜力。

虽然“编译时注入”听起来像是一种对 Go 简洁哲学的“背叛”,但在解决大规模微服务治理、遗留代码维护等现实难题时,它无疑是一剂强有力的解药。

对于那些渴望从重复劳动中解脱出来的 Gopher 来说,这或许就是你们一直在等待的“魔法”。

参考资料

  • https://www.youtube.com/watch?v=8Rw-fVEjihw
  • https://www.datadoghq.com/blog/go-instrumentation-orchestrion/
  • https://x.com/felixge/status/1865034549832368242
  • https://github.com/DataDog/orchestrion
  • https://datadoghq.dev/orchestrion/docs/architecture
  • https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation

你的插桩之痛

自动插桩无疑是未来的方向。在你的项目中,目前是如何处理链路追踪埋点的?是忍受手动埋点的繁琐,还是已经尝试过类似的自动化工具?你对
这种修改 AST 甚至 Runtime 的“黑魔法”持什么态度?

欢迎在评论区分享你的看法或踩坑经历! 让我们一起探索 Go 可观测性的最佳实践。

如果这篇文章为你打开了 Go 编译工具链的新大门,别忘了点个【赞】和【在看】,并转发给你的架构师朋友,让他也来学两招!


还在为“复制粘贴喂AI”而烦恼?我的新专栏 AI原生开发工作流实战 将带你:

  • 告别低效,重塑开发范式
  • 驾驭AI Agent(Claude Code),实现工作流自动化
  • 从“AI使用者”进化为规范驱动开发的“工作流指挥家”

扫描下方二维码,开启你的AI原生开发之旅。


你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?

  • 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
  • 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
  • 想打造生产级的Go服务,却在工程化实践中屡屡受挫?

继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!

我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。

目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

© 2026, bigwhite. 版权所有.

🔲 ☆

内存去哪儿了?一个让大多数 Gopher 都无法清晰回答的问题

本文永久链接 – https://tonybai.com/2026/01/15/where-did-the-memory-go-gopher-unanswered-question

大家好,我是Tony Bai。

“我的服务内存又在缓慢增长了,pprof 显示不出明显的泄漏点……内存到底去哪儿了?

这句午夜梦回的拷问,或许是许多 Go 开发者心中最深的恐惧。

这一切的根源,可能始于一个你自以为早已掌握的基础问题:“Go 的状态 (state) 存在哪里?” Go 开发者 Abhishek Singh之前断言:“我保证,一大半的 Go 开发者都无法清晰地回答这个问题。”

你的答案是什么?“在 goroutine 里”?“在栈上”?“由 Go runtime 管理”?

如果你的脑中闪过的是这些模糊的念头,那么你可能就找到了“内存失踪案”的“第一案发现场”。这个看似不起眼的认知模糊,正是导致无数生产环境中“内存缓慢泄露”、“goroutine 永不消亡”、“随机延迟飙升”等“灵异事件”的根源。

本文,将为你揭示这个问题的精确答案,并以此为起点,修复你关于 Go 内存管理的“心智模型”,让你从此能够清晰地回答:“内存,到底去哪儿了?”

揭晓答案与核心心智模型

首先,那个简单而重要的正确答案是:

Go 的状态,就是由 Go runtime 管理的内存,它要么在栈 (stack) 上,要么在堆 (heap) 上。

然而,知道这个答案只是第一步。真正关键的,是摒弃那个导致所有问题的错误直觉,转而建立如下正确的核心心智模型

Goroutine 不拥有内存,引用 (References) 才拥有。
一个 Goroutine 的退出,并不会释放内存。

当一个 goroutine 结束时,它仅仅是停止了执行。它所创建或引用的任何内存,只要仍然被其他东西持有着引用,就永远不会被垃圾回收器 (GC) 回收。

这些“其他东西”,就是你程序中的“内存锚点”,它们包括:

  • 一个全局变量
  • 一个 channel
  • 一个闭包
  • 一个 map
  • 一个被互斥锁保护的结构体
  • 一个未被取消的 context

这,就是几乎所有“Go 内存泄漏”的根本原因。 “内存去哪儿了?”——它被这些看不见的“锚点”,牢牢地拴在了堆上。

三大“内存锚点”——Goroutine 泄漏的元凶

Abhishek 将那些导致内存无法被回收的“引用持有者”,形象地称为“内存锚点”。其中,最常见、也最隐蔽的有三种。

“永生”的 Goroutine:被遗忘的循环

创建 goroutine 很廉价,但泄漏它们却极其昂贵。一个典型的“生命周期 Bug”:

// 经典错误:启动一个运行无限循环的 goroutine
go func() {
    for {
        work() // 假设 work() 会引用一些数据
    }
}()

这个 goroutine 永远不会退出。它会永久地持有 work() 函数所引用的任何数据,阻止 GC 回收它们。如果你在每个 HTTP 请求中都启动一个这样的“即发即忘”(fire-and-forget) 的 goroutine,你的服务内存将会线性增长,直至崩溃。

这不是内存泄漏,是你设计了一个“不朽的工作负载”。

Channel:不止传递数据,更持有引用

Channel 不仅仅是数据的搬运工,它们更是强力的引用持有者

ch := make(chan *BigStruct)
go func() {
    // 这个 goroutine 阻塞在这里,等待向 channel 发送数据
    ch <- &BigStruct{...}
}()

// 如果没有其他 goroutine 从 ch 中接收数据...

那么:

  • 那个 &BigStruct{…} 将永久地被 ch 持有。
  • 那个发送数据的 goroutine 将永久地阻塞。
  • GC 永远无法回收 BigStruct 和这个 goroutine 的栈。

这告诉我们:无缓冲或未被消费的 Channel,是缓慢的死亡。 它们会像“锚”一样,将数据和 goroutine 牢牢地钉在内存中。

context:被忽视的生命周期边界

context 包是 Go 中定义生命周期边界的“标准语言”。然而,一个常见的错误是,启动一个 goroutine 时,向其传递了一个永远不会被取消的 context。

错误模式

// 传递一个 background context,等于没有传递任何“停止信号”
go doWork(context.Background())

这个 doWork goroutine,一旦启动,就没有任何机制可以通知它停止。如果它内部是一个 for-select 循环,它就会永远运行下去。

正确的模式

// 从父 context 创建一个可取消的 context
ctx, cancel := context.WithCancel(parentCtx)
// 确保在函数退出时,无论如何都会调用 cancel
defer cancel() 

go doWork(ctx)

没有 cancel,就没有清理 (No cancel -> no cleanup)。context 不会“魔法般地”自己取消。

“不是 Bug,是生命周期”——如何诊断与思考

Abhishek 强调,我们习惯于称之为“泄漏”的许多问题,实际上并非 Go 语言的 Bug,而是我们自己设计的“生命周期 Bug”

诊断“三板斧”

  1. pprof (无可争议):这是你的第一、也是最重要的工具。通过 import _ “net/http/pprof” 引入它,并重点关注:

    • 堆内存增长 (heap profile)
    • 内存分配热点 (allocs profile)
    • goroutine 数量随时间的变化
  2. Goroutine Dumps: 通过 curl http://localhost:6060/debug/pprof/goroutine?debug=2 获取所有 goroutine 的详细堆栈信息。如果 goroutine 的数量只增不减,你就找到了泄漏的“犯罪现场”。

  3. 灵魂三问 (The Ownership Question):在审查任何一段持有状态的代码时,问自己三个问题:

    • 谁拥有这段内存?(Who owns this memory?)
    • 它应该在什么时候消亡?(When should it die?)
    • 是什么引用,让它得以存活?(What reference keeps it alive?)

那些我们不愿承认的“泄漏”

  • 即发即忘的 goroutine
  • 没有消费者的 channel
  • 永不取消的 context
  • 用作缓存却没有淘汰策略的 map
  • 捕获了巨大对象的闭包
  • 为每个请求启动的、永不退出的后台 worker

真正的教训 —— Go 奖励那些思考“责任”的工程师

Go 并没有隐藏内存,它暴露了责任。
GC 无法修复糟糕的所有权设计。

这是本篇最核心、也最深刻的结论。Go 的垃圾回收器,为你解决了“何时 free”的机械问题,但它将一个更高级、也更重要的责任,交还给了你——设计清晰的“所有权”和“生命周期”

Goroutine 不会自动清理自己,Channel 不会自动排空自己,Context 不会自动取消自己。这些都不是语言的缺陷,而是其设计哲学的体现。

Go 奖励那些能够思考以下问题的工程师:

  • 生命周期 (Lifetimes):这个 goroutine 应该在什么时候开始,什么时候结束?
  • 所有权 (Ownership):这份数据由谁创建,由谁负责,最终应该由谁来释放对其的最后一个引用?
  • 反压 (Backpressure):当消费者处理不过来时,生产者是否应该被阻塞?我的 channel 是否应该有界?

你不需要成为一名 Go 运行时专家,你只需要开始用“生命周期”的视角,去设计你的并发程序,并偶尔用 pprof 来验证你的设计。

这,就是修复 Go 内存问题“心智模型”的终极之道。

资料链接:https://x.com/0xlelouch_/status/2000485400884785320


你的“捉鬼”经历

内存泄漏就像幽灵,看不见摸不着却真实存在。在你的 Go 开发生涯中,是否也曾遇到过让你抓狂的内存泄漏或 Goroutine 暴涨?最终你是如何定位并解决的?

欢迎在评论区分享你的“捉鬼”故事和独门排查技巧! 让我们一起守护服务的稳定性。

如果这篇文章帮你修复了关于内存的心智模型,别忘了点个【赞】和【在看】,并转发给你的团队,让大家一起避坑!


还在为“复制粘贴喂AI”而烦恼?我的新专栏 AI原生开发工作流实战 将带你:

  • 告别低效,重塑开发范式
  • 驾驭AI Agent(Claude Code),实现工作流自动化
  • 从“AI使用者”进化为规范驱动开发的“工作流指挥家”

扫描下方二维码,开启你的AI原生开发之旅。


你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?

  • 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
  • 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
  • 想打造生产级的Go服务,却在工程化实践中屡屡受挫?

继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!

我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。

目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

© 2026, bigwhite. 版权所有.

🔲 ⭐

JS EditContext API 简介

by zhangxinxu from https://www.zhangxinxu.com/wordpress/?p=11618
本文可全文转载,独立域名个人网站无需授权,但需要保留原作者、出处以及文中链接,任何网站均可摘要聚合,商用请联系授权。

EditContext API 简介

一、EditContext API的作用和设计背景

JS EditContext API可以用来实现比较高级的Web输入编辑功能,包括控制更精细,反应更灵敏,和输入法配合更完美等。

目前我们实现内容输入与编辑多是借用<input><textarea>元素,如果有富文本编辑内容(常见的文章发布编辑器),则是借助contenteditable属性,或者使用CSS -webkit-user-modify属性。

但是,以contenteditable属性举例,其自带的很多内置行为在高级开发的时候是不需要的。

举几个例子,有序或无需列表在起始位置按下Backspace删除,默认行为是当前列表删除,同时内容和上一个列表合并,但往往我们希望的是取消列表状态。

Tab键之后二级缩进而不是创建Tab空格。

链接文本后面换行或输入空格,自动超链接。

换行使用<p>而不是div。

内容inline-blockinline-flex之后需要作为整体,光标可以定位在后面,但是不能在里面。

这些内置行为均无法满足。

以及,一些开发场景需要使用Canvas做图形编辑器,例如,B站或者抖音视频上传时候封面的DIY,其中就有canvas中实现文本输入。

又比如需要协调开发的在线文档开发等。

总而言之,现有的Web编辑能力只能实现一些低层级的编辑功能,想要高级定制,需要很大的实现成本,基于这个背景,就设计了EditContext API,这个API是Microsoft Edge 团队推动支持的,可以将文本输入与 HTML DOM 视图分离,从而可以提供更好的开发体验。

二、EditContext的语法和使用

EditContext对象支持4个属性,7个方法和5种事件,具体参见MDN文档,我这里不做介绍,我自己也懒得去学习了,估计以后也用不到,因为Safari还没有看到支持的迹象。

EditContext属性和方法

这里,通过一个案例展示EditContext的使用,给定一个canvas元素:

<canvas id="editor"></canvas>

则下面这段JavaScript代码可以监听canvas画布上输入的内容(点击canvas元素,浏览器会自动):

const editContext = new EditContext();
const editorElement = document.getElementById("editor");
editorElement.editContext = editContext;

editContext.addEventListener('textupdate', (event) => {
    const newText = event.text;
    // 处理新输入的文本
    console.log('输入的文本是:', newText);
});

下图是我开启输入法之后的console输出效果截图:

输入法与提示

此时,我们就可以识别输入的内容,在canvas上进行绘制,实现canvas画布上的文字输入效果。

兼容性

EditContext API目前兼容性不算好,所以文本的标题才是简介:

EditContext API的兼容性

大家目前了解下就好了。

三、就说这么多

好吧,关于这个API就说这么多,我也懒得深入,因为深入了也是会被AI也剽窃了去,并不会给我带来任何好处,自然就没有研究这类我自己以后也不会使用的特性的动力了。

举例来说,今日有尝试询问AI对EditContext的理解,结果发现很多都是一本正经地胡说八道。

胡说八道示意

因为啥呢,因为这个特性特别的新的,业内,尤其中文圈子内,还没有相关的学习资料,于是就有很多错误的输出。

但是,等哪天抓取了我或者其他一些人的文章输出,自己再“学习”一下,输出会更准确些。

但,搜索相关信息的用户并不知道这个信息源是我提供了,也不会跳转到我这篇文章,对我而言,知识输出就是个吃力不讨好的事情,从某种意义上讲,AI的出现会抑制个体的原创动力。

ok,不再扯淡了,至此,本文over,赶在清明假期结束前写完了。

🧑‍🧑‍🧒🧑‍🧑‍🧒‍🧒🧑‍🧒

本文为原创文章,会经常更新知识点以及修正一些错误,因此转载请保留原出处,方便溯源,避免陈旧错误知识的误导,同时有更好的阅读体验。
本文地址:https://www.zhangxinxu.com/wordpress/?p=11618

(本篇完)

🔲 ☆

JS EditContext API 简介

by zhangxinxu from https://www.zhangxinxu.com/wordpress/?p=11618
本文可全文转载,独立域名个人网站无需授权,但需要保留原作者、出处以及文中链接,任何网站均可摘要聚合,商用请联系授权。

EditContext API 简介

一、EditContext API的作用和设计背景

JS EditContext API可以用来实现比较高级的Web输入编辑功能,包括控制更精细,反应更灵敏,和输入法配合更完美等。

目前我们实现内容输入与编辑多是借用<input><textarea>元素,如果有富文本编辑内容(常见的文章发布编辑器),则是借助contenteditable属性,或者使用CSS -webkit-user-modify属性。

但是,以contenteditable属性举例,其自带的很多内置行为在高级开发的时候是不需要的。

举几个例子,有序或无需列表在起始位置按下Backspace删除,默认行为是当前列表删除,同时内容和上一个列表合并,但往往我们希望的是取消列表状态。

Tab键之后二级缩进而不是创建Tab空格。

链接文本后面换行或输入空格,自动超链接。

换行使用<p>而不是div。

内容inline-blockinline-flex之后需要作为整体,光标可以定位在后面,但是不能在里面。

这些内置行为均无法满足。

以及,一些开发场景需要使用Canvas做图形编辑器,例如,B站或者抖音视频上传时候封面的DIY,其中就有canvas中实现文本输入。

又比如需要协调开发的在线文档开发等。

总而言之,现有的Web编辑能力只能实现一些低层级的编辑功能,想要高级定制,需要很大的实现成本,基于这个背景,就设计了EditContext API,这个API是Microsoft Edge 团队推动支持的,可以将文本输入与 HTML DOM 视图分离,从而可以提供更好的开发体验。

二、EditContext的语法和使用

EditContext对象支持4个属性,7个方法和5种事件,具体参见MDN文档,我这里不做介绍,我自己也懒得去学习了,估计以后也用不到,因为Safari还没有看到支持的迹象。

EditContext属性和方法

这里,通过一个案例展示EditContext的使用,给定一个canvas元素:

<canvas id="editor"></canvas>

则下面这段JavaScript代码可以监听canvas画布上输入的内容(点击canvas元素,浏览器会自动):

const editContext = new EditContext();
const editorElement = document.getElementById("editor");
editorElement.editContext = editContext;

editContext.addEventListener('textupdate', (event) => {
    const newText = event.text;
    // 处理新输入的文本
    console.log('输入的文本是:', newText);
});

下图是我开启输入法之后的console输出效果截图:

输入法与提示

此时,我们就可以识别输入的内容,在canvas上进行绘制,实现canvas画布上的文字输入效果。

兼容性

EditContext API目前兼容性不算好,所以文本的标题才是简介:

EditContext API的兼容性

大家目前了解下就好了。

三、就说这么多

好吧,关于这个API就说这么多,我也懒得深入,因为深入了也是会被AI也剽窃了去,并不会给我带来任何好处,自然就没有研究这类我自己以后也不会使用的特性的动力了。

举例来说,今日有尝试询问AI对EditContext的理解,结果发现很多都是一本正经地胡说八道。

胡说八道示意

因为啥呢,因为这个特性特别的新的,业内,尤其中文圈子内,还没有相关的学习资料,于是就有很多错误的输出。

但是,等哪天抓取了我或者其他一些人的文章输出,自己再“学习”一下,输出会更准确些。

但,搜索相关信息的用户并不知道这个信息源是我提供了,也不会跳转到我这篇文章,对我而言,知识输出就是个吃力不讨好的事情,从某种意义上讲,AI的出现会抑制个体的原创动力。

ok,不再扯淡了,至此,本文over,赶在清明假期结束前写完了。

🧑‍🧑‍🧒🧑‍🧑‍🧒‍🧒🧑‍🧒

本文为原创文章,会经常更新知识点以及修正一些错误,因此转载请保留原出处,方便溯源,避免陈旧错误知识的误导,同时有更好的阅读体验。
本文地址:https://www.zhangxinxu.com/wordpress/?p=11618

(本篇完)

❌