Easton Notes
知识八股GoJS 转 Go

JS 转 Go:陷阱与惯用法

本页关键词:= 遮蔽、slice append、range 副本、defer LIFO、nil 接口、JSON 导出、循环导入、gofmt、sync.Pool


一、并发与生命周期

死锁:程序里所有 goroutine 都卡住了,没有任何一个能继续推进。常见现象是运行时报:

fatal error: all goroutines are asleep - deadlock!

常见原因:无缓冲 channel 两边都在发、或都在收;Mutex 重复加锁;select 没有 default 且所有 channel 都不就绪。修复方向:确认发送和接收能配对;必要时用缓冲、超时、context 退出。

数据竞争:多个 goroutine 同时读写同一份数据,且至少有一个在写。JS 主线程里少见这个问题,但 Go 很常见。

var n int
go func() { n++ }()
go func() { n++ }()

这类代码结果不稳定。Go 用 go test -race ./... 抓数据竞争。修复:Mutex 保护共享变量、channel 交所有权,或用原子操作处理简单计数。

goroutine 泄漏:请求结束了,但后台 goroutine 还卡着。典型原因是接收方走了,发送还在无缓冲 channel 上阻塞;或者 range ch 等关闭,但发送方从不 close

正确思路:始终设计退出路径。发送方负责 close channel;长生命周期任务监听 ctx.Done()

goroutine 里 panic:没 recover 就整进程退出,不像 Node 一个 Promise rejection 只打日志。recover 不能跨 goroutine,所以对外服务要在 HTTP 中间件、任务入口、goroutine 内部边界 defer recover()

channel:发送方关闭;不要 close 已关闭的;不要向关闭的 channel 发。nil channel 会永久阻塞,可用来在 select 里禁用某个 case。

var ch chan int // nil
// ch <- 1      // 永远阻塞
// <-ch         // 永远阻塞

二、切片、循环、defer

append 必须接住返回值

s := []int{1, 2, 3}
append(s, 4)    // 错:可能丢掉新底层数组
s = append(s, 4)

为什么必须接住?因为 append 可能复用原底层数组,也可能扩容后分配新数组。返回值才是最新切片头。

子切片共享数组:b := a[0:2]; b[0] = 9 会改 a

a := []int{1, 2, 3}
b := a[0:2]
b[0] = 9
fmt.Println(a) // [9 2 3]

要独立拷贝用 slices.Clonecopy

:= 遮蔽

err := doA()
if err != nil { return err }
if err := doB(); err != nil { // 这是新的 err,只在 if 内
    return err
}
// 外面的 err 仍是 doA 的结果

现象是:你以为更新了外层变量,其实在内层造了一个同名新变量。想更新已有变量用 =,或 err = doB()go vet 能抓一部分,但最好靠代码习惯规避。

range 是副本

for _, v := range items {
    v.X = 1 // 改不到 items 里
}
for i := range items {
    items[i].X = 1
}

v 是元素副本,不是原元素本身。要改原切片,用索引 items[i]。指针切片才适合「range 出来还能改对象」。Go 1.22 前闭包捕获循环变量几乎总是最后一轮的值,用形参传入。

defer LIFO,参数当时求值:

defer fmt.Println("First")
defer fmt.Println("Second")
// 输出 Second 然后 First

func f() {
    n := 1
    defer fmt.Println(n) // 打印 1,不是 2
    n = 2
}

两个点:

  • LIFO:后 defer 的先执行。
  • 参数当时求值:defer fmt.Println(n) 注册时 n 是 1,所以最后打印 1。

defer mu.Unlock() 紧跟 Lock()。循环里 defer 会堆到函数结束才跑,不是本轮循环结束就跑;文件要在循环内手动 Close,或包一层函数。


三、类型、JSON、包

JSON 只导出大写字段

type User struct {
    Name string `json:"name"`
    age  int    // 不会出现在 JSON 里
}

原因不是 JSON tag 写没写,而是 Go 的可见性规则:小写字段包外不可见,encoding/json 这个包读不到。习惯 JS 全公开的人最容易踩。私有字段想序列化只能自己写 MarshalJSON,一般不要。

nil 接口 ≠ nil 指针

var p *MyError = nil
var err error = p
fmt.Println(err == nil) // false:接口里还有类型 *MyError

接口值内部可以理解成 (动态类型, 动态值)。上面 err 的动态类型是 *MyError,动态值是 nil,所以整个接口不是 nil。返回 error 时写 return nil,不要 return 某个 nil 指针。自定义 error 用值还是指针要在整个包内一致。

未使用变量 / import 是编译错误,不是 warning。占位用 _

v, err := do()
_ = v // 临时占位,表示我知道这个变量没用
if err != nil {
    return err
}

循环导入:A import B、B import A,直接失败。Go 不允许包之间互相依赖。修复方式:抽 interfaces/models 第三包,或让一方只依赖接口。

go.mod 和源码不一致go mod tidy。不要手改 go.sum

变量零值可用var sb strings.Buildervar mu sync.Mutex 不用 new。map、切片、指针、channel 的零值是 nil,写 map 前要 make

var mu sync.Mutex // 可以直接 Lock
var m map[string]int

mu.Lock()
mu.Unlock()

// m["a"] = 1 // panic:nil map 不能写
m = make(map[string]int)
m["a"] = 1

四、惯用法(写得像 Go)

  • 格式化无争论:提交前 gofmt。Go 社区不靠团队约定争缩进和换行,工具说了算。
  • 命名短而准:包名小写短词;导出类型 CamelCase;缩写全大写 URLID
  • 错误要么处理,要么往上包:见到 error 就处理或 fmt.Errorf("context: %w", err),不要吞。
  • 接口小:在使用方定义接口,而不是在实现方预先摊大接口。谁需要抽象,谁定义最小方法集。
  • 组合优于继承:嵌套结构体,不造类层次。Go 没有 class 继承,复用靠组合。
  • make vs newmake 给 slice/map/chan;new(T) 得到 *T 且指向零值,多数时候 x := T{} / &T{} 更清楚。
  • 少反射、少 any:热路径用具体类型。对象池用 sync.Pool 减 GC,先 pprof 再上。
  • 控制 goroutine 数量:来一个请求可以一个 goroutine;批处理用 worker pool,不要 for 里无上限 go
  • 包职责单一main 只组装配置、路由和依赖;业务放 internal/
  • 现代特性点到为止:泛型用于容器/工具函数;embed 把静态文件打进二进制;模块用 go.mod,不要 GOPATH 模式。

性能顺序:正确 → 测量(pprof)→ 改分配和算法。过早用接口、反射、超细粒度锁通常更慢。

面试要点:从 JS 转过来最高频的三连:JSON 字段大小写、:= 造新变量、err 接口装了 nil 指针。这三道能讲清楚,陷阱篇就算过关。

On this page