学习路线
教材对应:《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,directa. 配置文件里的值:可以是网址、本地路径、数字、开关
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.22module 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 myapp,go 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.go到hello.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 heap4.2 实测:为什么打印一行字要 1.9 MB?
| 构建方式 | 文件大小 |
|---|---|
默认go build | 1,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
结论拆开看:
- 符号表 + DWARF 调试信息占了 656 KB(33%)。这部分只是给调试器用的,生产环境用
-s -w砍掉,体积立减三分之一。这是 Go 服务发布的标准操作。 - 剩下的 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();// ← 分号被自动插在这里,函数体没了{// ← 这个 { 成了孤魂野鬼这个坑影响所有带大括号的语句:if、for、switch、func全都要遵守。
好消息是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
更实际的影响:
- 镜像更小
- 如果打进 Docker 镜像,拉取、推送、部署都更快。
- 容器启动时加载的数据更少。
- 分发更快
- CI/CD 上传下载时间减少。
- 内网分发、边缘节点更新更省带宽。
- 存储更省
- 镜像仓库占用减少。
- 备份、归档压力变小。
- 成本更低
- 云厂商按存储、流量计费时,直接省钱。
- 节点磁盘压力小,能放更多服务。
- 代价
- 生产环境用 -ldflags=“-s -w” 的代价,是丧失事后用调试器深入分析的能力。
- 不能直接用 go tool nm、objdump 友好地分析。
- 调试版本要单独保留一份带符号的。
所以实际做法通常是:
开发 / 测试:默认编译,保留符号,方便调试。 生产发布:-ldflags="-s -w",减小体积。 同时保留一份带符号的二进制,用于事后分析。练习 1.3(符号表探查)
运行go tool nm a.exe,回答:
- 你的程序里有
runtime.gcStart这个符号吗?它说明什么? - 找找有没有
net/http相关的符号?为什么?(提示:你 import 了吗) - 把
import "fmt"换成import "os"然后用os.Exit(0)(不输出任何东西),重新编译看看体积变化。
- runtime.gcStart
说明 Go 运行时包含垃圾回收器,并且 GC 启动路径被链接进 exe
你只打印一行字,GC 代码也在里面,这是 exe 1.9 MB 的主要原因之一
- net/http无
go tool nm .\a.exe | Select-String “net/http”
go tool nm .\a.exe | Select-String "http"打印结果为空
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。