第 1 课 · Hello, World 与一个 Go 程序的诞生
2026/9/12 20:39:41 网站建设 项目流程

学习路线

教材对应:《Go程序设计语言》第 1 章 入门
环境:Go 1.22.2 / windows-amd64


0. 先建立一个核心认知

Go 是编译型语言,但它自带一个"迷你操作系统"住进你的程序里。

这句话是你后面理解一切"为什么"的钥匙。

  • Python 写的print("hello")需要你机器上装了 Python 解释器才能跑;
  • Go 写的fmt.Println("hello")编译完是一个独立的 .exe,丢到任何一台同架构的 Windows 上就能跑,什么都不用装。

误区

a. 错误理解:直接在powershell中运行

b. 正确理解:先在开发机写 .go 源码,用 Go 编译出 .exe;然后把 .exe 放到另一台同架构的 Windows 上直接运行,目标机不用装 Go。开发机仍然需要装 Go 才能编译。

代价是什么?往下看第 4 节


1. Go 是什么:三股血缘(书 p.5–7)

书的前言部分讲了 Go 的"家谱",这不是闲聊,它直接解释了 Go 为什么长这样:

血缘继承了什么你现在能看到的地方
C 语言表达式语法、控制流、基础类型、指针、传值调用、追求机器码效率if/for/int/*&
Pascal → Modula-2 → Oberon包的概念、导入/声明语法、方法声明语法import "fmt"package main、方法写在类型外面
CSP(Tony Hoare, 1978)“用通信来共享内存,而不是用共享内存来通信”goroutine+channel(第 8 章,全书灵魂)

这张表的意思是:
a. C 语言:给了 Go 表达式、控制流、基础类型、指针、传值、追求效率
b. Pascal/Modula-2/Oberon:给了 Go 包、导入、声明语法、方法写在类型外面、极简模块化。
c. CSP:给了 Go goroutine + channel 的并发哲学
C 给了 Go 身体,Oberon 给了 Go 骨架,CSP 给了 Go 灵魂。

还有两个 Go 自己发明的东西,别的语言抄不走的:

  • slice(切片):动态数组 + 底层共享内存,兼顾性能和灵活
  • defer 语句:延迟执行,Go 里资源释放的标准姿势

补充:书上这句「用通信来共享内存」是整本书的题眼。现在记住就行,后续拆开讲透。面试被问"Go 并发和其他语言有什么本质区别",答这一句就对了。


2. 环境:建你的第一个 Go 项目

2.1 四个环境变量,别再混淆

# 真实地址隐藏,用x/X代替 GOROOT = D:\xxxxxxxxx Go 自己装在哪(编译器、标准库源码在这) GOPATH = F:\XXXXXXXXXXX 你的工作区(下载的第三方包缓存在这) GOMODCACHE = XXXXXXXXX 模块缓存的真正位置 GOPROXY = https://goproxy.cn 下载第三方包走哪个源

GOPROXY改成了goproxy.cn(原本是proxy.golang.org,国内会卡)。
配置文件在C:\peizhiwenjian,你自己改的话用:

goenv-wGOPROXY=https://goproxy.cn,direct

a. 配置文件里的值:可以是网址、本地路径、数字、开关

b. Go 的用户级配置文件在这个本地文件里:C:\peizhiwenjian

c. 你自己改,意思是往这个本地文件里写一条配置:GOPROXY=https://goproxy.cn,direct,相当于在本地配置文件里,给 GOPROXY 这个格子填了一个网址

d.GOPROXY 作用:Go 在下载第三方模块时,需要知道去哪里下载。

2.2 go.mod:现代 Go 项目的身份证

# 创建(mkdir )一个名为 go-study 的目录# 执行成功后,进入这个目录mkdirgo-study&&cdgo-study# 把当前目录初始化成一个 Go 模块,模块名是: go-studygo mod init go-study

会生成一个go.mod

# go.mod 的内容 module go-study go 1.22

module go-study
这是模块路径 / 模块名。
它表示:这个模块的导入路径前缀是 go-study。

⚠️ 书与现实的第 1 处代差
书上讲的是 GOPATH 模式:所有代码必须放在$GOPATH/src/你的项目/下面,否则编译不过。
**这套东西在 Go 1.16 之后已经被废弃了。**现在是 Go Modules 时代:项目可以放在磁盘任何位置,只要有go.mod就是一个独立项目。


3. Hello, World 逐行拆解(书 p.13)

packagemainimport"fmt"funcmain(){fmt.Println("Hello, 世界")}

四行代码,每一行都不能改,我逐行讲:

第 1 行package main

  • package包声明,必须写在文件第一行(注释除外)。
  • Go 里没有"类"和"命名空间",包就是唯一的组织单位
  • main这个包名是特殊的:它告诉编译器"这玩意儿要编译成可执行文件,不是库"。
    • 你写成package myappgo build会成功但不产生 .exe——它编出来的是个库。

第 2 行import "fmt"

  • 导入标准库的fmt包(format 的缩写,负责格式化输入输出)。
  • **import 了就必须用,不用就编译报错。**这是 Go 的硬性规定,不是警告。
    • 为什么这么狠?因为 Go 的设计哲学是"编译快",不用的依赖会被立刻清掉,避免像 C++ 那样 include 一堆头文件导致编译半小时。
  • 多个包的标准写法:
import("fmt""os")

第 3 行func main() {

  • func声明函数;main是入口函数名。
  • main函数必须满足三个条件:在main包里、名字叫main、无参数无返回值。少一个都编译不过。
  • 开头的{必须和func在同一行——原因见第 6 节,这是新手第一大坑。

第 4 行fmt.Println("Hello, 世界")

  • fmt是包名,Println是该包导出的函数(大写字母开头 = 对外可见,小写则是包内私有)。
  • Go 的源文件默认是 UTF-8,所以字符串里直接写中文完全没问题。
  • 注意:Go 的字符串不是"一堆字符",而是一段不可变的字节序列。所以"Hello, 世界"的字节长度是 13 而不是 9("世界"每个汉字 3 个字节,UTF-8 编码)。
    • 这个点在第 3 章(p.77)会展开,现在先埋个种子。

没有分号?

Go 语句末尾不写分号。但不是没有,是编译器帮你插了——见第 6 节。


4. 底层:从hello.gohello.exe发生了什么

4.1 编译的五个阶段

阶段做什么产物
1. 词法分析把源码切成 token(标识符、关键字、字面量)token 流
2. 语法分析按语法规则组装成树抽象语法树 AST
3. 类型检查检查类型是否匹配,做逃逸分析带类型的 AST
4. 中间代码 + 优化转成 SSA 形式,做内联、死代码消除、边界检查消除优化后的中间码
5. 生成机器码 + 链接编译成 amd64 机器码,和runtime 静态链接.exe

想看第 4 阶段编译器做了哪些优化,可以加参数:

go build-gcflags="-m"hello.go# 打印内联和逃逸分析决策
acer@DESKTOP-SDPT4C3 MINGW64 /f/go/clara-pra $ go build-gcflags="-m"hello.go# command-line-arguments./hello.go:5:6: can inline main ./hello.go:6:13: inlining call to fmt.Println ./hello.go:6:13:... argument does not escape ./hello.go:6:14:"Hello, World!"escapes to heap

4.2 实测:为什么打印一行字要 1.9 MB?

构建方式文件大小
默认go build1,988,608 字节(1.90 MB)
去掉符号表和调试信息-ldflags="-s -w"1,332,224 字节(1.27 MB)
再关闭内联-gcflags="-l"1,332,736 字节

1 MB = 1024 KB
1942 KB ÷ 1024 ≈ 1.8965 MB ≈ 1.90 MB

go build -ldflags="-s -w" -o hello.exe hello.go

go build -ldflags="-s -w" -gcflags="-l" -o hello.exe hello.go

结论拆开看:

  1. 符号表 + DWARF 调试信息占了 656 KB(33%)。这部分只是给调试器用的,生产环境用-s -w砍掉,体积立减三分之一。这是 Go 服务发布的标准操作。
  2. 剩下的 1.27 MB 几乎全是 Go runtime。你的代码只有几行,但 runtime 被静态链接进来了。

4.3 用符号表证明这件事

-ldflags=“-s -w”
由于之前执行上述命令,去掉了符号表的版本,所以结果是这样的

重新编一个默认版本
go build -o hello_default.exe hello.go

go tool nm hello.exe|wc-l# 总共 2063 个符号

按包统计符号数量,排前几名的是:

符号数说明
runtime.1413调度器、GC、内存分配器、栈管理
reflect.86反射(因为 fmt 用了反射)
syscall.81系统调用封装
sync.47锁和原子操作
fmt.44你真正 import 的那个包
main.1你写的代码

你写的全部代码,在这个 1.9 MB 的 exe 里只占 1 个符号。

那 1413 个 runtime 符号里包括:

T runtime.mallocgc 内存分配器(你每次 make/map/切片扩容 都走它) T runtime.gcStart 垃圾回收的启动入口 T runtime.newproc 你写 go func(){} 时真正调用的东西 T runtime.schedule goroutine 调度器的主循环

这就是 Go 的取舍:用体积换"零依赖部署 + 内置并发 + 自动 GC"。
一个 Go 的 Docker 镜像可以做到只有几 MB,因为里面除了这个静态二进制什么都不用装。


5. 底层:执行go tool nm hello.exe之后,第一个执行的代码不是你的 main

在符号表里能看到两个"main":

437100 T runtime.main ← 真正的入口 489520 T main.main ← 你写的

程序启动的真实顺序是:

操作系统加载 PE 文件 ↓ _rt0_amd64_windows (汇编,设置栈) ↓ runtime·rt0_go (初始化 m0/g0,即主线程和第一个 goroutine 的栈) ↓ runtime·schedinit (初始化调度器、内存分配器、GC、创建 P) ↓ runtime·newproc (创建一个 goroutine 去跑 runtime.main) ↓ runtime·mstart (启动调度循环,让出 CPU) ↓ runtime.main (Go 层面的入口) ├─ 启动 GC 后台清扫 goroutine ├─ 执行所有包的 init() 函数 └─ **调用 main.main** ← 你的代码在这里才第一次被执行 ↓ main.main 返回 → runtime.exit(0)

关键认知:你的main.main是跑在一个goroutine里的。Go 程序从启动那一刻起就是并发的,哪怕你一行go都没写。

这点书上完全没讲,但理解了它,第 8、9 章的很多东西会突然变得自然。


6. Go 的隐藏规则:自动分号插入(新手第一大坑)

规则:Go 编译器在扫描时,如果一行的末尾是下面这些东西之一,就自动插入一个分号

标识符(变量名、函数名)、字面量(数字、字符串) break continue fallthrough return ++ -- ) ] }

所以func main()这一行末尾是),编译器实际看到的是func main();

推论:{绝对不能另起一行。

我实测了一下,把大括号换行:

funcmain(){fmt.Println("brace on new line")}

编译器报:

demo\bad_brace.go:6:1: syntax error: unexpected semicolon or newline before {

因为编译器看到的是:

funcmain();// ← 分号被自动插在这里,函数体没了{// ← 这个 { 成了孤魂野鬼

这个坑影响所有带大括号的语句ifforswitchfunc全都要遵守。
好消息是gofmt会自动帮你修正,IDE 保存时自动格式化就永远不会遇到。


7. go run / go build / go install 到底差在哪

命令做什么产物什么时候用
go run hello.go编译到临时目录再立刻执行,跑完删掉无(看不到文件)写两行代码快速验证
go build在当前目录生成可执行文件hello.exe本地调试、要拿产物
go install编译并放到$GOPATH/bin$GOPATH/bin/hello.exe想把工具装成全局命令

注意go run每次都要重新编译,所以启动比go build出来的 exe 慢。感觉"go run 好慢"是正常的。


8. gofmt:Go 不讨论代码风格

Go 官方直接把代码格式统一了,gofmt是标准:

gofmt-l.# 列出格式不合规的文件gofmt-w.# 直接改写成规范格式

特点

  • 用 tab 缩进,不是空格。Go 官方就是这么定的。
  • 没有"要不要换行""空格几个"这种争论——gofmt说了算。
  • 这是 Go 工程文化的一部分:所有 Go 项目代码看起来都像一个人写的。

配好 IDE 的 “Format on Save”,这个习惯从第一课就养成。

8.1 vscode中配Format on Save



配好后,你写:

packagemainimport"fmt"funcmain(){fmt.Println("hello")}

按 Ctrl+S,自动变成:

packagemainimport"fmt"funcmain(){fmt.Println("hello")}

9. 本课小结

问题答案
package main为什么特殊它决定编译出可执行文件而不是库
import 了不用会怎样编译报错(不是警告)
大写字母开头的标识符意味着什么对外可见(exported)
为什么 hello world 有 1.9 MB静态链接了 Go runtime(1413 个符号)
发布时怎么瘦身go build -ldflags="-s -w",减 33%
{能不能换行不能,自动分号插入会导致语法错误
你的 main 是程序第一个跑的代码吗不是,是runtime.main调用它的

10. 练习题

要求:先自己写,写完再对答案。卡住超过 15 分钟就看提示,别死磕。


练习 1.1(基础)

写一个程序,输出三行:你的名字、你公司的名字、一句你想说的话。
要求:用且只用一个fmt.Println调用完成。

提示:查一下Println接受几个参数。

packagemainimport"fmt"funcmain(){fmt.Println("Jenna\nCompany\nI'm the best!")}
PS F:\go\clara-pra\0911pra>go build-oa.exe 01pra.go PS F:\go\clara-pra\0911pra>go run .\01pra.go Jenna Company I'm the best!


练习 1.2(体积验证)

在你自己机器上重现第 4.2 节的实验:

go build-oa.exe 01pra.go go build-ldflags="-s -w"-ob.exe 01pra.go


对比两者大小,算出符号表占了多少百分比。
然后思考:如果你的服务有 100 个 Go 微服务要发布,砍掉这 33% 意味着什么?

假设每个微服务默认镜像 / 二进制是 20 MB:

假设:单个项目大小 20 MB,去符号后约 13.4 MB
那么:100 个项目总大小 2000 MB,去符号后约 1340 MB
节省 — 约 660 MB

更实际的影响:

  1. 镜像更小
  • 如果打进 Docker 镜像,拉取、推送、部署都更快。
  • 容器启动时加载的数据更少。
  • 分发更快
  • CI/CD 上传下载时间减少。
  • 内网分发、边缘节点更新更省带宽。
  1. 存储更省
  • 镜像仓库占用减少。
  • 备份、归档压力变小。
  • 成本更低
  • 云厂商按存储、流量计费时,直接省钱。
  • 节点磁盘压力小,能放更多服务。
  1. 代价
  • 生产环境用 -ldflags=“-s -w” 的代价,是丧失事后用调试器深入分析的能力。
  • 不能直接用 go tool nm、objdump 友好地分析。
  • 调试版本要单独保留一份带符号的。

所以实际做法通常是:

开发 / 测试:默认编译,保留符号,方便调试。 生产发布:-ldflags="-s -w",减小体积。 同时保留一份带符号的二进制,用于事后分析。

练习 1.3(符号表探查)

运行go tool nm a.exe,回答:

  1. 你的程序里有runtime.gcStart这个符号吗?它说明什么?
  2. 找找有没有net/http相关的符号?为什么?(提示:你 import 了吗)
  3. import "fmt"换成import "os"然后用os.Exit(0)(不输出任何东西),重新编译看看体积变化。
  1. runtime.gcStart
    说明 Go 运行时包含垃圾回收器,并且 GC 启动路径被链接进 exe
    你只打印一行字,GC 代码也在里面,这是 exe 1.9 MB 的主要原因之一
  1. net/http无
    go tool nm .\a.exe | Select-String “net/http”
    go tool nm .\a.exe | Select-String "http"打印结果为空

  1. c.exe 会比 a.exe 小一些,但不会小太多。
去掉 fmt 确实能省掉 fmt、reflect、strconv、internal/fmtsort 等一堆包。 但 Go 运行时本身还在:调度器、GC、内存分配器、栈管理、启动代码。 这些才是大头,尤其是 runtime 那 1400 多个符号。 os.Exit(0) 本身也依赖 runtime 的一部分,但比 fmt.Println 轻。

练习 1.4(自动分号,必做)

故意写一段大括号换行的错误代码,亲眼看一下那个报错。

然后想一想:下面这段代码能编译过吗?为什么?

x:=1+2

提示:第一行末尾是+,不在自动插入分号的列表里。
Go 不是“按行结束语句”,而是“在特定 token 后自动插入分号”。
行尾是 +、-、*、/、&&、||、, 等,不会插分号,可以继续下一行。
行尾是标识符、字面量、)、]、}、return 等,会自动插分号,语句就结束了。


练习 1.5(思考题,不难但要动脑)

Go 的import不用就报错。你觉得这个设计:

  • 解决了什么问题?
  • 带来了什么麻烦?(提示:想想调试时临时注释掉一段代码)

解决了什么:
编译产物干净,不打包没用的包
依赖图真实,不留僵尸依赖
显式区分“副作用导入”和“普通导入”
编译更快
代码始终整洁

带来了什么麻烦:
调试时注释代码要同时改 import
临时试代码要来回加删 import
重构时容易被打断
初学者容易卡在 imported and not used

缓解方式:
用 _ 表示副作用导入
用 goimports 或 IDE 自动整理 import
配好 Format on Save / Organize Imports
临时调试可以用 _ = pkg.Something 占位


练习 1.6(进阶 · 为下节课铺垫)

fmt.Println("Hello, 世界")输出的这个字符串,它的字节长度字符个数分别是多少?

先自己猜,然后写程序验证:

s:="Hello, 世界"fmt.Println(len(s))// 这个是字节长度

怎么拿到"字符个数"?自己查一下utf8包或者range遍历。

这题答不出来没关系,它是第 3 章(p.77)的核心内容,下下节课我会讲。


下一课:第 1 章剩下的部分——命令行参数(p.16)、查找重复的行(p.20),以及 Go 里最重要的两个数据结构:slice 和 map。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询