<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Tony Bai &#187; GCC</title>
	<atom:link href="http://tonybai.com/tag/gcc/feed/" rel="self" type="application/rss+xml" />
	<link>https://tonybai.com</link>
	<description>一个程序员的心路历程</description>
	<lastBuildDate>Sat, 11 Apr 2026 22:57:27 +0000</lastBuildDate>
	<language>en</language>
	<sy:updatePeriod>hourly</sy:updatePeriod>
	<sy:updateFrequency>1</sy:updateFrequency>
	<generator>http://wordpress.org/?v=3.2.1</generator>
		<item>
		<title>Go 2026 路线图曝光：SIMD、泛型方法与无 C 工具链 CGO —— 性能与表达力的双重飞跃？</title>
		<link>https://tonybai.com/2025/11/28/go-2026-roadmap-revealed/</link>
		<comments>https://tonybai.com/2025/11/28/go-2026-roadmap-revealed/#comments</comments>
		<pubDate>Fri, 28 Nov 2025 00:45:22 +0000</pubDate>
		<dc:creator>bigwhite</dc:creator>
				<category><![CDATA[技术志]]></category>
		<category><![CDATA[arena]]></category>
		<category><![CDATA[arm64]]></category>
		<category><![CDATA[async/await]]></category>
		<category><![CDATA[AVX512]]></category>
		<category><![CDATA[CacheLineContention]]></category>
		<category><![CDATA[Cgo]]></category>
		<category><![CDATA[Clang]]></category>
		<category><![CDATA[GC]]></category>
		<category><![CDATA[GCC]]></category>
		<category><![CDATA[genericmethod]]></category>
		<category><![CDATA[Go]]></category>
		<category><![CDATA[go1.26]]></category>
		<category><![CDATA[Golang]]></category>
		<category><![CDATA[goroutine]]></category>
		<category><![CDATA[highlevelAPI]]></category>
		<category><![CDATA[JavaScript]]></category>
		<category><![CDATA[L1/L2Cache]]></category>
		<category><![CDATA[LastLevelCache]]></category>
		<category><![CDATA[LLC]]></category>
		<category><![CDATA[memoryregions]]></category>
		<category><![CDATA[NEON]]></category>
		<category><![CDATA[NUMA]]></category>
		<category><![CDATA[Promise]]></category>
		<category><![CDATA[purego]]></category>
		<category><![CDATA[runtime.free]]></category>
		<category><![CDATA[runtime.freegc]]></category>
		<category><![CDATA[scalablevectors]]></category>
		<category><![CDATA[Schedulingaffinity]]></category>
		<category><![CDATA[Shardedvalues]]></category>
		<category><![CDATA[SIMD]]></category>
		<category><![CDATA[Specializedmalloc]]></category>
		<category><![CDATA[strings.Builder]]></category>
		<category><![CDATA[STW]]></category>
		<category><![CDATA[SVE]]></category>
		<category><![CDATA[sync.Sharded]]></category>
		<category><![CDATA[Tensor]]></category>
		<category><![CDATA[uniontype]]></category>
		<category><![CDATA[wasm]]></category>
		<category><![CDATA[Wasmstackswitching]]></category>
		<category><![CDATA[WriteBarrier]]></category>
		<category><![CDATA[云原生]]></category>
		<category><![CDATA[交叉编译]]></category>
		<category><![CDATA[内存区域]]></category>
		<category><![CDATA[内存释放]]></category>
		<category><![CDATA[分片值]]></category>
		<category><![CDATA[单指令多数据]]></category>
		<category><![CDATA[向量化算法]]></category>
		<category><![CDATA[垃圾回收]]></category>
		<category><![CDATA[性能]]></category>
		<category><![CDATA[无C工具链CGO]]></category>
		<category><![CDATA[显式释放]]></category>
		<category><![CDATA[模式匹配]]></category>
		<category><![CDATA[泛型方法]]></category>
		<category><![CDATA[编译器]]></category>
		<category><![CDATA[联合类型]]></category>
		<category><![CDATA[表达力]]></category>
		<category><![CDATA[调度亲和性]]></category>
		<category><![CDATA[路线图]]></category>
		<category><![CDATA[运行时]]></category>
		<category><![CDATA[逃逸分析]]></category>

		<guid isPermaLink="false">https://tonybai.com/?p=5450</guid>
		<description><![CDATA[本文永久链接 &#8211; https://tonybai.com/2025/11/28/go-2026-roadmap-revealed 大家好，我是Tony Bai。 在最近的一期 Go 编译器与运行时团队会议纪要中，我们惊喜地发现了一份关于 2026 年的规划 (2026 planning，如下图)。这份规划虽然简短，但其包含的信息量却足以让任何一位关注 Go 语言未来的开发者心跳加速。 从榨干硬件潜能的 SIMD 和运行时手动内存释放(runtime.free)，到呼声极高的泛型方法(generic method)与联合类型(union type)，再到彻底解决交叉编译痛点的无 C 工具链 CGO，Go 团队正密谋着一场关于性能、表达力与工程体验的全方位变革。 本文将结合最新的设计文档、CL (Change List) 记录和社区核心 Issue，和大家一起解析一下这份 Go 2026 路线图背后的技术细节与战略意图。 性能的极限突围 —— 榨干硬件的每一滴油水 一直以来，Go 在性能上的策略都是“足够好”。但在 2026 规划中，我们看到了 Go 团队向“极致性能”发起的冲锋，目标直指 AI、科学计算和高频交易等对延迟极度敏感的领域。 SIMD：从“汇编黑魔法”到“原生公民” 关键词：SIMD (ARM64, scalable vectors &#38; high-level API) 解读： 现状：目前在 Go 中使用 SIMD（单指令多数据）主要依赖手写汇编，不仅难以维护，而且无法被编译器内联优化，甚至会阻碍异步抢占。 变革：规划明确提出了 [...]]]></description>
			<content:encoded><![CDATA[<p><img src="https://tonybai.com/wp-content/uploads/2025/go-2026-roadmap-revealed-1.png" alt="" /></p>
<p><a href="https://tonybai.com/2025/11/28/go-2026-roadmap-revealed">本文永久链接</a> &#8211; https://tonybai.com/2025/11/28/go-2026-roadmap-revealed</p>
<p>大家好，我是Tony Bai。</p>
<p>在最近的一期 <a href="https://github.com/golang/go/issues/43930#issuecomment-3576250284">Go 编译器与运行时团队会议纪要</a>中，我们惊喜地发现了一份关于 <strong>2026 年的规划 (2026 planning，如下图)</strong>。这份规划虽然简短，但其包含的信息量却足以让任何一位关注 Go 语言未来的开发者心跳加速。</p>
<p><img src="https://tonybai.com/wp-content/uploads/2025/go-2026-roadmap-revealed-2.png" alt="" /></p>
<p>从榨干硬件潜能的 <strong><a href="https://tonybai.com/2025/08/22/go-simd-package-preview">SIMD</a></strong> 和<strong>运行时手动内存释放(<a href="https://tonybai.com/2025/09/18/go-runtime-free-proposal">runtime.free</a>)</strong>，到呼声极高的<strong>泛型方法(generic method)</strong>与<strong>联合类型(union type)</strong>，再到彻底解决交叉编译痛点的<strong>无 C 工具链 CGO</strong>，Go 团队正密谋着一场关于<strong>性能、表达力与工程体验</strong>的全方位变革。</p>
<p>本文将结合最新的设计文档、CL (Change List) 记录和社区核心 Issue，和大家一起解析一下这份 Go 2026 路线图背后的技术细节与战略意图。</p>
<p><img src="https://tonybai.com/wp-content/uploads/2025/paid/google-adk-in-action-qr.png" alt="" /></p>
<hr />
<h2>性能的极限突围 —— 榨干硬件的每一滴油水</h2>
<p>一直以来，Go 在性能上的策略都是“足够好”。但在 2026 规划中，我们看到了 Go 团队向“极致性能”发起的冲锋，目标直指 AI、科学计算和高频交易等对延迟极度敏感的领域。</p>
<h3>SIMD：从“汇编黑魔法”到“原生公民”</h3>
<ul>
<li><strong>关键词</strong>：SIMD (ARM64, scalable vectors &amp; high-level API)</li>
<li><strong>解读</strong>：
<ul>
<li><strong>现状</strong>：目前在 Go 中使用 SIMD（单指令多数据）主要依赖手写汇编，不仅难以维护，而且无法被编译器内联优化，甚至会阻碍异步抢占。</li>
<li><strong>变革</strong>：规划明确提出了 <strong>“high-level API”</strong>。这意味着 Go 将提供一套<strong>原生的、类型安全的 SIMD 库</strong>。开发者可以用纯 Go 代码编写向量化算法，由编译器自动映射到底层的 AVX-512 (x86) 或 NEON/SVE (ARM) 指令。</li>
<li><strong>Scalable Vectors</strong>：特别提到的“可伸缩向量”，直指 ARM64 的 <strong>SVE (Scalable Vector Extension)</strong> 技术。这将允许同一份 Go 二进制代码，在不同向量长度（128位到2048位）的硬件上自动适配，实现性能的“线性扩展”，这对于 AI 推理场景至关重要。</li>
<li><strong>进展</strong>：在2026年初发布的Go 1.26中，Cherry Mui 提交的关于 Architecture-specific SIMD intrinsics 的提案将以GO实验特性落地，这意味着Go开发者将拥有原生的simd包实现，目前这一工作已在紧锣密鼓地进行中。</li>
</ul>
</li>
</ul>
<h3>runtime.free：打破 GC 的“金科玉律”</h3>
<ul>
<li><strong>关键词</strong>：runtime.free, Specialized malloc</li>
<li><strong>解读</strong>：这是一个颠覆性的变化。Go 一直以自动 GC 著称，但在极致性能场景下，GC 的 CPU 和 STW 开销仍是瓶颈。
<ul>
<li><strong>显式释放</strong>：根据设计文档 《<a href="https://go.dev/design/74299-runtime-freegc">Directly freeing user memory to reduce GC work</a> 》和相关 CL (如 CL 673695)，runtime.freegc 允许将不再使用的堆内存<strong>立即归还</strong>给分配器，供后续重用，而<strong>完全绕过 GC 扫描</strong>。</li>
<li><strong>编译器辅助</strong>：这并非让用户手动管理内存（那样太不安全）。Go 的愿景是让<strong>编译器</strong>通过逃逸分析和生命周期分析，<strong>自动插入</strong> free 调用。例如，在 strings.Builder 的扩容过程中，旧的 buffer 可以被立即释放。</li>
<li><strong>实测数据</strong>：在早期的原型测试中，优化后的 strings.Builder 性能提升了 <strong>2 倍</strong>！配合针对无指针对象 (noscan) 优化的专用分配器 (Specialized malloc)，Go 的临时对象分配性能将逼近栈分配。</li>
</ul>
</li>
</ul>
<hr />
<h2>可伸缩性的新高度 —— 拥抱超多核时代</h2>
<p>随着 CPU 核心数向 128 核甚至更高迈进，传统的并发模式开始遇到“扩展性墙”。Go 2026 规划给出了一套组合拳。</p>
<h3>分片值 (Sharded Values)</h3>
<ul>
<li><strong>关键词</strong>：Sharded values</li>
<li><strong>痛点</strong>：在高并发场景下，对同一个全局计数器或 sync.Pool 的访问，会导致严重的<strong>缓存行争用 (Cache Line Contention)</strong>，让多核优势荡然无存。</li>
<li><strong>解决方案</strong>：Go团队提出一个名为<a href="https://tonybai.com/2025/05/19/shardedvalue-per-cpu-proposal/">sync.Sharded</a> 的提案(详见 Issue #18802)，sync.Sharded 旨在提供一种<strong>“每 P (Processor) 本地化”</strong>的数据结构。
<ul>
<li><strong>无锁读写</strong>：每个 P 只操作自己本地的分片，完全无锁，零竞争。</li>
<li><strong>按需聚合</strong>：只在需要读取总值时，才遍历所有分片进行聚合。</li>
<li>这比现有的 sync.Map 或 atomic 操作在高核数机器上将有数量级的性能提升。</li>
</ul>
</li>
</ul>
<h3>调度亲和性 (Scheduling Affinity)</h3>
<ul>
<li><strong>关键词</strong>：Scheduling affinity</li>
<li><strong>解读</strong>：Go 调度器的“工作窃取”机制虽然平衡了负载，但也导致 Goroutine 经常在不同 CPU 核心间“漂移”，破坏了 L1/L2 缓存的热度。
<ul>
<li><strong>新机制</strong>：在 Issue #65694中，Go团队 计划引入一种机制，允许将一组相关的 Goroutine <strong>“绑定”</strong> 或 <strong>“倾向”</strong> 于特定的 P 或 NUMA 节点。这对于数据库、高频交易系统等缓存敏感型应用是巨大的利好，能显著减少 <strong>LLC (Last Level Cache) Miss</strong>。</li>
</ul>
</li>
</ul>
<h3>内存区域 (Memory Regions)</h3>
<ul>
<li><strong>关键词</strong>：Memory regions</li>
<li><strong>解读</strong>：在 <strong>Arena</strong>试验失败后，Michael Knyszek发起了一个名为Memory regions方案的讨论（具体见 <a href="https://github.com/golang/go/discussions/70257">Discussion #70257</a>)，其核心思想是，通过一个 region.Do(func() { &#8230; }) 调用，将一个函数作用域内的所有内存分配<strong>隐式地</strong>绑定到一个临时的、与 goroutine 绑定的区域中。这个优雅设计的背后，是<strong>极其复杂的实现</strong>。它需要在开启区域的 goroutine 中启用一个特殊的、低开销的<strong>写屏障（write barrier）</strong>来动态追踪内存的逃逸。虽然理论上可行，但其实现复杂度和潜在的性能开销，使其成为一个长期且充满不确定性的研究课题。在2026年，Go团队要在这个方案上有所突破，依旧任重道远。</li>
</ul>
<hr />
<h2>语言表达力的觉醒 —— 填补泛型后的最后拼图</h2>
<p>在泛型落地后，Go 社区对语言特性的渴望并未止步。规划中提到的几个特性，将进一步提升 Go 的表达力。</p>
<h3>泛型方法 (Generic Methods)</h3>
<ul>
<li><strong>关键词</strong>：generic methods</li>
<li><strong>背景</strong>：这是泛型引入后最大的遗憾之一。目前 Go 不支持在接口方法或结构体方法中定义额外的类型参数。</li>
<li><strong>展望</strong>：参考 <a href="https://github.com/golang/go/issues/49085">Issue #49085</a>，尽管实现难度极大（涉及运行时字典传递或单态化膨胀），但核心团队将其列入规划，表明他们正在寻找突破口。一旦实现，像 Stream.Map[T, U](func(T) U) 这样流畅的链式调用将成为可能。</li>
</ul>
<h3>联合类型 (Union Types)</h3>
<ul>
<li><strong>关键词</strong>：union type</li>
<li><strong>解读</strong>：参考 <a href="https://github.com/golang/go/issues/19412">Issue #19412</a>，这不仅仅是泛型约束中的 A | B。真正的联合类型（类似 Rust 的 Enum 或 TypeScript 的 Union）可以让 Go 拥有更强大的模式匹配能力。配合可能的 match 语法，它将彻底改变 Go 的错误处理和状态机编写方式，使其更安全、更简洁。</li>
</ul>
<h3>Tensor (?) —— AI 时代的入场券</h3>
<ul>
<li><strong>关键词</strong>：maybe tensor (?)</li>
<li><strong>解读</strong>：这个带问号的项充满了想象力。它暗示 Go 团队可能正在严肃考虑为 <strong>AI/ML 工作负载</strong>提供原生的多维数组支持。如果 Go 能在语言层面原生支持高效的 Tensor 操作和自动微分，它将有资格挑战 Python 在 AI 基础设施领域的统治地位。当然这一切还只是猜测。</li>
</ul>
<hr />
<h2>工具链革命 —— 无痛 CGO</h2>
<h3>无 C 工具链的 CGO (CGO without C toolchain)</h3>
<ul>
<li><strong>关键词</strong>：cgo without C toolchain</li>
<li><strong>痛点</strong>：目前启用 CGO 就意味着必须安装 GCC/Clang，且失去了跨平台交叉编译的便利性（CGO_ENABLED=0 是多少 Gopher 的无奈之选）。</li>
<li><strong>解决方案</strong>：Go 团队的目标是实现<strong>“纯 Go 的 C 交互”</strong>。这可能通过两种路径实现：
<ul>
<li><strong>运行时加载</strong>：类似 purego，在运行时动态加载共享库并调用，无需编译期链接。</li>
<li><strong>内置微型链接器</strong>：Go 编译器直接解析 C 头文件并生成调用代码。</li>
<li>无论上述哪种方式，或是其他方式，一旦实现，<strong>“Write once, compile anywhere”</strong> 的承诺将在 CGO 场景下也得以兑现。</li>
</ul>
</li>
</ul>
<h3>Wasm 栈切换</h3>
<ul>
<li><strong>关键词</strong>：Wasm stack switching</li>
<li><strong>解读</strong>：这是为了更好地支持 <strong>Go 在浏览器中的异步模型</strong>。通过栈切换（Stack Switching），Go 可以更高效地挂起和恢复 Wasm 的执行，从而与 JavaScript 的 Promise 和 async/await 机制无缝互操作，显著减小 Wasm 产物的体积并提升性能。</li>
</ul>
<hr />
<h2>小结：性能与表达力的双重飞跃</h2>
<p>看完这份 2026 路线图，我们不禁感叹：Go 语言正在经历它的<strong>“成人礼”</strong>。</p>
<ul>
<li><strong>在性能上</strong>，它不再满足于“够用”，而是通过 SIMD、手动内存管理和亲和性调度，向 C/C++ 统治的“极致性能领域”发起冲击。</li>
<li><strong>在表达力上</strong>，它正在补齐泛型后的最后短板，通过泛型方法和联合类型，让代码更优雅、更安全。</li>
<li><strong>在体验上</strong>，它致力于抹平 CGO 和交叉编译的最后一道坎。</li>
</ul>
<p>这是一个野心勃勃的计划。如果这些特性在 2026 年真地能如期落地，Go 将不再仅仅是“云原生的语言”，它将成为一个<strong>全能、极致、且依旧简单</strong>的通用计算平台。</p>
<h2>参考资料</h2>
<ul>
<li>Go compiler and runtime meeting notes &#8211; https://github.com/golang/go/issues/43930#issuecomment-3576250284</li>
<li>Directly freeing user memory to reduce GC work &#8211; https://go.dev/design/74299-runtime-freegc</li>
<li>runtime, cmd/compile: add runtime.freegc and runtime.freegcTracked to reduce GC work &#8211; https://github.com/golang/go/issues/74299</li>
<li>715761: runtime: support runtime.freegc in size-specialized mallocs for noscan objects &#8211; https://go-review.googlesource.com/c/go/+/715761</li>
<li>simd: architecture-specific SIMD intrinsics under a GOEXPERIMENT &#8211; https://github.com/golang/go/issues/73787</li>
<li>proposal: sync: support for sharded values &#8211; https://github.com/golang/go/issues/18802</li>
<li>runtime: stronger affinity between G ↔ P ↔ M ↔ CPU?  &#8211; https://github.com/golang/go/issues/65694</li>
<li>https://github.com/golang/go/discussions/70257 &#8211; https://github.com/golang/go/discussions/70257</li>
<li>Region-based memory management &#8211; https://en.wikipedia.org/wiki/Region-based_memory_management</li>
<li>proposal: spec: add sum types / discriminated unions &#8211; https://github.com/golang/go/issues/19412</li>
<li>proposal: spec: allow type parameters in methods &#8211; https://github.com/golang/go/issues/49085</li>
</ul>
<hr />
<p>还在为“复制粘贴喂AI”而烦恼？我的新专栏 <strong>《<a href="http://gk.link/a/12EPd">AI原生开发工作流实战</a>》</strong> 将带你：</p>
<ul>
<li>告别低效，重塑开发范式</li>
<li>驾驭AI Agent(Claude Code)，实现工作流自动化</li>
<li>从“AI使用者”进化为规范驱动开发的“工作流指挥家”</li>
</ul>
<p>扫描下方二维码，开启你的AI原生开发之旅。</p>
<p><img src="https://tonybai.com/wp-content/uploads/2025/ai-native-dev-workflow-qr.png" alt="" /></p>
<hr />
<p>你的Go技能，是否也卡在了“熟练”到“精通”的瓶颈期？</p>
<ul>
<li>想写出更地道、更健壮的Go代码，却总在细节上踩坑？</li>
<li>渴望提升软件设计能力，驾驭复杂Go项目却缺乏章法？</li>
<li>想打造生产级的Go服务，却在工程化实践中屡屡受挫？</li>
</ul>
<p>继《<a href="http://gk.link/a/10AVZ">Go语言第一课</a>》后，我的《<a href="http://gk.link/a/12yGY">Go语言进阶课</a>》终于在极客时间与大家见面了！</p>
<p>我的全新极客时间专栏 《<a href="http://gk.link/a/12yGY">Tony Bai·Go语言进阶课</a>》就是为这样的你量身打造！30+讲硬核内容，带你夯实语法认知，提升设计思维，锻造工程实践能力，更有实战项目串讲。</p>
<p>目标只有一个：助你完成从“Go熟练工”到“Go专家”的蜕变！ 现在就加入，让你的Go技能再上一个新台阶！</p>
<p><img src="https://tonybai.com/wp-content/uploads/course-card/iamtonybai-banner-2.gif" alt="" /></p>
<hr />
<p>商务合作方式：撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求，请扫描下方公众号二维码，与我私信联系。</p>
<p><img src="http://image.tonybai.com/img/tonybai/iamtonybai-wechat-qr.png" alt="" /></p>
<p style='text-align:left'>&copy; 2025, <a href='https://tonybai.com'>bigwhite</a>. 版权所有. </p>
]]></content:encoded>
			<wfw:commentRss>https://tonybai.com/2025/11/28/go-2026-roadmap-revealed/feed/</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<item>
		<title>内核之外的冰山：为什么说从零写一个操作系统已几乎不可能？</title>
		<link>https://tonybai.com/2025/08/16/brand-new-os-impossible/</link>
		<comments>https://tonybai.com/2025/08/16/brand-new-os-impossible/#comments</comments>
		<pubDate>Sat, 16 Aug 2025 00:03:53 +0000</pubDate>
		<dc:creator>bigwhite</dc:creator>
				<category><![CDATA[技术志]]></category>
		<category><![CDATA[BSD]]></category>
		<category><![CDATA[cargo]]></category>
		<category><![CDATA[cgroups]]></category>
		<category><![CDATA[GCC]]></category>
		<category><![CDATA[Glibc]]></category>
		<category><![CDATA[Go]]></category>
		<category><![CDATA[Golang]]></category>
		<category><![CDATA[gomodule]]></category>
		<category><![CDATA[JS]]></category>
		<category><![CDATA[Kernel]]></category>
		<category><![CDATA[libc]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[node.js]]></category>
		<category><![CDATA[open]]></category>
		<category><![CDATA[OS]]></category>
		<category><![CDATA[POSIX]]></category>
		<category><![CDATA[Printf]]></category>
		<category><![CDATA[proc]]></category>
		<category><![CDATA[Read]]></category>
		<category><![CDATA[Redox]]></category>
		<category><![CDATA[relibc]]></category>
		<category><![CDATA[Rust]]></category>
		<category><![CDATA[syscall]]></category>
		<category><![CDATA[write]]></category>
		<category><![CDATA[内核]]></category>
		<category><![CDATA[操作系统]]></category>
		<category><![CDATA[补丁]]></category>

		<guid isPermaLink="false">https://tonybai.com/?p=5040</guid>
		<description><![CDATA[本文永久链接 &#8211; https://tonybai.com/2025/08/16/brand-new-os-impossible 大家好，我是Tony Bai。 对于许多心怀浪漫主义的开发者来说，“从零开始编写一个属于自己的操作系统”，或许是技术生涯中最终极、最性感的梦想。这几乎是现代编程世界的“创世纪”，是掌控计算机每一个比特的至高权力。 然而，最近一位名为 Wildan M 的工程师，在他的一篇博文中，用一次亲身参与 Redox OS 项目的经历，给我们所有人泼了一盆冷水。他的结论简单而又颠覆： 现在，从零开始编写一个全新的、能被广泛采用的操作系统，已几乎是一项不可能完成的任务。 而其真正的难点，并非我们想象中那个神秘而复杂的内核，而在于内核之外，那座看不见的、庞大到令人绝望的“冰山”。 冰山一角：内核，那个“最简单”的部分 故事的主角是 Redox OS，一个雄心勃勃的项目。它旨在用内存安全的 Rust 语言，构建一个现代的、基于微内核架构的、可以替代 Linux 和 BSD 的完整操作系统。 当我们谈论“写一个 OS”时，我们通常指的是编写内核。那么 Redox OS 的内核有多复杂呢？文章给出了惊人的数据： * 代码量： 约 3 万行 (30k LoC)。 * 启动速度： 大多数情况下，不到 1 秒。 在短短十年间，Redox 团队已经完成了动态链接、Unix 套接字等核心功能。这无疑是令人敬佩的工程壮举。但 Wildan 指出，这仅仅是浮出水面的冰山一角。一个能启动的内核，距离一个“能用”的操作系统，还有着遥远的距离。 冰山之下：生态移植的“五层地狱” 当作者兴致勃勃地想为 Redox OS 贡献力量，尝试将一些现代程序（如 Go, Node.js, Rust [...]]]></description>
			<content:encoded><![CDATA[<p><img src="https://tonybai.com/wp-content/uploads/2025/brand-new-os-impossible-1.png" alt="" /></p>
<p><a href="https://tonybai.com/2025/08/16/brand-new-os-impossible">本文永久链接</a> &#8211; https://tonybai.com/2025/08/16/brand-new-os-impossible</p>
<p>大家好，我是Tony Bai。</p>
<p>对于许多心怀浪漫主义的开发者来说，“从零开始编写一个属于自己的操作系统”，或许是技术生涯中最终极、最性感的梦想。这几乎是现代编程世界的“创世纪”，是掌控计算机每一个比特的至高权力。</p>
<p>然而，最近一位名为 Wildan M 的工程师，在<a href="https://blog.wellosoft.net/writing-a-brand-new-os-is-almost-impossible-by-now">他的一篇博文</a>中，用一次亲身参与 Redox OS 项目的经历，给我们所有人泼了一盆冷水。他的结论简单而又颠覆：</p>
<p><strong>现在，从零开始编写一个全新的、能被广泛采用的操作系统，已几乎是一项不可能完成的任务。</strong></p>
<p>而其真正的难点，并非我们想象中那个神秘而复杂的内核，而在于内核之外，那座看不见的、庞大到令人绝望的“冰山”。</p>
<h2>冰山一角：内核，那个“最简单”的部分</h2>
<p>故事的主角是 Redox OS，一个雄心勃勃的项目。它旨在用内存安全的 Rust 语言，构建一个现代的、基于微内核架构的、可以替代 Linux 和 BSD 的完整操作系统。</p>
<p>当我们谈论“写一个 OS”时，我们通常指的是编写内核。那么 Redox OS 的内核有多复杂呢？文章给出了惊人的数据：<br />
*   <strong>代码量：</strong> 约 3 万行 (30k LoC)。<br />
*   <strong>启动速度：</strong> 大多数情况下，不到 1 秒。</p>
<p>在短短十年间，Redox 团队已经完成了动态链接、Unix 套接字等核心功能。这无疑是令人敬佩的工程壮举。但 Wildan 指出，这仅仅是浮出水面的冰山一角。一个能启动的内核，距离一个“能用”的操作系统，还有着遥远的距离。</p>
<h2>冰山之下：生态移植的“五层地狱”</h2>
<p>当作者兴致勃勃地想为 Redox OS 贡献力量，尝试将一些现代程序（如 Go, Node.js, Rust 编译器）移植上去时，他才真正撞上了那座隐藏在水面之下的巨大冰山。</p>
<p>一个现代操作系统之所以“能用”，是因为它能运行我们日常使用的所有软件。而将这些软件“搬”到一个全新的操作系统上，需要闯过一重又一重难关。</p>
<p><strong>第一层：系统调用 (Syscall) 的鸿沟</strong></p>
<p>这是最底层的障碍。每个操作系统都有自己的一套与硬件和内核交互的“语言”，即系统调用。Redox OS 的 syscall 与我们熟知的 Linux 完全不同。这意味着，任何需要与内核打交道的程序（几乎是所有程序），都必须重写这部分逻辑，告诉它如何在新世界里“说话”。</p>
<p><strong>第二层：libc 的重担</strong></p>
<p>为了不让每个程序都去痛苦地学习 syscall 这门“方言”，操作系统通常会提供一个标准的“翻译官”——C 标准库 (libc)。它将复杂的 syscall 封装成开发者熟悉的函数（如 printf, open, read）。因此，一个新 OS 的核心任务之一，就是自己实现一个兼容的 libc。Redox 为此用 Rust 实现了一个名为 relibc 的项目，其工程量之浩大可想而知。</p>
<p><strong>第三层：POSIX 的“几乎兼容”陷阱</strong></p>
<p>即便新 OS 像 Redox 一样，努力兼容 POSIX 这个通用标准，噩梦也远未结束。因为无数现有的软件，早已深度依赖于 Linux 特有的、非 POSIX 的功能，比如解析 /proc 文件系统、操作 cgroups 等。结果就是，即使有了 relibc，你依然需要为这些软件挨个打上无数的“补丁”。文章提到，仅 Redox OS 的官方“软件食谱 (Cookbook)”中，就包含了<strong>约 70 个</strong>这样的补丁。</p>
<p><strong>第四层：编译器的“先有鸡还是先有蛋”</strong></p>
<p>你想在新 OS 上原生编译软件吗？那你首先需要一个能在这个 OS 上运行的编译器，比如 GCC、Rustc 或 Go 编译器。但问题是，移植编译器本身，就是所有软件移植任务中最复杂、最艰巨的一种。它需要处理极其底层的二进制格式、链接方式和系统调用。这形成了一个经典的“鸡生蛋还是蛋生鸡”的困局。</p>
<p><strong>第五层：语言生态的“次元壁”</strong></p>
<p>如果说移植 C 语言程序还只是“困难模式”，那么移植那些拥有自己庞大生态的现代语言程序（如 Rust, Go, Node.js），则是“地狱模式”。这些语言的包管理器（如 Cargo, Go Modules）会从中央仓库下载海量依赖，你很难像修改 C 代码一样，通过一个简单的 .patch 文件来修复所有问题。唯一的办法，往往是去 fork 无数个核心依赖库，然后逐一修改，这几乎是一项不可能完成的任务。</p>
<h2>小结：生态，才是那座无法逾越的山</h2>
<p>当 Wildan 经历过这一切后，他得出了文章开头的那个结论。</p>
<p>一个操作系统的成功，或许 <strong>20% 在于内核的精巧，而 80% 在于其上能否运行用户想要的所有软件。</strong> 后者，那个由编译器、标准库、第三方包、应用软件共同构成的庞大生态，才是真正的、几乎无法被复制的“护城河”。</p>
<p>这就像建造一座城市。你可以设计出最宏伟、最先进的市政厅（内核），但如果没有配套的道路、水电、学校、医院、商店（软件生态），这座城市就永远只是一座无法住人的“鬼城”。</p>
<p>这篇文章并非是要劝退所有对底层技术抱有热情的开发者。正如作者所说，如果你想<strong>学习</strong>，从零开始或加入 Redox 这样的项目，会是一段极其宝贵的经历。但如果你想构建一个<strong>被广泛采用</strong>的新 OS，你面对的将不仅仅是技术挑战，更是一个需要说服全球成千上万开发者为你“投票”的社会学难题。</p>
<p>这或许就是对那些仍在坚持构建新 OS 的探索者们，我们应该报以最高敬意的原因。因为他们挑战的，不仅仅是代码，更是一整个时代建立起来的软件文明。</p>
<p>资料链接：https://blog.wellosoft.net/writing-a-brand-new-os-is-almost-impossible-by-now</p>
<hr />
<p>你的Go技能，是否也卡在了“熟练”到“精通”的瓶颈期？</p>
<ul>
<li>想写出更地道、更健壮的Go代码，却总在细节上踩坑？</li>
<li>渴望提升软件设计能力，驾驭复杂Go项目却缺乏章法？</li>
<li>想打造生产级的Go服务，却在工程化实践中屡屡受挫？</li>
</ul>
<p>继《<a href="http://gk.link/a/10AVZ">Go语言第一课</a>》后，我的《<a href="http://gk.link/a/12yGY">Go语言进阶课</a>》终于在极客时间与大家见面了！</p>
<p>我的全新极客时间专栏 《<a href="http://gk.link/a/12yGY">Tony Bai·Go语言进阶课</a>》就是为这样的你量身打造！30+讲硬核内容，带你夯实语法认知，提升设计思维，锻造工程实践能力，更有实战项目串讲。</p>
<p>目标只有一个：助你完成从“Go熟练工”到“Go专家”的蜕变！ 现在就加入，让你的Go技能再上一个新台阶！</p>
<p><img src="https://tonybai.com/wp-content/uploads/course-card/iamtonybai-banner-2.gif" alt="" /></p>
<hr />
<p>商务合作方式：撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求，请扫描下方公众号二维码，与我私信联系。</p>
<p><img src="http://image.tonybai.com/img/tonybai/iamtonybai-wechat-qr.png" alt="" /></p>
<p style='text-align:left'>&copy; 2025, <a href='https://tonybai.com'>bigwhite</a>. 版权所有. </p>
]]></content:encoded>
			<wfw:commentRss>https://tonybai.com/2025/08/16/brand-new-os-impossible/feed/</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<item>
		<title>Go语言进入“后元老时代”？Ian Lance Taylor离职引发的思考：传承、创新与社区</title>
		<link>https://tonybai.com/2025/05/11/ian-lance-taylor-leave-go/</link>
		<comments>https://tonybai.com/2025/05/11/ian-lance-taylor-leave-go/#comments</comments>
		<pubDate>Sun, 11 May 2025 10:03:29 +0000</pubDate>
		<dc:creator>bigwhite</dc:creator>
				<category><![CDATA[技术志]]></category>
		<category><![CDATA[AI]]></category>
		<category><![CDATA[AustinClements]]></category>
		<category><![CDATA[Database]]></category>
		<category><![CDATA[GCC]]></category>
		<category><![CDATA[gccgo]]></category>
		<category><![CDATA[Go]]></category>
		<category><![CDATA[Golang]]></category>
		<category><![CDATA[GoModuleProxy]]></category>
		<category><![CDATA[Google]]></category>
		<category><![CDATA[Gopher]]></category>
		<category><![CDATA[GoTeam]]></category>
		<category><![CDATA[GoVulnerability]]></category>
		<category><![CDATA[IanLanceTaylor]]></category>
		<category><![CDATA[KenThompson]]></category>
		<category><![CDATA[ML]]></category>
		<category><![CDATA[RobertGriesemer]]></category>
		<category><![CDATA[RobPike]]></category>
		<category><![CDATA[RussCox]]></category>
		<category><![CDATA[SWIG]]></category>
		<category><![CDATA[WebAssembly]]></category>
		<category><![CDATA[人工智能]]></category>
		<category><![CDATA[泛型]]></category>

		<guid isPermaLink="false">https://tonybai.com/?p=4683</guid>
		<description><![CDATA[本文永久链接 &#8211; https://tonybai.com/2025/05/11/ian-lance-taylor-leave-go 大家好，我是Tony Bai。 今天，Go 语言社区传来一个令人瞩目又略感“悲伤”的消息：Go核心团队的元老级人物 Ian Lance Taylor在为 Google 效力 19 年后，宣布离开。对于许多 Gopher 来说，Ian Taylor 的名字与 Go 语言的早期发展、GCC Go 前端 gccgo 的诞生，以及历时多年最终在 Go 1.18 实现的泛型设计紧密相连。 他的离开，不仅仅是一位资深工程师的职业变动，更像是一个时代的注脚，引发我们对 Go 语言发展阶段、团队演进以及开源项目生命力的深层思考。我们是否可以说，Go 语言正在步入一个“后元老时代”？这又意味着什么？在这篇文章中，我们就来简单聊聊。 一位“老兵”的自白与 Go 的变迁 在 Ian Taylor 的告别博文《Leaving Google》中，他回顾了自己从 2008 年加入 Go 团队（几乎与 Russ Cox 同期）至今的历程。他对自己角色的定位是：“追踪我所能追踪的关于项目的一切，并寻找需要帮助的领域。” 从为 GCC 添加 Go 前端以确保语言规范的清晰，到为 Google 内部构建系统和 SWIG 添加 [...]]]></description>
			<content:encoded><![CDATA[<p><img src="https://tonybai.com/wp-content/uploads/2025/ian-lance-taylor-leave-go-1.png" alt="" /></p>
<p><a href="https://tonybai.com/2025/05/11/ian-lance-taylor-leave-go">本文永久链接</a> &#8211; https://tonybai.com/2025/05/11/ian-lance-taylor-leave-go</p>
<p>大家好，我是Tony Bai。</p>
<p>今天，Go 语言社区传来一个令人瞩目又略感“悲伤”的消息：Go核心团队的元老级人物 Ian Lance Taylor在为 Google 效力 19 年后，宣布离开。对于许多 Gopher 来说，Ian Taylor 的名字与 Go 语言的早期发展、GCC Go 前端 gccgo 的诞生，以及历时多年最终在 Go 1.18 实现的泛型设计紧密相连。</p>
<p>他的离开，不仅仅是一位资深工程师的职业变动，更像是一个时代的注脚，引发我们对 Go 语言发展阶段、团队演进以及开源项目生命力的深层思考。我们是否可以说，Go 语言正在步入一个“后元老时代”？这又意味着什么？在这篇文章中，我们就来简单聊聊。</p>
<h2>一位“老兵”的自白与 Go 的变迁</h2>
<p>在 Ian Taylor 的告别博文《<a href="https://www.airs.com/blog/archives/670">Leaving Google</a>》中，他回顾了自己从 2008 年加入 Go 团队（几乎与 Russ Cox 同期）至今的历程。他对自己角色的定位是：“追踪我所能追踪的关于项目的一切，并寻找需要帮助的领域。” 从为 GCC 添加 Go 前端以确保语言规范的清晰，到为 Google 内部构建系统和 SWIG 添加 Go 支持，再到推动泛型的落地，<strong>Ian Taylor 的贡献无疑是奠基性的</strong>。</p>
<p>然而，最引人深思的是他对自己离开的解释：“Google has changed, and Go has changed, and the overall computer programming environment has changed. It’s become clear over the last year or so that I am no longer a good fit for the Go project at Google.” （谷歌变了，Go 也变了，整个计算机编程环境都变了。在过去一年左右的时间里，我已经越来越不适合谷歌的 Go 项目了。）</p>
<p>他还坦诚地剖析了自己的工作方式：“我能很快看到人们今天遇到的问题，以及他们明天会遇到的问题，并且我常常能够解决这些问题。但我迟迟未能看到那些能帮助人们做他们没有尝试去做、因此也没有错过的那些新事物的想法，比如 Go 模块代理和 Go 漏洞数据库。”</p>
<p>这段话意味深长。它似乎在暗示这么几点：</p>
<ul>
<li><strong>Go 项目的成熟</strong>：Go 已从最初“希望成为其他语言有用想法的范例”的探索期，成长为一个被广泛接受和使用的成熟语言。其面临的挑战和发展重心可能已从核心语言特性的打磨，转向生态系统的完善、开发者体验的优化以及应对更大规模应用的新需求。</li>
<li><strong>能力与阶段的匹配</strong>：Ian Taylor 所擅长的“解决已知和可预见问题”的能力，在项目早期至关重要。但随着项目的成熟，或许更需要能够预见和开创“用户尚未意识到其需求”的创新型人才。他提到的 Go module proxy 和 Go vulnerability database 正是这类创新的代表。</li>
<li><strong>“新陈代谢”的必然</strong>：成功的开源项目如同生命体，核心团队成员的更迭是其发展过程中的自然现象。这并非衰落的信号，反而可能是项目适应新环境、焕发新活力的契机。</li>
</ul>
<h2>Go 语言的“后元老时代”：挑战与机遇并存</h2>
<p>如果我们将 Go 的早期核心开发者（如 Rob Pike, Ken Thompson, Robert Griesemer, Russ Cox, Ian Lance Taylor 等）视为“元老”，那么随着时间的推移和人员的变动，Go 语言是否正在进入一个由新一代核心开发者主导，更加依赖成熟流程和广大社区贡献的“后元老时代”？</p>
<blockquote>
<p>注：随着2024年<a href="https://tonybai.com/2024/10/10/pass-torch-to-go-new-leadership-team">Russ Cox将Go团队旗手的角色“让位”给Austin Clements</a>，随着今天Ian Lance Taylor的离职，目前曾经的元老团队仅剩下Robert Griesemer一人还在Go核心团队一线为Go做着贡献。</p>
</blockquote>
<p>我认为，这并非悲观的论调，而是对现实的客观描述，其中蕴含着独特的挑战与机遇：</p>
<p><strong>传承</strong></p>
<p>元老们奠定的设计哲学、简洁高效的文化基因、以及对工程实践的极致追求，是 Go 语言最宝贵的财富。如何在团队演进中确保这些核心价值不被稀释，并得到良好传承，是至关重要的。这需要完善的文档、清晰的设计原则、以及新核心成员对 Go 精神的深刻理解。</p>
<p><strong>创新</strong></p>
<p>Ian Taylor 的自省提醒我们，成熟项目也需要持续创新以避免僵化。他明确指出：“任何编程语言都不会‘完成’——编程环境总是在变化，语言必须进化，否则就会消亡。” 对于 Go 而言，未来的创新可能更多体现在：</p>
<ul>
<li><strong>标准库的与时俱进</strong>：以适应新的编程范式和技术趋势（例如 AI/ML 对数据处理和并行计算的需求、云原生领域的新标准等）。</li>
<li><strong>工具链的智能化与易用性</strong>：如更好的调试工具、性能分析工具、更智能的 IDE 支持等。</li>
<li><strong>生态系统的拓展与治理</strong>：如何更好地支持和管理庞大的第三方库生态，确保质量和安全。</li>
<li><strong>拥抱新兴领域</strong>：在 AI 赋能开发、WebAssembly、IoT 等领域，Go 能否抓住新的增长点？</li>
</ul>
<p>这些创新，可能需要不同于早期核心特性设计的思维模式和技能组合。</p>
<p><strong>社区</strong></p>
<p>随着 Go 的普及，其社区已经成为一支不可忽视的力量。在“后元老时代”，社区的角色可能愈发重要：<br />
*   <strong>贡献的多元化</strong>：从代码贡献到文档撰写、Bug 反馈、布道推广，社区成员可以在各个层面参与。<br />
*   <strong>人才的培养皿</strong>：许多未来的核心贡献者可能就来自于活跃的社区成员。<br />
*   <strong>需求的反馈源</strong>：广泛的社区用户是检验语言特性和工具实用性的最佳试金石。<br />
*   <strong>生态的共建者</strong>：第三方库的繁荣离不开社区的共同努力。</p>
<p>Ian Taylor 也表示“希望将来能再次为 Go 做出贡献”，这正体现了开源精神的魅力——即使离开官方团队，热爱和能力依然可以通过社区持续发光发热。</p>
<h2>对我们 Gopher 的启示</h2>
<p>Ian Lance Taylor 的离开，以及他对 Go 变迁的洞察，对我们每一位 Gopher 来说，都是一次宝贵的反思机会：</p>
<ol>
<li><strong>拥抱变化，持续学习</strong>：编程语言和技术环境在不断进化。作为开发者，我们需要保持好奇心和学习能力，跟上时代的步伐。</li>
<li><strong>理解语言背后的哲学</strong>：学习一门语言，不仅要掌握其语法，更要理解其设计哲学和核心价值观。这有助于我们写出更“Go-idiomatic”的代码，并更好地参与社区讨论。</li>
<li><strong>贡献的力量</strong>：无论能力大小，我们都可以通过各种方式为 Go 社区做出贡献。每一次提问、每一个 Bug 报告、每一篇分享，都是在为这个生态添砖加瓦。</li>
<li><strong>思考个人与项目的匹配</strong>：Ian Taylor 的经历也提醒我们，个人职业发展需要考虑自身能力特点与项目/公司发展阶段的匹配度。</li>
</ol>
<h2>小结</h2>
<p>Ian Lance Taylor 的离开，无疑是 Go 社区的一个损失，但更是 Go 语言走向更成熟、更开放阶段的一个标志。这不是一个时代的结束，而更像是一个新篇章的序曲。</p>
<p>Go 语言的未来，将由 Google 的持续投入、新一代核心开发者的智慧、以及全球数百万 Gopher 的共同努力来书写。</p>
<p><strong>让我们向 Ian Taylor 致以崇高的敬意，感谢他为 Go 所做的一切！</strong></p>
<p>传承不息，创新不止，社区共荣——这或许就是 Go 语言“后元老时代”最值得期待的图景。</p>
<ul>
<li>Ian Taylor博文的地址：https://www.airs.com/blog/archives/670</li>
</ul>
<hr />
<p><strong>Go的未来，你我共塑：聊聊你的看法</strong></p>
<p>Ian Lance Taylor的离开标志着一个时代的节点，也开启了对Go语言“后元老时代”的无限遐想。<strong>你如何看待Go语言当前的演进阶段？在传承元老们奠定的基石之上，你认为Go在创新方面最需要突破的方向是什么？作为社区的一员，你又将如何参与到Go的未来建设中？</strong></p>
<p><strong>欢迎在评论区留下你的思考、祝福或任何想对Go社区说的话！</strong> 让我们一起见证并参与Go的下一个十年。</p>
<p><strong>想与Go一同进化，系统把握语言精髓与未来趋势？</strong></p>
<p>在Go语言迈入新发展阶段的今天，深刻理解其设计哲学、掌握核心原理、并洞察前沿创新（如AI与Go的结合）变得尤为重要。如果你渴望与Go一同成长，系统性地提升自己的技术认知，并与一群对Go充满热情的开发者深度交流…</p>
<p>那么，我的 <strong>「Go &amp; AI 精进营」知识星球</strong> 正是这样一个为你搭建的平台！这里不仅有【Go原理课】带你追本溯源，【Go进阶课】助你技艺精进，【Go避坑课】让你从容应对挑战，更有关于Go未来发展方向的探讨和AI赋能的实践分享。我会亲自为你答疑解惑，你还能与众多优秀的Gopher思想碰撞，共同探索Go在“后元老时代”的无限可能。</p>
<p><strong>立即扫码加入，与我们一起传承Go的优秀基因，拥抱创新，共建繁荣社区！</strong></p>
<p><img src="http://image.tonybai.com/img/tonybai/gopher-and-ai-tribe-zsxq-small-card.jpg" alt="img{512x368}" /></p>
<hr />
<p>商务合作方式：撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求，请扫描下方公众号二维码，与我私信联系。</p>
<p><img src="http://image.tonybai.com/img/tonybai/iamtonybai-wechat-qr.png" alt="" /></p>
<p style='text-align:left'>&copy; 2025, <a href='https://tonybai.com'>bigwhite</a>. 版权所有. </p>
]]></content:encoded>
			<wfw:commentRss>https://tonybai.com/2025/05/11/ian-lance-taylor-leave-go/feed/</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<item>
		<title>Go 1.25链接器提速、执行文件瘦身：DWARF 5调试信息格式升级终落地</title>
		<link>https://tonybai.com/2025/05/08/go-dwarf5/</link>
		<comments>https://tonybai.com/2025/05/08/go-dwarf5/#comments</comments>
		<pubDate>Thu, 08 May 2025 00:05:53 +0000</pubDate>
		<dc:creator>bigwhite</dc:creator>
				<category><![CDATA[技术志]]></category>
		<category><![CDATA[BSS]]></category>
		<category><![CDATA[CD]]></category>
		<category><![CDATA[CI]]></category>
		<category><![CDATA[Clang]]></category>
		<category><![CDATA[Compiler]]></category>
		<category><![CDATA[Debug]]></category>
		<category><![CDATA[delve]]></category>
		<category><![CDATA[DWARF]]></category>
		<category><![CDATA[GCC]]></category>
		<category><![CDATA[GDB]]></category>
		<category><![CDATA[github]]></category>
		<category><![CDATA[GNU]]></category>
		<category><![CDATA[Go]]></category>
		<category><![CDATA[go1.25]]></category>
		<category><![CDATA[Golang]]></category>
		<category><![CDATA[issue]]></category>
		<category><![CDATA[link]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[macos]]></category>
		<category><![CDATA[objump]]></category>
		<category><![CDATA[relocation]]></category>
		<category><![CDATA[section]]></category>
		<category><![CDATA[Unix]]></category>
		<category><![CDATA[代码段]]></category>
		<category><![CDATA[数据段]]></category>
		<category><![CDATA[编译]]></category>
		<category><![CDATA[调试]]></category>
		<category><![CDATA[重定位]]></category>

		<guid isPermaLink="false">https://tonybai.com/?p=4664</guid>
		<description><![CDATA[本文永久链接 &#8211; https://tonybai.com/2025/05/08/go-dwarf5 大家好，我是Tony Bai。 对于许多Go开发者来说，调试信息的格式可能是一个相对底层的细节。然而，这个细节却对编译速度、最终可执行文件的大小以及调试体验有着深远的影响。经过长达六年的讨论、等待生态成熟和密集的开发工作，Go 语言工具链终于在主干分支（预计将包含在 Go 1.25 中）默认启用了 DWARF version 5 作为其调试信息的标准格式（Issue #26379）。这一看似“幕后”的变更，实则为 Go 开发者带来了切实的链接速度提升和可执行文件体积的优化。在这篇文章中，我们就来对DWARF5落地Go这件事儿做一个简单的解读。 为何需要升级到 DWARF 5？旧格式的痛点 DWARF (Debugging With Attributed Record Formats) 是类 Unix 系统上广泛使用的调试信息标准。Go 之前使用的 DWARF 版本（主要是 v2 和 v4）虽然成熟，但在现代软件开发实践中暴露出一些不足： 大量的重定位 (Relocations): 旧版 DWARF 格式通常包含大量需要链接器处理的地址重定位信息。根据 2018 年的初步分析（by aclements），在当时的 go 二进制文件中，高达 49% 的重定位条目都源于 DWARF 数据。这显著增加了链接器的工作负担，拖慢了构建速度，尤其是对于大型项目。 冗长的位置和范围列表 (Location/Range Lists): 用于描述变量生命周期和代码范围的 .debug_loc 和 [...]]]></description>
			<content:encoded><![CDATA[<p><img src="https://tonybai.com/wp-content/uploads/2025/go-dwarf5-1.jpg" alt="" /></p>
<p><a href="https://tonybai.com/2025/05/08/go-dwarf5">本文永久链接</a> &#8211; https://tonybai.com/2025/05/08/go-dwarf5</p>
<p>大家好，我是Tony Bai。</p>
<p>对于许多Go开发者来说，调试信息的格式可能是一个相对底层的细节。然而，这个细节却对编译速度、最终可执行文件的大小以及调试体验有着深远的影响。经过长达六年的讨论、等待生态成熟和密集的开发工作，Go 语言工具链终于在主干分支（预计将包含在 Go 1.25 中）默认启用了 <strong>DWARF version 5</strong> 作为其调试信息的标准格式（<a href="https://github.com/golang/go/issues/26379">Issue #26379</a>）。这一看似“幕后”的变更，实则为 Go 开发者带来了切实的<strong>链接速度提升</strong>和<strong>可执行文件体积的优化</strong>。在这篇文章中，我们就来对DWARF5落地Go这件事儿做一个简单的解读。</p>
<h2>为何需要升级到 DWARF 5？旧格式的痛点</h2>
<p>DWARF (Debugging With Attributed Record Formats) 是类 Unix 系统上广泛使用的调试信息标准。Go 之前使用的 DWARF 版本（主要是 v2 和 v4）虽然成熟，但在现代软件开发实践中暴露出一些不足：</p>
<ol>
<li><strong>大量的重定位 (Relocations):</strong> 旧版 DWARF 格式通常包含大量需要链接器处理的地址重定位信息。根据 2018 年的初步分析（by aclements），在当时的 go 二进制文件中，高达 <strong>49%</strong> 的重定位条目都源于 DWARF 数据。这显著增加了链接器的工作负担，拖慢了构建速度，尤其是对于大型项目。</li>
<li><strong>冗长的位置和范围列表 (Location/Range Lists):</strong> 用于描述变量生命周期和代码范围的 .debug_loc 和 .debug_ranges 等section的数据在旧格式下可能非常庞大。即便经过压缩，它们也能占到可执行文件大小的相当一部分（例如，当时 go 二进制的 12MiB 中占 6%）。</li>
<li><strong>缺乏官方 Go 语言代码:</strong> 虽然不影响功能，但 DWARF 5 正式为 Go 语言分配了官方的语言代码 (DW_LANG_Go)。</li>
</ol>
<p>DWARF 5 标准针对这些痛点进行了改进，其关键优势在于：</p>
<ul>
<li><strong>位置无关表示 (Position-Independent Representations):</strong> DWARF 5 引入了如 .debug_addr, .debug_rnglists, .debug_loclists 等新 Section 格式，它们的设计能大幅减少甚至消除对重定位的需求，从而减轻链接器负担。</li>
<li><strong>更紧凑的列表格式:</strong> 新的列表格式 (.debug_rnglists, .debug_loclists) 比旧的 (.debug_ranges, .debug_loc) 更为紧凑，有助于减小调试信息的大小。</li>
</ul>
<h2>从提案到落地：漫长的等待与集中的开发</h2>
<p>尽管 DWARF 5 的优势显而易见，但 Go 社区在 2018 年提出该想法时（by aclements），整个开发工具生态（如调试器 LLDB、macOS 的链接器和 dsymutil 工具等）对其支持尚不完善。因此，该提案被暂时搁置，等待时机成熟。</p>
<p>近年来，随着主流工具链（GCC 7.1+, GDB 8.0+, Clang 14+）纷纷将 DWARF 5 作为默认选项，生态环境逐渐成熟。Go 团队成员 <strong>Than McIntosh</strong> 承担了将 Go 工具链迁移到 DWARF 5 的主要开发工作。这涉及对编译器 (cmd/compile) 和链接器 (cmd/link) 的大量修改，引入了新的 GOEXPERIMENT=dwarf5 实验开关进行测试，并提交了一系列相关的变更集 (CLs)，包括：</p>
<ul>
<li>添加 DWARF 5 相关常量和 relocation 类型定义。</li>
<li>实现对 .debug_addr, .debug_rnglists, .debug_loclists section 的生成和支持。</li>
<li>更新 DWARF 5 的行号表 (line table) 支持。</li>
<li>适配 x/debug/dwtest 和 internal/gocore 等内部库。</li>
<li>协调 Delve 调试器对 DWARF 5 的支持。</li>
</ul>
<h2>成果显著：链接速度提升与体积优化</h2>
<p>经过广泛的测试和 compilebench 基准评估，启用 DWARF 5 带来了可观的性能收益：</p>
<ul>
<li><strong>链接速度显著提升:</strong> ExternalLinkCompiler 基准测试显示链接时间减少了 <strong>约 14%</strong>。这主要得益于 DWARF 5 减少了链接器需要处理的重定位数量。</li>
<li><strong>可执行文件体积减小:</strong> HelloSize 和 CmdGoSize 基准显示最终可执行文件大小平均减小了 <strong>约 3%</strong>。这归功于 DWARF 5 更紧凑的列表格式。</li>
<li><strong>编译时间略有改善:</strong> 整体编译时间 (geomean) 也有约 <strong>1.9%</strong> 的小幅提升。</li>
</ul>
<p>虽然对代码段 (.text)、数据段 (.data)、BSS 段的大小几乎没有影响，但链接耗时和最终文件大小的优化对于大型项目和 CI/CD 流程来说意义重大。</p>
<h2>挑战与妥协：并非所有平台一步到位</h2>
<p>在推进 DWARF 5 的过程中，也遇到了一些平台兼容性问题，导致 Go 团队采取了审慎的策略：</p>
<ol>
<li><strong>macOS dsymutil 限制:</strong> 旧版本的 macOS Xcode 自带的 dsymutil 工具（用于处理和分离 DWARF 信息）不支持 DWARF 5 新引入的 .debug_rnglists 和 .debug_loclists section。这会导致在使用<strong>外部链接 (external linking)</strong> 构建 CGO 程序时，Go 代码的调试信息丢失。虽然 LLVM 17 (对应 Xcode 16+) 已修复此问题，但考虑到仍有大量开发者使用旧版 Xcode（官方支持最低到 Xcode 14），Go 团队决定<strong>在 macOS 和 iOS 平台上进行外部链接时，暂时回退到 DWARF 4</strong>。未来当最低支持的 Xcode 版本兼容 DWARF 5 后，有望统一。</li>
<li><strong>AIX 平台限制:</strong> AIX 使用的 XCOFF 文件格式本身不支持 DWARF 5 所需的 Section 类型。因此，<strong>AIX 平台将继续使用 DWARF 4</strong> (GOEXPERIMENT=nodwarf5 默认开启)。</li>
<li><strong>GNU objdump 兼容性:</strong> objdump 工具在解析 Go 生成的 monolithic .debug_addr section 时会打印警告（因为它期望每个编译单元都有一个 header，而 Go 链接器只生成一个）。这被认为是一个 objdump 的小问题（已提议向上游提交修复），不影响实际功能，因此 Go 团队决定继续采用 monolithic 方式。</li>
</ol>
<h2>对开发者的影响与总结</h2>
<p>对于大多数 Go 开发者而言，这项变更将在 Go 1.25 及以后版本中<strong>默认生效</strong>（除了上述 macOS 外部链接和 AIX 平台）。你将自动享受到<strong>更快的链接速度</strong>和<strong>略小的可执行文件</strong>。</p>
<ul>
<li><strong>调试体验:</strong> 虽然 DWARF 5 本身设计更优，但对日常使用 Delve 等调试器的直接体验影响可能不明显，主要好处体现在工具链效率和文件大小上。</li>
<li><strong>注意事项:</strong> 如果你在 macOS 上进行 CGO 开发并使用外部链接，或者面向 AIX 平台，需要了解调试信息格式仍将是 DWARF 4。</li>
</ul>
<p>总而言之，Go 工具链采纳 DWARF 5 是一个重要的里程碑。它不仅解决了旧格式的一些固有问题，提升了构建效率，也是 Go 语言紧跟底层技术标准发展、持续优化开发者体验的重要一步。这项历时多年的工作最终落地，体现了 Go 社区在推动技术演进方面的耐心和决心。</p>
<h2>参考资料</h2>
<ul>
<li><a href="https://github.com/golang/go/issues/26379">cmd/compile: consider using DWARF 5</a> &#8211; https://github.com/golang/go/issues/26379</li>
<li><a href="https://dwarfstd.org/dwarf5std.html">DWARF Version 5</a> &#8211; https://dwarfstd.org/dwarf5std.html</li>
</ul>
<hr />
<p><strong>聊聊你的编译构建体验</strong></p>
<p>Go 1.25 工具链的这项 DWARF 5 升级，虽然“藏”在幕后，但实实在在地为我们带来了链接速度和文件大小的优化。<strong>你在日常的 Go 项目开发中，是否也曾被编译链接速度或可执行文件体积困扰过？</strong> 你对 Go 工具链在这些方面的持续改进有什么期待或建议吗？或者，你是否了解其他能有效优化构建体验的技巧？</p>
<p><strong>欢迎在评论区分享你的经验、痛点与期待！</strong> 让我们共同见证 Go 工具链的进步。</p>
<p><strong>想深入探索Go的编译、链接与底层奥秘？</strong></p>
<p>如果你对 Go 工具链如何工作、编译优化、链接器原理，乃至像 DWARF 这样的底层细节充满兴趣，希望系统性地构建对 Go 语言“从源码到可执行文件”全链路的深刻理解&#8230;</p>
<p>那么，我的 <strong>「Go &amp; AI 精进营」知识星球</strong> 正是为你打造的深度学习平台！这里有【Go原理课】带你解密语言核心机制，【Go进阶课】助你掌握高级技巧，更有【Go避坑课】让你少走弯路。我会亲自为你解答各种疑难问题，你还可以与众多热爱钻研的Gopher们一同交流，探索Go的更多可能，包括它在AI等前沿领域的应用。</p>
<p><strong>扫码加入，与我们一同潜入Go的底层世界，成为更懂Go的开发者！</strong></p>
<p><img src="http://image.tonybai.com/img/tonybai/gopher-and-ai-tribe-zsxq-small-card.jpg" alt="img{512x368}" /></p>
<hr />
<p>商务合作方式：撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求，请扫描下方公众号二维码，与我私信联系。</p>
<p><img src="http://image.tonybai.com/img/tonybai/iamtonybai-wechat-qr.png" alt="" /></p>
<p style='text-align:left'>&copy; 2025, <a href='https://tonybai.com'>bigwhite</a>. 版权所有. </p>
]]></content:encoded>
			<wfw:commentRss>https://tonybai.com/2025/05/08/go-dwarf5/feed/</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<item>
		<title>惊！Go在十亿次循环和百万任务中表现不如Java，究竟为何？</title>
		<link>https://tonybai.com/2024/12/02/why-go-sucks/</link>
		<comments>https://tonybai.com/2024/12/02/why-go-sucks/#comments</comments>
		<pubDate>Sun, 01 Dec 2024 22:08:04 +0000</pubDate>
		<dc:creator>bigwhite</dc:creator>
				<category><![CDATA[技术志]]></category>
		<category><![CDATA[Atoi]]></category>
		<category><![CDATA[benchmark]]></category>
		<category><![CDATA[C]]></category>
		<category><![CDATA[centos]]></category>
		<category><![CDATA[Clang]]></category>
		<category><![CDATA[Compiler]]></category>
		<category><![CDATA[CPU]]></category>
		<category><![CDATA[GC]]></category>
		<category><![CDATA[GCC]]></category>
		<category><![CDATA[gcflags]]></category>
		<category><![CDATA[gnet]]></category>
		<category><![CDATA[Go]]></category>
		<category><![CDATA[Golang]]></category>
		<category><![CDATA[goroutine]]></category>
		<category><![CDATA[Intn]]></category>
		<category><![CDATA[Java]]></category>
		<category><![CDATA[lensm]]></category>
		<category><![CDATA[LoopUnrolling]]></category>
		<category><![CDATA[optimize]]></category>
		<category><![CDATA[OS]]></category>
		<category><![CDATA[rand]]></category>
		<category><![CDATA[random]]></category>
		<category><![CDATA[real]]></category>
		<category><![CDATA[RES]]></category>
		<category><![CDATA[runtime]]></category>
		<category><![CDATA[Rust]]></category>
		<category><![CDATA[strconv]]></category>
		<category><![CDATA[sys]]></category>
		<category><![CDATA[syscall]]></category>
		<category><![CDATA[time]]></category>
		<category><![CDATA[top]]></category>
		<category><![CDATA[Uint32]]></category>
		<category><![CDATA[User]]></category>
		<category><![CDATA[waitgroup]]></category>
		<category><![CDATA[优化]]></category>
		<category><![CDATA[内存]]></category>
		<category><![CDATA[寄存器]]></category>
		<category><![CDATA[循环]]></category>
		<category><![CDATA[循环展开]]></category>
		<category><![CDATA[指令]]></category>
		<category><![CDATA[栈]]></category>
		<category><![CDATA[流水线]]></category>
		<category><![CDATA[测试]]></category>
		<category><![CDATA[系统调用]]></category>
		<category><![CDATA[缓存]]></category>
		<category><![CDATA[编译器]]></category>
		<category><![CDATA[边界检查]]></category>
		<category><![CDATA[随机数]]></category>

		<guid isPermaLink="false">https://tonybai.com/?p=4418</guid>
		<description><![CDATA[本文永久链接 &#8211; https://tonybai.com/2024/12/02/why-go-sucks 编程语言比较的话题总是能吸引程序员的眼球！ 近期外网的两篇编程语言对比的文章在国内程序员圈里引起热议。一篇是由Ben Dicken (@BenjDicken) 做的语言性能测试，对比了十多种主流语言在执行10亿次循环(一个双层循环：1万 * 10 万)的速度；另一篇则是一个名为hez2010的开发者做的内存开销测试，对比了多种语言在处理百万任务时的内存开销。 下面是这两项测试的结果示意图： 10亿循环测试结果 百万任务内存开销测试结果 我们看到：在这两项测试中，Go的表现不仅远不及NonGC的C/Rust，甚至还落后于Java，尤其是在内存开销测试中，Go的内存使用显著高于以“吃内存”著称的Java。这一结果让许多开发者感到意外，因为Go通常被认为是轻量级的语言，然而实际的测试结果却揭示了其在高并发场景下的“内存效率不足”。 那么究竟为何在这两项测试中，Go的表现都不及预期呢？在这篇文章中，我将探讨可能的原因，以供大家参考。 我们先从十亿次循环测试开始。 1. 循环测试跑的慢，都因编译器优化还不够 下面是作者给出的Go测试程序： // why-go-sucks/billion-loops/go/code.go package main import ( "fmt" "math/rand" "os" "strconv" ) func main() { input, e := strconv.Atoi(os.Args[1]) // Get an input number from the command line if e != nil { panic(e) } u [...]]]></description>
			<content:encoded><![CDATA[<p><img src="https://tonybai.com/wp-content/uploads/why-go-sucks-1.png" alt="" /></p>
<p><a href="https://tonybai.com/2024/12/02/why-go-sucks">本文永久链接</a> &#8211; https://tonybai.com/2024/12/02/why-go-sucks</p>
<p>编程语言比较的话题总是能吸引程序员的眼球！</p>
<p>近期外网的两篇编程语言对比的文章在国内程序员圈里引起热议。一篇是由<a href="https://benjdd.com">Ben Dicken (@BenjDicken)</a> 做的<a href="https://benjdd.com/languages/">语言性能测试</a>，对比了十多种主流语言在执行10亿次循环(一个双层循环：1万 * 10 万)的速度；另一篇则是一个名为hez2010的开发者做的<a href="https://hez2010.github.io/async-runtimes-benchmarks-2024/">内存开销测试</a>，对比了多种语言在处理百万任务时的内存开销。</p>
<p>下面是这两项测试的结果示意图：</p>
<p><img src="https://tonybai.com/wp-content/uploads/why-go-sucks-2.png" alt="" /><br />
<center>10亿循环测试结果</center></p>
<p><img src="https://tonybai.com/wp-content/uploads/why-go-sucks-3.png" alt="" /><br />
<center>百万任务内存开销测试结果</center></p>
<p>我们看到：在这两项测试中，Go的表现不仅远不及NonGC的C/Rust，甚至还落后于Java，尤其是在内存开销测试中，Go的内存使用显著高于以“吃内存”著称的Java。这一结果让许多开发者感到意外，因为Go通常被认为是轻量级的语言，然而实际的测试结果却揭示了其在高并发场景下的“内存效率不足”。</p>
<p>那么究竟为何在这两项测试中，Go的表现都不及预期呢？在这篇文章中，我将探讨可能的原因，以供大家参考。</p>
<p>我们先从<strong>十亿次循环测试</strong>开始。</p>
<h2>1. 循环测试跑的慢，都因编译器优化还不够</h2>
<p>下面是作者给出的<a href="https://github.com/bddicken/languages/blob/main/loops/go/code.go">Go测试程序</a>：</p>
<pre><code>// why-go-sucks/billion-loops/go/code.go 

package main

import (
    "fmt"
    "math/rand"
    "os"
    "strconv"
)

func main() {
    input, e := strconv.Atoi(os.Args[1]) // Get an input number from the command line
    if e != nil {
        panic(e)
    }
    u := int32(input)
    r := int32(rand.Intn(10000))        // Get a random number 0 &lt;= r &lt; 10k
    var a [10000]int32                  // Array of 10k elements initialized to 0
    for i := int32(0); i &lt; 10000; i++ { // 10k outer loop iterations
        for j := int32(0); j &lt; 100000; j++ { // 100k inner loop iterations, per outer loop iteration
            a[i] = a[i] + j%u // Simple sum
        }
        a[i] += r // Add a random value to each element in array
    }
    fmt.Println(a[r]) // Print out a single element from the array
}
</code></pre>
<p>这段代码通过命令行参数获取一个整数，然后生成一个随机数，接着通过两层循环对一个数组的每个元素进行累加，最终输出该数组中以随机数为下标对应的数组元素的值。</p>
<p>我们再来看一下”竞争对手”的测试代码。C测试代码如下：</p>
<pre><code>// why-go-sucks/billion-loops/c/code.c

#include "stdio.h"
#include "stdlib.h"
#include "stdint.h"

int main (int argc, char** argv) {
  int u = atoi(argv[1]);               // Get an input number from the command line
  int r = rand() % 10000;              // Get a random integer 0 &lt;= r &lt; 10k
  int32_t a[10000] = {0};              // Array of 10k elements initialized to 0
  for (int i = 0; i &lt; 10000; i++) {    // 10k outer loop iterations
    for (int j = 0; j &lt; 100000; j++) { // 100k inner loop iterations, per outer loop iteration
      a[i] = a[i] + j%u;               // Simple sum
    }
    a[i] += r;                         // Add a random value to each element in array
  }
  printf("%d\n", a[r]);                // Print out a single element from the array
}
</code></pre>
<p>下面是Java的测试代码：</p>
<pre><code>// why-go-sucks/billion-loops/java/code.java

package jvm;

import java.util.Random;

public class code {

    public static void main(String[] args) {
        var u = Integer.parseInt(args[0]); // Get an input number from the command line
        var r = new Random().nextInt(10000); // Get a random number 0 &lt;= r &lt; 10k
        var a = new int[10000]; // Array of 10k elements initialized to 0
        for (var i = 0; i &lt; 10000; i++) { // 10k outer loop iterations
            for (var j = 0; j &lt; 100000; j++) { // 100k inner loop iterations, per outer loop iteration
                a[i] = a[i] + j % u; // Simple sum
            }
            a[i] += r; // Add a random value to each element in array
        }
        System.out.println(a[r]); // Print out a single element from the array
    }
}
</code></pre>
<p>你可能不熟悉C或Java，但从代码的形式上来看，C、Java与Go的代码确实处于“同等条件”。这不仅意味着它们在相同的硬件和软件环境中运行，更包括它们采用了相同的计算逻辑和算法，以及一致的输入参数处理等方面的相似性。</p>
<p>为了确认一下原作者的测试结果，我在一台阿里云ECS上(amd64，8c32g，CentOS 7.9)对上面三个程序进行了测试(使用time命令测量计算耗时)，得到一个基线结果。我的环境下，C、Java和Go的编译器版本如下：</p>
<pre><code>$go version
go version go1.23.0 linux/amd64

$java -version
openjdk version "17.0.9" 2023-10-17 LTS
OpenJDK Runtime Environment Zulu17.46+19-CA (build 17.0.9+8-LTS)
OpenJDK 64-Bit Server VM Zulu17.46+19-CA (build 17.0.9+8-LTS, mixed mode, sharing)

$gcc -v
使用内建 specs。
COLLECT_GCC=gcc
COLLECT_LTO_WRAPPER=/usr/libexec/gcc/x86_64-redhat-linux/4.8.5/lto-wrapper
目标：x86_64-redhat-linux
配置为：../configure --prefix=/usr --mandir=/usr/share/man --infodir=/usr/share/info --with-bugurl=http://bugzilla.redhat.com/bugzilla --enable-bootstrap --enable-shared --enable-threads=posix --enable-checking=release --with-system-zlib --enable-__cxa_atexit --disable-libunwind-exceptions --enable-gnu-unique-object --enable-linker-build-id --with-linker-hash-style=gnu --enable-languages=c,c++,objc,obj-c++,java,fortran,ada,go,lto --enable-plugin --enable-initfini-array --disable-libgcj --with-isl=/builddir/build/BUILD/gcc-4.8.5-20150702/obj-x86_64-redhat-linux/isl-install --with-cloog=/builddir/build/BUILD/gcc-4.8.5-20150702/obj-x86_64-redhat-linux/cloog-install --enable-gnu-indirect-function --with-tune=generic --with-arch_32=x86-64 --build=x86_64-redhat-linux
线程模型：posix
gcc 版本 4.8.5 20150623 (Red Hat 4.8.5-44) (GCC)
</code></pre>
<p>测试步骤与结果如下：</p>
<pre><code>Go代码测试：

$cd why-go-sucks/billion-loops/go
$go build -o code code.go
$time ./code 10
456953

real    0m3.766s
user    0m3.767s
sys 0m0.007s

C代码测试：

$cd why-go-sucks/billion-loops/c
$gcc -O3 -std=c99 -o code code.c
$time ./code 10
459383

real    0m3.005s
user    0m3.005s
sys 0m0.000s

Java代码测试：

$javac -d . code.java
$time java -cp . jvm.code 10
456181

real    0m3.105s
user    0m3.092s
sys 0m0.027s
</code></pre>
<p>从测试结果看到(基于real时间)：采用-O3优化的C代码最快，Java落后一个身位，而<strong>Go则比C慢了25%，比Java慢了21%</strong>。</p>
<blockquote>
<p>注：time命令的输出结果通常包含三个主要部分：real、user和sys。real是从命令开始执行到结束所经过的实际时间（墙钟时间），我们依次指标为准。user是程序在<strong>用户模式下执行所消耗的CPU时间</strong>。sys则是程序<strong>在内核模式下执行所消耗的CPU时间（系统调用）</strong>。如果总时间（real）略低于用户时间（user），这表明程序可能在某些时刻被调度或等待，而不是持续占用CPU。这种情况可能是由于输入输出操作、等待资源等原因。如果real时间显著小于user时间，这种情况通常发生在并发程序中，其中多个线程或进程在不同的时间段执行，导致总的用户CPU时间远大于实际的墙钟时间。sys时间保持较低，说明系统调用的频率较低，程序主要是执行计算而非进行大量的系统交互。</p>
</blockquote>
<p>这时作为Gopher的你可能会说：<strong>原作者编写的Go测试代码不够优化，我们能优化到比C还快</strong>！</p>
<p>大家都知道原代码是不够优化的，随意改改计算逻辑就能带来大幅提升。但我们不能忘了“同等条件”这个前提。你采用的优化方法，其他语言（C、Java）也可以采用。</p>
<p>那么，在不改变“同等条件”的前提下，我们还能优化点啥呢？本着能提升一点是一点的思路，我们尝试从下面几个点优化一下，看看效果：</p>
<ul>
<li>去除不必要的if判断</li>
<li>使用更快的rand实现</li>
<li>关闭边界检查</li>
<li>避免逃逸</li>
</ul>
<p>下面是修改之后的代码：</p>
<pre><code>// why-go-sucks/billion-loops/go/code_optimize.go 

package main

import (
    "fmt"
    "math/rand"
    "os"
    "strconv"
)

func main() {
    input, _ := strconv.Atoi(os.Args[1]) // Get an input number from the command line
    u := int32(input)
    r := int32(rand.Uint32() % 10000)   // Use Uint32 for faster random number generation
    var a [10000]int32                  // Array of 10k elements initialized to 0
    for i := int32(0); i &lt; 10000; i++ { // 10k outer loop iterations
        for j := int32(0); j &lt; 100000; j++ { // 100k inner loop iterations, per outer loop iteration
            a[i] = a[i] + j%u // Simple sum
        }
        a[i] += r // Add a random value to each element in array
    }
    z := a[r]
    fmt.Println(z) // Print out a single element from the array
}
</code></pre>
<p>我们编译并运行一下测试：</p>
<pre><code>$cd why-go-sucks/billion-loops/go
$go build -o code_optimize -gcflags '-B' code_optimize.go
$time ./code_optimize 10
459443

real    0m3.761s
user    0m3.759s
sys 0m0.011s
</code></pre>
<p>对比一下最初的测试结果，这些“所谓的优化”没有什么卵用，优化前你估计也能猜测到这个结果，因为除了关闭边界检查，其他优化都<strong>没有处于循环执行的热路径之上</strong>。</p>
<blockquote>
<p>注：rand.Uint32() % 10000的确要比rand.Intn(10000)快，我自己的benchmark结果是快约1倍。</p>
</blockquote>
<p>那Go程序究竟慢在哪里呢？在“同等条件”下，我能想到的只能是<strong>Go编译器后端在代码优化方面优化做的还不够</strong>，相较于GCC、Java等老牌编译器还有明显差距。</p>
<p>比如说，原先的代码中在内层循环中频繁访问a&#91;i&#93;，导致数组访问的读写操作较多（从内存加载a&#91;i&#93;，更新值后写回）。GCC和Java编译器在后端很可能做了这样的优化：将数组元素累积到一个临时变量中，并在外层循环结束后写回数组，这样做可以<strong>减少内层循环中的内存读写操作，充分利用CPU缓存和寄存器，加速数据处理</strong>。</p>
<blockquote>
<p>注：数组从内存或缓存读，而一个临时变量很大可能是从寄存器读，那读取速度相差还是很大的。</p>
</blockquote>
<p>如果我们手工在Go中实施这一优化，看看能达到什么效果呢？我们改一下最初版本的Go代码(code.go)，新代码如下：</p>
<pre><code>// why-go-sucks/billion-loops/go/code_local_var.go 

package main

import (
    "fmt"
    "math/rand"
    "os"
    "strconv"
)

func main() {
    input, e := strconv.Atoi(os.Args[1]) // Get an input number from the command line
    if e != nil {
        panic(e)
    }
    u := int32(input)
    r := int32(rand.Intn(10000))        // Get a random number 0 &lt;= r &lt; 10k
    var a [10000]int32                  // Array of 10k elements initialized to 0
    for i := int32(0); i &lt; 10000; i++ { // 10k outer loop iterations
        temp := a[i]
        for j := int32(0); j &lt; 100000; j++ { // 100k inner loop iterations, per outer loop iteration
            temp += j % u // Simple sum
        }
        temp += r // Add a random value to each element in array
        a[i] = temp
    }
    fmt.Println(a[r]) // Print out a single element from the array
}
</code></pre>
<p>编译并运行测试：</p>
<pre><code>$go build -o code_local_var code_local_var.go
$time ./code_local_var 10
459169

real    0m3.017s
user    0m3.017s
sys 0m0.007s
</code></pre>
<p>我们看到，测试结果直接就比Java略好一些了。显然Go编译器没有做这种优化，从code.go的汇编也大致可以看出来：</p>
<p><img src="https://tonybai.com/wp-content/uploads/why-go-sucks-4.png" alt="" /><br />
<center>使用<a href="https://github.com/loov/lensm">lensm</a>生成的汇编与go源码对应关系</center></p>
<p>而Java显然做了这类优化，我们在原Java代码版本上按上述优化逻辑修改了一下：</p>
<pre><code>// why-go-sucks/billion-loops/java/code_local_var.java

package jvm;

import java.util.Random;

public class code {

    public static void main(String[] args) {
        var u = Integer.parseInt(args[0]); // 获取命令行输入的整数
        var r = new Random().nextInt(10000); // 生成随机数 0 &lt;= r &lt; 10000
        var a = new int[10000]; // 定义长度为10000的数组a

        for (var i = 0; i &lt; 10000; i++) { // 10k外层循环迭代
            var temp = a[i]; // 使用临时变量存储 a[i] 的值
            for (var j = 0; j &lt; 100000; j++) { // 100k内层循环迭代，每次外层循环迭代
                temp += j % u; // 更新临时变量的值
            }
            a[i] = temp + r; // 将临时变量的值加上 r 并写回数组
        }
        System.out.println(a[r]); // 输出 a[r] 的值
    }
}
</code></pre>
<p>但从运行这个“优化”后的程序的结果来看，其对java代码的提升幅度几乎可以忽略不计：</p>
<pre><code>$time java -cp . jvm.code 10
450375

real    0m3.043s
user    0m3.028s
sys 0m0.027s
</code></pre>
<p>这也直接证明了即便采用的是原版java代码，java编译器也会生成带有抽取局部变量这种优化的可执行代码，java程序员无需手工进行此类优化。</p>
<p>像这种编译器优化，还有不少，比如大家比较熟悉的循环展开(Loop Unrolling)也可以提升Go程序的性能：</p>
<pre><code>// why-go-sucks/billion-loops/go/code_loop_unrolling.go

package main

import (
    "fmt"
    "math/rand"
    "os"
    "strconv"
)

func main() {
    input, e := strconv.Atoi(os.Args[1]) // Get an input number from the command line
    if e != nil {
        panic(e)
    }
    u := int32(input)
    r := int32(rand.Intn(10000))        // Get a random number 0 &lt;= r &lt; 10k
    var a [10000]int32                  // Array of 10k elements initialized to 0
    for i := int32(0); i &lt; 10000; i++ { // 10k outer loop iterations
        var sum int32
        // Unroll inner loop in chunks of 4 for optimization
        for j := int32(0); j &lt; 100000; j += 4 {
            sum += j % u
            sum += (j + 1) % u
            sum += (j + 2) % u
            sum += (j + 3) % u
        }
        a[i] = sum + r // Add the accumulated sum and random value
    }

    fmt.Println(a[r]) // Print out a single element from the array
}
</code></pre>
<p>运行这个Go测试程序，性能如下：</p>
<pre><code>$go build -o code_loop_unrolling code_loop_unrolling.go
$time ./code_loop_unrolling 10
458908

real    0m2.937s
user    0m2.940s
sys 0m0.002s
</code></pre>
<p>循环展开可以增加指令级并行性，因为展开后的代码块中可以有更多的独立指令，比如示例中的计算j % u、(j+1) % u、(j+2) % u和(j+3) % u，这些计算操作是独立的，可以并行执行，打破了依赖链，从而更好地利用处理器的并行流水线。而原版Go代码中，每次迭代都会根据前一次迭代的结果更新a&#91;i&#93;，形成一个依赖链，这种顺序依赖性迫使处理器只能按顺序执行这些指令，导致流水线停顿。</p>
<p>不过其他语言也可以做同样的手工优化，比如我们对C代码做同样的优化(why-go-sucks/billion-loops/c/code_loop_unrolling.c)，c测试程序的性能可以提升至2.7s水平，这也证明了初版C程序即便在-O3的情况下编译器也没有自动为其做这个优化：</p>
<pre><code>$time ./code_loop_unrolling 10
459383

real    0m2.723s
user    0m2.722s
sys 0m0.001s
</code></pre>
<p>到这里我们就不再针对这个10亿次循环的性能问题做进一步展开了，从上面的探索得到的初步结论就是<strong>Go编译器优化做的还不到位所致</strong>，期待后续Go团队能在编译器优化方面投入更多精力，争取早日追上GCC/Clang、Java这些成熟的编译器优化水平。</p>
<p>下面我们再来看Go在百万任务场景下内存开销大的“问题”。</p>
<h2>2. 内存占用高，问题出在Goroutine实现原理</h2>
<p>我们先来看第二个问题的测试代码：</p>
<pre><code>package main

import (
    "fmt"
    "os"
    "strconv"
    "sync"
    "time"
)

func main() {
    numRoutines := 100000
    if len(os.Args) &gt; 1 {
        n, err := strconv.Atoi(os.Args[1])
        if err == nil {
            numRoutines = n
        }
    }

    var wg sync.WaitGroup
    for i := 0; i &lt; numRoutines; i++ {
        wg.Add(1)
        go func() {
            time.Sleep(10 * time.Second)
            wg.Done()
        }()
    }
    wg.Wait()
}
</code></pre>
<p>这个代码其实就是根据传入的task数量启动等同数量的goroutine，然后每个goroutine模拟工作负载sleep 10s，这等效于百万长连接的场景，只有连接，但没有收发消息。</p>
<p>相对于上一个问题，这个问题更好解释一些。</p>
<p>Go使用的groutine是一种有栈协程，文章中使用的是每个task一个goroutine的模型，且维护百万任务一段时间，这会真实创建百万个goroutine（G数据结构），并为其分配栈空间(2k起步)，这样你可以算一算，不考虑其他结构的占用，仅每个goroutine的栈空间所需的内存都是极其可观的：</p>
<pre><code>mem = 1000000 * 2000 Bytes = 2000000000 Bytes = 2G Bytes
</code></pre>
<p>所以启动100w goroutine，保底就2GB内存出去了，这与原作者测试的结果十分契合(原文是2.5GB多)。并且，内存还会随着goroutine数量增长而线性增加。</p>
<p>那么如何能减少内存使用呢？如果采用每个task一个goroutine的模型，这个内存占用很难省去，除非将来Go团队对goroutine实现做大修。</p>
<p>如果task是网络通信相关的，可以使用类似gnet这样的直接基于epoll建构的框架，其主要的节省在于不会启动那么多goroutine，而是通过一个goroutine池来处理数据，每个池中的goroutine负责一批网络连接或网络请求。</p>
<p>在一些Gopher的印象中，Goroutine一旦分配就不回收，这会使他们会误认为一旦分配了100w goroutine，这2.5G内存空间将始终被占用，真实情况是这样么？我们用一个示例程序验证一下就好了：</p>
<pre><code>// why-go-sucks/million-tasks/million-tasks.go

package main

import (
    "fmt"
    "log"
    "os"
    "os/signal"
    "runtime"
    "sync"
    "syscall"
    "time"
)

// 打印当前内存使用情况和相关信息
func printMemoryUsage() {
    var m runtime.MemStats
    runtime.ReadMemStats(&amp;m)

    // 获取当前 goroutine 数量
    numGoroutines := runtime.NumGoroutine()

    // 获取当前线程数量
    numThreads := runtime.NumCPU() // Go runtime 不直接提供线程数量，但可以通过 NumCPU 获取逻辑处理器数量

    fmt.Printf("======&gt;\n")
    fmt.Printf("Alloc = %v MiB", bToMb(m.Alloc))
    fmt.Printf("\tTotalAlloc = %v MiB", bToMb(m.TotalAlloc))
    fmt.Printf("\tSys = %v MiB", bToMb(m.Sys))
    fmt.Printf("\tNumGC = %v", m.NumGC)
    fmt.Printf("\tNumGoroutines = %v", numGoroutines)
    fmt.Printf("\tNumThreads = %v\n", numThreads)
    fmt.Printf("&lt;======\n\n")
}

// 将字节转换为 MB
func bToMb(b uint64) uint64 {
    return b / 1024 / 1024
}

func main() {
    const signal1Goroutines = 900000
    const signal2Goroutines = 90000
    const signal3Goroutines = 10000

    // 用于接收退出信号
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)

    // 控制 goroutine 的退出
    signal1Chan := make(chan struct{})
    signal2Chan := make(chan struct{})
    signal3Chan := make(chan struct{})

    var wg sync.WaitGroup
    ticker := time.NewTicker(5 * time.Second)
    go func() {
        for range ticker.C {
            printMemoryUsage()
        }
    }()

    // 等待退出信号
    go func() {
        count := 0
        for {
            &lt;-sigChan
            count++
            if count == 1 {
                log.Println("收到第一类goroutine退出信号")
                close(signal1Chan) // 关闭 signal1Chan，通知第一类 goroutine 退出
                continue
            }
            if count == 2 {
                log.Println("收到第二类goroutine退出信号")
                close(signal2Chan) // 关闭 signal2Chan，通知第二类 goroutine 退出
                continue
            }
            log.Println("收到第三类goroutine退出信号")
            close(signal3Chan) // 关闭 signal3Chan，通知第三类 goroutine 退出
            return
        }
    }()

    // 启动第一类 goroutine（在收到 signal1 时退出）
    log.Println("开始启动第一类goroutine...")
    for i := 0; i &lt; signal1Goroutines; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            // 模拟工作
            for {
                select {
                case &lt;-signal1Chan:
                    return
                default:
                    time.Sleep(10 * time.Second) // 模拟一些工作
                }
            }
        }(i)
    }
    log.Println("启动第一类goroutine(900000) ok")

    time.Sleep(time.Second * 5)

    // 启动第二类 goroutine（在收到 signal2 时退出）
    log.Println("开始启动第二类goroutine...")
    for i := 0; i &lt; signal2Goroutines; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            // 模拟工作
            for {
                select {
                case &lt;-signal2Chan:
                    return
                default:
                    time.Sleep(10 * time.Second) // 模拟一些工作
                }
            }
        }(i)
    }
    log.Println("启动第二类goroutine(90000) ok")

    time.Sleep(time.Second * 5)

    // 启动第三类goroutine（随程序退出而退出）
    log.Println("开始启动第三类goroutine...")
    for i := 0; i &lt; signal3Goroutines; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            // 模拟工作
            for {
                select {
                case &lt;-signal3Chan:
                    return
                default:
                    time.Sleep(10 * time.Second) // 模拟一些工作
                }
            }
        }(i)
    }
    log.Println("启动第三类goroutine(90000) ok")

    // 等待所有 goroutine 完成
    wg.Wait()
    fmt.Println("所有 goroutine 已退出，程序结束")
}
</code></pre>
<p>这个程序我就不详细解释了。大致分三类goroutine，第一类90w个，在我发送第一个ctrl+c信号后退出，第二类9w个，在我发送第二个ctrl+c信号后退出，最后一类1w个，随着程序退出而退出。</p>
<p>在我的执行环境下编译和执行一下这个程序，并结合runtime输出以及使用top -p pid的方式查看其内存占用：</p>
<pre><code>$go build million-tasks.go
$./million-tasks 

2024/12/01 22:07:03 开始启动第一类goroutine...
2024/12/01 22:07:05 启动第一类goroutine(900000) ok
======&gt;
Alloc = 511 MiB TotalAlloc = 602 MiB    Sys = 2311 MiB  NumGC = 9   NumGoroutines = 900004  NumThreads = 8
&lt;======

2024/12/01 22:07:10 开始启动第二类goroutine...
2024/12/01 22:07:11 启动第二类goroutine(90000) ok
======&gt;
Alloc = 577 MiB TotalAlloc = 668 MiB    Sys = 2553 MiB  NumGC = 9   NumGoroutines = 990004  NumThreads = 8
&lt;======

2024/12/01 22:07:16 开始启动第三类goroutine...
2024/12/01 22:07:16 启动第三类goroutine(90000) ok
======&gt;
Alloc = 597 MiB TotalAlloc = 688 MiB    Sys = 2593 MiB  NumGC = 9   NumGoroutines = 1000004 NumThreads = 8
&lt;======

======&gt;
Alloc = 600 MiB TotalAlloc = 690 MiB    Sys = 2597 MiB  NumGC = 9   NumGoroutines = 1000004 NumThreads = 8
&lt;======
... ...

======&gt;
Alloc = 536 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 10  NumGoroutines = 1000004 NumThreads = 8
&lt;======
</code></pre>
<p>100w goroutine全部创建ok后，我们查看一下top输出：</p>
<pre><code>  PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
 5800 root      20   0 3875556   2.5g    988 S  54.0  8.2   0:30.92 million-tasks
</code></pre>
<p>我们看到RES为2.5g，和我们预期的一致！</p>
<p>接下来，我们停掉第一批90w个goroutine，看RES是否会下降，何时会下降！</p>
<p>输入ctrl+c，停掉第一批90w goroutine：</p>
<pre><code>^C2024/12/01 22:10:15 收到第一类goroutine退出信号
======&gt;
Alloc = 536 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 10  NumGoroutines = 723198  NumThreads = 8
&lt;======

======&gt;
Alloc = 536 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 10  NumGoroutines = 723198  NumThreads = 8
&lt;======

======&gt;
Alloc = 536 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 10  NumGoroutines = 100004  NumThreads = 8
&lt;======

======&gt;
Alloc = 536 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 10  NumGoroutines = 100004  NumThreads = 8
&lt;======
... ...
</code></pre>
<p>但同时刻的top显示RES并没有变化：</p>
<pre><code>  PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
 5800 root      20   0 3875812   2.5g    988 S   0.0  8.2   0:56.38 million-tasks
</code></pre>
<p>等待两个GC间隔的时间后(大约4分)，Goroutine的栈空间被释放：</p>
<pre><code>======&gt;
Alloc = 465 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 12  NumGoroutines = 100004  NumThreads = 8
&lt;======

======&gt;
Alloc = 465 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 12  NumGoroutines = 100004  NumThreads = 8
&lt;======

======&gt;
Alloc = 465 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 12  NumGoroutines = 100004  NumThreads = 8
&lt;======

======&gt;
Alloc = 465 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 12  NumGoroutines = 100004  NumThreads = 8
&lt;======
</code></pre>
<p>top显示RES从2.5g下降为大概700多MB（RES的单位是KB）：</p>
<pre><code>  PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
 5800 root      20   0 3875812 764136    992 S   0.0  2.4   1:01.87 million-tasks
</code></pre>
<p>接下来，我们再停掉第二批9w goroutine：</p>
<pre><code>^C2024/12/01 22:16:21 收到第二类goroutine退出信号
======&gt;
Alloc = 465 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 13  NumGoroutines = 100004  NumThreads = 8
&lt;======

======&gt;
Alloc = 465 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 13  NumGoroutines = 100004  NumThreads = 8
&lt;======

======&gt;
Alloc = 465 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 13  NumGoroutines = 10004   NumThreads = 8
&lt;======

======&gt;
Alloc = 465 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 13  NumGoroutines = 10004   NumThreads = 8
&lt;======

</code></pre>
<p>此时，top值也没立即改变：</p>
<pre><code>  PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
 5800 root      20   0 3875812 764136    992 S   0.0  2.4   1:05.99 million-tasks
</code></pre>
<p>大约等待一个GC间隔(2分钟)后，top中RES下降：</p>
<pre><code>======&gt;
Alloc = 458 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 14  NumGoroutines = 10004   NumThreads = 8
&lt;======

======&gt;
Alloc = 458 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 14  NumGoroutines = 10004   NumThreads = 8
&lt;======

======&gt;
Alloc = 458 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 14  NumGoroutines = 10004   NumThreads = 8
&lt;======
</code></pre>
<p>RES变为不到700M：</p>
<pre><code>  PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
 5800 root      20   0 3875812 699156    992 S   0.0  2.2   1:06.75 million-tasks
</code></pre>
<p>第三次按下ctrl+c，程序退出：</p>
<pre><code>^C2024/12/01 22:18:46 收到第三类goroutine退出信号
======&gt;
Alloc = 458 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 14  NumGoroutines = 10003   NumThreads = 8
&lt;======

======&gt;
Alloc = 458 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 14  NumGoroutines = 10003   NumThreads = 8
&lt;======

所有 goroutine 已退出，程序结束
</code></pre>
<p>我们看到Go是会回收goroutine占用的内存空间的，并且归还给OS，只是这种归还比较lazy。尤其是，第二次停止goroutine前，go程序剩下10w goroutine，按理论来讲需占用大约200MB的空间，实际上却是700多MB；第二次停止goroutine后，goroutine数量降为1w，理论占用应该在20MB，但实际却是600多MB，我们看到go运行时这种lazy归还OS内存的行为可能也是“故意为之”，是为了避免反复从OS申请和归还内存。</p>
<h2>3. 小结</h2>
<p>本文主要探讨了Go语言在十亿次循环和百万任务的测试中的表现令人意外地逊色于Java和C语言的原因。我认为Go在循环执行中的慢速表现，主要是其编译器优化不足，影响了执行效率。 而在内存开销方面，Go的Goroutine实现是使得内存使用量大幅增加的“罪魁祸首”，这是由于每个Goroutine初始都会分配固定大小的栈空间。</p>
<p>通过本文的探讨，我的主要目的是希望大家不要以讹传讹，而是要搞清楚背后的真正原因，并正视Go在某些方面的不足，以及其当前在某些应用上下文中的局限性。 同时，也希望Go开发团队在编译器优化方面进行更多投入，以提升Go在高性能计算领域的竞争力。</p>
<p>本文涉及的源码可以在<a href="https://github.com/bigwhite/experiments/tree/master/why-go-sucks">这里</a>下载。</p>
<h2>4. 参考资料</h2>
<ul>
<li><a href="https://benjdd.com/languages/">Billion nested loop iterations</a> &#8211; https://benjdd.com/languages/</li>
<li><a href="https://hez2010.github.io/async-runtimes-benchmarks-2024/">How Much Memory Do You Need in 2024 to Run 1 Million Concurrent Tasks?</a> &#8211; https://hez2010.github.io/async-runtimes-benchmarks-2024/</li>
</ul>
<hr />
<p><a href="https://public.zsxq.com/groups/51284458844544">Gopher部落知识星球</a>在2024年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。同时，我们也会加强代码质量和最佳实践的分享，包括如何编写简洁、可读、可测试的Go代码。此外，我们还会加强星友之间的交流和互动。欢迎大家踊跃提问，分享心得，讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾。让我相聚在Gopher部落，享受coding的快乐! 欢迎大家踊跃加入！</p>
<p><img src="http://image.tonybai.com/img/tonybai/gopher-tribe-zsxq-small-card.png" alt="img{512x368}" /><br />
<img src="http://image.tonybai.com/img/tonybai/go-programming-from-beginner-to-master-qr.png" alt="img{512x368}" /></p>
<p><img src="http://image.tonybai.com/img/tonybai/go-first-course-banner.png" alt="img{512x368}" /><br />
<img src="http://image.tonybai.com/img/tonybai/imooc-go-column-pgo-with-qr.jpg" alt="img{512x368}" /></p>
<p>著名云主机服务厂商DigitalOcean发布最新的主机计划，入门级Droplet配置升级为：1 core CPU、1G内存、25G高速SSD，价格5$/月。有使用DigitalOcean需求的朋友，可以打开这个<a href="https://m.do.co/c/bff6eed92687">链接地址</a>：https://m.do.co/c/bff6eed92687 开启你的DO主机之路。</p>
<p>Gopher Daily(Gopher每日新闻) &#8211; https://gopherdaily.tonybai.com</p>
<p>我的联系方式：</p>
<ul>
<li>微博(暂不可用)：https://weibo.com/bigwhite20xx</li>
<li>微博2：https://weibo.com/u/6484441286</li>
<li>博客：tonybai.com</li>
<li>github: https://github.com/bigwhite</li>
<li>Gopher Daily归档 &#8211; https://github.com/bigwhite/gopherdaily</li>
<li>Gopher Daily Feed订阅 &#8211; https://gopherdaily.tonybai.com/feed</li>
</ul>
<p><img src="http://image.tonybai.com/img/tonybai/iamtonybai-wechat-qr.png" alt="" /></p>
<p>商务合作方式：撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。</p>
<p style='text-align:left'>&copy; 2024, <a href='https://tonybai.com'>bigwhite</a>. 版权所有. </p>
]]></content:encoded>
			<wfw:commentRss>https://tonybai.com/2024/12/02/why-go-sucks/feed/</wfw:commentRss>
		<slash:comments>2</slash:comments>
		</item>
		<item>
		<title>Go编译的几个细节，连专家也要停下来想想</title>
		<link>https://tonybai.com/2024/11/11/some-details-about-go-compilation/</link>
		<comments>https://tonybai.com/2024/11/11/some-details-about-go-compilation/#comments</comments>
		<pubDate>Sun, 10 Nov 2024 22:13:45 +0000</pubDate>
		<dc:creator>bigwhite</dc:creator>
				<category><![CDATA[技术志]]></category>
		<category><![CDATA[.NET]]></category>
		<category><![CDATA[archive]]></category>
		<category><![CDATA[C]]></category>
		<category><![CDATA[centos]]></category>
		<category><![CDATA[CFLAGS]]></category>
		<category><![CDATA[Cgo]]></category>
		<category><![CDATA[CGO_ENABLED]]></category>
		<category><![CDATA[Compiler]]></category>
		<category><![CDATA[crypto]]></category>
		<category><![CDATA[DWARF]]></category>
		<category><![CDATA[expvar]]></category>
		<category><![CDATA[fmt]]></category>
		<category><![CDATA[GCC]]></category>
		<category><![CDATA[gcflags]]></category>
		<category><![CDATA[getaddrinfo]]></category>
		<category><![CDATA[getnameinfo]]></category>
		<category><![CDATA[Glibc]]></category>
		<category><![CDATA[glibc-static]]></category>
		<category><![CDATA[Go]]></category>
		<category><![CDATA[go-sqlite3]]></category>
		<category><![CDATA[go1.23]]></category>
		<category><![CDATA[gobuild]]></category>
		<category><![CDATA[Golang]]></category>
		<category><![CDATA[golist]]></category>
		<category><![CDATA[gomodule]]></category>
		<category><![CDATA[helloworld]]></category>
		<category><![CDATA[import]]></category>
		<category><![CDATA[init]]></category>
		<category><![CDATA[inittask]]></category>
		<category><![CDATA[Inline]]></category>
		<category><![CDATA[ldd]]></category>
		<category><![CDATA[ldflags]]></category>
		<category><![CDATA[LD_LIBRARY_PATH]]></category>
		<category><![CDATA[libresolv.so]]></category>
		<category><![CDATA[linker]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[Make]]></category>
		<category><![CDATA[Makefile]]></category>
		<category><![CDATA[NameResolution]]></category>
		<category><![CDATA[nm]]></category>
		<category><![CDATA[Package]]></category>
		<category><![CDATA[sqlite-devel]]></category>
		<category><![CDATA[sqlite3]]></category>
		<category><![CDATA[TinyGo]]></category>
		<category><![CDATA[yum]]></category>
		<category><![CDATA[优化]]></category>
		<category><![CDATA[内联]]></category>
		<category><![CDATA[动态链接]]></category>
		<category><![CDATA[包]]></category>
		<category><![CDATA[死码消除]]></category>
		<category><![CDATA[编译器]]></category>
		<category><![CDATA[静态链接]]></category>

		<guid isPermaLink="false">https://tonybai.com/?p=4383</guid>
		<description><![CDATA[本文永久链接 &#8211; https://tonybai.com/2024/11/11/some-details-about-go-compilation 在Go开发中，编译相关的问题看似简单，但实则蕴含许多细节。有时，即使是Go专家也需要停下来，花时间思考答案或亲自验证。本文将通过几个具体问题，和大家一起探讨Go编译过程中的一些你可能之前未曾关注的细节。 注：本文示例使用的环境为Go 1.23.0、Linux Kernel 3.10.0和CentOS 7.9。 1. Go编译默认采用静态链接还是动态链接？ 我们来看第一个问题：Go编译默认采用静态链接还是动态链接呢？ 很多人脱口而出：动态链接，因为CGO_ENABLED默认值为1，即开启Cgo。也有些人会说：“其实Go编译器默认是静态链接的，只有在使用C语言库时才会动态链接”。那么到底哪个是正确的呢？ 我们来看一个具体的示例。但在这之前，我们要承认一个事实，那就是CGO_ENABLED默认值为1，你可以通过下面命令来验证这一点： $go env&#124;grep CGO_ENABLED CGO_ENABLED='1' 验证Go默认究竟是哪种链接，我们写一个hello, world的Go程序即可： // go-compilation/main.go package main import "fmt" func main() { fmt.Println("hello, world") } 构建该程序： $go build -o helloworld-default main.go 之后，我们查看一下生成的可执行文件helloworld-default的文件属性： $file helloworld-default helloworld-default: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped $ldd [...]]]></description>
			<content:encoded><![CDATA[<p><img src="https://tonybai.com/wp-content/uploads/some-details-about-go-compilation-1.png" alt="" /></p>
<p><a href="https://tonybai.com/2024/11/11/some-details-about-go-compilation">本文永久链接</a> &#8211; https://tonybai.com/2024/11/11/some-details-about-go-compilation</p>
<p>在Go开发中，编译相关的问题看似简单，但实则蕴含许多细节。有时，即使是Go专家也需要停下来，花时间思考答案或亲自验证。本文将通过几个具体问题，和大家一起探讨Go编译过程中的一些你可能之前未曾关注的细节。</p>
<blockquote>
<p>注：本文示例使用的环境为<a href="https://tonybai.com/2024/08/19/some-changes-in-go-1-23/">Go 1.23.0</a>、Linux Kernel 3.10.0和CentOS 7.9。</p>
</blockquote>
<h2>1. Go编译默认采用静态链接还是动态链接？</h2>
<p>我们来看第一个问题：Go编译默认采用静态链接还是动态链接呢？</p>
<p>很多人脱口而出：<a href="https://tonybai.com/2011/07/07/also-talk-about-shared-library-2/">动态链接</a>，因为CGO_ENABLED默认值为1，即开启Cgo。也有些人会说：“其实Go编译器默认是静态链接的，只有在使用C语言库时才会动态链接”。那么到底哪个是正确的呢？</p>
<p>我们来看一个具体的示例。但在这之前，我们要承认一个事实，那就是CGO_ENABLED默认值为1，你可以通过下面命令来验证这一点：</p>
<pre><code>$go env|grep CGO_ENABLED
CGO_ENABLED='1'
</code></pre>
<p>验证Go默认究竟是哪种链接，我们写一个hello, world的Go程序即可：</p>
<pre><code>// go-compilation/main.go

package main

import "fmt"

func main() {
    fmt.Println("hello, world")
}
</code></pre>
<p>构建该程序：</p>
<pre><code>$go build -o helloworld-default main.go
</code></pre>
<p>之后，我们查看一下生成的可执行文件helloworld-default的文件属性：</p>
<pre><code>$file helloworld-default
helloworld-default: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped
$ldd helloworld-default
   不是动态可执行文件
</code></pre>
<p>我们看到，虽然CGO_ENABLED=1，但默认情况下，Go构建出的helloworld程序是静态链接的(statically linked)。</p>
<p>那么默认情况下，Go编译器是否都会采用静态链接的方式来构建Go程序呢？我们给上面的main.go添加一行代码：</p>
<pre><code>// go-compilation/main-with-os-user.go

package main

import (
    "fmt"
    _ "os/user"
)

func main() {
    fmt.Println("hello, world")
}
</code></pre>
<p>和之前的hello, world不同的是，这段代码多了一行<strong>包的空导入</strong>，导入的是os/user这个包。</p>
<p>编译这段代码，我们得到helloworld-with-os-user可执行文件。</p>
<pre><code>$go build -o helloworld-with-os-user main-with-os-user.go
</code></pre>
<p>使用file和ldd检视文件helloworld-with-os-user：</p>
<pre><code>$file helloworld-with-os-user
helloworld-with-os-user: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), not stripped

$ldd helloworld-with-os-user
    linux-vdso.so.1 =&gt;  (0x00007ffcb8fd4000)
    libpthread.so.0 =&gt; /lib64/libpthread.so.0 (0x00007fb5d6fce000)
    libc.so.6 =&gt; /lib64/libc.so.6 (0x00007fb5d6c00000)
    /lib64/ld-linux-x86-64.so.2 (0x00007fb5d71ea000)
</code></pre>
<p>我们看到：<strong>一行新代码居然让helloworld从静态链接变为了动态链接</strong>，同时这也是如何编译出一个hello world版的动态链接Go程序的答案。</p>
<p>通过nm命令我们还可以查看Go程序依赖了哪些C库的符号：</p>
<pre><code>$nm -a helloworld-with-os-user |grep " U "
                 U abort
                 U __errno_location
                 U fprintf
                 U fputc
                 U free
                 U fwrite
                 U malloc
                 U mmap
                 U munmap
                 U nanosleep
                 U pthread_attr_destroy
                 U pthread_attr_getstack
                 U pthread_attr_getstacksize
                 U pthread_attr_init
                 U pthread_cond_broadcast
                 U pthread_cond_wait
                 U pthread_create
                 U pthread_detach
                 U pthread_getattr_np
                 U pthread_key_create
                 U pthread_mutex_lock
                 U pthread_mutex_unlock
                 U pthread_self
                 U pthread_setspecific
                 U pthread_sigmask
                 U setenv
                 U sigaction
                 U sigaddset
                 U sigemptyset
                 U sigfillset
                 U sigismember
                 U stderr
                 U strerror
                 U unsetenv
                 U vfprintf
</code></pre>
<p>由此，我们可以得到一个结论，在默认情况下(CGO_ENABLED=1)，Go会尽力使用静态链接的方式，但在某些情况下，会采用动态链接。那么究竟在哪些情况下会默认生成动态链接的程序呢？我们继续往下看。</p>
<h2>2. 在何种情况下默认会生成动态链接的Go程序？</h2>
<p>在以下几种情况下，Go编译器会默认(CGO_ENABLED=1)生成动态链接的可执行文件，我们逐一来看一下。</p>
<h3>2.1 一些使用C实现的标准库包</h3>
<p>根据上述示例，我们可以看到，在某些情况下，即使只依赖标准库，Go 仍会在CGO_ENABLED=1的情况下采用动态链接。这是因为代码依赖的标准库包使用了C版本的实现。虽然这种情况并不常见，但<a href="https://pkg.go.dev/os/user">os/user包</a>和<a href="https://pkg.go.dev/net">net包</a>是两个典型的例子。</p>
<p>os/user包的示例在前面我们已经见识过了。user包允许开发者通过名称或ID查找用户账户。对于大多数Unix系统(包括linux)，该包内部有两种版本的实现，用于解析用户和组ID到名称，并列出附加组ID。一种是用纯Go编写，解析/etc/passwd和/etc/group文件。另一种是基于cgo的，依赖于标准C库（libc）中的例程，如getpwuid_r、getgrnam_r和getgrouplist。当cgo可用(CGO_ENABLED=1)，并且特定平台的libc实现了所需的例程时，将使用基于cgo的（libc支持的）代码，即采用动态链接方式。</p>
<p>同样，net包在名称解析(Name Resolution，即域名或主机名对应IP查找)上针对大多数Unix系统也有两个版本的实现：一个是纯Go版本，另一个是基于C的版本。C版本会在cgo可用且特定平台实现了相关C函数(比如getaddrinfo和getnameinfo等)时使用。</p>
<p>下面是一个简单的使用net包并采用动态链接的示例：</p>
<pre><code>// go-compilation/main-with-net.go

package main

import (
    "fmt"
    _ "net"
)

func main() {
    fmt.Println("hello, world")
}
</code></pre>
<p>编译后，我们查看一下文件属性：</p>
<pre><code>$go build -o helloworld-with-net main-with-net.go 

$file helloworld-with-net
helloworld-with-net: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), not stripped

$ldd helloworld-with-net
    linux-vdso.so.1 =&gt;  (0x00007ffd75dfd000)
    libresolv.so.2 =&gt; /lib64/libresolv.so.2 (0x00007fdda2cf9000)
    libpthread.so.0 =&gt; /lib64/libpthread.so.0 (0x00007fdda2add000)
    libc.so.6 =&gt; /lib64/libc.so.6 (0x00007fdda270f000)
    /lib64/ld-linux-x86-64.so.2 (0x00007fdda2f13000)
</code></pre>
<p>我们看到C版本实现依赖了libresolv.so这个用于名称解析的C库。</p>
<p>由此可得，当Go在默认cgo开启时，一旦依赖了标准库中拥有C版本实现的包，比如os/user、net等，Go编译器会采用动态链接的方式编译Go可执行程序。</p>
<h3>2.2 显式使用cgo调用外部C程序</h3>
<p>如果使用cgo与外部C代码交互，那么生成的可执行文件必然会包含动态链接。下面我们来看一个调用cgo的简单示例。</p>
<p>首先，建立一个简单的C lib：</p>
<pre><code>// go-compilation/my-c-lib

$tree my-c-lib
my-c-lib
├── Makefile
├── mylib.c
└── mylib.h

// go-compilation/my-c-lib/Makefile

.PHONY:  all static

all:
        gcc -c -fPIC -o mylib.o mylib.c
        gcc -shared -o libmylib.so mylib.o
static:
        gcc -c -fPIC -o mylib.o mylib.c
        ar rcs libmylib.a mylib.o

// go-compilation/my-c-lib/mylib.h

#ifndef MYLIB_H
#define MYLIB_H

void hello();
int add(int a, int b);

#endif // MYLIB_H

// go-compilation/my-c-lib/mylib.c

#include &lt;stdio.h&gt;

void hello() {
    printf("Hello from C!\n");
}

int add(int a, int b) {
    return a + b;
}
</code></pre>
<p>执行make all构建出动态链接库libmylib.so！接下来，我们编写一个Go程序通过cgo调用libmylib.so中：</p>
<pre><code>// go-compilation/main-with-call-myclib.go 

package main

/*
#cgo CFLAGS: -I ./my-c-lib
#cgo LDFLAGS: -L ./my-c-lib -lmylib
#include "mylib.h"
*/
import "C"
import "fmt"

func main() {
    // 调用 C 函数
    C.hello()

    // 调用 C 中的加法函数
    result := C.add(3, 4)
    fmt.Printf("Result of addition: %d\n", result)
}
</code></pre>
<p>编译该源码：</p>
<pre><code>$go build -o helloworld-with-call-myclib main-with-call-myclib.go
</code></pre>
<p>通过ldd可以看到，可执行文件helloworld-with-call-myclib是动态链接的，并依赖libmylib.so：</p>
<pre><code>$ldd helloworld-with-call-myclib
    linux-vdso.so.1 =&gt;  (0x00007ffcc39d8000)
    libmylib.so =&gt; not found
    libpthread.so.0 =&gt; /lib64/libpthread.so.0 (0x00007f7166df5000)
    libc.so.6 =&gt; /lib64/libc.so.6 (0x00007f7166a27000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f7167011000)
</code></pre>
<p>设置LD_LIBRARY_PATH(为了让程序找到libmylib.so)并运行可执行文件helloworld-with-call-myclib：</p>
<pre><code>$ LD_LIBRARY_PATH=./my-c-lib:$LD_LIBRARY_PATH ./helloworld-with-call-myclib
Hello from C!
Result of addition: 7
</code></pre>
<h3>2.3 使用了依赖cgo的第三方包</h3>
<p>在日常开发中，我们经常依赖一些第三方包，有些时候这些第三方包依赖cgo，比如<a href="https://github.com/mattn/go-sqlite3">mattn/go-sqlite3</a>。下面就是一个依赖go-sqlite3包的示例：</p>
<pre><code>// go-compilation/go-sqlite3/main.go
package main

import (
    "database/sql"
    "fmt"
    "log"

    _ "github.com/mattn/go-sqlite3"
)

func main() {
    // 打开数据库（如果不存在，则创建）
    db, err := sql.Open("sqlite3", "./test.db")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    // 创建表
    sqlStmt := `CREATE TABLE IF NOT EXISTS user (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT);`
    _, err = db.Exec(sqlStmt)
    if err != nil {
        log.Fatalf("%q: %s\n", err, sqlStmt)
    }

    // 插入数据
    _, err = db.Exec(`INSERT INTO user (name) VALUES (?)`, "Alice")
    if err != nil {
        log.Fatal(err)
    }

    // 查询数据
    rows, err := db.Query(`SELECT id, name FROM user;`)
    if err != nil {
        log.Fatal(err)
    }
    defer rows.Close()

    for rows.Next() {
        var id int
        var name string
        err = rows.Scan(&amp;id, &amp;name)
        if err != nil {
            log.Fatal(err)
        }
        fmt.Printf("%d: %s\n", id, name)
    }

    // 检查查询中的错误
    if err = rows.Err(); err != nil {
        log.Fatal(err)
    }
}
</code></pre>
<p>编译和运行该源码：</p>
<pre><code>$go build demo
$ldd demo
    linux-vdso.so.1 =&gt;  (0x00007ffe23d8e000)
    libdl.so.2 =&gt; /lib64/libdl.so.2 (0x00007faf0ddef000)
    libpthread.so.0 =&gt; /lib64/libpthread.so.0 (0x00007faf0dbd3000)
    libc.so.6 =&gt; /lib64/libc.so.6 (0x00007faf0d805000)
    /lib64/ld-linux-x86-64.so.2 (0x00007faf0dff3000)
$./demo
1: Alice
</code></pre>
<p>到这里，有些读者可能会问一个问题：如果需要在上述依赖场景中生成静态链接的Go程序，该怎么做呢？接下来，我们就来看看这个问题的解决细节。</p>
<h2>3. 如何在上述情况下实现静态链接？</h2>
<p>到这里是不是有些烧脑了啊！我们针对上一节的三种情况，分别对应来看一下静态编译的方案。</p>
<h3>3.1 仅依赖标准包</h3>
<p>在前面我们说过，之所以在使用os/user、net包时会在默认情况下采用动态链接，是因为Go使用了这两个包对应功能的C版实现，如果要做静态编译，让Go编译器选择它们的纯Go版实现即可。那我们仅需要关闭CGO即可，以依赖标准库os/user为例：</p>
<pre><code>$CGO_ENABLED=0 go build -o helloworld-with-os-user-static main-with-os-user.go
$file helloworld-with-os-user-static
helloworld-with-os-user-static: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped
$ldd helloworld-with-os-user-static
    不是动态可执行文件
</code></pre>
<h3>3.2 使用cgo调用外部c程序（静态链接）</h3>
<p>对于依赖cgo调用外部c的程序，我们要使用静态链接就必须要求外部c库提供静态库，因此，我们需要my-c-lib提供一份libmylib.a，这通过下面命令可以实现(或执行make static)：</p>
<pre><code>$gcc -c -fPIC -o mylib.o mylib.c
$ar rcs libmylib.a mylib.o
</code></pre>
<p>有了libmylib.a后，我们还要让Go程序静态链接该.a文件，于是我们需要修改一下Go源码中cgo链接的flag，加上静态链接的选项：</p>
<pre><code>// go-compilation/main-with-call-myclib-static.go
... ...
#cgo LDFLAGS: -static -L my-c-lib -lmylib
... ...
</code></pre>
<p>编译链接并查看一下文件属性：</p>
<pre><code>$go build -o helloworld-with-call-myclib-static main-with-call-myclib-static.go

$file helloworld-with-call-myclib-static
helloworld-with-call-myclib-static: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, for GNU/Linux 2.6.32, BuildID[sha1]=b3da3ed817d0d04230460069b048cab5f5bfc3b9, not stripped
</code></pre>
<p>我们得到了预期的结果！</p>
<h3>3.3 依赖使用cgo的外部go包（静态链接）</h3>
<p>最麻烦的是这类情况，要想实现静态链接，我们需要找出外部go依赖的所有c库的.a文件(静态共享库)。以我们的go-sqlite3示例为例，go-sqlite3是sqlite库的go binding，它依赖sqlite库，同时所有第三方c库都依赖libc，我们还要准备一份libc的.a文件，下面我们就先安装这些：</p>
<pre><code>$yum install -y gcc glibc-static sqlite-devel
... ...

已安装:
  sqlite-devel.x86_64 0:3.7.17-8.el7_7.1                                                                                          

更新完毕:
  glibc-static.x86_64 0:2.17-326.el7_9.3
</code></pre>
<p>接下来，我们就来以静态链接的方式在go-compilation/go-sqlite3-static下编译一下：</p>
<pre><code>$go build -tags 'sqlite_omit_load_extension' -ldflags '-linkmode external -extldflags "-static"' demo

$file ./demo
./demo: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, for GNU/Linux 2.6.32, BuildID[sha1]=c779f5c3eaa945d916de059b56d94c23974ce61c, not stripped
</code></pre>
<p>这里命令行中的-tags &#8216;sqlite_omit_load_extension&#8217;用于禁用SQLite3的动态加载功能，确保更好的静态链接兼容性。而-ldflags &#8216;-linkmode external -extldflags “-static”&#8216;的含义是使用外部链接器(比如gcc linker)，并强制静态链接所有库。</p>
<p>我们再看完略烧脑的几个细节后，再来看一个略轻松的话题。</p>
<h2>4. Go编译出的可执行文件过大，能优化吗？</h2>
<p>Go编译出的二进制文件一般较大，一个简单的“Hello World”程序通常在2MB左右：</p>
<pre><code>$ls -lh helloworld-default
-rwxr-xr-x 1 root root 2.1M 11月  3 10:39 helloworld-default
</code></pre>
<p>这一方面是因为Go将整个runtime都编译到可执行文件中了，另一方面也是因为Go静态编译所致。那么在默认情况下，Go二进制文件的大小还有优化空间么？方法不多，有两种可以尝试：</p>
<ul>
<li>去除符号表和调试信息</li>
</ul>
<p>在编译时使用-ldflags=”-s -w”标志可以去除符号表和调试符号，其中-s用于去掉符号表和调试信息，-w用于去掉DWARF调试信息，这样能显著减小文件体积。以helloworld为例，可执行文件的size减少了近四成：</p>
<pre><code>$go build -ldflags="-s -w" -o helloworld-default-nosym main.go
$ls -l
-rwxr-xr-x 1 root root 2124504 11月  3 10:39 helloworld-default
-rwxr-xr-x 1 root root 1384600 11月  3 13:34 helloworld-default-nosym
</code></pre>
<ul>
<li>使用tinygo</li>
</ul>
<p><a href="https://github.com/tinygo-org/tinygo/">TinyGo</a>是一个Go语言的编译器，它专为资源受限的环境而设计，例如微控制器、WebAssembly和其他嵌入式设备。TinyGo的目标是提供一个轻量级的、能在小型设备上运行的Go运行时，同时尽可能支持Go语言的特性。tinygo的一大优点就是生成的二进制文件通常比标准Go编译器生成的文件小得多：</p>
<pre><code>$tinygo build -o helloworld-tinygo main.go
$ls -l
总用量 2728
-rwxr-xr-x  1 root root 2128909 11月  5 05:43 helloworld-default*
-rwxr-xr-x  1 root root  647600 11月  5 05:45 helloworld-tinygo*
</code></pre>
<p>我们看到：tinygo生成的可执行文件的size仅是原来的30%。</p>
<blockquote>
<p>注：虽然TinyGo在特定场景（如IoT和嵌入式开发）中非常有用，但在常规服务器环境中，由于生态系统兼容性、性能、调试支持等方面的限制，可能并不是最佳选择。对于需要高并发、复杂功能和良好调试支持的应用，标准Go仍然是更合适的选择。</p>
<p>注：这里使用的tinygo为0.34.0版本。</p>
</blockquote>
<h2>5. 未使用的符号是否会被编译到Go二进制文件中？</h2>
<p>到这里，相信读者心中也都会萦绕一些问题：到底哪些符号被编译到最终的Go二进制文件中了呢？未使用的符号是否会被编译到Go二进制文件中吗？在这一小节中，我们就来探索一下。</p>
<p>出于对Go的了解，我们已经知道无论是GOPATH时代，还是Go module时代，Go的编译单元始终是包(package)，一个包（无论包中包含多少个Go源文件）都会作为一个编译单元被编译为一个目标文件(.a)，然后Go链接器会将多个目标文件链接在一起生成可执行文件，因此如果一个包被依赖，那么它就会进入到Go二进制文件中，它内部的符号也会进入到Go二进制文件中。</p>
<p>那么问题来了！是否被依赖包中的所有符号都会被放到最终的可执行文件中呢？我们以最简单的helloworld-default为例，它依赖fmt包，并调用了fmt包的Println函数，我们看看Println这个符号是否会出现在最终的可执行文件中：</p>
<pre><code>$nm -a helloworld-default | grep "Println"
000000000048eba0 T fmt.(*pp).doPrintln
</code></pre>
<p>居然没有！我们初步怀疑是inline优化在作祟。接下来，关闭优化再来试试：</p>
<pre><code>$go build -o helloworld-default-noinline -gcflags='-l -N' main.go

$nm -a helloworld-default-noinline | grep "Println"
000000000048ec00 T fmt.(*pp).doPrintln
0000000000489ee0 T fmt.Println
</code></pre>
<p>看来的确如此！不过当使用”fmt.”去过滤helloworld-default-noinline的所有符号时，我们发现fmt包的一些常见的符号并未包含在其中，比如Printf、Fprintf、Scanf等。</p>
<p>这是因为Go编译器的一个重要特性：死码消除(dead code elimination)，即编译器会将未使用的代码和数据从最终的二进制文件中剔除。</p>
<p>我们再来继续探讨一个衍生问题：如果Go源码使用空导入方式导入了一个包，那么这个包是否会被编译到Go二进制文件中呢？其实道理是一样的，如果用到了里面的符号，就会存在，否则不会。</p>
<p>以空导入os/user为例，即便在CGO_ENABLED=0的情况下，因为没有使用os/user中的任何符号，在最终的二进制文件中也不会包含user包：</p>
<pre><code>$CGO_ENABLED=0 go build -o helloworld-with-os-user-noinline -gcflags='-l -N' main-with-os-user.go
[root@iZ2ze18rmx2avqb5xgb4omZ helloworld]# nm -a helloworld-with-os-user-noinline |grep user
0000000000551ac0 B runtime.userArenaState
</code></pre>
<p>但是如果是带有init函数的包，且init函数中调用了同包其他符号的情况呢？我们以expvar包为例看一下：</p>
<pre><code>// go-compilation/main-with-expvar.go

package main

import (
    _ "expvar"
    "fmt"
)

func main() {
    fmt.Println("hello, world")
}
</code></pre>
<p>编译并查看一下其中的符号：</p>
<pre><code>$go build -o helloworld-with-expvar-noinline -gcflags='-l -N' main-with-expvar.go
$nm -a helloworld-with-expvar-noinline|grep expvar
0000000000556480 T expvar.appendJSONQuote
00000000005562e0 T expvar.cmdline
00000000005561c0 T expvar.expvarHandler
00000000005568e0 T expvar.(*Func).String
0000000000555ee0 T expvar.Func.String
00000000005563a0 T expvar.init.0
00000000006e0560 D expvar..inittask
0000000000704550 d expvar..interfaceSwitch.0
... ...
</code></pre>
<p>除此之外，如果一个包即便没有init函数，但有需要初始化的全局变量，比如crypto包的hashes：</p>
<pre><code>// $GOROOT/src/crypto/crypto.go
var hashes = make([]func() hash.Hash, maxHash)
</code></pre>
<p>crypto包的相关如何也会进入最终的可执行文件中，大家自己动手不妨试试。下面是我得到的一些输出：</p>
<pre><code>$go build -o helloworld-with-crypto-noinline -gcflags='-l -N' main-with-crypto.go
$nm -a helloworld-with-crypto-noinline|grep crypto
00000000005517b0 B crypto.hashes
000000000048ee60 T crypto.init
0000000000547280 D crypto..inittask
</code></pre>
<p>有人会问：os/user包也有一些全局变量啊，为什么这些符号没有被包含在可执行文件中呢？比如：</p>
<pre><code>// $GOROOT/src/os/user/user.go
var (
    userImplemented      = true
    groupImplemented     = true
    groupListImplemented = true
)
</code></pre>
<p>这就要涉及Go包初始化的逻辑了。我们看到crypto包包含在可执行文件中的符号中有crypto.init和crypto..inittask这两个符号，显然这不是crypto包代码中的符号，而是Go编译器为crypto包自动生成的init函数和inittask结构。</p>
<p>Go编译器会为每个包生成一个init函数，即使包中没有显式定义init函数，同时<a href="https://go.dev/src/cmd/compile/internal/pkginit/init.go">每个包都会有一个inittask结构</a>，用于运行时的包初始化系统。当然这么说也不足够精确，如果一个包没有init函数、需要初始化的全局变量或其他需要运行时初始化的内容，则编译器不会为其生成init函数和inittask。比如上面的os/user包。</p>
<p>os/user包确实有上述全局变量的定义，但是这些变量是在编译期就可以确定值的常量布尔值，而且未被包外引用或在包内用于影响控制流。Go编译器足够智能，能够判断出这些初始化是”无副作用的”，不需要在运行时进行初始化。只有真正需要运行时初始化的包才会生成init和inittask。这也解释了为什么空导入os/user包时没有相关的init和inittask符号，而crypto、expvar包有的init.0和inittask符号。</p>
<h2>6. 如何快速判断Go项目是否依赖cgo？</h2>
<p>在使用开源Go项目时，我们经常会遇到项目文档中没有明确说明是否依赖Cgo的情况。这种情况下，如果我们需要在特定环境（比如CGO_ENABLED=0）下使用该项目，就需要事先判断项目是否依赖Cgo，有些时候还要快速地给出判断。</p>
<p>那究竟是否可以做到这种快速判断呢？我们先来看看一些常见的作法。</p>
<p>第一类作法是源码层面的静态分析。最直接的方式是检查源码中是否存在import “C”语句，这种引入方式是CGO使用的显著标志。</p>
<pre><code>// 在项目根目录中执行
$grep -rn 'import "C"' .
</code></pre>
<p>这个命令会递归搜索当前目录下所有文件，显示包含import “C”的行号和文件路径，帮助快速定位CGO的使用位置。</p>
<p>此外，CGO项目通常包含特殊的编译指令，这些指令以注释形式出现在源码中，比如前面见识过的#cgo CFLAGS、#cgo LDFLAGS等，通过对这些编译指令的检测，同样可以来判断项目是否依赖CGO。</p>
<p>不过第一类作法并不能查找出Go项目的依赖包是否依赖cgo。而找出直接依赖或间接依赖是否依赖cgo，我们需要工具帮忙，比如使用Go工具链提供的命令分析项目依赖：</p>
<pre><code>$go list -deps -f '{{.ImportPath}}: {{.CgoFiles}}' ./...  | grep -v '\[\]'
</code></pre>
<p>其中ImportPath是依赖包的导入路径，而CgoFiles则是依赖中包含import “C”的Go源文件。我们以go-sqlite3那个依赖cgo的示例来验证一下：</p>
<pre><code>// cd go-compilation/go-sqlite3

$go list -deps -f '{{.ImportPath}}: {{.CgoFiles}}' ./...  | grep -v '\[\]'
runtime/cgo: [cgo.go]
github.com/mattn/go-sqlite3: [backup.go callback.go error.go sqlite3.go sqlite3_context.go sqlite3_load_extension.go sqlite3_opt_serialize.go sqlite3_opt_userauth_omit.go sqlite3_other.go sqlite3_type.go]
</code></pre>
<p>用空导入os/user的示例再来看一下：</p>
<pre><code>$go list -deps -f '{{.ImportPath}}: {{.CgoFiles}}'  main-with-os-user.go | grep -v '\[\]'
runtime/cgo: [cgo.go]
os/user: [cgo_lookup_cgo.go getgrouplist_unix.go]
</code></pre>
<p>我们知道os/user有纯go和C版本两个实现，因此上述判断只能说“对了一半”，当我关闭CGO_ENABLED时，Go编译器不会使用基于cgo的C版实现。</p>
<p>那是否在禁用cgo的前提下对源码进行一次编译便能验证项目是否对cgo有依赖呢？这样做显然谈不上是一种“快速”的方法，那是否有效呢？我们来对上面的go-sqlite3项目做一个测试，我们在关闭CGO_ENABLED时，编译一下该示例：</p>
<pre><code>// cd go-compilation/go-sqlite3
$ CGO_ENABLED=0 go build demo
</code></pre>
<p>我们看到，Go编译器并未报错！似乎该项目不需要cgo!  但真的是这样吗？我们运行一下编译后的demo可执行文件：</p>
<pre><code>$ ./demo
2024/11/03 22:10:36 "Binary was compiled with 'CGO_ENABLED=0', go-sqlite3 requires cgo to work. This is a stub": CREATE TABLE IF NOT EXISTS user (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT);
</code></pre>
<p>我们看到成功编译出来的程序居然出现运行时错误，提示需要cgo！</p>
<p>到这里，没有一种方法可以快速、精确的给出项目是否依赖cgo的判断。也许判断Go项目是否依赖CGO并没有捷径，需要从源码分析、依赖检查和构建测试等多个维度进行。</p>
<h2>7. 小结</h2>
<p>在本文中，我们深入探讨了Go语言编译过程中的几个重要细节，尤其是在静态链接和动态链接的选择上。通过具体示例，我们了解到：</p>
<ul>
<li>
<p>默认链接方式：尽管CGO_ENABLED默认值为1，Go编译器在大多数情况下会采用静态链接，只有在依赖特定的C库或标准库包时，才会切换到动态链接。</p>
</li>
<li>
<p>动态链接的条件：我们讨论了几种情况下Go会默认生成动态链接的可执行文件，包括依赖使用C实现的标准库包、显式使用cgo调用外部C程序，以及使用依赖cgo的第三方包。</p>
</li>
<li>
<p>实现静态链接：对于需要动态链接的场景，我们也提供了将其转为静态链接的解决方案，包括关闭CGO、使用静态库，以及处理依赖cgo的外部包的静态链接问题。</p>
</li>
<li>
<p>二进制文件优化：我们还介绍了如何通过去除符号表和使用TinyGo等方法来优化生成的Go二进制文件的大小，以满足不同场景下的需求。</p>
</li>
<li>
<p>符号编译与死码消除：最后，我们探讨了未使用的符号是否会被编译到最终的二进制文件中，并解释了Go编译器的死码消除机制。</p>
</li>
</ul>
<p>通过这些细节探讨，我希望能够帮助大家更好地理解Go编译的复杂性，并在实际开发中做出更明智的选择，亦能在面对Go编译相关问题时，提供有效的解决方案。</p>
<p>本文涉及的源码可以在<a href="https://github.com/bigwhite/experiments/tree/master/go-compilation">这里</a>下载。</p>
<hr />
<p><a href="https://public.zsxq.com/groups/51284458844544">Gopher部落知识星球</a>在2024年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。同时，我们也会加强代码质量和最佳实践的分享，包括如何编写简洁、可读、可测试的Go代码。此外，我们还会加强星友之间的交流和互动。欢迎大家踊跃提问，分享心得，讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾。让我相聚在Gopher部落，享受coding的快乐! 欢迎大家踊跃加入！</p>
<p><img src="http://image.tonybai.com/img/tonybai/gopher-tribe-zsxq-small-card.png" alt="img{512x368}" /><br />
<img src="http://image.tonybai.com/img/tonybai/go-programming-from-beginner-to-master-qr.png" alt="img{512x368}" /></p>
<p><img src="http://image.tonybai.com/img/tonybai/go-first-course-banner.png" alt="img{512x368}" /><br />
<img src="http://image.tonybai.com/img/tonybai/imooc-go-column-pgo-with-qr.jpg" alt="img{512x368}" /></p>
<p>著名云主机服务厂商DigitalOcean发布最新的主机计划，入门级Droplet配置升级为：1 core CPU、1G内存、25G高速SSD，价格5$/月。有使用DigitalOcean需求的朋友，可以打开这个<a href="https://m.do.co/c/bff6eed92687">链接地址</a>：https://m.do.co/c/bff6eed92687 开启你的DO主机之路。</p>
<p>Gopher Daily(Gopher每日新闻) &#8211; https://gopherdaily.tonybai.com</p>
<p>我的联系方式：</p>
<ul>
<li>微博(暂不可用)：https://weibo.com/bigwhite20xx</li>
<li>微博2：https://weibo.com/u/6484441286</li>
<li>博客：tonybai.com</li>
<li>github: https://github.com/bigwhite</li>
<li>Gopher Daily归档 &#8211; https://github.com/bigwhite/gopherdaily</li>
<li>Gopher Daily Feed订阅 &#8211; https://gopherdaily.tonybai.com/feed</li>
</ul>
<p><img src="http://image.tonybai.com/img/tonybai/iamtonybai-wechat-qr.png" alt="" /></p>
<p>商务合作方式：撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。</p>
<p style='text-align:left'>&copy; 2024, <a href='https://tonybai.com'>bigwhite</a>. 版权所有. </p>
]]></content:encoded>
			<wfw:commentRss>https://tonybai.com/2024/11/11/some-details-about-go-compilation/feed/</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<item>
		<title>Gopher的Rust第一课：Rust代码组织</title>
		<link>https://tonybai.com/2024/06/06/gopher-rust-first-lesson-organizing-rust-code/</link>
		<comments>https://tonybai.com/2024/06/06/gopher-rust-first-lesson-organizing-rust-code/#comments</comments>
		<pubDate>Thu, 06 Jun 2024 15:13:51 +0000</pubDate>
		<dc:creator>bigwhite</dc:creator>
				<category><![CDATA[技术志]]></category>
		<category><![CDATA[bazel]]></category>
		<category><![CDATA[binary]]></category>
		<category><![CDATA[cargo]]></category>
		<category><![CDATA[Cargo.lock]]></category>
		<category><![CDATA[Cargo.toml]]></category>
		<category><![CDATA[CMake]]></category>
		<category><![CDATA[crate]]></category>
		<category><![CDATA[DataFusion]]></category>
		<category><![CDATA[GCC]]></category>
		<category><![CDATA[Go]]></category>
		<category><![CDATA[go.mod]]></category>
		<category><![CDATA[Golang]]></category>
		<category><![CDATA[gomodule]]></category>
		<category><![CDATA[Gopher]]></category>
		<category><![CDATA[lib.rs]]></category>
		<category><![CDATA[library]]></category>
		<category><![CDATA[main]]></category>
		<category><![CDATA[Makefile]]></category>
		<category><![CDATA[Module]]></category>
		<category><![CDATA[Package]]></category>
		<category><![CDATA[repo]]></category>
		<category><![CDATA[rlib]]></category>
		<category><![CDATA[Rust]]></category>
		<category><![CDATA[rustc]]></category>
		<category><![CDATA[self]]></category>
		<category><![CDATA[Thread]]></category>
		<category><![CDATA[tokio]]></category>
		<category><![CDATA[workspace]]></category>
		<category><![CDATA[编译器]]></category>

		<guid isPermaLink="false">https://tonybai.com/?p=4187</guid>
		<description><![CDATA[本文永久链接 &#8211; https://tonybai.com/2024/06/06/gopher-rust-first-lesson-organizing-rust-code 在上一章的讲解中，我们编写了第一个Rust示例程序”hello, world”，并给出了rustc版和cargo版本。在真实开发中，我们都会使用cargo来创建和管理Rust包。不过，Hello, world示例非常简单，仅仅由一个Rust源码文件组成，而且所有源码文件都在同一个目录中。但真实世界中的实用Rust程序，无论是公司商业项目，还是一些知名的开源项目，甚至是一些稍复杂一些的供教学使用的示例程序，它们通常可不会这么简单，都有着复杂的代码结构。 Rust初学者在阅读这些项目源码时便仿佛进入了迷宫，不知道该走哪条（阅读代码的）路径，不知道每个目录代表的含义，也不知道自己想看的源码究竟在哪个目录下。但目前市面上的Rust入门教程大多没有重视初学者的这一问题，要么没有对Rust项目代码组织结构进行针对性的讲解，要么是将讲解放到书籍的后面章节。 根据我个人的学习经验来看，理解一个实用Rust项目的代码组织结构越早，对后续的Rust学习越有益处。同时，掌握Rust项目的代码组织结构也是Rust开发者走向编写复杂Rust程序的必经的一步。并且，初学者在了解项目的代码组织结构后，便可以自主阅读一些复杂的Rust项目的源码，可提高Rust学习的效率，提升学习效果。因此，我决定在介绍Rust基础语法之前先在本章中系统地介绍Rust的代码组织结构，以满足很多Rust初学者的述求。 但在介绍Rust代码组织结构之前，我们需要先来系统说明一下Rust代码组织结构中的几个重要概念，它们是了解Rust项目代码组织结构的前提。 4.1 回顾Go代码组织 Go项目代码组织由module和package两级组成。通常来说，每个Go repo就是一个module，由repo根目录下的go.mod定义，go.mod文件所在目录也被称为module root。go.mod中典型内容如下： // go.mod module github.com/user/mymodule[/vN] go 1.22.1 ... ... go.mod中的module directive一行后面的github.com/user/mymodule/[vN]是module path。module path一来可以反映该module的具体网络位置，同时也是该module下面的Go package导入(import)路径的组成部分。module root下的子目录中通常存放着该module下面的Go package，比如module root/foo目录下存放的Go包的导入路径为github.com/user/mymodule[/vN]/foo。 Go package是Go的编译单元，也是功能单元，代码内外部导入和引用的单位也都是包。而go module是后加入的，更多用于管理包的版本（一个module下的所有包都统一进行版本管理）以及构建时第三方依赖和版本的管理。 更多关于Go module和package管理以及Go项目布局的内容，可以详见我的极客时间《Go语言第一课》专栏。 个人认为Go的module和package的两级管理还是很好理解和管理的，在这方面Rust的代码组织形式又是怎样的呢？接下来，我们就来正式看看Rust的代码组织。 4.2 rustc-only的Rust项目 Rust是系统编程语言，这让我想起了当初在Go成为我个人主力语言之前使用C/C++进行开发的岁月。C/C++是没有像go或Rust的cargo那样的统一的包依赖管理器和项目构建管理工具的。编译器(如gcc等)是核心工具，而项目构建管理则经常由其他工具负责，如Makefile、CMake，或者是Google的Bazel等。在Windows上开发应用的，则往往使用微软或其他开发者工具公司提供的IDE，如当年炙手可热的Visual Studio系列。 下面表格展示了各语言的编译器/链接器和构建管理工具的关系： 像cargo、go这样的“一站式”工具链都旨在为开发者提供体验更为友好的交互接口的，在幕后，它们仍然依赖于底层的编译器和链接器（如rustc和go tool compile/link）来执行实际的代码编译。 不过，像cargo这样的高级工具也给开发人员带来了额外的抽象，或是叫“掩盖”了一些真相，这有时候让人看不清构建过程的本质，比如：很多Gopher用了很多年Go，但却不知道go tool compile/link的存在。 本着只有in hard way，才能看到和抓住本质的思路，以及之前学习用系统编程语言C/C++时经验，这里我们先来看一些rustc-only的Rust项目。Rustc-only的Rust项目是指不使用Cargo创建和管理的Rust项目，而是直接使用rustc编译器来编译和构建项目。这意味着开发者需要编写自己的构建脚本，例如使用Makefile或其他构建工具来管理项目的构建过程。 不过，请注意：这类项目极少用于生产，即便是那些不需要复杂的依赖管理的小型项目。这里使用rustc-only的Rust项目仅仅是为了学习和了解Rustc编译器的主要功能机制以及Rust语言在代码组织上的一些抽象，比如module等。 下面我们就从最简单的rustc-only项目开始，先来看看只有一个Rust源文件且无其他依赖项的“最简项目”。 4.2.1 单文件项目 所谓单文件项目，即只有一个Rust源文件，例如前面章节中的hello_world.rs，这种项目可以直接使用rustc编译器来编译和运行： [...]]]></description>
			<content:encoded><![CDATA[<p><img src="https://tonybai.com/wp-content/uploads/gopher-rust-first-lesson-organizing-rust-code-1.png" alt="" /></p>
<p><a href="https://tonybai.com/2024/06/06/gopher-rust-first-lesson-organizing-rust-code">本文永久链接</a> &#8211; https://tonybai.com/2024/06/06/gopher-rust-first-lesson-organizing-rust-code</p>
<p>在上一章的讲解中，我们编写了<a href="https://tonybai.com/2024/05/27/gopher-rust-first-lesson-first-rust-program">第一个Rust示例程序”hello, world”</a>，并给出了rustc版和cargo版本。在真实开发中，我们都会使用cargo来创建和管理Rust包。不过，Hello, world示例非常简单，仅仅由一个Rust源码文件组成，而且所有源码文件都在同一个目录中。但真实世界中的实用Rust程序，无论是公司商业项目，还是一些知名的开源项目，甚至是一些稍复杂一些的供教学使用的示例程序，它们通常可不会这么简单，都有着复杂的代码结构。</p>
<p>Rust初学者在阅读这些项目源码时便仿佛进入了迷宫，不知道该走哪条（阅读代码的）路径，不知道每个目录代表的含义，也不知道自己想看的源码究竟在哪个目录下。但目前市面上的Rust入门教程大多没有重视初学者的这一问题，要么没有对Rust项目代码组织结构进行针对性的讲解，要么是将讲解放到书籍的后面章节。</p>
<p>根据我个人的学习经验来看，理解一个实用Rust项目的代码组织结构越早，对后续的Rust学习越有益处。同时，掌握Rust项目的代码组织结构也是Rust开发者走向编写复杂Rust程序的必经的一步。并且，初学者在了解项目的代码组织结构后，便可以自主阅读一些复杂的Rust项目的源码，可提高Rust学习的效率，提升学习效果。因此，我决定在介绍Rust基础语法之前先在本章中系统地介绍Rust的代码组织结构，以满足很多Rust初学者的述求。</p>
<p>但在介绍Rust代码组织结构之前，我们需要先来系统说明一下Rust代码组织结构中的几个重要概念，它们是了解Rust项目代码组织结构的前提。</p>
<h2>4.1 回顾Go代码组织</h2>
<p>Go项目代码组织由module和package两级组成。通常来说，每个Go repo就是一个module，由repo根目录下的go.mod定义，go.mod文件所在目录也被称为module root。go.mod中典型内容如下：</p>
<pre><code>// go.mod
module github.com/user/mymodule[/vN]

go 1.22.1

... ...
</code></pre>
<p>go.mod中的module directive一行后面的github.com/user/mymodule/[vN]是module path。module path一来可以反映该module的具体网络位置，同时也是该module下面的Go package导入(import)路径的组成部分。module root下的子目录中通常存放着该module下面的Go package，比如module root/foo目录下存放的Go包的导入路径为github.com/user/mymodule[/vN]/foo。</p>
<p>Go package是Go的编译单元，也是功能单元，代码内外部导入和引用的单位也都是包。而go module是后加入的，更多用于管理包的版本（一个module下的所有包都统一进行版本管理）以及构建时第三方依赖和版本的管理。</p>
<blockquote>
<p>更多关于Go module和package管理以及Go项目布局的内容，可以详见我的极客时间<a href="http://gk.link/a/10AVZ">《Go语言第一课》</a>专栏。</p>
</blockquote>
<p>个人认为Go的module和package的两级管理还是很好理解和管理的，在这方面Rust的代码组织形式又是怎样的呢？接下来，我们就来正式看看Rust的代码组织。</p>
<h2>4.2 rustc-only的Rust项目</h2>
<p>Rust是系统编程语言，这让我想起了当初在Go成为我个人主力语言之前使用C/C++进行开发的岁月。C/C++是没有像go或Rust的cargo那样的统一的包依赖管理器和项目构建管理工具的。编译器(如gcc等)是核心工具，而项目构建管理则经常由其他工具负责，如Makefile、CMake，或者是Google的<a href="https://github.com/bazelbuild/bazel">Bazel</a>等。在Windows上开发应用的，则往往使用微软或其他开发者工具公司提供的IDE，如当年炙手可热的Visual Studio系列。</p>
<p>下面表格展示了各语言的编译器/链接器和构建管理工具的关系：</p>
<p><img src="https://tonybai.com/wp-content/uploads/gopher-rust-first-lesson-organizing-rust-code-2.png" alt="" /></p>
<p>像cargo、go这样的“一站式”工具链都旨在为开发者提供体验更为友好的交互接口的，在幕后，它们仍然依赖于底层的编译器和链接器（如rustc和go tool compile/link）来执行实际的代码编译。</p>
<p>不过，像cargo这样的高级工具也给开发人员带来了额外的抽象，或是叫“掩盖”了一些真相，这有时候让人看不清构建过程的本质，比如：很多Gopher用了很多年Go，但却不知道go tool compile/link的存在。</p>
<p>本着只有in hard way，才能看到和抓住本质的思路，以及之前学习用系统编程语言C/C++时经验，这里我们先来看一些<strong>rustc-only的Rust项目</strong>。Rustc-only的Rust项目是指不使用Cargo创建和管理的Rust项目，而是直接使用rustc编译器来编译和构建项目。这意味着开发者需要编写自己的构建脚本，例如使用Makefile或其他构建工具来管理项目的构建过程。</p>
<p>不过，请注意：<strong>这类项目极少用于生产</strong>，即便是那些不需要复杂的依赖管理的小型项目。这里使用rustc-only的Rust项目仅仅是为了学习和了解Rustc编译器的主要功能机制以及Rust语言在代码组织上的一些抽象，比如module等。</p>
<p>下面我们就从最简单的rustc-only项目开始，先来看看只有一个Rust源文件且无其他依赖项的“最简项目”。</p>
<h3>4.2.1 单文件项目</h3>
<p>所谓单文件项目，即只有一个Rust源文件，例如前面章节中的hello_world.rs，这种项目可以直接使用rustc编译器来编译和运行：</p>
<pre><code>// rust-guide-for-gopher/organizing-rust-code/rustc-only/single/hello-world/hello_world.rs
fn main() {
    println!("Hello, world!");
}
</code></pre>
<p>对于顶层带有main函数的源文件，rustc会默认将其视为binary crate类型的源文件，并将其编译为可执行二进制文件hello_world。</p>
<p>我们当然也可以强制的让rustc将该源文件视为library crate类型的源文件，并将其编译为其他类型的crate输出文件，rustc支持多种crate type：</p>
<pre><code>      --crate-type [bin|lib|rlib|dylib|cdylib|staticlib|proc-macro]
                        Comma separated list of types of crates
                        for the compiler to emit
</code></pre>
<p>在<a href="https://doc.rust-lang.org/rustc/what-is-rustc.html">rustc的文档</a>中，各种crate类型的含义如下：</p>
<pre><code>lib — Generates a library kind preferred by the compiler, currently defaults to rlib.
rlib — A Rust static library.
staticlib — A native static library.
dylib — A Rust dynamic library.
cdylib — A native dynamic library.
bin — A runnable executable program.
proc-macro — Generates a format suitable for a procedural macro library that may be loaded by the compiler.
</code></pre>
<p>不过，如果强制将带有顶层main函数的rust源文件视为lib crate型的，那么rustc将会报warning，提醒你函数main将是死代码，永远不会被用到：</p>
<pre><code>$rustc --crate-type lib hello_world.rs
warning: function `main` is never used
 --&gt; hello_world.rs:1:4
  |
1 | fn main() {
  |    ^^^^
  |
  = note: `#[warn(dead_code)]` on by default

warning: 1 warning emitted
</code></pre>
<p>但即便如此，一个名为libhello_world.rlib的文件依然会被rustc生成出来！（目前&#8211;crate-type lib等同于&#8211;create-type rlib)。</p>
<h3>4.2.2 有外部依赖项的单文件项目</h3>
<p>日常开发中，像上面的Hello, World级别的trivial应用是极其少见的，一个non-trivial的Rust应用或多或少都会有一些依赖。这里我们也来看一下如何基于rustc来构建带有外部依赖的单文件项目。下面是一个带有外部依赖的示例：</p>
<pre><code>// organizing-rust-code/rustc-only/single/hello-world-with-deps/hello_world.rs
extern crate rand;

use rand::Rng;

fn main() {
    let mut rng = rand::thread_rng();
    let num: u32 = rng.gen();
    println!("Random number: {}", num);
}
</code></pre>
<p>这个示例程序依赖一个名为rand的crate，要编译该程序，我们必须先手动下载rand的crate源码，并在本地将rand源码编译为示例程序所需的rust library。下面步骤展示了如何下载和构建rand crate：</p>
<pre><code>$curl -LO https://crates.io/api/v1/crates/rand/0.8.5/download
$tar -xvf download
</code></pre>
<p>解压后，我们将看到rand-0.8.5这样的一个crate目录，进入该目录，我们执行cargo build来构建rand crate：</p>
<pre><code>$cd rand-0.8.5
$cargo build
... ...
   Finished dev [unoptimized + debuginfo] target(s) in 0.19s
</code></pre>
<p>cargo构建出的librand.rlib就在rand-0.8.5/target/debug下。</p>
<blockquote>
<p>注：rlib的命名方式：lib+{crate_name}.rlib</p>
</blockquote>
<p>接下来，我们就来构建一下依赖rand crate的hello_world.rs：</p>
<pre><code>// 在organizing-rust-code/rustc-only/single/hello-world-with-deps下面执行

$rustc --verbose  -L ./rand-0.8.5/target/debug  --extern rand=librand.rlib hello_world.rs
error[E0463]: can't find crate for `rand_core` which `rand` depends on
 --&gt; hello_world.rs:1:1
  |
1 | extern crate rand;
  | ^^^^^^^^^^^^^^^^^^ can't find crate

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0463`.
</code></pre>
<p>我们看到rustc的编译错误提示：无法找到rand crate依赖的rand_core crate！也就是说我们除了向rustc提供hello_world.rs依赖的rand crate之外，还要向rustc提供rand crate的各种依赖！</p>
<p>rand crate的各种依赖在哪里呢？我们在构建rand crate时，cargo build将各种依赖都放在了rand-0.8.5/target/debug/deps目录下了：</p>
<pre><code>$ls -l|grep ".rlib"
-rw-r--r--   1 tonybai  staff     6896  4 29 06:45 libcfg_if-cd6bebf18fb9c234.rlib
-rw-r--r--   1 tonybai  staff   204072  4 29 06:45 libgetrandom-df6a8e95e188fc56.rlib
-rw-r--r--   1 tonybai  staff  1651320  4 29 06:45 liblibc-f16531562d07b476.rlib
-rw-r--r--   1 tonybai  staff   959408  4 29 06:45 libppv_lite86-f1d97d485bc43617.rlib
-rw-r--r--   1 tonybai  staff  1784376  4 29 06:45 librand-9a91ea8db926e840.rlib
-rw-r--r--   1 tonybai  staff   987936  4 29 06:45 librand_chacha-6fe22bd8b3bb228c.rlib
-rw-r--r--   1 tonybai  staff   256768  4 29 06:45 librand_core-fc905f6ca5f8533b.rlib
</code></pre>
<p>我们看到其中还包含了librand自身：librand-9a91ea8db926e840.rlib。我们来试试基于deps目录下的这些依赖rlib编译一下：</p>
<pre><code>$rustc --verbose  --extern rand=rand-0.8.5/target/debug/deps/librand-9a91ea8db926e840.rlib -L rand-0.8.5/target/debug/deps  --extern rand_core=librand_core-fc905f6ca5f8533b.rlib --extern getrandom=libgetrandom-df6a8e95e188fc56.rlib --extern cfg_if=libcfg_if-cd6bebf18fb9c234.rlib --extern libc=liblibc-f16531562d07b476.rlib --extern rand_chacha=librand_chacha-6fe22bd8b3bb228c.rlib --extern ppv_lite86=libppv_lite86-f1d97d485bc43617.rlib  hello_world.rs
</code></pre>
<p>我们用rustc成功编译了带有外部依赖的Rust源码。不过这里要注意的是rustc对直接依赖和间接依赖的crate的定位方式有所不同。</p>
<p>对于直接依赖的crate，比如这里的rand crate，我们需要给出具体路径，它不依赖-L的位置指示，所以这里我们使用了&#8211;extern rand=rand-0.8.5/target/debug/deps/librand-9a91ea8db926e840.rlib。</p>
<p>对于间接依赖的crate，比如rand crate依赖的rand_core，rust会结合-L指示的位置以及&#8211;extern一起来定位，这里-L指示路径为rand-0.8.5/target/debug/deps，&#8211;extern rand_core=librand_core-fc905f6ca5f8533b.rlib，那么rustc就会在rand-0.8.5/target/debug/deps下面搜索librand_core-fc905f6ca5f8533b.rlib是否存在。</p>
<p>我们运行rustc构建出的可执行文件，输出如下：</p>
<pre><code>$./hello_world
Random number: 431751199
</code></pre>
<h3>4.2.3 有外部依赖的多文件项目</h3>
<p>在Go中，如果某个目录下有多个源文件，那么通常这几个源文件均归属于同一个Go包(可能的例外的是&#42;_test.go文件的包名)。但在Rust中，情况就会变得复杂了一些，我们来看一个例子：</p>
<pre><code>// organizing-rust-code/rustc-only/multi/multi-file-with-deps

$tree -F -L 2
.
├── main.rs
├── sub1/
│   ├── bar.rs
│   ├── foo.rs
│   └── mod.rs
└── sub2.rs

</code></pre>
<p>在这个示例中，我们看到除了main.rs之外，还有一个sub2.rs以及一个目录sub1，sub1下面还有三个rs文件。我们从main.rs开始，逐一看一下各个源文件的内容：</p>
<pre><code>// organizing-rust-code/rustc-only/multi/multi-file-with-deps/main.rs
 1 extern crate rand;
 2 use rand::Rng;
 3
 4 mod sub1;
 5 mod sub2;
 6
 7 mod sub3 {
 8     pub fn func1() {
 9         println!("called {}::func1()", module_path!());
10     }
11     pub fn func2() {
12         self::func1();
13         println!("called {}::func2()", module_path!());
14         super::func1();
15     }
16 }
17
18 fn func1() {
19     println!("called {}::func1()", module_path!());
20 }
21
22 fn main() {
23     println!("current module: {}", module_path!());
24     let mut rng = rand::thread_rng();
25     let num: u32 = rng.gen();
26     println!("Random number: {}", num);
27
28     sub1::func1();
29     sub2::func1();
30     sub3::func2();
31 }
</code></pre>
<p>在main.rs中，我们除了看到了第1~2行的对外部rand crate的依赖外，我们还看到了一种新的语法元素：<strong>rust module</strong>。这里涉及sub1~sub3三个module，我们分别来看一下。先来看一下最直观的、定义在main.rs中的sub3 module。</p>
<p>第7行~第16行的代码定义了一个名为sub3的module，它包含两个函数func1和func2，这两个函数前面的pub关键字表明他们是sub3 module的publish函数，可以被module之外的代码所访问。任何未标记为pub的函数都是私有的，只能在模块内部及其子模块中使用。</p>
<p>在sub3 module的func2函数中，我们调用了self::func1()函数，self指代是模块自身，因此这个self::func1()函数就是sub3的func1函数。而接下来调用的super::func1()调用的语义你大概也能猜到。super指代的是sub3的父模块，而super::func1()就是sub3的父模块中的func1函数。</p>
<p>sub3的父模块就是这个项目的顶层模块，我们在main函数的入口处使用module_path!宏输出了该顶层模块的名称。</p>
<p>和sub3在main.rs中定义不同，sub1和sub2也分别代表了另外两种module的定义方式。</p>
<p>当Rust编译器看到第4行mod sub1后，它会寻找当前目录下是否有名为sub1.rs的源文件或是sub1/mod.rs源文件。在这个示例中，sub1定义在sub1目录下的mod.rs中：</p>
<pre><code>// organizing-rust-code/rustc-only/multi/multi-file-with-deps/sub1/mod.rs

pub mod bar;
pub mod foo;

pub fn func1() {
    println!("called {}::func1()", module_path!());
    foo::func1();
    bar::func1();
}
</code></pre>
<p>我们看到sub1/mod.rs中定义了一个公共函数func1，同时也在最开始处又嵌套定义了bar和foo两个module，并在func1中调用了两个嵌套子module的函数：</p>
<p>bar和foo两个module都是使用单文件module定义的，编译器会在sub1目录下搜寻foo.rs和bar.rs：</p>
<pre><code>// organizing-rust-code/rustc-only/multi/multi-file-with-deps/sub1/foo.rs
pub fn func1() {
    println!("called {}::func1()", module_path!());
}

// organizing-rust-code/rustc-only/multi/multi-file-with-deps/sub1/bar.rs
pub fn func1() {
    println!("called {}::func1()", module_path!());
}
</code></pre>
<p>而main.rs中的sub2也是一个单文件的module，其源码位于顶层目录下的sub2.rs文件中：</p>
<pre><code>// organizing-rust-code/rustc-only/multi/multi-file-with-deps/sub2.rs
pub fn func1() {
    println!("called {}::func1()", module_path!());
}
</code></pre>
<p>现在我们来编译和执行一下这个既有外部依赖，又是多文件且有多个module的rustc-only项目：</p>
<pre><code>$rustc --verbose  --extern rand=rand-0.8.5/target/debug/deps/librand-9a91ea8db926e840.rlib -L rand-0.8.5/target/debug/deps  --extern rand_core=librand_core-fc905f6ca5f8533b.rlib --extern getrandom=libgetrandom-df6a8e95e188fc56.rlib --extern cfg_if=libcfg_if-cd6bebf18fb9c234.rlib --extern libc=liblibc-f16531562d07b476.rlib --extern rand_chacha=librand_chacha-6fe22bd8b3bb228c.rlib --extern ppv_lite86=libppv_lite86-f1d97d485bc43617.rlib  main.rs 

$./main
current module: main
Random number: 2691905579
called main::sub1::func1()
called main::sub1::foo::func1()
called main::sub1::bar::func1()
called main::sub2::func1()
called main::sub3::func1()
called main::sub3::func2()
called main::func1()
</code></pre>
<p>上面示例演示了三种rust module的定义方法：</p>
<ol>
<li>直接将定义嵌入在某个rust源文件中：</li>
</ol>
<pre><code>mod module_name {

}
</code></pre>
<ol>
<li>通过module_name.rs</li>
<li>通过module_name/mod.rs</li>
</ol>
<p>在一个单crate的项目中，通过rust module可以满足项目内部代码组织的需要。</p>
<p>最后，我们再来看一个有多个crate的项目形式。</p>
<h3>4.2.4 有多个crate的项目</h3>
<p>下面是一个有着多个crate项目的示例：</p>
<pre><code>// organizing-rust-code/rustc-only/workspace

$tree -L 2 -F
.
├── main.rs
├── my_local_crate1/
│   └── lib.rs
└── my_local_crate2/
    └── lib.rs

</code></pre>
<p>在这个示例中有三个crate，一个是顶层的binary类型的crate，入口为main.rs，另外两个都是lib类型的crate，入口都在lib.rs中，我们贴一下他们的源码：</p>
<pre><code>// organizing-rust-code/rustc-only/workspace/main.rs
extern crate my_local_crate1;
extern crate my_local_crate2;

fn main() {
    let x = 5;
    let y = my_local_crate1::add_one(x);
    let z = my_local_crate2::multiply_two(y);
    println!("Result: {}", z);
}

// organizing-rust-code/rustc-only/workspace/my_local_crate1/lib.rs
pub fn add_one(x: i32) -&gt; i32 {
    x + 1
}

// organizing-rust-code/rustc-only/workspace/my_local_crate2/lib.rs
pub fn multiply_two(x: i32) -&gt; i32 {
    x * 2
}
</code></pre>
<p>要构建这个带有三个crate的项目，我们需要首先编译my_local_crate1和my_local_crate2这两个lib crates：</p>
<pre><code>$rustc --crate-type lib --crate-name my_local_crate1 my_local_crate1/lib.rs
$rustc --crate-type lib --crate-name my_local_crate2 my_local_crate2/lib.rs
</code></pre>
<p>这会在项目顶层目录下生成两个rlib文件：</p>
<pre><code>$ls  |grep rlib
libmy_local_crate1.rlib
libmy_local_crate2.rlib
</code></pre>
<p>之后，我们就可以用之前学到的方法编译binary crate了：</p>
<pre><code>$rustc --extern my_local_crate1=libmy_local_crate1.rlib --extern my_local_crate2=libmy_local_crate2.rlib main.rs
</code></pre>
<p>上述的几个rustc-only的rust项目都是hard模式的，即一切都需要手工去做，包括下载crate、编译crate时传入各种路径等。在真正的生产中，Rustacean们是不会这么做的，而是会直接使用cargo对rust项目进行管理。接下来，我们就来系统地看一下使用cargo进行rust项目管理以及对应的rust代码组织形式。</p>
<h2>4.3 使用cargo管理的Rust项目</h2>
<p>在前面的章节中，我们见识过了：Rust的包管理器Cargo是一个强大的工具，可以帮助我们轻松地管理Rust项目，cargo才是生产类项目的项目构建管理工具标准，它可以让Rustacean避免复杂的手工rustc操作。Cargo提供了许多功能，包括依赖项管理、构建和测试等。不过在这篇文章中，我不会介绍这些功能，而是看看使用cargo管理的Rust项目都有哪些代码组织模式。</p>
<p>Rust项目的代码组织结构可以分为两类：单一package和多个package。</p>
<p>什么是package？在之前的rust-only项目中，我们可从未见到过package！package是cargo引入的一个管理单元概念，它指的是一个独立的Rust项目，包含了源代码、依赖项和配置信息。每个Package都有一个唯一的名称和版本号，用于标识和管理项目。因此，在<a href="https://doc.rust-lang.org/cargo/index.html">the cargo book</a>中，cargo也被称为“Rust package manager”，crates.io也被称为“the Rust community’s package registry”。</p>
<p>最能直观体现package存在的就是下面Cargo.toml中的配置了：</p>
<pre><code>[package]
name = "hello_world"
version = "0.1.0"
edition = "2021"

[dependencies]
</code></pre>
<p>下面我们就来看看不同类型的rust package的代码组织形式。我们先从单一package形态的项目来开始。</p>
<h3>4.3.1 单一package的rust项目</h3>
<p>单一package项目是指整个项目只有一个Cargo.toml文件。这种项目还可以进一步分为三类：</p>
<ol>
<li>单一Binary Crate</li>
<li>单一Library Crate</li>
<li>多个Binary Crate和一个Library Crate</li>
</ol>
<p>下面我们分别举例来说明一下这三类项目。</p>
<h4>4.3.1.1 单一Binary Crate</h4>
<p>我们进入organizing-rust-code/cargo/single-package/single-binary-crate，然后执行下面命令来创建一个单一Binary Crate的项目：</p>
<pre><code>$cargo new hello_world --bin
     Created binary (application) `hello_world` package
</code></pre>
<p>这个例子我们在之前的章节中也是见过的，它的结构如下：</p>
<pre><code>$tree hello_world
hello_world
├── Cargo.toml
└── src
    └── main.rs

1 directory, 2 files
</code></pre>
<p>默认生成的Cargo.toml内容如下：</p>
<pre><code>[package]
name = "hello_world"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
</code></pre>
<p>使用cargo build即可完成该项目的构建：</p>
<pre><code>$cargo build
   Compiling hello_world v0.1.0 (/Users/tonybai/Go/src/github.com/bigwhite/experiments/rust-guide-for-gopher/organizing-rust-code/cargo/single-package/single-binary-crate/hello_world)
    Finished dev [unoptimized + debuginfo] target(s) in 1.16s
</code></pre>
<p>为了更显式地体现这是一个binary crate，我们可以在Cargo.toml增加如下内容：</p>
<pre><code>[[bin]]
name = "hello_world"
path = "src/main.rs"
</code></pre>
<p>这不会影响cargo的构建结果！</p>
<p>通过cargo run可以查看构建出的可执行文件的运行结果：</p>
<pre><code>$cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.06s
     Running `target/debug/hello_world`
Hello, world!
</code></pre>
<p>接下来，我们再来看看单一library crate的rust项目。</p>
<h4>4.3.1.2 单一Library Crate</h4>
<p>我们进入organizing-rust-code/cargo/single-package/single-library-crate，然后执行下面命令来创建一个单一Library Crate的项目：</p>
<pre><code>$cargo new my_library --lib
     Created library `my_library` package
</code></pre>
<p>创建后的my_library项目的结构如下：</p>
<pre><code>$tree
.
├── Cargo.toml
└── src
    └── lib.rs
</code></pre>
<p>默认生成的Cargo.toml如下：</p>
<pre><code>[package]
name = "my_library"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
</code></pre>
<p>和binary crate的一样，我们也可以显式指定target：</p>
<pre><code>[lib]
name = "my_library"
path = "src/lib.rs"
</code></pre>
<p>注意，这里是[lib]而不是[[lib]]，这是因为在一个carge package中最多只能存在一个library crate，但binary crate可以有多个。</p>
<p>接下来，我们就看看一个由多个binary crate和一个library crate混合构成的rust项目。</p>
<h4>4.3.1.3 多个Binary Crate和一个Library Crate</h4>
<p>我们在organizing-rust-code/cargo/single-package/hybrid-crates下面执行如下命令创建这个多crates混合项目：</p>
<pre><code>$cargo new my_project
     Created binary (application) `my_project` package
</code></pre>
<p>上述命令默认创建了一个binary crate的project，我们需要配置一下Cargo.toml，将其改造为多个crates并存的project：</p>
<pre><code>[package]
name = "my_project"
version = "0.1.0"
edition = "2021"

[[bin]]
name = "cmd1"
path = "src/main1.rs"

[[bin]]
name = "cmd2"
path = "src/main2.rs"

[lib]
name = "my_library"
path = "src/lib.rs"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
</code></pre>
<p>这里定义了三个crates。两个binary crates: cmd1、cmd2以及一个library crate：my_library。</p>
<p>如果我们执行cargo build，cargo会将三个crate都构建出来：</p>
<pre><code>$cargo build
   Compiling my_project v0.1.0 (/Users/tonybai/Go/src/github.com/bigwhite/experiments/rust-guide-for-gopher/organizing-rust-code/cargo/single-package/hybrid-crates/my_project)
    Finished dev [unoptimized + debuginfo] target(s) in 0.80s

</code></pre>
<p>我们可以在target/debug下找到构建出的crates：cmd1、cmd2和libmy_library.rlib：</p>
<pre><code>$ls target/debug
build/          cmd1.d          cmd2.d          examples/       libmy_library.d
cmd1*           cmd2*           deps/           incremental/        libmy_library.rlib
</code></pre>
<p>我们也可以通过cargo分别运行两个binary crate：</p>
<pre><code>$cargo run --bin cmd1
    Finished dev [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/cmd1`
cmd1

$cargo run --bin cmd2
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/cmd2`
cmd2
</code></pre>
<h4>4.3.1.4 典型的cargo package</h4>
<p>在The cargo book中，有一个典型的cargo package的示例：</p>
<pre><code>.
├── Cargo.lock
├── Cargo.toml
├── src/
│   ├── lib.rs
│   ├── main.rs
│   └── bin/
│       ├── named-executable.rs
│       ├── another-executable.rs
│       └── multi-file-executable/
│           ├── main.rs
│           └── some_module.rs
├── benches/
│   ├── large-input.rs
│   └── multi-file-bench/
│       ├── main.rs
│       └── bench_module.rs
├── examples/
│   ├── simple.rs
│   └── multi-file-example/
│       ├── main.rs
│       └── ex_module.rs
└── tests/
    ├── some-integration-tests.rs
    └── multi-file-test/
        ├── main.rs
        └── test_module.rs

</code></pre>
<p>在这样一个典型的项目中：</p>
<ul>
<li>Cargo.toml和Cargo.lock文件存储在包的根目录（包根目录）中。</li>
<li>源代码位于src目录中。</li>
<li>默认的库文件是src/lib.rs。</li>
<li>默认的可执行文件是src/main.rs。</li>
<li>其他可执行文件可以放在src/bin/目录中。</li>
<li>基准测试位于benches目录中。</li>
<li>示例位于examples目录中。</li>
<li>集成测试位于tests目录中。</li>
</ul>
<h3>4.3.2 多package的rust项目</h3>
<p>一些中大型的Rust项目都是多package的，比如rust的异步编程事实标准<a href="https://github.com/tokio-rs/tokio">tokio库</a>、刚刚升级为Apache基金会顶级项目的<a href="https://github.com/apache/datafusion">SQL查询引擎datafusion</a>等。以tokio为例，这些项目的顶层Cargo.toml都是这样的：</p>
<pre><code>// https://github.com/tokio-rs/tokio/blob/master/Cargo.toml
[workspace]
resolver = "2"
members = [
  "tokio",
  "tokio-macros",
  "tokio-test",
  "tokio-stream",
  "tokio-util",

  # Internal
  "benches",
  "examples",
  "stress-test",
  "tests-build",
  "tests-integration",
]

[workspace.metadata.spellcheck]
config = "spellcheck.toml"
</code></pre>
<p>上面这个Cargo.toml示例与我们在前面见到的Cargo.toml都不一样，它并不包含package配置，其主要的配置为workspace。我们看到workspace的members字段中配置了该项目下的其他package。正是通过这个配置，cargo可以在一个项目里管理和构建多个package。</p>
<p><a href="https://doc.rust-lang.org/cargo/reference/workspaces.html">工作空间（Workspace）</a>是一组一个或多个包（Package）的集合，这些包称为工作空间成员（Workspace Members），它们一起被管理。接下来，我们就来创建一个多package的cargo项目。</p>
<h4>4.3.2.1 cargo管理的多package项目</h4>
<p>由于cargo并没有提供cargo new my-pakcage &#8211;workspace这样的命令行参数，项目的顶层Cargo.toml需要我们手动创建和编辑。</p>
<pre><code>$cd organizing-rust-code/cargo/multi-packages
$mkdir my-workspace
$cd my-workspace
$cargo new package1 --bin
     Created binary (application) `package1` package
$cargo new package2 --lib
     Created library `package2` package
$cargo new package3 --lib
     Created library `package3` package
</code></pre>
<p>接下来，我们手工创建和编辑一下项目顶层的Cargo.toml如下：</p>
<pre><code>// organizing-rust-code/cargo/multi-packages/my-workspace/Cargo.toml
[workspace]
resolver = "2"
members = [
    "package1",
    "package2",
    "package3",
]
</code></pre>
<p>保存后，我们可以在项目顶层目录下使用下面命令检查整个工作空间（workspace）中的所有包（package），确保它们的代码正确无误，不包含任何编译错误：</p>
<pre><code>$cargo check --workspace
    Checking package1 v0.1.0 (/Users/tonybai/Go/src/github.com/bigwhite/experiments/rust-guide-for-gopher/organizing-rust-code/cargo/multi-packages/my-workspace/package1)
    Checking package2 v0.1.0 (/Users/tonybai/Go/src/github.com/bigwhite/experiments/rust-guide-for-gopher/organizing-rust-code/cargo/multi-packages/my-workspace/package2)
    Checking package3 v0.1.0 (/Users/tonybai/Go/src/github.com/bigwhite/experiments/rust-guide-for-gopher/organizing-rust-code/cargo/multi-packages/my-workspace/package3)
    Finished dev [unoptimized + debuginfo] target(s) in 0.18s
</code></pre>
<p>在顶层目录执行cargo build，cargo会build工作空间中的所有package：</p>
<pre><code>$cargo build
   Compiling package3 v0.1.0 (/Users/tonybai/Go/src/github.com/bigwhite/experiments/rust-guide-for-gopher/organizing-rust-code/cargo/multi-packages/my-workspace/package3)
   Compiling package2 v0.1.0 (/Users/tonybai/Go/src/github.com/bigwhite/experiments/rust-guide-for-gopher/organizing-rust-code/cargo/multi-packages/my-workspace/package2)
   Compiling package1 v0.1.0 (/Users/tonybai/Go/src/github.com/bigwhite/experiments/rust-guide-for-gopher/organizing-rust-code/cargo/multi-packages/my-workspace/package1)
    Finished dev [unoptimized + debuginfo] target(s) in 0.64s
</code></pre>
<p>构建后，该项目的目录结构变成下面这个样子：</p>
<pre><code>$tree -L 2 -F
.
├── Cargo.lock
├── Cargo.toml
├── package1/
│   ├── Cargo.toml
│   └── src/
├── package2/
│   ├── Cargo.toml
│   └── src/
├── package3/
│   ├── Cargo.toml
│   └── src/
└── target/
    ├── CACHEDIR.TAG
    └── debug/

</code></pre>
<p>我们看到该项目下的所有package共享一个共同的 Cargo.lock 文件，该文件位于工作空间的根目录下。并且，所有包共享一个共同的输出目录，默认情况下是工作空间根目录下的一个名为target的目录，该target目录下的布局如下：</p>
<pre><code>$tree -F -L 2 ./target
./target
├── CACHEDIR.TAG
└── debug/
    ├── build/
    ├── deps/
    ├── examples/
    ├── incremental/
    ├── libpackage2.d
    ├── libpackage2.rlib
    ├── libpackage3.d
    ├── libpackage3.rlib
    ├── package1*
    └── package1.d
</code></pre>
<p>我们在这下面可以找到所有package的编译输出结果，比如package1、libpackage2.rlib以及libpackage3.rlib。</p>
<p>当然，你也可以指定一个package来构建或运行：</p>
<pre><code>$cargo build -p package1
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
$cargo build -p package2
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
$cargo run -p package1
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/package1`
Hello, world!
</code></pre>
<h4>4.3.2.2 带有外部依赖和内部依赖的多package项目</h4>
<p>我们复制一份my-workspace，改名为my-workspace-with-deps，修改一下package1/src/main.rs，为其增加外部依赖rand crate：</p>
<pre><code>// organizing-rust-code/cargo/multi-packages/my-workspace-with-deps/package1/src/main.rs
extern crate rand;

use rand::Rng;

fn main() {
    let mut rng = rand::thread_rng();
    let num: u32 = rng.gen();
    println!("Random number: {}", num);
}
</code></pre>
<p>接下来，我们需要修改一下package1/Cargo.toml，手工加上对rand crate的依赖配置：</p>
<pre><code>// organizing-rust-code/cargo/multi-packages/my-workspace-with-deps/package1/Cargo.toml
[package]
name = "package1"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
rand = "0.8.5"

</code></pre>
<p>保存后，我们执行package1的构建：</p>
<pre><code>$cargo build -p package1
  Downloaded getrandom v0.2.14 (registry `rsproxy`)
  Downloaded libc v0.2.154 (registry `rsproxy`)
  Downloaded 2 crates (780.6 KB) in 1m 07s
   Compiling libc v0.2.154
   Compiling cfg-if v1.0.0
   Compiling ppv-lite86 v0.2.17
   Compiling getrandom v0.2.14
   Compiling rand_core v0.6.4
   Compiling rand_chacha v0.3.1
   Compiling rand v0.8.5
   Compiling package1 v0.1.0 (/Users/tonybai/Go/src/github.com/bigwhite/experiments/rust-guide-for-gopher/organizing-rust-code/cargo/multi-packages/my-workspace-with-deps/package1)
    Finished dev [unoptimized + debuginfo] target(s) in 1m 46s
</code></pre>
<p>我们看到：cargo会自动下载package1的直接外部依赖以及相关间接依赖。构建成功后，可以执行一下package1的编译结果：</p>
<pre><code>$cargo run -p package1
    Finished dev [unoptimized + debuginfo] target(s) in 0.09s
     Running `target/debug/package1`
Random number: 3840180495
</code></pre>
<p>接下来，我们再为package1添加内部依赖，比如依赖package2的编译结果：</p>
<pre><code>// organizing-rust-code/cargo/multi-packages/my-workspace-with-deps/package1/src/main.rs

extern crate package2;
extern crate rand;

use rand::Rng;

fn main() {
    let mut rng = rand::thread_rng();
    let num: u32 = rng.gen();
    println!("Random number: {}", num);
    let result = package2::add(2, 2);
    println!("result: {}", result);
}

// organizing-rust-code/cargo/multi-packages/my-workspace-with-deps/package1/Cargo.toml
[package]
name = "package1"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
rand = "0.8.5"
package2 = { path = "../package2" }
</code></pre>
<p>我们看到：package1的main.rs依赖package2这个crate中的add函数，我们在package1的Cargo.toml中为package1添加了新依赖package2，由于package2仅仅存放在本地，所以这里我们使用了path方式指定package2的位置。</p>
<p>我们执行一下添加内部依赖后的package1：</p>
<pre><code>$cargo run -p package1
    Finished dev [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/package1`
Random number: 2485645524
result: 4
</code></pre>
<h2>4.4 小结</h2>
<p>本文循序渐进地讨论了在Rust项目中如何组织代码的问题，这对于Rust初学者来说尤为有用。</p>
<p>我们首先回顾了Go语言中的代码组织方式，介绍了Go项目代码组织的两个层级：module和package。然后，我们将Rust项目可以分为两种类型：使用rustc编译器的项目和使用Cargo的项目。</p>
<p>对于rustc-only的项目，开发者需要编写自己的构建脚本来管理项目的构建过程。</p>
<p>文章从最简单的单文件rustc-only项目开始介绍，展示了如何使用rustc编译器来编译和运行这种项目，并逐步介绍了带有外部依赖的rustc-only项目以及多文件项目的情况，引出了rust module概念。</p>
<p>rustc-only项目很少用于生产环境，这种方式主要用于学习和了解Rustc编译器的功能机制以及Rust语言的代码组织抽象。</p>
<p>在实际开发中，使用Cargo来创建和管理Rust包是常见的做法。在本章的后半段，我们介绍了使用cargo管理的rust项目的代码组织情况，包括单package项目和多package项目以及如何为项目引入外部和内部依赖。</p>
<p>总体而言，本文旨在帮助初学者理解和掌握Rust项目的代码组织结构，以提高学习效率和学习效果。通过介绍rustc-only项目和cargo管理的项目，读者可以逐步了解Rust代码组织的基本概念和实践方法。</p>
<p>本文涉及的源码可以在<a href="https://github.com/bigwhite/experiments/tree/master/rust-guide-for-gopher/organizing-rust-code">这里</a>下载。</p>
<h2>4.5 参考资料</h2>
<ul>
<li><a href="https://doc.rust-lang.org/book">The book</a> &#8211; https://doc.rust-lang.org/book</li>
<li><a href="https://doc.rust-lang.org/cargo/index.html">The cargo book</a> &#8211; https://doc.rust-lang.org/cargo/index.html</li>
<li><a href="https://doc.rust-lang.org/rustc/index.html">The rustc book</a> &#8211; https://doc.rust-lang.org/rustc/index.html</li>
</ul>
<hr />
<p><a href="https://public.zsxq.com/groups/51284458844544">Gopher部落知识星球</a>在2024年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。同时，我们也会加强代码质量和最佳实践的分享，包括如何编写简洁、可读、可测试的Go代码。此外，我们还会加强星友之间的交流和互动。欢迎大家踊跃提问，分享心得，讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾。让我相聚在Gopher部落，享受coding的快乐! 欢迎大家踊跃加入！</p>
<p><img src="http://image.tonybai.com/img/tonybai/gopher-tribe-zsxq-small-card.png" alt="img{512x368}" /><br />
<img src="http://image.tonybai.com/img/tonybai/go-programming-from-beginner-to-master-qr.png" alt="img{512x368}" /></p>
<p><img src="http://image.tonybai.com/img/tonybai/go-first-course-banner.png" alt="img{512x368}" /><br />
<img src="http://image.tonybai.com/img/tonybai/imooc-go-column-pgo-with-qr.jpg" alt="img{512x368}" /></p>
<p>著名云主机服务厂商DigitalOcean发布最新的主机计划，入门级Droplet配置升级为：1 core CPU、1G内存、25G高速SSD，价格5$/月。有使用DigitalOcean需求的朋友，可以打开这个<a href="https://m.do.co/c/bff6eed92687">链接地址</a>：https://m.do.co/c/bff6eed92687 开启你的DO主机之路。</p>
<p>Gopher Daily(Gopher每日新闻) &#8211; https://gopherdaily.tonybai.com</p>
<p>我的联系方式：</p>
<ul>
<li>微博(暂不可用)：https://weibo.com/bigwhite20xx</li>
<li>微博2：https://weibo.com/u/6484441286</li>
<li>博客：tonybai.com</li>
<li>github: https://github.com/bigwhite</li>
<li>Gopher Daily归档 &#8211; https://github.com/bigwhite/gopherdaily</li>
</ul>
<p><img src="http://image.tonybai.com/img/tonybai/iamtonybai-wechat-qr.png" alt="" /></p>
<p>商务合作方式：撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。</p>
<p style='text-align:left'>&copy; 2024, <a href='https://tonybai.com'>bigwhite</a>. 版权所有. </p>
]]></content:encoded>
			<wfw:commentRss>https://tonybai.com/2024/06/06/gopher-rust-first-lesson-organizing-rust-code/feed/</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<item>
		<title>通过实例理解Go内联优化</title>
		<link>https://tonybai.com/2022/10/17/understand-go-inlining-optimisations-by-example/</link>
		<comments>https://tonybai.com/2022/10/17/understand-go-inlining-optimisations-by-example/#comments</comments>
		<pubDate>Sun, 16 Oct 2022 21:40:25 +0000</pubDate>
		<dc:creator>bigwhite</dc:creator>
				<category><![CDATA[技术志]]></category>
		<category><![CDATA[ast]]></category>
		<category><![CDATA[benchmark]]></category>
		<category><![CDATA[budget]]></category>
		<category><![CDATA[CALL]]></category>
		<category><![CDATA[Channel]]></category>
		<category><![CDATA[devirtualization]]></category>
		<category><![CDATA[directive]]></category>
		<category><![CDATA[GC]]></category>
		<category><![CDATA[GCC]]></category>
		<category><![CDATA[gcflags]]></category>
		<category><![CDATA[Go]]></category>
		<category><![CDATA[go1.17]]></category>
		<category><![CDATA[Golang]]></category>
		<category><![CDATA[Inline]]></category>
		<category><![CDATA[inlining]]></category>
		<category><![CDATA[internal]]></category>
		<category><![CDATA[IR]]></category>
		<category><![CDATA[LLVM]]></category>
		<category><![CDATA[NOPL]]></category>
		<category><![CDATA[SSA]]></category>
		<category><![CDATA[中间代码]]></category>
		<category><![CDATA[中间表示]]></category>
		<category><![CDATA[内联优化]]></category>
		<category><![CDATA[字节跳动]]></category>
		<category><![CDATA[寄存器]]></category>
		<category><![CDATA[微服务]]></category>
		<category><![CDATA[抽象语法树]]></category>
		<category><![CDATA[指示符]]></category>
		<category><![CDATA[汇编]]></category>
		<category><![CDATA[编译原理]]></category>
		<category><![CDATA[编译器]]></category>
		<category><![CDATA[调用规约]]></category>
		<category><![CDATA[递归]]></category>

		<guid isPermaLink="false">https://tonybai.com/?p=3685</guid>
		<description><![CDATA[本文永久链接 &#8211; https://tonybai.com/2022/10/17/understand-go-inlining-optimisations-by-example 移动互联网时代，直面C端用户的业务系统规模一般都很庞大，系统消耗的机器资源也很可观，系统使用的CPU核数、内存都是在消耗公司的真金白银。在服务水平不下降的前提下尽量降低单服务实例的资源消耗，即我们俗称的“少吃草多产奶”，一直是各个公司经营人员的目标，有些公司每降低1%的CPU核数使用，每年都能节省几十万的开销。 在编程语言选择不变的情况下，要想持续降低服务资源消耗，一方面要靠开发人员对代码性能持续地打磨，另一方面依靠编程语言编译器在编译优化方面提升带来的效果则更为自然和直接。不过，这两方面也是相辅相成的，开发人员如果能对编译器的优化场景和手段理解更为透彻的话，就能写出对编译优化更为友好的代码，从而获得更好的性能优化效果。 Go核心团队在Go编译器优化方面一直在持续投入并取得了不俗的效果，虽然和老牌的GCC和llvm的代码优化功力相比还有不小的空间。近期看到的一篇文章“字节大规模微服务语言发展之路”中也有提到：字节内部通过修改Go编译器的内联优化(收益最大的改动)，从而让字节内部服务的Go代码获得了更多的优化机会，实现了线上服务10-20%的性能提升以及内存资源使用的下降，节约了大概了十几万个核。 看到这么明显的效果，想必各位读者都很想了解一下Go编译器的内联优化了。别急，在这一篇文章中，我就和大家一起来学习和理解一下Go编译器的内联优化。希望通过本文的学习，能让大家掌握如下内容： 什么是内联优化以及它的好处是什么 内联优化在Go编译过程中所处的环节和实现原理 哪些代码能被内联优化，哪些还不能被内联优化 如何控制Go编译器的内联优化 内联优化的弊端有哪些 下面我们就先来了解一下什么是内联优化。 1. 什么是编译器的内联优化 内联(inlining)是编程语言编译器常用的优化手段，其优化的对象为函数，也称为函数内联。如果某函数F支持内联，则意味着编译器可以用F的函数体/函数定义替换掉对函数F进行调用的代码，以消除函数调用带来的额外开销，这个过程如下图所示： 我们知道Go从1.17版本才改为基于寄存器的调用规约，之前的版本一直是基于栈传递参数与返回值，函数调用的开销更大，在这样的情况下，内联优化的效果也就更为显著。 除此之外，内联优化之后，编译器的优化决策可以不局限在每个单独的函数(比如上图中的函数g)上下文中做出，而是可以在函数调用链上做出了(内联替换后，代码变得更平(flat)了)。比如上图中对g后续执行的优化将不局限在g上下文，由于f的内联，让编译器可以在g->f这个调用链的上下文上决策后续要执行的优化手段，即内联让编译器可以看得更广更远了。 我们来看一个简单的例子： // github.com/bigwhite/experiments/tree/master/inlining-optimisations/add/add.go //go:noinline func add(a, b int) int { return a + b } func main() { var a, b = 5, 6 c := add(a, b) println(c) } 这个例子中，我们的关注点是add函数，在add函数定义上方，我们用//go:noinline告知编译器对add函数关闭inline，我们构建该程序，得到可执行文件：add-without-inline；然后去掉//go:noinline这一行，再进行一次程序构建，得到可执行文件add，我们用lensm工具以图形化的方式查看一下这两个可执行文件的汇编代码，并做以下对比： 我们看到：非内联优化的版本add-without-inline如我们预期那样，在main函数中通过CALL指令调用了add函数；但在内联优化版本中，add函数的函数体并没有替换掉main函数中调用add函数位置上的代码，main函数调用add函数的位置上对应的是一个NOPL的汇编指令，这是一条不执行任何操作的空指令。那么add函数实现的汇编代码哪去了呢？ // add函数实现的汇编代码 ADDQ BX, AX [...]]]></description>
			<content:encoded><![CDATA[<p><img src="https://tonybai.com/wp-content/uploads/understand-go-inlining-optimisations-by-example-1.png" alt="" /></p>
<p><a href="https://tonybai.com/2022/10/17/understand-go-inlining-optimisations-by-example">本文永久链接</a> &#8211; https://tonybai.com/2022/10/17/understand-go-inlining-optimisations-by-example</p>
<hr />
<p>移动互联网时代，直面C端用户的业务系统规模一般都很庞大，系统消耗的机器资源也很可观，系统使用的CPU核数、内存都是在消耗公司的真金白银。在服务水平不下降的前提下尽量降低单服务实例的资源消耗，即我们俗称的“少吃草多产奶”，一直是各个公司经营人员的目标，有些公司每降低1%的CPU核数使用，每年都能节省几十万的开销。</p>
<p>在编程语言选择不变的情况下，要想持续降低服务资源消耗，一方面要靠开发人员对代码性能持续地打磨，另一方面<strong>依靠编程语言编译器在编译优化方面提升带来的效果则更为自然和直接</strong>。不过，这两方面也是相辅相成的，开发人员如果能对编译器的优化场景和手段理解更为透彻的话，就能写出对编译优化更为友好的代码，从而获得更好的性能优化效果。</p>
<p>Go核心团队在Go编译器优化方面一直在持续投入并取得了不俗的效果，虽然和老牌的<a href="http://gcc.gnu.org">GCC</a>和<a href="https://llvm.org">llvm</a>的代码优化功力相比还有不小的空间。近期看到的一篇文章<a href="https://mp.weixin.qq.com/s/0X4lasAf5Sbt_tromlqwIQ">“字节大规模微服务语言发展之路”</a>中也有提到：字节内部通过修改Go编译器的内联优化(收益最大的改动)，从而让字节内部服务的Go代码获得了更多的优化机会，实现了线上服务10-20%的性能提升以及内存资源使用的下降，节约了大概了十几万个核。</p>
<p>看到这么明显的效果，想必各位读者都很想了解一下Go编译器的内联优化了。别急，在这一篇文章中，我就和大家一起来学习和理解一下Go编译器的内联优化。希望通过本文的学习，能让大家掌握如下内容：</p>
<ul>
<li>什么是内联优化以及它的好处是什么</li>
<li>内联优化在Go编译过程中所处的环节和实现原理</li>
<li>哪些代码能被内联优化，哪些还不能被内联优化</li>
<li>如何控制Go编译器的内联优化</li>
<li>内联优化的弊端有哪些</li>
</ul>
<p>下面我们就先来了解一下什么是内联优化。</p>
<hr />
<h3>1. 什么是编译器的内联优化</h3>
<p><a href="http://en.wikipedia.org/wiki/Inline_expansion">内联(inlining)</a>是编程语言编译器常用的优化手段，其优化的对象为函数，也称为<strong>函数内联</strong>。如果某函数F支持内联，则意味着编译器可以用F的函数体/函数定义替换掉对函数F进行调用的代码，以消除函数调用带来的额外开销，这个过程如下图所示：</p>
<p><img src="https://tonybai.com/wp-content/uploads/understand-go-inlining-optimisations-by-example-2.png" alt="" /></p>
<p>我们知道Go从<a href="https://tonybai.com/2021/08/17/some-changes-in-go-1-17">1.17版本</a>才改为<a href="https://tonybai.com/2021/08/20/using-register-based-calling-convention-in-go-1-17/">基于寄存器的调用规约</a>，之前的版本一直是基于栈传递参数与返回值，函数调用的开销更大，在这样的情况下，内联优化的效果也就更为显著。</p>
<p>除此之外，内联优化之后，编译器的优化决策可以不局限在每个单独的函数(比如上图中的函数g)上下文中做出，而是可以在函数调用链上做出了(内联替换后，代码变得更平(flat)了)。比如上图中对g后续执行的优化将不局限在g上下文，由于f的内联，让编译器可以在g->f这个调用链的上下文上决策后续要执行的优化手段，即<strong>内联让编译器可以看得更广更远了</strong>。</p>
<p>我们来看一个简单的例子：</p>
<pre><code>// github.com/bigwhite/experiments/tree/master/inlining-optimisations/add/add.go

//go:noinline
func add(a, b int) int {
    return a + b
}

func main() {
    var a, b = 5, 6
    c := add(a, b)
    println(c)
}
</code></pre>
<p>这个例子中，我们的关注点是add函数，在add函数定义上方，我们用//go:noinline告知编译器对add函数关闭inline，我们构建该程序，得到可执行文件：<strong>add-without-inline</strong>；然后去掉//go:noinline这一行，再进行一次程序构建，得到可执行文件<strong>add</strong>，我们用<a href="https://github.com/loov/lensm">lensm工具</a>以图形化的方式查看一下这两个可执行文件的汇编代码，并做以下对比：</p>
<p><img src="https://tonybai.com/wp-content/uploads/understand-go-inlining-optimisations-by-example-3.png" alt="" /></p>
<p>我们看到：非内联优化的版本add-without-inline如我们预期那样，在main函数中通过CALL指令调用了add函数；但在内联优化版本中，add函数的函数体并没有替换掉main函数中调用add函数位置上的代码，main函数调用add函数的位置上对应的是一个NOPL的汇编指令，这是一条不执行任何操作的空指令。那么add函数实现的汇编代码哪去了呢？</p>
<pre><code>// add函数实现的汇编代码
ADDQ BX, AX
RET
</code></pre>
<p>结论是：被优化掉了！这就是前面说的<strong>内联为后续的优化提供更多的机会</strong>。add函数调用被替换为add函数的实现后，Go编译器直接可以确定调用结果为11，于是连加法运算都省略了，直接将add函数的结果换成了一个常数11(0xb)，然后直接将常量11传给了println内置函数(MOVL 0xb, AX)。</p>
<p>通过一个简单的benchmark，也可以看出内联与非内联add的性能差异：</p>
<pre><code>// 开启内联优化
$go test -bench .
goos: darwin
goarch: amd64
pkg: github.com/bigwhite/experiments/inlining-optimisations/add
BenchmarkAdd-8      1000000000           0.2720 ns/op
PASS
ok      github.com/bigwhite/experiments/inlining-optimisations/add  0.307s

// 关闭内联优化
$go test -bench .
goos: darwin
goarch: amd64
pkg: github.com/bigwhite/experiments/inlining-optimisations/add
BenchmarkAdd-8      818820634            1.357 ns/op
PASS
ok      github.com/bigwhite/experiments/inlining-optimisations/add  1.268s
</code></pre>
<p>我们看到：<strong>内联版本是非内联版本性能的5倍左右</strong>。</p>
<p>到这里，很多朋友可能会问：既然内联优化的效果这么好，为什么不将Go程序内部的所有函数都内联了，这样整个Go程序就变成了一个大函数，中间再没有任何函数调用了，这样性能是不是可以变得更高呢？虽然理论上可能是这种情况，但内联优化不是没有开销的，并且针对不同复杂性的函数，内联的效果也是不同的。下面我就和大家一起先来看看内联优化的开销！</p>
<h3>2. 内联优化的“开销”</h3>
<p>在真正理解内联优化的开销之前，我们先来看看内联优化在Go编译过程中的位置，即处于哪个环节。</p>
<h4>Go编译过程</h4>
<p>和所有静态语言编译器一样，Go编译过程大致分为如下几个阶段：</p>
<p><img src="https://tonybai.com/wp-content/uploads/understand-go-inlining-optimisations-by-example-4.png" alt="" /></p>
<ul>
<li>编译前端</li>
</ul>
<p>Go团队并没有刻意将Go编译过程分为我们常识中的前后端，如果非要这么分，源码分析(包括词法和语法分析)、类型检查和中间表示(Intermediate Representation)构建可以归为逻辑上的编译前端，后面的其余环节都划归为后端。</p>
<p>源码分析形成抽象语法树，然后是基于抽象语法树的类型检查，待类型检查通过后，Go编译器将AST转换为一个与目标平台无关的中间代码表示。</p>
<p>目前Go有两种IR实现方式，一种是irgen（又名”-G=3&#8243;或是”noder2&#8243;），irgen是从<a href="https://tonybai.com/2022/04/20/some-changes-in-go-1-18">Go 1.18版本</a>开始使用的实现(这也是一种类似AST的结构)；另外一种是unified IR，在<a href="https://tonybai.com/2022/08/22/some-changes-in-go-1-19">Go 1.19版本</a>中，我们可以使用GOEXPERIMENT=unified启用它，根据最新消息，unified IR将在Go 1.20版本落地。</p>
<blockquote>
<p>注：现代编程语言编译过程多数会多次生成中间代码(IR)，比如下面要提到的静态单赋值形式(SSA)也是一种IR形式。针对每种IR，编译器都会有一些优化动作：</p>
</blockquote>
<p><img src="https://tonybai.com/wp-content/uploads/understand-go-inlining-optimisations-by-example-5.png" alt="" /><br />
<center>图：编译优化过程(图来自https://www.slideserve.com/heidi-farmer/ssa-static-single-assignment-form)</center></p>
<ul>
<li>编译后端</li>
</ul>
<p>编译后端的第一步是一个被Go团队称为中端(middle end)的环节，在这个环节中，Go编译器将基于上面的中间代码进行多轮(pass)的优化，包括死代码消除、内联优化、方法调用实体化(devirtualization)和逃逸分析等。</p>
<blockquote>
<p>注：devirtualization是指将通过接口变量调用的方法转换为接口的动态类型变量直接调用该方法，消除通过接口进行方法表查找的过程。</p>
</blockquote>
<p>接下来是中间代码遍历(walk)，这个环节是基于上述IR表示的最后一轮优化，它主要是将复杂的语句分解成单独的、更简单的语句，引入临时变量并重新评估执行顺序，同时在这个环节，它还会将一些高层次的Go结构转换为更底层、更基础的操作结构，比如将switch语句转换为二分查找或跳表，将对map和channel的操作替换为运行时的调用(如mapaccess)等。</p>
<p>接下来是编译后端的最后两个环节，首先是将IR转换为SSA(静态单一赋值)形式，并再次基于SSA做多轮优化，最后针对目标架构，基于SSA的最终形式生成机器相关的汇编指令，然后交给汇编器生成可重定位的目标机器码。</p>
<blockquote>
<p>注： 编译器(go compiler)产生的可重定位的目标机器码最终提供给链接器(linker)生成可执行文件。</p>
</blockquote>
<p>我们看到<strong>Go内联发生在中端环节，是基于IR中间代码的一种优化手段，在IR层面上实现函数是否可内联的决策，以及对可内联函数在其调用处的函数体替换</strong>。</p>
<p>一旦了解了Go内联所处环节，我们就能大致判断出Go内联优化带来的开销了。</p>
<h4>Go内联优化的开销</h4>
<p>我们用一个实例来看一下Go内联优化的开销。<a href="https://tonybai.com/2022/09/08/make-reviewdog-support-gitlab-push-commit-to-preserve-the-code-quality-floor">reviewdog</a>是一个纯Go实现的支持github、gitlab等主流代码托管平台的代码评审工具，它的规模大约有12k行(使用<a href="https://gitlab.com/esr/loccount">loccount</a>统计)：</p>
<pre><code>// reviewdog代码行数统计结果：

$loccount .
all          SLOC=14903   (100.00%) LLOC=4613    in 141 files
Go           SLOC=12456   (83.58%)  LLOC=4584    in 106 files
... ...
</code></pre>
<p>我们在开启内联优化和关闭内联优化的情况下分别对reviewdog进行构建，采集其构建时间与构建出的二进制文件的size，结果如下：</p>
<pre><code>// 开启内联优化(默认)
$time go build -o reviewdog-inline -a github.com/reviewdog/reviewdog/cmd/reviewdog
go build -o reviewdog-inline -a github.com/reviewdog/reviewdog/cmd/reviewdog  53.87s user 9.55s system 567% cpu 11.181 total

// 关闭内联优化
$time go build -o reviewdog-noinline -gcflags=all="-l" -a github.com/reviewdog/reviewdog/cmd/reviewdog
go build -o reviewdog-noinline -gcflags=all="-l" -a   43.25s user 8.09s system 566% cpu 9.069 total

$ ls -l
-rwxrwxr-x  1 tonybai tonybai 23080429 Oct 13 12:05 reviewdog-inline*
-rwxrwxr-x  1 tonybai tonybai 20745006 Oct 13 12:04 reviewdog-noinline*
... ...
</code></pre>
<p>我们看到开启内联优化的版本，其编译消耗时间比关闭内联优化版本的编译时间多出24%左右，并且生成的二进制文件size要大出11%左右 &#8211; <strong>这就是内联优化带来的开销</strong>！即会拖慢编译器并导致生成的二进制文件size变大。</p>
<blockquote>
<p>注：hello world级别的程序是否开启内联优化大多数情况是看不出来太多差异的，无论是编译时间，还是二进制文件的size。</p>
</blockquote>
<p>由于我们知道了内联优化所处的环节，因此这种开销就可以很好地给予解释：根据内联优化的定义，一旦某个函数被决策为可内联，那么程序中所有调用该函数的位置的代码就会被替换为该函数的实现，从而消除掉函数调用带来的运行时开销，同时这也导致了在IR(中间代码)层面出现一定的代码“膨胀”。前面也说过，代码膨胀后的“副作用”是编译器可以以更广更远的视角看待代码，从而可能实施的优化手段会更多。可实施的优化轮次越多，编译器执行的就越慢，这进一步增加了编译器的耗时；同时膨胀的代码让编译器需要在后面环节处理和生成更多代码，不仅增加耗时，还增加了最终二进制文件的size。</p>
<p>Go向来对编译速度和binary size较为敏感，所以Go采用了相对保守的内联优化策略。那么到底Go编译器是如何决策一个函数是否可以内联呢？下面我们就来简单看看Go编译器是如何决策哪些函数可以实施内联优化的。</p>
<h3>3. 函数内联的决策原理</h3>
<p>前面说过，内联优化是编译中端多轮(pass)优化中的一轮，因此它的逻辑相对独立，<strong>它基于IR代码进行，改变的也是IR代码</strong>。我们可以在Go源码的$GOROOT/src/cmd/compile/internal/inline/inl.go中找到Go编译器进行内联优化的主要代码。</p>
<blockquote>
<p>注：Go编译器内联优化部分的代码的位置和逻辑在以前的版本以及在未来的版本中可能有变化，目前本文提到的是代码是Go 1.19.1中的源码。</p>
</blockquote>
<p>内联优化IR优化环节会做两件事：第一遍历IR中所有函数，通过CanInline判断某个函数是否可以内联，对于可内联的函数，保存相应信息，比如函数body等，供后续做内联函数替换使用；第二呢，则是对函数中调用的所有内联函数进行替换。 我们重点关注CanInline，即<strong>Go编译器究竟是如何决策一个函数是否可以内联的</strong>！</p>
<p>内联优化过程的“驱动逻辑”在$GOROOT/src/cmd/compile/internal/gc/main.go的Main函数中：</p>
<pre><code>// $GOROOT/src/cmd/compile/internal/gc/main.go
func Main(archInit func(*ssagen.ArchInfo)) {
    base.Timer.Start("fe", "init")

    defer handlePanic()

    archInit(&amp;ssagen.Arch)
    ... ...

    // Enable inlining (after RecordFlags, to avoid recording the rewritten -l).  For now:
    //  default: inlining on.  (Flag.LowerL == 1)
    //  -l: inlining off  (Flag.LowerL == 0)
    //  -l=2, -l=3: inlining on again, with extra debugging (Flag.LowerL &gt; 1)
    if base.Flag.LowerL &lt;= 1 {
        base.Flag.LowerL = 1 - base.Flag.LowerL
    }
    ... ...

    // Inlining
    base.Timer.Start("fe", "inlining")
    if base.Flag.LowerL != 0 {
        inline.InlinePackage()
    }
    noder.MakeWrappers(typecheck.Target) // must happen after inlining
    ... ...
}
</code></pre>
<p>从代码中我们看到：如果没有全局关闭内联优化(base.Flag.LowerL != 0)，那么Main就会调用inline包的InlinePackage函数执行内联优化。InlinePackage的代码如下：</p>
<pre><code>// $GOROOT/src/cmd/compile/internal/inline/inl.go
func InlinePackage() {
    ir.VisitFuncsBottomUp(typecheck.Target.Decls, func(list []*ir.Func, recursive bool) {
        numfns := numNonClosures(list)
        for _, n := range list {
            if !recursive || numfns &gt; 1 {
                // We allow inlining if there is no
                // recursion, or the recursion cycle is
                // across more than one function.
                CanInline(n)
            } else {
                if base.Flag.LowerM &gt; 1 {
                    fmt.Printf("%v: cannot inline %v: recursive\n", ir.Line(n), n.Nname)
                }
            }
            InlineCalls(n)
        }
    })
}
</code></pre>
<p>InlinePackage遍历每个顶层声明的函数，对于非递归函数或递归前跨越一个以上函数的递归函数，通过调用CanInline函数判断其是否可以内联，无论是否可以内联，接下来都会调用InlineCalls函数对其函数定义中调用的内联函数进行替换。</p>
<blockquote>
<p>VisitFuncsBottomUp是根据函数调用图从底向上遍历的，这样可以保证每次在调用analyze时，列表中的每个函数都只调用列表中的其他函数，或者是在之前的调用中已经analyze过(在这里就是被内联函数体替换过)的函数。</p>
</blockquote>
<p>什么是递归前跨越一个以上函数的递归函数，看下面这个例子就懂了：</p>
<pre><code>// github.com/bigwhite/experiments/tree/master/inlining-optimisations/recursion/recursion1.go
func main() {
    f(100)
}

func f(x int) {
    if x &lt; 0 {
        return
    }
    g(x - 1)
}
func g(x int) {
    h(x - 1)
}
func h(x int) {
    f(x - 1)
}
</code></pre>
<p>f是一个递归函数，但并非自己调用自己，而是通过g -> h这个函数链最终又调回自己，而这个函数链长度>1，所以f是可以内联的：</p>
<pre><code>$go build -gcflags '-m=2'  recursion1.go
./recursion1.go:7:6: can inline f with cost 67 as: func(int) { if x &lt; 0 { return  }; g(x - 1) }
</code></pre>
<p>我们继续看CanInline函数。CanInline函数有100多行代码，其主要逻辑分为三个部分。</p>
<p>首先是对一些//go:xxx指示符(directive)的判定，当该函数包含下面指示符时，则该函数不能内联：</p>
<ul>
<li>//go:noinline</li>
<li>//go:norace或构建命令行中包含-race选项</li>
<li>//go:nocheckptr</li>
<li>//go:cgo_unsafe_args</li>
<li>//go:uintptrkeepalive</li>
<li>//go:uintptrescapes</li>
<li>&#8230; &#8230;</li>
</ul>
<p>其次会对该函数的状态做判定，比如如果函数体为空，则不能内联；如果未做类型检查(typecheck)，则不能内联等。</p>
<p>最后调用visitor.tooHairy对函数的复杂性做判定。判定方法就是先为此次遍历(visitor)设置一个初始最大预算(budget)，这个初始最大预算值为一个常量(inlineMaxBudget)，目前其值为80：</p>
<pre><code>// $GOROOT/src/cmd/compile/internal/inline/inl.go
const (
    inlineMaxBudget       = 80
)
</code></pre>
<p>然后在visitor.tooHairy函数中遍历该函数实现中的各个语法元素：</p>
<pre><code>// $GOROOT/src/cmd/compile/internal/inline/inl.go
func CanInline(fn *ir.Func) {
    ... ...
    visitor := hairyVisitor{
        budget:        inlineMaxBudget,
        extraCallCost: cc,
    }
    if visitor.tooHairy(fn) {
        reason = visitor.reason
        return
    }
    ... ...
}
</code></pre>
<p>不同元素对预算的消耗都有不同，比如调用一次append，visitor预算值就要减去inlineExtraAppendCost，再比如如果该函数是中间函数(而非叶子函数)，那么visitor预算值也要减去v.extraCallCost，即57。就这样一路下来，如果预算被用光，即v.budget &lt; 0，则说明这个函数过于复杂，不能被内联；相反，如果一路下来，预算依然有，那么说明这个函数相对简单，可以被内联优化。</p>
<blockquote>
<p>注：为什么inlineExtraCallCost的值是57？这是一个经验值，是<a href="https://github.com/golang/go/issues/19348#issuecomment-439370742">通过一个benchmark得出来的</a>。</p>
</blockquote>
<p>一旦确定可以被内联，那么Go编译器就会将一些信息保存下来，保存到IR中该函数节点的Inl字段中：</p>
<pre><code>// $GOROOT/src/cmd/compile/internal/inline/inl.go
func CanInline(fn *ir.Func) {
    ... ...
    n.Func.Inl = &amp;ir.Inline{
        Cost: inlineMaxBudget - visitor.budget,
        Dcl:  pruneUnusedAutos(n.Defn.(*ir.Func).Dcl, &amp;visitor),
        Body: inlcopylist(fn.Body),

        CanDelayResults: canDelayResults(fn),
    }
    ... ...
}
</code></pre>
<p>Go编译器设置budget值为80，显然是不想让过于复杂的函数被内联优化，这是为什么呢？主要是权衡内联优化带来的收益与其开销。让更复杂的函数内联，开销会增大，但收益却可能不会有明显增加，即所谓的“投入产出比”不足。</p>
<p>从上面的原理描述可知，对那些size不大(复杂性较低)、被反复调用的函数施以内联的效果可能更好。而对于那些过于复杂的函数，函数调用的开销占其执行开销的比重已经十分小了，甚至可忽略不计，这样内联效果就会较差。</p>
<p>很多人会说：内联后不是还有更多编译器优化机会么？问题在于究竟是否有优化机会以及会实施哪些更多的优化，这是无法预测的事情。</p>
<h3>4. 对Go编译器的内联优化进行干预</h3>
<p>最后我们再来看看如何对Go编译器的内联优化进行干预。Go编译器默认是开启全局内联优化的，并按照上面inl.go中CanInline的决策流程来确定一个函数是否可以内联。</p>
<p>不过Go也给了我们控制内联的一些手段，比如我们可以在某个函数上显式告知编译器不要对该函数进行内联，我们以上面示例中的add.go为例：</p>
<pre><code>//go:noinline
func add(a, b int) int {
    return a + b
}
</code></pre>
<p>通过//go:noinline指示符，我们可以禁止对add的内联：</p>
<pre><code>$go build -gcflags '-m=2' add.go
./add.go:4:6: cannot inline add: marked go:noinline
</code></pre>
<blockquote>
<p>注：禁止某个函数内联不会影响InlineCalls函数对该函数内部调用的内联函数的函数体替换。</p>
</blockquote>
<p>我们也可以在更大范围关闭内联优化，借助-gcflags &#8216;-l&#8217;选项，我们可以在全局范围关闭优化，即Flag.LowerL == 0，Go编译器的InlinePackage将不会执行。</p>
<p>我们以前面提到过的reviewdog来验证一下：</p>
<pre><code>// 默认开启内联
$go build -o reviewdog-inline github.com/reviewdog/reviewdog/cmd/reviewdog

// 关闭内联
$go build -o reviewdog-noinline -gcflags '-l' github.com/reviewdog/reviewdog/cmd/reviewdog
</code></pre>
<p>之后我们查看一下生成的binary文件size：</p>
<pre><code>$ls -l |grep reviewdog
-rwxrwxr-x  1 tonybai tonybai 23080346 Oct 13 20:28 reviewdog-inline*
-rwxrwxr-x  1 tonybai tonybai 23087867 Oct 13 20:28 reviewdog-noinline*
</code></pre>
<p>我们发现noinline版本居然比inline版本的size还要略大！这是为什么呢？这与-gcflags参数的传递方式有关，如果只是像上面命令行那样传入-gcflags &#8216;-l&#8217;，关闭内联仅适用于当前package，即cmd/reviewdog，而该package的依赖等都不会受到影响。-gcflags支持pattern匹配：</p>
<pre><code>-gcflags '[pattern=]arg list'
    arguments to pass on each go tool compile invocation.
</code></pre>
<p>我们可以通过设置不同pattern来匹配更多包，比如all这个模式就可以包括当前包的所有依赖，我们再来试试：</p>
<pre><code>$go build -o reviewdog-noinline-all -gcflags='all=-l' github.com/reviewdog/reviewdog/cmd/reviewdog
$ls -l |grep reviewdog
-rw-rw-r--  1 tonybai tonybai     3154 Sep  2 10:56 reviewdog.go
-rwxrwxr-x  1 tonybai tonybai 23080346 Oct 13 20:28 reviewdog-inline*
-rwxrwxr-x  1 tonybai tonybai 23087867 Oct 13 20:28 reviewdog-noinline*
-rwxrwxr-x  1 tonybai tonybai 20745006 Oct 13 20:30 reviewdog-noinline-all*
</code></pre>
<p>这回我们看到reviewdog-noinline-all要比reviewdog-inline的size小了不少，这是因为all将reviewdog依赖的各个包的内联也都关闭了。</p>
<h3>5. 小结</h3>
<p>在这篇文章中，我带大家一起了解了Go内联相关的知识，包括内联的概念、内联的作用、内联优化的“开销”以及Go编译器进行函数内联决策的原理，最后我还给出控制Go编译器内联优化的手段。</p>
<p>内联优化是一种重要的优化手段，使用得当将会给你的系统带来不小的性能改善。Go编译器组也在对Go内联优化做持续改善，从之前仅支持叶子函数的内联，到现在支持非叶子节点函数的内联，相信Go开发者在未来还会继续得到这方面带来的性能红利。</p>
<p>本文涉及的源码可以在<a href="https://github.com/bigwhite/experiments/tree/master/inlining-optimisations">这里</a>下载。</p>
<h3>6. 参考资料</h3>
<ul>
<li>Introduction to the Go compiler &#8211; https://go.dev/src/cmd/compile/README</li>
<li>Proposal: Mid-stack inlining in the Go compiler &#8211; https://github.com/golang/proposal/blob/master/design/19348-midstack-inlining.md</li>
<li>Mid-stack inlining in the Go compiler &#8211; https://golang.org/s/go19inliningtalk</li>
<li>Inlining optimisations in Go &#8211; https://dave.cheney.net/2020/04/25/inlining-optimisations-in-go</li>
<li>Mid-stack inlining in Go &#8211; https://dave.cheney.net/2020/05/02/mid-stack-inlining-in-go</li>
<li>cmd/compile: relax recursive restriction while inlining  &#8211; https://github.com/golang/go/issues/29737</li>
</ul>
<hr />
<p><a href="https://wx.zsxq.com/dweb2/index/group/51284458844544">“Gopher部落”知识星球</a>旨在打造一个精品Go学习和进阶社群！高品质首发Go技术文章，“三天”首发阅读权，每年两期Go语言发展现状分析，每天提前1小时阅读到新鲜的Gopher日报，网课、技术专栏、图书内容前瞻，六小时内必答保证等满足你关于Go语言生态的所有需求！2022年，Gopher部落全面改版，将持续分享Go语言与Go应用领域的知识、技巧与实践，并增加诸多互动形式。欢迎大家加入！</p>
<p><img src="http://image.tonybai.com/img/tonybai/gopher-tribe-zsxq-small-card.png" alt="img{512x368}" /><br />
<img src="http://image.tonybai.com/img/tonybai/go-programming-from-beginner-to-master-qr.png" alt="img{512x368}" /></p>
<p><img src="http://image.tonybai.com/img/tonybai/go-first-course-banner.png" alt="img{512x368}" /><br />
<img src="http://image.tonybai.com/img/tonybai/imooc-go-column-pgo-with-qr.jpg" alt="img{512x368}" /></p>
<p><a href="https://tonybai.com/">我爱发短信</a>：企业级短信平台定制开发专家 https://tonybai.com/。smspush : 可部署在企业内部的定制化短信平台，三网覆盖，不惧大并发接入，可定制扩展； 短信内容你来定，不再受约束, 接口丰富，支持长短信，签名可选。2020年4月8日，中国三大电信运营商联合发布《5G消息白皮书》，51短信平台也会全新升级到“51商用消息平台”，全面支持5G RCS消息。</p>
<p>著名云主机服务厂商DigitalOcean发布最新的主机计划，入门级Droplet配置升级为：1 core CPU、1G内存、25G高速SSD，价格5$/月。有使用DigitalOcean需求的朋友，可以打开这个<a href="https://m.do.co/c/bff6eed92687">链接地址</a>：https://m.do.co/c/bff6eed92687 开启你的DO主机之路。</p>
<p>Gopher Daily(Gopher每日新闻)归档仓库 &#8211; https://github.com/bigwhite/gopherdaily</p>
<p>我的联系方式：</p>
<ul>
<li>微博：https://weibo.com/bigwhite20xx</li>
<li>博客：tonybai.com</li>
<li>github: https://github.com/bigwhite</li>
</ul>
<p><img src="http://image.tonybai.com/img/tonybai/iamtonybai-wechat-qr.png" alt="" /></p>
<p>商务合作方式：撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。</p>
<p style='text-align:left'>&copy; 2022, <a href='https://tonybai.com'>bigwhite</a>. 版权所有. </p>
]]></content:encoded>
			<wfw:commentRss>https://tonybai.com/2022/10/17/understand-go-inlining-optimisations-by-example/feed/</wfw:commentRss>
		<slash:comments>8</slash:comments>
		</item>
		<item>
		<title>Go语言数据竞争检测与数据竞争模式</title>
		<link>https://tonybai.com/2022/06/21/data-race-detection-and-pattern-in-go/</link>
		<comments>https://tonybai.com/2022/06/21/data-race-detection-and-pattern-in-go/#comments</comments>
		<pubDate>Tue, 21 Jun 2022 14:42:08 +0000</pubDate>
		<dc:creator>bigwhite</dc:creator>
				<category><![CDATA[技术志]]></category>
		<category><![CDATA[AddressSanitizer]]></category>
		<category><![CDATA[arxiv]]></category>
		<category><![CDATA[Build]]></category>
		<category><![CDATA[C]]></category>
		<category><![CDATA[Channel]]></category>
		<category><![CDATA[closure]]></category>
		<category><![CDATA[Cpp]]></category>
		<category><![CDATA[CSP]]></category>
		<category><![CDATA[data-race]]></category>
		<category><![CDATA[defer]]></category>
		<category><![CDATA[DmitryVyukov]]></category>
		<category><![CDATA[err]]></category>
		<category><![CDATA[error]]></category>
		<category><![CDATA[function-literal]]></category>
		<category><![CDATA[GCC]]></category>
		<category><![CDATA[Go]]></category>
		<category><![CDATA[go1.1]]></category>
		<category><![CDATA[go1.19]]></category>
		<category><![CDATA[gobuild]]></category>
		<category><![CDATA[Golang]]></category>
		<category><![CDATA[Google]]></category>
		<category><![CDATA[goroutine]]></category>
		<category><![CDATA[gorun]]></category>
		<category><![CDATA[gotest]]></category>
		<category><![CDATA[gotools]]></category>
		<category><![CDATA[LeakSanitizer]]></category>
		<category><![CDATA[LLVM]]></category>
		<category><![CDATA[map]]></category>
		<category><![CDATA[MemorySanitizer]]></category>
		<category><![CDATA[Mutex]]></category>
		<category><![CDATA[panic]]></category>
		<category><![CDATA[paper]]></category>
		<category><![CDATA[race]]></category>
		<category><![CDATA[race-detector]]></category>
		<category><![CDATA[runtime]]></category>
		<category><![CDATA[select]]></category>
		<category><![CDATA[ShadowCell]]></category>
		<category><![CDATA[sync]]></category>
		<category><![CDATA[Thread]]></category>
		<category><![CDATA[ThreadSanitizer]]></category>
		<category><![CDATA[TSan]]></category>
		<category><![CDATA[uber]]></category>
		<category><![CDATA[Valgrind]]></category>
		<category><![CDATA[waitgroup]]></category>
		<category><![CDATA[函数]]></category>
		<category><![CDATA[函数字面值]]></category>
		<category><![CDATA[切片]]></category>
		<category><![CDATA[单体仓库]]></category>
		<category><![CDATA[并发]]></category>
		<category><![CDATA[数据竞争]]></category>
		<category><![CDATA[数组]]></category>
		<category><![CDATA[方法]]></category>
		<category><![CDATA[标准库]]></category>
		<category><![CDATA[检测]]></category>
		<category><![CDATA[线程]]></category>
		<category><![CDATA[谷歌]]></category>
		<category><![CDATA[闭包]]></category>

		<guid isPermaLink="false">https://tonybai.com/?p=3597</guid>
		<description><![CDATA[本文永久链接 &#8211; https://tonybai.com/2022/06/21/data-race-detection-and-pattern-in-go uber，就是那个早早退出中国打车市场的优步，是Go语言早期接纳者，也是Go技术栈的“重度用户”。uber内部的Go代码仓库有5000w+行Go代码，有2100个Go实现的独立服务，这样的Go应用规模在世界范围内估计也是Top3了吧。 uber不仅用Go，还经常输出它们使用Go的经验与教训，uber工程博客就是这些高质量Go文章的载体，这些文章都值得想“深造”的gopher们反复阅读和体会。 近期该博客发布了两篇有关Go并发数据竞争的文章，一篇为《Dynamic Data Race Detection in Go Code》，另一篇为《Data Race Patterns in Go》。这两篇文章也源于uber工程师发表在arxiv上的预印版论文《A Study of Real-World Data Races in Golang》。 感慨一下：不得不佩服国外工程师的这种“下得了厨房，还上得了厅堂”的研发能力，这也是我在团队中为大家树立的目标。 这里和大家过一下这两篇精简版的博客文章，希望我们都能有收获。 一. Go内置data race detector 我们知道：并发程序不好开发，更难于调试。并发是问题的滋生地，即便Go内置并发并提供了基于CSP并发模型的并发原语(goroutine、channel和select)，实际证明，现实世界中，Go程序带来的并发问题并没有因此减少(手动允悲)。“没有银弹”再一次应验！ 不过Go核心团队早已意识到了这一点，在Go 1.1版本中就为Go工具增加了race detector，通过在执行go工具命令时加入-race，该detector可以发现程序中因对同一变量的并发访问(至少一个访问是写操作)而引发潜在并发错误的地方。Go标准库也是引入race detector后的受益者。race detector曾帮助Go标准库检测出42个数据竞争问题。 race detector基于Google一个团队开发的工具Thread Sanitizer(TSan)(除了thread sanitizer，google还有一堆sanitizer，比如：AddressSanitizer, LeakSanitizer, MemorySanitizer等)。第一版TSan的实现发布于2009年，其使用的检测算法“源于”老牌工具Valgrind。出世后，TSan就帮助Chromium浏览器团队找出近200个潜在的并发问题，不过第一版TSan有一个最大的问题，那就是慢！。 因为有了成绩，开发团队决定重写TSan，于是就有了v2版本。与V1版本相比，v2版本有几个主要变化： 编译期注入代码(instrumentation)； 重新实现运行时库，并内置到编译器(LLVM和GCC)中； 除了可以做数据竞争(data race)检测外，还可以检测死锁、加锁状态下的锁释放等问题； 与V1版本相比，v2版本性能提升约20倍； 支持Go语言。 那么TSan v2究竟是怎么工作的呢？我们继续往下看。 二. ThreadSanitizer v2版本工作原理 根据Thread Sanitizer [...]]]></description>
			<content:encoded><![CDATA[<p><img src="https://tonybai.com/wp-content/uploads/data-race-detection-and-pattern-in-go-1.png" alt="" /></p>
<p><a href="https://tonybai.com/2022/06/21/data-race-detection-and-pattern-in-go">本文永久链接</a> &#8211; https://tonybai.com/2022/06/21/data-race-detection-and-pattern-in-go</p>
<p>uber，就是那个早早退出中国打车市场的优步，是Go语言早期接纳者，也是Go技术栈的“重度用户”。<a href="https://eng.uber.com/data-race-patterns-in-go/">uber内部的Go代码仓库有5000w+行Go代码</a>，有2100个Go实现的独立服务，这样的Go应用规模在世界范围内估计也是Top3了吧。</p>
<p>uber不仅用Go，还经常输出它们使用Go的经验与教训，<a href="https://eng.uber.com/">uber工程博客</a>就是这些高质量Go文章的载体，这些文章都值得想“深造”的gopher们反复阅读和体会。</p>
<p>近期该博客发布了两篇有关Go并发数据竞争的文章，一篇为<a href="https://eng.uber.com/dynamic-data-race-detection-in-go-code/">《Dynamic Data Race Detection in Go Code》</a>，另一篇为<a href="https://eng.uber.com/data-race-patterns-in-go/">《Data Race Patterns in Go》</a>。这两篇文章也源于uber工程师发表在arxiv上的预印版论文<a href="https://arxiv.org/pdf/2204.00764.pdf">《A Study of Real-World Data Races in Golang》</a>。</p>
<blockquote>
<p>感慨一下：不得不佩服国外工程师的这种“下得了厨房，还上得了厅堂”的研发能力，这也是我在团队中为大家树立的目标。</p>
</blockquote>
<p>这里和大家过一下这两篇精简版的博客文章，希望我们都能有收获。</p>
<hr />
<h3>一. Go内置data race detector</h3>
<p>我们知道：并发程序不好开发，更难于调试。并发是问题的滋生地，即便Go内置并发并提供了基于CSP并发模型的并发原语(goroutine、channel和select)，实际证明，<a href="https://songlh.github.io/paper/go-study.pdf">现实世界中，Go程序带来的并发问题并没有因此减少</a>(手动允悲)。<strong>“没有银弹”再一次应验</strong>！</p>
<p>不过Go核心团队早已意识到了这一点，在<a href="https://go.dev/doc/go1.1#race">Go 1.1版本</a>中就为Go工具增加了race detector，通过在执行go工具命令时加入-race，该detector可以发现程序中因对同一变量的并发访问(至少一个访问是写操作)而引发潜在并发错误的地方。Go标准库也是引入race detector后的受益者。race detector曾<a href="https://go.dev/blog/race-detector">帮助Go标准库检测出42个数据竞争问题</a>。</p>
<p>race detector基于Google一个团队开发的工具<a href="https://github.com/google/sanitizers">Thread Sanitizer(TSan)</a>(除了thread sanitizer，google还有一堆sanitizer，比如：AddressSanitizer, LeakSanitizer, MemorySanitizer等)。第一版TSan的实现发布于2009年，其使用的检测算法“源于”老牌工具Valgrind。出世后，TSan就帮助Chromium浏览器团队找出近200个潜在的并发问题，不过第一版TSan有一个最大的问题，那就是<strong>慢！</strong>。</p>
<p>因为有了成绩，开发团队决定重写TSan，于是就有了v2版本。与V1版本相比，v2版本有几个主要变化：</p>
<ul>
<li>编译期注入代码(instrumentation)；</li>
<li>重新实现运行时库，并内置到编译器(LLVM和GCC)中；</li>
<li>除了可以做数据竞争(data race)检测外，还可以检测死锁、加锁状态下的锁释放等问题；</li>
<li>与V1版本相比，v2版本性能提升约20倍；</li>
<li>支持Go语言。</li>
</ul>
<p>那么TSan v2究竟是怎么工作的呢？我们继续往下看。</p>
<h3>二. ThreadSanitizer v2版本工作原理</h3>
<p>根据<a href="https://github.com/google/sanitizers/wiki/ThreadSanitizerAlgorithm">Thread Sanitizer wiki上对v2版算法的描述</a>，Thread Sanitizer分为两部分：<strong>注入代码与运行时库</strong>。</p>
<h4>1. 注入代码</h4>
<p>第一部分是在编译阶段配合编译器在源码中注入代码。那么<strong>在什么位置注入什么代码呢</strong>？前面说过Thread Sanitizer会跟踪程序中的每次内存访问，因此TSan会在每次内存访问的地方注入代码，当然下面的情况除外：</p>
<ul>
<li>肯定不会出现数据竞争的内存访问</li>
</ul>
<p>比如：全局常量的读访问、函数中对已被证明不会<a href="https://tonybai.com/2021/05/24/understand-go-escape-analysis-by-example/">逃逸到堆</a>上的内存的访问；</p>
<ul>
<li>冗余访问：写入某个内存位置之前发生的读操作</li>
<li>&#8230; &#8230;</li>
</ul>
<p>那么注入的什么代码呢？下面是一个在函数foo内写内存操作的例子：</p>
<p><img src="https://tonybai.com/wp-content/uploads/data-race-detection-and-pattern-in-go-2.png" alt="" /></p>
<p>我们看到对地址p的写操作前注入了&#95;&#95;tsan_write4函数，函数foo的入口和出口分别注入了&#95;&#95;tsan_func_entry和 &#95;&#95;tsan_func_exit。而对于需要注入代码的内存读操作，注入代码则是&#95;&#95;tsan_read4；原子内存操作使用&#95;&#95;tsan_atomic进行注入&#8230;。</p>
<h4>2. TSan运行时库</h4>
<p>一旦在编译期注入代码完毕，构建出带有TSan的Go程序，那么在Go程序运行阶段，起到数据竞争检测作用的就是Tsan运行时库了。TSan是如何检测到有数据竞争的呢？</p>
<p>TSan的检测借助了一个称为<strong>Shadow Cell</strong>的概念。什么是Shadow Cell呢？一个Shadow Cell本身是一个8字节的内存单元，它代表一个对某个内存地址的读/写操作的<strong>事件</strong>，即每次对某内存块的写或读操作都会生成一个Shadow Cell。显然Shadow Cell作为内存读写事件的记录者，其本身存储了与此事件相关的信息，如下图：</p>
<p><img src="https://tonybai.com/wp-content/uploads/data-race-detection-and-pattern-in-go-3.png" alt="" /></p>
<p>我们看到，每个Shadow Cell记录了线程ID、时钟时间、操作访问内存的位置(偏移)和长度以及该内存访问事件的操作属性(是否是写操作)。<strong>针对每个应用程序的8字节内存，TSan都会对应有一组(N个)Shadow Cell</strong>，如下图：</p>
<p><img src="https://tonybai.com/wp-content/uploads/data-race-detection-and-pattern-in-go-4.png" alt="" /></p>
<p>N可以取2、4和8。N的取值直接影响TSan带来的开销以及data race检测的“精度”。</p>
<h4>3. 检测算法</h4>
<p>有了代码注入，也有了记录内存访问事件的Shadow Cell，那么TSan是通过什么逻辑检测data race的呢？我们结合<a href="http://gcc.gnu.org/wiki/cauldron2012?action=AttachFile&amp;do=get&amp;target=kcc.pdf">Google大神Dmitry Vyukov在一次speak中举的例子</a>来看一下检测算法是怎么运作的：</p>
<p>我们以N=8为例(即8个Shadow Cell用于跟踪和校验一个应用的8字节内存块)，下面是初始情况，假设此时尚没有对该8字节应用内存块的读写操作：</p>
<p><img src="https://tonybai.com/wp-content/uploads/data-race-detection-and-pattern-in-go-5.png" alt="" /></p>
<p>现在，一个线程T1向该块内存的前两个字节进行了写操作，写操作会生成第一个Shadow Cell，如下图所示：</p>
<p><img src="https://tonybai.com/wp-content/uploads/data-race-detection-and-pattern-in-go-6.png" alt="" /></p>
<p>这里我们结合图中的Shadow Cell说说Pos字段。Pos字段描述的是写/读操作访问的8字节内存单元的起始偏移与长度，比如这里的<strong>0:2</strong>代表的就是起始字节为第一个字节，长度为2个字节。此时Shadow Cell窗口只有一个Shadow Cell，不存在race的可能。</p>
<p>接下来，一个线程T2又针对该块内存的后四个字节进行了一次读操作，读操作会生成第二个Shadow Cell，如下图所示：</p>
<p><img src="https://tonybai.com/wp-content/uploads/data-race-detection-and-pattern-in-go-7.png" alt="" /></p>
<p>此次读操作涉及的字节与第一个Shadow Cell没有交集，不存在data race的可能。</p>
<p>再接下来，一个线程T3针对该块内存的前四个字节进行了一次写操作，写操作会生成第三个Shadow Cell，如下图所示：</p>
<p><img src="https://tonybai.com/wp-content/uploads/data-race-detection-and-pattern-in-go-8.png" alt="" /></p>
<p>我们看到T1和T3两个线程对该内存块的访问有重叠区域，且T1为写操作，那么这种情况就有可能存在data race。而TSan的race检测算法本质上就是一个状态机，每当发生一次内存访问，都会走一遍状态机。状态机的逻辑也很简单，就是遍历这块内存对应的Shadow Cell窗口中的所有Cell，用最新的Cell与已存在的Cell逐一比对，如果存在race，则给出warning。</p>
<p>像这个例子中T1的write与T3的read区域重叠，如果Shallow Cell1的时钟E1没有happens-before Shadow Cell的时钟E3，那么就存在data race的情况。happens-before如何判定，我们可以从tsan的实现中找到端倪：</p>
<pre><code>https://code.woboq.org/gcc/libsanitizer/tsan/tsan_rtl.cc.html

static inline bool HappensBefore(Shadow old, ThreadState *thr) {
    return thr-&gt;clock.get(old.TidWithIgnore()) &gt;= old.epoch();
}
</code></pre>
<p>在这个例子中，对应一个8字节应用内存的一组Shadow Cell的数量为N=8，但内存访问是高频事件，因此很快Shadow Cell窗口就会写满，那么新的Shadow Cell存储在哪里呢？在这种情况下，TSan算法会随机删除一个old Shadow Cell，并将新Shadow Cell写入。这也印证了前面提到的：N值的选取会在一定程度上影响到TSan的检测精度。</p>
<p>好了，初步了解了TSan v2的检测原理后，我们再回到uber的文章，看看uber是在何时部署race检测的。</p>
<h3>三. 何时部署一个动态的Go数据竞争检测器</h3>
<p>通过前面对TSan原理的简单描述我们也可以看出，-race带来的数据竞争检测对程序运行性能和开销的影响还是蛮大的。Go官方文档<a href="https://go.dev/doc/articles/race_detector">《Data Race Detector》</a>一文中给出使用-race构建的Go程序相较于正常构建的Go程序，运行时其内存开销是后者的5-10倍，执行时间是2-20倍。但我们知道race detector只能在程序运行时才能实施数据竞争问题的检测。因此，Gopher在使用-race都会比较慎重，尤其是在生产环境中。 2013年，Dmitry Vyukov和Andrew Gerrand联合撰写的介绍Go race detector的文章<a href="https://go.dev/blog/race-detector">“introducing the go race detector”</a>中也直言：<strong>在生产环境一直开着race detector是不实际的</strong>。他们推荐两个使用race detector的时机：一个是在测试执行中开启race detector，尤其是集成测试和压力测试场景下；另外一个则是在生产环境下开启race detector，但具体操作是：仅在众多服务实例中保留一个带有race detector的服务实例，但有多少流量打到这个实例上，你自己看着办^_^。</p>
<p>那么，uber内部是怎么做的呢？前面提到过：uber内部有一个包含5000w+行代码的单一仓库，在这个仓库中有10w+的单元测试用例。uber在部署race detector的时机上遇到两个问题：</p>
<ul>
<li>由于-race探测结果的不确定性，使得针对每个pr进行race detect的效果不好。</li>
</ul>
<p>比如：某个pr存在数据竞争，但race detector执行时没有检测到；后来的没有data race的PR在执行race detect时可能会因前面的pr中的data race而被检测出问题，这就可能影响该pr的顺利合入，影响相关开发人员的效率。</p>
<p>同时，将已有的5000w+代码中的所有data race情况都找出来本身也是不可能的事情。</p>
<ul>
<li>race detector的开销会影响到SLA(我理解是uber内部的CI流水线也有时间上的SLA(给开发者的承诺)，每个PR跑race detect，可能无法按时跑完)，并且提升硬件成本</li>
</ul>
<p>针对上述这两个问题，给出的部署策略是“事后检测”，即每隔一段时间，取出一版代码仓库的快照，然后在-race开启的情况下，把所有单元测试用例跑一遍。好吧，似乎没有什么新鲜玩意。很多公司可能都是这么做的。</p>
<p>发现data race问题，就发报告给相应开发者。这块uber工程师做了一些工作，通过data race检测结果信息找出最可能引入该bug的作者，并将报告发给他。</p>
<p>不过有一个数据值得大家参考：在没有data race检测的情况下，uber内部跑完所有单元测试的时间p95位数是25分钟，而在启用data race后，这个时间增加了4倍，约为100分钟。</p>
<p>uber工程师在2021年中旬实施的上述实验，在这一实验过程中，他们找到了产生data race的主要代码模式，后续他们可能会针对这些模式制作静态代码分析工具，以更早、更有效地帮助开发人员捕捉代码中的data race问题。接下来，我们就来看看这些代码模式。</p>
<h3>四. 常见的数据竞争模式都有哪些</h3>
<p><a href="https://eng.uber.com/data-race-patterns-in-go/">uber工程师总结了7类数据竞争模式</a>，我们逐一看一下。</p>
<h4>1. 闭包的“锅”</h4>
<p><a href="https://tonybai.com/2021/08/09/when-variables-captured-by-closures-are-recycled-in-go">Go语言原生提供了对闭包(closure)的支持</a>。在Go语言中，闭包就是<a href="https://tip.golang.org/ref/spec#Function_literals">函数字面值</a>。闭包可以引用其包裹函数(surrounding function)中定义的变量。然后，这些变量在包裹函数和函数字面值之间共享，只要它们可以被访问，这些变量就会继续存在。</p>
<p>不过不知道大家是否意识到了Go闭包对其包裹函数中的变量的捕捉方式都是通过引用的方式。而不像C++等语言那样可以选择通过值方式(by value)还是引用方式(by reference)进行捕捉。引用的捕捉方式意味着一旦闭包在一个新的goroutine中执行，那么两个goroutine对被捕捉的变量的访问就很大可能形成数据竞争。“不巧的”的是在Go中闭包常被用来作为一个goroutine的执行函数。</p>
<p>uber文章中给出了三个与这种无差别的通过引用方式对变量的捕捉方式导致的数据竞争模式的例子：</p>
<ul>
<li>例子1</li>
</ul>
<p><img src="https://tonybai.com/wp-content/uploads/data-race-detection-and-pattern-in-go-9.png" alt="" /></p>
<p>这第一个例子中，每次循环都基于一个闭包函数创建一个新的goroutine，这些goroutine都捕捉了外面的循环变量job，这就在多个goroutine之间建立起对job的竞争态势。</p>
<ul>
<li>例子2</li>
</ul>
<p><img src="https://tonybai.com/wp-content/uploads/data-race-detection-and-pattern-in-go-10.png" alt="" /></p>
<p>例子2中闭包与变量声明作用域的结合共同造就了新goroutine中的err变量就是外部Foo函数的返回值err。这就会造成err值成为两个goroutine竞争的“焦点”。</p>
<ul>
<li>例子3</li>
</ul>
<p><img src="https://tonybai.com/wp-content/uploads/data-race-detection-and-pattern-in-go-11.png" alt="" /></p>
<p>例子3中，<a href="https://tonybai.com/2022/05/20/solving-problems-in-generic-function-implementation-using-named-return-values">具名返回值变量</a>result被作为新goroutine执行函数的闭包所捕获，导致了两个goroutine在result这个变量上产生数据竞争。</p>
<h4>2. 切片的“锅”</h4>
<p>切片是Go内置的复合数据类型，与传统数组相比，切片具备动态扩容的能力，并且在传递时传递的是“切片描述符”，开销小且固定，这让其在Go语言中得到了广泛的应用。但灵活的同时，切片也是Go语言中“挖坑”最多的数据类型之一，大家在使用切片时务必认真细致，稍不留神就可能犯错。</p>
<p>下面是一个在切片变量上形成数据竞争的例子：</p>
<p><img src="https://tonybai.com/wp-content/uploads/data-race-detection-and-pattern-in-go-12.png" alt="" /></p>
<p>从这份代码来看，开发人员虽然对被捕捉的切片变量myResults通过mutex做了同步，但在后面创建新goroutine时，在传入切片时却因没有使用mutex保护。不过例子代码似乎有问题，传入的myResults似乎没有额外的使用。</p>
<h4>3. map的“锅”</h4>
<p>map是Go另外一个最常用的内置复合数据类型， 对于go入学者而言，由map导致的问题可能仅次于切片。go map并非goroutine-safe的，go禁止对map变量的并发读写。但由于是内置hash表类型，map在go编程中得到了十分广泛的应用。</p>
<p><img src="https://tonybai.com/wp-content/uploads/data-race-detection-and-pattern-in-go-13.png" alt="" /></p>
<p>上面例子就是一个并发读写map的例子，不过与slice不同，go在map实现中内置了对并发读写的检测，即便不加入-race，一旦发现也会抛出panic。</p>
<h4>4. 误传值惹的祸</h4>
<p>Go推荐使用传值语义，因为它简化了逃逸分析，并使变量有更好的机会被分配到栈中，从而减少GC的压力。但有些类型是不能通过传值方式传递的，比如下面例子中的sync.Mutex：</p>
<p><img src="https://tonybai.com/wp-content/uploads/data-race-detection-and-pattern-in-go-14.png" alt="" /></p>
<p>sync.Mutex是一个零值可用的类型，我们无需做任何初始赋值即可使用Mutex实例。但Mutex类型有内部状态的：</p>
<p><img src="https://tonybai.com/wp-content/uploads/data-race-detection-and-pattern-in-go-15.png" alt="" /></p>
<p>通过传值方式会导致状态拷贝，失去了在多个goroutine间同步数据访问的作用，就像上面例子中的Mutex类型变量m那样。</p>
<h4>5. 误用消息传递(channel)与共享内存</h4>
<p>Go采用CSP的并发模型，而channel类型充当goroutine间的通信机制。虽然相对于共享内存，CSP并发模型更为高级，但从实际来看，在对CSP模型理解不到位的情况下，使用channel时也十分易错。</p>
<p><img src="https://tonybai.com/wp-content/uploads/data-race-detection-and-pattern-in-go-16.png" alt="" /></p>
<p>这个例子中的问题在于Start函数启动的goroutine可能阻塞在f.ch的send操作上。因为，一旦ctx cancel了，Wait就会退出，此时没有goroutine再在f.ch上阻塞读，这将导致Start函数启动的新goroutine可能阻塞在“f.ch &lt;- 1”这一行上。</p>
<p>大家也可以看到，像这样的问题很细微，如果不细致分析，很难肉眼识别出来。</p>
<h4>6. sync.WaitGroup误用导致data race问题</h4>
<p>sync.WaitGroup是Go并发程序常用的用于等待一组goroutine退出的机制。它通过Add和Done方法实现内部计数的调整。而Wait方法用于等待，直到内部计数器为0才会返回。不过像下面例子中的对WaitGroup的误用会导致data race问题：</p>
<p><img src="https://tonybai.com/wp-content/uploads/data-race-detection-and-pattern-in-go-17.png" alt="" /></p>
<p>我们看到例子中的代码将wg.Add(1)放在了goroutine执行的函数中了，而没有像正确方法那样，将Add(1)放在goroutine创建启动之前，这就导致了对WaitGroup内部计数器形成了数据竞争，很可能因goroutine调度问题，是的Add(1)在未来得及调用，从而导致Wait提前返回。</p>
<p>下面这个例子则是由于defer函数在函数返回时的执行顺序问题，导致两个goroutine在locationErr这个变量上形成数据竞争：</p>
<p><img src="https://tonybai.com/wp-content/uploads/data-race-detection-and-pattern-in-go-18.png" alt="" /></p>
<p>main goroutine在判断locationErr是否为nil的时候，另一个goroutine中的doCleanup可能执行，也可能没有执行。</p>
<h4>7. 并行的表驱动测试可能引发数据竞争</h4>
<p>Go内置单测框架，并支持并行测试(testing.T.Parallel())。但如若使用并行测试，则极其容易导致数据竞争问题，原文没有给出例子，这个大家自行体会吧。</p>
<h3>五. 小结</h3>
<p>关于data race的代码模式，在uber发布这两篇文章之前，也有一些资料对数据竞争问题的代码模式进行了分类整理，比如下面两个资源，大家可以参照着看。</p>
<ul>
<li>《Data Race Detector》- https://go.dev/doc/articles/race_detector</li>
<li>《ThreadSanitizer Popular Data Races》- https://github.com/google/sanitizers/wiki/ThreadSanitizerPopularDataRaces中的模式</li>
</ul>
<p>在刚刚发布的<a href="https://tonybai.com/2022/06/12/go-1-19-foresight">Go 1.19beta1版本</a>中提到，最新的-race升级到了TSan v3版本，race检测性能相对于上一版将提升1.5倍-2倍，内存开销减半，并且没有对goroutine的数量的上限限制。</p>
<blockquote>
<p>注：Go要使用-race，则必须启用CGO。</p>
</blockquote>
<pre><code>// runtime/race.go

//go:nosplit
func raceinit() (gctx, pctx uintptr) {
    // cgo is required to initialize libc, which is used by race runtime
    if !iscgo {
        throw("raceinit: race build must use cgo")
    }
    ... ...
}
</code></pre>
<h3>六. 参考资料</h3>
<ul>
<li>“Finding races and memory errors with compiler instrumentation” &#8211; http://gcc.gnu.org/wiki/cauldron2012?action=AttachFile&amp;do=get&amp;target=kcc.pdf</li>
<li>《Race detection and more with ThreadSanitizer 2》 &#8211; https://lwn.net/Articles/598486/</li>
<li>《Google ThreadSanitizer &#8212; 排查多线程问题data race的大杀器》- https://zhuanlan.zhihu.com/p/139000777</li>
<li>《Introducing the Go Race Detector》- https://go.dev/blog/race-detector</li>
<li>ThreadSanitizer Algorithm V2 &#8211; https://github.com/google/sanitizers/wiki/ThreadSanitizerAlgorithm</li>
<li>paper: FastTrack: Efficient and Precise Dynamic Race Detection &#8211; https://users.soe.ucsc.edu/~cormac/papers/pldi09.pdf</li>
<li>paper: Eraser: A Dynamic Data Race Detector for Multithreaded Programs &#8211; https://homes.cs.washington.edu/~tom/pubs/eraser.pdf</li>
</ul>
<hr />
<p><a href="https://wx.zsxq.com/dweb2/index/group/51284458844544">“Gopher部落”知识星球</a>旨在打造一个精品Go学习和进阶社群！高品质首发Go技术文章，“三天”首发阅读权，每年两期Go语言发展现状分析，每天提前1小时阅读到新鲜的Gopher日报，网课、技术专栏、图书内容前瞻，六小时内必答保证等满足你关于Go语言生态的所有需求！2022年，Gopher部落全面改版，将持续分享Go语言与Go应用领域的知识、技巧与实践，并增加诸多互动形式。欢迎大家加入！</p>
<p><img src="http://image.tonybai.com/img/tonybai/gopher-tribe-zsxq-small-card.png" alt="img{512x368}" /><br />
<img src="http://image.tonybai.com/img/tonybai/go-programming-from-beginner-to-master-qr.png" alt="img{512x368}" /></p>
<p><img src="http://image.tonybai.com/img/tonybai/go-first-course-banner.png" alt="img{512x368}" /><br />
<img src="http://image.tonybai.com/img/tonybai/imooc-go-column-pgo-with-qr.jpg" alt="img{512x368}" /></p>
<p><a href="https://tonybai.com/">我爱发短信</a>：企业级短信平台定制开发专家 https://tonybai.com/。smspush : 可部署在企业内部的定制化短信平台，三网覆盖，不惧大并发接入，可定制扩展； 短信内容你来定，不再受约束, 接口丰富，支持长短信，签名可选。2020年4月8日，中国三大电信运营商联合发布《5G消息白皮书》，51短信平台也会全新升级到“51商用消息平台”，全面支持5G RCS消息。</p>
<p>著名云主机服务厂商DigitalOcean发布最新的主机计划，入门级Droplet配置升级为：1 core CPU、1G内存、25G高速SSD，价格5$/月。有使用DigitalOcean需求的朋友，可以打开这个<a href="https://m.do.co/c/bff6eed92687">链接地址</a>：https://m.do.co/c/bff6eed92687 开启你的DO主机之路。</p>
<p>Gopher Daily(Gopher每日新闻)归档仓库 &#8211; https://github.com/bigwhite/gopherdaily</p>
<p>我的联系方式：</p>
<ul>
<li>微博：https://weibo.com/bigwhite20xx</li>
<li>博客：tonybai.com</li>
<li>github: https://github.com/bigwhite</li>
</ul>
<p><img src="http://image.tonybai.com/img/tonybai/iamtonybai-wechat-qr.png" alt="" /></p>
<p>商务合作方式：撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。</p>
<p style='text-align:left'>&copy; 2022, <a href='https://tonybai.com'>bigwhite</a>. 版权所有. </p>
]]></content:encoded>
			<wfw:commentRss>https://tonybai.com/2022/06/21/data-race-detection-and-pattern-in-go/feed/</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<item>
		<title>追求极简：Docker镜像构建演化史</title>
		<link>https://tonybai.com/2017/12/21/the-concise-history-of-docker-image-building/</link>
		<comments>https://tonybai.com/2017/12/21/the-concise-history-of-docker-image-building/#comments</comments>
		<pubDate>Wed, 20 Dec 2017 23:31:48 +0000</pubDate>
		<dc:creator>bigwhite</dc:creator>
				<category><![CDATA[技术志]]></category>
		<category><![CDATA[alpine]]></category>
		<category><![CDATA[baseimage]]></category>
		<category><![CDATA[builder]]></category>
		<category><![CDATA[busybox]]></category>
		<category><![CDATA[cgroups]]></category>
		<category><![CDATA[CSDN]]></category>
		<category><![CDATA[docker]]></category>
		<category><![CDATA[Dockerfile]]></category>
		<category><![CDATA[dotCloud]]></category>
		<category><![CDATA[GCC]]></category>
		<category><![CDATA[Glibc]]></category>
		<category><![CDATA[Go]]></category>
		<category><![CDATA[Golang]]></category>
		<category><![CDATA[image]]></category>
		<category><![CDATA[LXC]]></category>
		<category><![CDATA[multi-stage-build]]></category>
		<category><![CDATA[musl-libc]]></category>
		<category><![CDATA[namespaces]]></category>
		<category><![CDATA[scratch]]></category>
		<category><![CDATA[Solaris]]></category>
		<category><![CDATA[Ubuntu]]></category>
		<category><![CDATA[unionfs]]></category>
		<category><![CDATA[多阶段构建]]></category>
		<category><![CDATA[容器]]></category>
		<category><![CDATA[程序员杂志]]></category>
		<category><![CDATA[镜像]]></category>

		<guid isPermaLink="false">http://tonybai.com/?p=2495</guid>
		<description><![CDATA[本文首发于CSDN《程序员》杂志2017.12期，这里是原文地址。 本文为《程序员》杂志授权转载，谢绝其他转载。全文如下： 自从2013年dotCloud公司(现已改名为Docker Inc)发布Docker容器技术以来，到目前为止已经有四年多的时间了。这期间Docker技术飞速发展，并催生出一个生机勃勃的、以轻量级容器技术为基础的庞大的容器平台生态圈。作为Docker三大核心技术之一的镜像技术在Docker的快速发展之路上可谓功不可没：镜像让容器真正插上了翅膀，实现了容器自身的重用和标准化传播，使得开发、交付、运维流水线上的各个角色真正围绕同一交付物，“test what you write, ship what you test”成为现实。 对于已经接纳和使用Docker技术在日常开发工作中的开发者而言，构建Docker镜像已经是家常便饭。但如何更高效地构建以及构建出Size更小的镜像却是很多Docker技术初学者心中常见的疑问，甚至是一些老手都未曾细致考量过的问题。本文将从一个Docker用户角度来阐述Docker镜像构建的演化史，希望能起到一定的解惑作用。 一、镜像：继承中的创新 谈镜像构建之前，我们先来简要说下镜像。 Docker技术本质上并不是新技术，而是将已有技术进行了更好地整合和包装。内核容器技术以一种完整形态最早出现在Sun公司的Solaris操作系统上，Solaris是当时最先进的服务器操作系统。2005年Sun发布了Solaris Container技术，从此开启了内核容器之门。 2008年，以Google公司开发人员为主导实现的Linux Container(即LXC)功能在被merge到Linux内核中。LXC是一种内核级虚拟化技术，主要基于Namespaces和Cgroups技术，实现共享一个操作系统内核前提下的进程资源隔离，为进程提供独立的虚拟执行环境，这样的一个虚拟的执行环境就是一个容器。本质上说，LXC容器与现在的Docker所提供容器是一样的。Docker也是基于Namespaces和Cgroups技术之上实现的，Docker的创新之处在于其基于Union File System技术定义了一套容器打包规范，真正将容器中的应用及其运行的所有依赖都封装到一种特定格式的文件中去，而这种文件就被称为镜像（即image），原理见下图（引自Docker官网）： 图1：Docker镜像原理 镜像是容器的“序列化”标准，这一创新为容器的存储、重用和传输奠定了基础。并且“坐上了巨轮”的容器镜像可以传播到世界每一个角落，这无疑助力了容器技术的飞速发展。 与Solaris Container、LXC等早期内核容器技术不同，Docker为开发者提供了开发者体验良好的工具集，这其中就包括了用于镜像构建的Dockerfile以及一种用于编写Dockerfile领域特定语言。采用Dockerfile方式构建成为镜像构建的标准方法，其可重复、可自动化、可维护以及分层精确控制等特点是采用传统采用docker commit命令提交的镜像所不能比拟的。 二、“镜像是个筐”：初学者的认知 “镜像是个筐，什么都往里面装” &#8211; 这句俏皮话可能是大部分Docker初学者对镜像最初认知的真实写照。这里我们用一个例子来生动地展示一下。我们将httpserver.go这个源文件编译为httpd程序并通过镜像发布，考虑到被编译的源码并非本文重点，这里使用了一个极简的demo代码： //httpserver.go package main import ( "fmt" "net/http" ) func main() { fmt.Println("http daemon start") fmt.Println(" -&#62; listen on port:8080") http.ListenAndServe(":8080", nil) } 接下来，我们来编写一个用于构建目标image的Dockerfile： From ubuntu:14.04 RUN [...]]]></description>
			<content:encoded><![CDATA[<p>本文首发于<a href="https://www.csdn.net/">CSDN</a><a href="http://programmer.csdn.net/">《程序员》</a>杂志<a href="http://blog.csdn.net/qq_40027052/article/details/78720370">2017.12期</a>，这里是<a href="https://mp.weixin.qq.com/s/6--iyRTiAtpSpsLd0Tgf8w">原文地址</a>。</p>
<p>本文为《程序员》杂志授权转载，谢绝其他转载。全文如下：</p>
<p>自从2013年<a href="https://en.wikipedia.org/wiki/DotCloud">dotCloud公司</a>(现已改名为<a href="https://en.wikipedia.org/wiki/Docker,_Inc.">Docker Inc</a>)发布<a href="http://tonybai.com/tag/docker">Docker容器技术</a>以来，到目前为止已经有四年多的时间了。这期间<a href="https://en.wikipedia.org/wiki/Docker_(software)">Docker技术</a>飞速发展，并催生出一个生机勃勃的、以轻量级容器技术为基础的庞大的容器平台生态圈。作为Docker三大核心技术之一的镜像技术在Docker的快速发展之路上可谓功不可没：镜像让容器真正插上了翅膀，实现了容器自身的重用和标准化传播，使得开发、交付、运维流水线上的各个角色真正围绕同一交付物，“test what you write, ship what you test”成为现实。</p>
<p>对于已经接纳和使用Docker技术在日常开发工作中的开发者而言，构建Docker镜像已经是家常便饭。但如何更高效地构建以及构建出Size更小的镜像却是很多Docker技术初学者心中常见的疑问，甚至是一些老手都未曾细致考量过的问题。本文将从一个Docker用户角度来阐述Docker镜像构建的演化史，希望能起到一定的解惑作用。</p>
<h3>一、镜像：继承中的创新</h3>
<p>谈镜像构建之前，我们先来简要说下<strong>镜像</strong>。</p>
<p>Docker技术本质上并不是新技术，而是将已有技术进行了更好地整合和包装。内核容器技术以一种完整形态最早出现在<a href="https://en.wikipedia.org/wiki/Sun_Microsystems">Sun公司</a>的<a href="https://en.wikipedia.org/wiki/Solaris_(operating_system)">Solaris操作系统</a>上，<a href="http://tonybai.com/tag/solaris">Solaris</a>是当时最先进的服务器操作系统。2005年Sun发布了<a href="https://en.wikipedia.org/wiki/Solaris_Containers">Solaris Container</a>技术，从此开启了内核容器之门。</p>
<p>2008年，以Google公司开发人员为主导实现的Linux Container(即<a href="https://en.wikipedia.org/wiki/LXC">LXC</a>)功能在被merge到<a href="https://www.kernel.org/">Linux内核</a>中。LXC是一种内核级虚拟化技术，主要基于<a href="https://en.wikipedia.org/wiki/Cgroups#NAMESPACE-ISOLATION">Namespaces</a>和<a href="https://en.wikipedia.org/wiki/Cgroups">Cgroups</a>技术，实现共享一个操作系统内核前提下的进程资源隔离，为进程提供独立的虚拟执行环境，这样的一个虚拟的执行环境就是一个容器。本质上说，LXC容器与现在的Docker所提供容器是一样的。Docker也是基于Namespaces和Cgroups技术之上实现的，Docker的<strong>创新之处</strong>在于其基于<a href="https://en.wikipedia.org/wiki/UnionFS">Union File System</a>技术定义了一套容器打包规范，真正将容器中的应用及其运行的所有依赖都封装到一种特定格式的文件中去，而这种文件就被称为<strong>镜像</strong>（即image），原理见下图（引自Docker官网）：</p>
<p><img src="http://tonybai.com/wp-content/uploads/docker-image-layers-and-container.png" alt="img{512x368}" /><br />
图1：Docker镜像原理</p>
<p>镜像是容器的“序列化”标准，这一创新为容器的存储、重用和传输奠定了基础。并且“坐上了巨轮”的容器镜像可以传播到世界每一个角落，这无疑助力了容器技术的飞速发展。</p>
<p>与<a href="https://en.wikipedia.org/wiki/Solaris_Containers">Solaris Container</a>、LXC等早期内核容器技术不同，Docker为开发者提供了开发者体验良好的工具集，这其中就包括了用于镜像构建的Dockerfile以及一种用于编写Dockerfile领域特定语言。采用Dockerfile方式构建成为镜像构建的标准方法，其可重复、可自动化、可维护以及分层精确控制等特点是采用传统采用docker commit命令提交的镜像所不能比拟的。</p>
<h3>二、“镜像是个筐”：初学者的认知</h3>
<p><strong>“镜像是个筐，什么都往里面装”</strong> &#8211; 这句俏皮话可能是大部分Docker初学者对镜像最初认知的真实写照。这里我们用一个例子来生动地展示一下。我们将httpserver.go这个源文件编译为httpd程序并通过镜像发布，考虑到被编译的源码并非本文重点，这里使用了一个极简的demo代码：</p>
<pre><code>//httpserver.go

package main

import (
        "fmt"
        "net/http"
)

func main() {
        fmt.Println("http daemon start")
        fmt.Println("  -&gt; listen on port:8080")
        http.ListenAndServe(":8080", nil)
}

</code></pre>
<p>接下来，我们来编写一个用于构建目标image的Dockerfile：</p>
<pre><code>From ubuntu:14.04

RUN apt-get update \
      &amp;&amp; apt-get install -y software-properties-common \
      &amp;&amp; add-apt-repository ppa:gophers/archive \
      &amp;&amp; apt-get update \
      &amp;&amp; apt-get install -y golang-1.9-go \
                            git \
      &amp;&amp; rm -rf /var/lib/apt/lists/*

ENV GOPATH /root/go
ENV GOROOT /usr/lib/go-1.9
ENV PATH="/usr/lib/go-1.9/bin:${PATH}"

COPY ./httpserver.go /root/httpserver.go
RUN go build -o /root/httpd /root/httpserver.go \
      &amp;&amp; chmod +x /root/httpd

WORKDIR /root
ENTRYPOINT ["/root/httpd"]
</code></pre>
<p>构建这个Image：</p>
<pre><code># docker build -t repodemo/httpd:latest .
//...构建输出这里省略...

# docker images
REPOSITORY                       TAG                 IMAGE ID            CREATED             SIZE
repodemo/httpd                   latest              183dbef8eba6        2 minutes ago       550MB
ubuntu                           14.04               dea1945146b9        2 months ago        188MB
</code></pre>
<p>整个镜像的构建过程因环境而定。如果您的网络速度一般，这个构建过程可能会花费你10多分钟甚至更多。最终如我们所愿，基于repodemo/httpd:latest这个镜像的容器可以正常运行：</p>
<pre><code># docker run repodemo/httpd
http daemon start
  -&gt; listen on port:8080

</code></pre>
<p>一个Dockerfile最终生产出一个镜像。Dockerfile由若干Command组成，每个Command执行结果都会单独形成一个layer。我们来探索一下构建出来的镜像：</p>
<pre><code># docker history 183dbef8eba6
IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
183dbef8eba6        21 minutes ago      /bin/sh -c #(nop)  ENTRYPOINT ["/root/httpd"]   0B
27aa721c6f6b        21 minutes ago      /bin/sh -c #(nop) WORKDIR /root                 0B
a9d968c704f7        21 minutes ago      /bin/sh -c go build -o /root/httpd /root/h...   6.14MB
... ...
aef7700a9036        30 minutes ago      /bin/sh -c apt-get update       &amp;&amp; apt-get...   356MB
.... ...
&lt;missing&gt;           2 months ago        /bin/sh -c #(nop) ADD file:8f997234193c2f5...   188MB

</code></pre>
<p>我们去除掉那些Size为0或很小的layer，我们看到三个size占比较大的layer，见下图：</p>
<p><img src="http://tonybai.com/wp-content/uploads/docker-image-history-2.png" alt="img{512x368}" /><br />
图2：Docker镜像分层探索</p>
<p>虽然Docker引擎利用r缓存机制可以让同主机下非首次的镜像构建执行得很快，但是在Docker技术热情催化下的这种构建思路让docker镜像在存储和传输方面的优势荡然无存，要知道一个ubuntu-server 16.04的虚拟机ISO文件的大小也就不过600多MB而已。</p>
<h3>三、”理性的回归”：builder模式的崛起</h3>
<p>Docker使用者在新技术接触初期的热情“冷却”之后迎来了“理性的回归”。根据上面分层镜像的图示，我们发现最终镜像中包含构建环境是多余的，我们只需要在最终镜像中包含足够支撑httpd运行的运行环境即可，而base image自身就可以满足。于是我们应该去除不必要的中间层：</p>
<p><img src="http://tonybai.com/wp-content/uploads/docker-image-history-3-1.png" alt="img{512x368}" /><br />
图3：去除不必要的分层</p>
<p>现在问题来了！如果不在同一镜像中完成应用构建，那么在哪里、由谁来构建应用呢？至少有两种方法：</p>
<ol>
<li>在本地构建并COPY到镜像中；</li>
<li>借助构建者镜像(builder image)构建。</li>
</ol>
<p>不过方法1本地构建有很多局限性，比如：本地环境无法复用、无法很好融入持续集成/持续交付流水线等。借助builder image进行构建已经成为Docker社区的一个最佳实践，Docker官方为此也推出了各种主流编程语言的官方base image，比如：<a href="http://tonybai.com/tag/go">go</a>、<a href="http://tonybai.com/tag/java">java</a>、node、<a href="http://tonybai.com/tag/python">python</a>以及<a href="http://tonybai.com/tag/ruby">ruby</a>等。借助builder image进行镜像构建的流程原理如下图：</p>
<p><img src="http://tonybai.com/wp-content/uploads/docker-image-history-3-2.png" alt="img{512x368}" /><br />
图4：借助builder image进行镜像构建的流程图</p>
<p>通过原理图，我们可以看到整个目标镜像的构建被分为了两个阶段：</p>
<ol>
<li>第一阶段：构建负责编译源码的构建者镜像；</li>
<li>第二阶段：将第一阶段的输出作为输入，构建出最终的目标镜像。</li>
</ol>
<p>我们选择golang:1.9.2作为builder base image，构建者镜像的Dockerfile.build如下：</p>
<pre><code>// Dockerfile.build

FROM golang:1.9.2

WORKDIR /go/src
COPY ./httpserver.go .

RUN go build -o httpd ./httpserver.go
</code></pre>
<p>执行构建：</p>
<pre><code># docker build -t repodemo/httpd-builder:latest -f Dockerfile.build .
</code></pre>
<p>构建好的应用程序httpd放在了镜像repodemo/httpd-builder中的/go/src目录下，我们需要一些“胶水”命令来连接两个构建阶段，这些命令将httpd从<strong>构建者镜像</strong>中取出并作为下一阶段构建的输入：</p>
<pre><code># docker create --name extract-httpserver repodemo/httpd-builder
# docker cp extract-httpserver:/go/src/httpd ./httpd
# docker rm -f extract-httpserver
# docker rmi repodemo/httpd-builder
</code></pre>
<p>通过上面的命令，我们将编译好的httpd程序拷贝到了本地。下面是目标镜像的Dockerfile：</p>
<pre><code>//Dockerfile.target
From ubuntu:14.04

COPY ./httpd /root/httpd
RUN chmod +x /root/httpd

WORKDIR /root
ENTRYPOINT ["/root/httpd"]
</code></pre>
<p>接下来我们来构建目标镜像：</p>
<pre><code># docker build -t repodemo/httpd:latest -f Dockerfile.target .
</code></pre>
<p>我们来看看这个镜像的“体格”：</p>
<pre><code># docker images
REPOSITORY                       TAG                 IMAGE ID            CREATED             SIZE
repodemo/httpd                   latest              e3d009d6e919        12 seconds ago      200MB
</code></pre>
<p>200MB！目标镜像的Size降为原来的 1/2 还多。</p>
<h3>四、“像赛车那样减去所有不必要的东西”：追求最小镜像</h3>
<p>前面我们构建出的镜像的Size已经缩小到200MB，但这还不够。200MB的“体格”在我们的网络环境下缓存和传输仍然很难令人满意。我们要为镜像进一步减重，减到尽可能的小，就像赛车那样，为了能减轻重量将所有不必要的东西都拆除掉：我们仅保留能支撑我们的应用运行的必要库、命令，其余的一律不纳入目标镜像。当然不仅仅是Size上的原因，小镜像还有额外的好处，比如：内存占用小，启动速度快，更加高效；不会因其他不必要的工具、库的漏洞而被攻击，减少了“攻击面”，更加安全。</p>
<p><img src="http://tonybai.com/wp-content/uploads/docker-image-history-4-1.png" alt="img{512x368}" /><br />
图5：目标镜像还能更小些吗？</p>
<p>一般应用开发者不会从scratch镜像从头构建自己的base image以及目标镜像的，开发者会挑选适合的base image。一些“蝇量级”甚至是“草量级”的官方base image的出现为这种情况提供了条件。</p>
<p><img src="http://tonybai.com/wp-content/uploads/docker-image-size.png" alt="img{512x368}" /><br />
图6：一些base image的Size比较(来自imagelayers.io截图)</p>
<p>从图中看，我们有两个选择：<a href="https://www.busybox.net/">busybox</a>和<a href="https://alpinelinux.org/">alpine</a>。</p>
<p>单从image的size上来说，busybox更小。不过busybox默认的libc实现是uClibc，而我们通常运行环境使用的libc实现都是glibc，因此我们要么选择静态编译程序，要么使用busybox:glibc镜像作为base image。</p>
<p>而 alpine image 是另外一种蝇量级 base image，它使用了比 glibc 更小更安全的 <a href="http://www.musl-libc.org/">musl libc</a> 库。 不过和 busybox image 相比，alpine image 体积还是略大。除了因为 musl比uClibc 大一些之外，alpine还在镜像中添加了自己的包管理系统apk，开发者可以使用apk在基于alpine的镜像中添 加需要的包或工具。因此，对于普通开发者而言，alpine image显然是更佳的选择。不过alpine使用的libc实现为<a href="http://www.musl-libc.org/">musl</a>，与基于glibc上编译出来的应用程序不兼容。如果直接将前面构建出的httpd应用塞入alpine，在容器启动时会遇到下面错误，因为加载器找不到glibc这个动态共享库文件：</p>
<pre><code>standard_init_linux.go:185: exec user process caused "no such file or directory"
</code></pre>
<p>对于Go应用来说，我们可以采用静态编译的程序，但一旦采用静态编译，也就意味着我们将失去一些libc提供的原生能力，比如：在linux上，你无法使用系统提供的DNS解析能力，只能使用Go自实现的DNS解析器。</p>
<p>我们还可以采用基于alpine的builder image，golang base image就提供了alpine 版本。 我们就用这种方式构建出一个基于alpine base image的极小目标镜像。</p>
<p><img src="http://tonybai.com/wp-content/uploads/docker-image-history-4-2.png" alt="img{512x368}" /><br />
图7：借助 alpine builder image 进行镜像构建的流程图</p>
<p>我们新建两个用于 alpine 版本目标镜像构建的 Dockerfile：Dockerfile.build.alpine 和Dockerfile.target.alpine：</p>
<pre><code>//Dockerfile.build.alpine
FROM golang:alpine

WORKDIR /go/src
COPY ./httpserver.go .

RUN go build -o httpd ./httpserver.go

// Dockerfile.target.alpine
From alpine

COPY ./httpd /root/httpd
RUN chmod +x /root/httpd

WORKDIR /root
ENTRYPOINT ["/root/httpd"]

</code></pre>
<p>构建builder镜像：</p>
<pre><code>#  docker build -t repodemo/httpd-alpine-builder:latest -f Dockerfile.build.alpine .

# docker images
REPOSITORY                       TAG                 IMAGE ID            CREATED              SIZE
repodemo/httpd-alpine-builder    latest              d5b5f8813d77        About a minute ago   275MB
</code></pre>
<p>执行“胶水”命令：</p>
<pre><code># docker create --name extract-httpserver repodemo/httpd-alpine-builder
# docker cp extract-httpserver:/go/src/httpd ./httpd
# docker rm -f extract-httpserver
# docker rmi repodemo/httpd-alpine-builder
</code></pre>
<p>构建目标镜像：</p>
<pre><code># docker build -t repodemo/httpd-alpine -f Dockerfile.target.alpine .

# docker images
REPOSITORY                       TAG                 IMAGE ID            CREATED             SIZE
repodemo/httpd-alpine            latest              895de7f785dd        13 seconds ago      16.2MB
</code></pre>
<p>16.2MB！目标镜像的Size降为不到原来的十分之一。我们得到了预期的结果。</p>
<h3>五、“要有光，于是便有了光”：对多阶段构建的支持</h3>
<p>至此，虽然我们实现了目标Image的最小化，但是整个构建过程却是十分繁琐，我们需要准备两个Dockerfile、需要准备“胶水”命令、需要清理中间产物等。作为Docker用户，我们希望用一个Dockerfile就能解决所有问题，于是就有了Docker引擎对多阶段构建(multi-stage build)的支持。注意：这个特性非常新，只有Docker 17.05.0-ce及以后的版本才能支持。</p>
<p>现在我们就按照“多阶段构建”的语法将上面的Dockerfile.build.alpine和Dockerfile.target.alpine合并到一个Dockerfile中：</p>
<pre><code>//Dockerfile

FROM golang:alpine as builder

WORKDIR /go/src
COPY httpserver.go .

RUN go build -o httpd ./httpserver.go

From alpine:latest

WORKDIR /root/
COPY --from=builder /go/src/httpd .
RUN chmod +x /root/httpd

ENTRYPOINT ["/root/httpd"]
</code></pre>
<p>Dockerfile的语法还是很简明和易理解的。即使是你第一次看到这个语法也能大致猜出六成含义。与之前Dockefile最大的不同在于在支持多阶段构建的Dockerfile中我们可以写多个“From baseimage”的语句了，每个From语句开启一个构建阶段，并且可以通过“as”语法为此阶段构建命名(比如这里的builder)。我们还可以通过COPY命令在两个阶段构建产物之间传递数据，比如这里传递的httpd应用，这个工作之前我们是使用“胶水”代码完成的。</p>
<p>构建目标镜像：</p>
<pre><code># docker build -t repodemo/httpd-multi-stage .

# docker images
REPOSITORY                       TAG                 IMAGE ID            CREATED             SIZE
repodemo/httpd-multi-stage       latest              35e494aa5c6f        2 minutes ago       16.2MB
</code></pre>
<p>我们看到通过多阶段构建特性构建的Docker Image与我们之前通过builder模式构建的镜像在效果上是等价的。</p>
<h3>六、来到现实</h3>
<p>沿着时间的轨迹，Docker 镜像构建走到了今天。追求又快又小的镜像已成为了 Docker 社区 的共识。社区在自创 builder 镜像构建的最佳实践后终于迎来了多阶段构建这柄利器，从此构建 出极简的镜像将不再困难。</p>
<hr />
<p>微博：<a href="http://weibo.com/bigwhite20xx">@tonybai_cn</a><br />
微信公众号：iamtonybai<br />
github: https://github.com/bigwhite</p>
<p>微信赞赏：<br />
<img src="http://tonybai.com/wp-content/uploads/wechat-zanshang-code-512x512.jpg" alt="img{512x368}" /></p>
<p style='text-align:left'>&copy; 2017, <a href='https://tonybai.com'>bigwhite</a>. 版权所有. </p>
]]></content:encoded>
			<wfw:commentRss>https://tonybai.com/2017/12/21/the-concise-history-of-docker-image-building/feed/</wfw:commentRss>
		<slash:comments>3</slash:comments>
		</item>
	</channel>
</rss>
