启动函数

在 Go 语言中,下面这个例子相信很多人在入门的时候都已经敲过了:

package main

import "fmt"

func main() {
	fmt.Println("hello world!")
}

main 函数几乎是每一门语言入门课程中的第一课。但在 Go 中,还有一个 init 函数也会在程序启动时执行。这个函数相比 main 不那么出名,却参与了每一个 Go 程序的包初始化过程。

本文按照“官方定义 → 最小示例 → 初始化顺序 → 导入方式 → 插件实践 → 底层理解 → 常见错误”的顺序说明 main 和 init。示例按照 Go 1.27 的语言规范整理,核心资料来自 Go 官方语言规范中的 Program initialization and execution、Import declarations 和 Effective Go 的 Initialization。

本文的核心判断是:init 适合完成确定的、局部的启动准备;需要返回错误、依赖外部资源或者需要控制生命周期的工作,应当放在显式的初始化函数中。

main 和 init 的异同

相同点

main 和 init 都由 Go 运行时在约定的启动阶段自动调用,函数声明都不能有参数,也不能有返回值:

func main() {}
func init() {}

不同点

项目maininit
所在位置只能在 main 包中可以在任意包中
数量一个 main 包只能有一个 main 函数一个包可以有多个 init 函数
调用时机所有包初始化完成后调用当前包的包级变量初始化完成后调用
是否可以手动调用可以像普通函数一样调用 main,但通常没有这样做的意义不能被显式引用或调用
是否允许参数和返回值不允许不允许

Go 官方规范对 main 的要求是:完整程序必须链接一个未被其他包导入的 main 包,这个包的名字必须是 main,并声明一个无参数、无返回值的 main 函数。

init 函数可以在包中的多个源文件里重复声明。init 这个标识符只能用于声明 init 函数,本身不会被声明成一个可以取地址、传参或调用的普通函数,所以以下写法是错误的:

func init() {}

func run() {
	init() // compile error: cannot refer to init
}

最小示例

package main

import "fmt"

func init() {
	fmt.Println("main init")
}

func main() {
	fmt.Println("main")
}

运行:

go run main.go

输出:

main init
main

这里可以先得到一个直观结论:当前包的 init 会先于当前包的 main 执行。

不过,“main 永远是程序执行的最后一个函数”这个说法需要稍微修正。更准确的说法是:程序先完成所有包的初始化,然后调用 main;当 main 返回时,程序退出,并不会等待其他非 main goroutine。

例如:

package main

import (
	"fmt"
	"time"
)

func main() {
	go func() {
		time.Sleep(time.Second)
		fmt.Println("goroutine")
	}()

	fmt.Println("main return")
}

输出通常只有:

main return

main 返回后进程已经退出,后台 goroutine 没有机会保证完成。服务程序需要通过 WaitGroup、context、信号处理和优雅关闭流程管理 goroutine 生命周期。

程序启动顺序

一个 Go 程序可以先按下面的流程理解:

编译或 go run

构造导入依赖图

初始化依赖包的包级变量

调用依赖包的 init

初始化 main 包的包级变量

调用 main 包的 init

调用 main

main 返回,程序退出

Go 官方规范给出的规则可以分成四层:

  1. 一个包依赖的包必须先完成初始化;
  2. 同一个包的包级变量先完成初始化;
  3. 再按照源文件呈现给编译器的顺序调用这个包中的所有 init;
  4. 所有包初始化完成后,调用 main 包中的 main。

初始化工作由一个 goroutine 顺序执行。init 可以启动其他 goroutine,但 Go 不会因为它启动了 goroutine 就自动等待这些 goroutine 完成,也不会在前一个 init 启动的 goroutine 完成后才调用下一个 init。

包级变量先于 init

package main

import "fmt"

var version = buildVersion()

func buildVersion() string {
	fmt.Println("build package variable")
	return "v1"
}

func init() {
	fmt.Println("init:", version)
}

func main() {
	fmt.Println("main")
}

输出:

build package variable
init: v1
main

包级变量的初始化表达式会在 init 之前求值。没有显式初始化表达式的变量先得到类型对应的零值,例如整数是 0,字符串是 "",指针、切片、map、channel 和接口是 nil。

包级变量会进行依赖分析

包级变量不只是按照文本从上到下执行。Go 会分析初始化表达式中的依赖,优先初始化已经满足依赖的变量:

package main

import "fmt"

var total = price + tax
var price = 100
var tax = 20

func main() {
	fmt.Println(total)
}

输出:

120

total 依赖 price 和 tax,所以即使 total 的声明写在前面,它也会等依赖完成后再初始化。

依赖可以通过函数体传递:

package main

import "fmt"

var result = calculate()
var base = 10

func calculate() int {
	return base * 2
}

func main() {
	fmt.Println(result)
}

这里 result 的初始化表达式调用了 calculate,而 calculate 使用了 base,所以初始化顺序会先准备 base,再计算 result。

如果包级变量之间形成循环依赖,程序会在编译阶段报错:

var a = b + 1
var b = a + 1

不要把复杂的业务状态放进包级变量,依赖越隐蔽,启动顺序越难审查。

init 的执行顺序

同一文件

同一个源文件中,多个 init 通常按照它们在源文件中出现的顺序执行:

package main

import "fmt"

func init() {
	fmt.Println("init 1")
}

func init() {
	fmt.Println("init 2")
}

func main() {
	fmt.Println("main")
}

输出:

init 1
init 2
main

同一包的多个文件

官方规范真正描述的是:init 按照源文件呈现给编译器的顺序执行。为了让结果可复现,构建系统应当按照词法文件名顺序把同一个包的文件交给编译器;go 命令通常会按照文件名排序处理源文件。

例如目录中有两个文件:

startup/
├── a_init.go
└── b_init.go

a_init.go:

package startup

import "fmt"

func init() {
	fmt.Println("a_init.go")
}

b_init.go:

package startup

import "fmt"

func init() {
	fmt.Println("b_init.go")
}

用 go 命令构建时通常看到:

a_init.go
b_init.go

但是业务代码不应该通过修改文件名来表达初始化依赖。需要明确顺序时,把逻辑放进一个显式函数中:

func Start() error {
	if err := loadConfig(); err != nil {
		return err
	}
	if err := connectStore(); err != nil {
		return err
	}
	return nil
}

这种写法比依赖 a_init.go、b_init.go 的文件名顺序更容易测试,也能把错误交给调用方处理。

不同包

如果 main 导入 service,而 service 又导入 repository,初始化顺序可以理解为:

repository 包级变量 -> repository init
service 包级变量     -> service init
main 包级变量        -> main init
main()

同一个包无论被多少个包导入,都只初始化一次。包的导入关系不能形成循环,否则编译器会报告 import cycle。

下面的例子可以观察依赖包先于当前包执行。

internal/feature/feature.go:

package feature

import "fmt"

func init() {
	fmt.Println("feature init")
}

func Enabled() bool {
	return true
}

main.go:

package main

import (
	"fmt"
	"example.com/startup-demo/internal/feature"
)

func init() {
	fmt.Println("main init")
}

func main() {
	_ = feature.Enabled()
	fmt.Println("main")
}

输出顺序是:

feature init
main init
main

导入前缀

Go 的导入声明可以指定默认包名、别名、点导入和空白标识符。它们改变的是当前源文件如何访问被导入包,不会改变包初始化的基本规则。

普通导入

import "GoProject/src/utils"

func main() {
	utils.AddSum(1, 2)
}

导入路径最后的包名通常就是访问前缀,但包声明中的 package 名称才是实际使用的名字。

别名导入

import (
	u "GoProject/src/utils"
)

func main() {
	u.AddSum(1, 2)
}

别名适合解决同名包冲突,也适合在一个文件中明确区分两个相似的包:

import (
	stdjson "encoding/json"
	fastjson "example.com/fastjson"
)

别名只是当前文件中的访问名称,不会修改包本身的名字,也不会改变初始化顺序。

点导入

import (
	. "GoProject/src/utils"
)

func main() {
	AddSum(1, 2)
}

点导入会把被导入包的导出标识符放入当前文件的文件块,因此可以省略包名前缀。它看起来简洁,但容易产生名称冲突,也会让读代码的人看不出 AddSum 来自哪个包。普通业务代码一般不建议使用点导入;测试和示例中偶尔会用到。

空白导入

import (
	_ "example.com/startup-demo/plugins/json"
)

空白导入表示:当前文件不直接引用这个包的导出标识符,但需要导入它产生的初始化副作用。Go 官方规范把它称为“只为了副作用而导入”。

标准库中常见的例子是:

import _ "net/http/pprof"

net/http/pprof 会在初始化阶段向默认 HTTP mux 注册调试处理器。数据库驱动、图片格式注册器和指标注册器也经常采用相同模式。

你给出的写法:

import _ "github.com/gin-gonic/gin"

语法上是合法的,但是否有意义取决于这个包的 init 是否提供了你需要的副作用。正常使用 Gin 时,通常应该直接导入并调用它的导出 API;空白导入更适合明确的“注册驱动”类包。

通过 init 实现插件注册

现在实现一个简单的编译期插件系统。主程序不需要知道每个插件的具体实现,只通过空白导入启用插件。插件包在 init 中向注册中心登记工厂函数。

目录结构

startup-demo/
├── go.mod
├── main.go
├── registry/
│   └── registry.go
└── plugins/
    ├── console/
    │   └── plugin.go
    └── json/
        └── plugin.go

初始化模块

mkdir startup-demo
cd startup-demo
go mod init example.com/startup-demo

registry/registry.go

package registry

import (
	"fmt"
	"sort"
	"sync"
)

// Factory 表示一个插件实例工厂。
type Factory func() string

var (
	mu        sync.RWMutex
	factories = make(map[string]Factory)
)

// MustRegister 在启动阶段登记插件。
// 名称为空、工厂为空或重复注册时直接 panic,避免服务带着不完整配置启动。
func MustRegister(name string, factory Factory) {
	if name == "" {
		panic("plugin name is empty")
	}
	if factory == nil {
		panic("plugin factory is nil: " + name)
	}

	mu.Lock()
	defer mu.Unlock()

	if _, exists := factories[name]; exists {
		panic("duplicate plugin: " + name)
	}
	factories[name] = factory
}

// Names 返回稳定排序后的插件名称。
func Names() []string {
	mu.RLock()
	defer mu.RUnlock()

	names := make([]string, 0, len(factories))
	for name := range factories {
		names = append(names, name)
	}
	sort.Strings(names)
	return names
}

// Build 根据插件名称创建一个实例。
func Build(name string) (string, error) {
	mu.RLock()
	factory, ok := factories[name]
	mu.RUnlock()
	if !ok {
		return "", fmt.Errorf("plugin %q not found", name)
	}
	return factory(), nil
}

这里的 sync.RWMutex 不是为了让 init 并发执行。Go 会顺序执行包初始化;加锁是为了让程序启动完成后,Names 和 Build 在多个请求 goroutine 中被安全调用。

plugins/console/plugin.go

package console

import "example.com/startup-demo/registry"

func init() {
	registry.MustRegister("console", func() string {
		return "console plugin ready"
	})
}

plugins/json/plugin.go

package json

import "example.com/startup-demo/registry"

func init() {
	registry.MustRegister("json", func() string {
		return "json plugin ready"
	})
}

main.go

package main

import (
	"fmt"

	_ "example.com/startup-demo/plugins/console"
	_ "example.com/startup-demo/plugins/json"
	"example.com/startup-demo/registry"
)

func main() {
	for _, name := range registry.Names() {
		value, err := registry.Build(name)
		if err != nil {
			panic(err)
		}
		fmt.Printf("%s: %s\n", name, value)
	}
}

运行结果:

console: console plugin ready
json: json plugin ready

这里的初始化顺序是:

registry 包初始化
console 包初始化并注册 console
json 包初始化并注册 json
main 包初始化
main()

main 没有直接调用 console 或 json 包的函数,但空白导入仍然让它们进入程序依赖图,因而触发了包初始化。

为什么 Names 要排序

插件之间没有依赖关系时,业务不应该把注册顺序当成插件优先级。注册中心用 map 保存插件,map 的遍历顺序不稳定,所以 Names 主动排序,保证日志、健康检查和测试输出稳定。

如果某个插件确实依赖另一个插件,应当把依赖表达成显式配置或显式启动步骤,而不是依赖文件名和 init 的偶然顺序。

重复注册如何处理

下面两个包同时注册 json:

func init() {
	registry.MustRegister("json", factory)
}

第二次注册会 panic,程序不会进入 main。这是一个有意的启动失败策略:插件名称重复意味着构建结果不明确,带着不明确的路由、序列化器或驱动启动,通常比直接失败更难排查。

如果重复注册是可接受的,则不要在 init 中隐式覆盖;可以设计带优先级的显式配置,并在 main 中完成选择。

高可用项目中的使用边界

适合放进 init 的工作

init 没有参数和返回值,因此适合做失败概率低、结果确定、范围局部的工作:

  • 注册编码器、驱动、插件工厂;
  • 构造只读的查找表;
  • 校验包内常量和静态配置;
  • 注册 net/http 的调试或指标处理器;
  • 准备不会访问外部系统的正则表达式、路由元数据和解析表。

例如:

var codeNames map[int]string

func init() {
	codeNames = map[int]string{
		200: "ok",
		404: "not_found",
		500: "internal_error",
	}
}

不适合放进 init 的工作

以下代码虽然可以写进 init,但通常会增加服务启动的不确定性:

func init() {
	// 不推荐:依赖外部系统,失败时无法返回 error
	conn, err := sql.Open("mysql", os.Getenv("DSN"))
	if err != nil {
		panic(err)
	}
	_ = conn
}

主要问题有:

  • 无法把错误返回给 main,只能 panic、记录日志或静默失败;
  • 单元测试导入包时也会执行,测试环境可能并没有数据库或网络;
  • 包导入者无法控制超时、重试、连接池和关闭时机;
  • 初始化副作用隐藏在依赖图中,调用方不容易知道启动会做什么;
  • 多个包的 init 可能隐式争抢环境变量、端口、文件和全局状态。

更清晰的服务入口是:

func main() {
	cfg, err := LoadConfig()
	if err != nil {
		log.Fatal(err)
	}

	app, err := NewApp(cfg)
	if err != nil {
		log.Fatal(err)
	}

	if err := app.Run(context.Background()); err != nil {
		log.Fatal(err)
	}
}

LoadConfig、NewApp 和 Run 都可以返回错误,也可以在测试中传入不同配置。高可用服务还可以在 Run 外层接入信号、超时和优雅关闭流程。

init 中启动 goroutine 的风险

func init() {
	go refreshCache()
}

这段代码不会让 Go 等待 refreshCache 完成。main 可能在缓存准备好之前就开始提供服务,也可能在 main 返回时直接终止这个 goroutine。

如果后台任务是服务的一部分,应当由应用生命周期显式管理:

func (a *App) Run(ctx context.Context) error {
	go a.refreshCache(ctx)
	return a.server.ListenAndServe()
}

然后由 Run 的调用方负责取消 ctx、关闭 HTTP server,并等待后台任务退出。

底层如何理解 init

init 不是用户代码中调用的普通函数。可以把 Go 编译器和运行时的启动过程简化成下面的伪代码:

func initializeProgram(packages []Package) {
	for _, pkg := range dependencyOrder(packages) {
		initializePackage(pkg)
	}
}

func initializePackage(pkg Package) {
	for _, variable := range variableDependencyOrder(pkg) {
		variable.initialize()
	}

	for _, initFunc := range pkg.initFunctionsInSourceOrder {
		initFunc()
	}
}

func runProgram(mainPackage Package) {
	initializeProgram(allPackages(mainPackage))
	mainPackage.main()
}

这段代码只用于理解,不是 Go 运行时的公开实现。实际编译器可以把多个 init 函数组织成包级初始化任务,链接器和运行时也会使用内部符号和启动代码。具体符号名称和布局可能随 Go 版本、架构和编译优化变化,业务代码不应该依赖它们。

这个模型解释了三个现象:

  1. 不需要从 main 手动调用 init;
  2. 包被多次导入时只初始化一次;
  3. init 中的 panic 会阻止后续初始化和 main 执行。

可以使用工具观察一个程序的包依赖,但不要把工具输出当成语言层的稳定 ABI:

go list -deps ./...
go build -x .

go list -deps 用于观察依赖包,go build -x 用于查看构建命令。它们适合排查“为什么某个插件被编译进来了”,不适合让业务逻辑依赖某个内部初始化符号。

init、空白导入和插件系统的限制

上面的插件示例是一种编译期插件注册:插件被编译进同一个二进制,是否启用由 import 关系决定。

它适合:

  • 固定构建版本中的协议、驱动和格式注册;
  • 由不同构建目标选择不同实现;
  • 启动时一次性建立不可变的插件目录;
  • 需要让业务包和具体实现解耦的项目。

它不等于热插拔:

  • 修改插件代码后需要重新编译和发布;
  • init 不能在运行时重新执行;
  • 空白导入不会从目录中自动扫描插件;
  • 想要运行时加载,需要配置驱动、RPC 插件、进程隔离或专门的插件协议。

因此,高可用系统通常采用两层设计:

编译期 init 注册固定能力
        ↓
显式启动函数校验配置、连接资源、构造服务
        ↓
运行期通过接口和 context 管理请求、超时和退出

init 只负责让能力进入注册表,真正的网络连接、健康检查、重试、熔断和优雅关闭由应用生命周期负责。

常见错误

把 init 当成构造函数

错误思路:

func init() {
	globalClient = newClientFromEnvironment()
}

调用方不能传配置,也不能检查错误。更好的设计是:

func NewClient(cfg Config) (*Client, error) {
	// 校验 cfg,建立连接,返回错误
	return &Client{}, nil
}

在 init 中调用 flag.Parse

包被测试、工具或其他命令导入时,init 可能比调用方准备好参数的时间更早执行。命令行解析应该放在 main 或明确的命令入口中:

func main() {
	flag.Parse()
	app, err := NewApp(loadFlags())
	if err != nil {
		log.Fatal(err)
	}
	_ = app
}

依赖文件名表达业务顺序

a_init.go 在 b_init.go 前面并不等于“所有构建系统都应该以文件名作为业务协议”。需要顺序时使用显式函数、依赖关系或配置排序。

在 init 中读取可变环境

环境变量、当前目录、时区、随机数和网络状态都可能随启动环境变化。把它们读入全局变量会降低测试可控性:

var port = os.Getenv("PORT")

如果确实是应用配置,应该在入口处读取并校验:

func LoadConfig() (Config, error) {
	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}
	return Config{Port: port}, nil
}

忘记 main 返回后程序会退出

启动 goroutine 后直接从 main 返回,不能保证任务完成。服务启动流程必须有明确的阻塞点和关闭流程。

混淆点导入和空白导入

import . "example.com/utils"

这是点导入,目的是省略包名前缀;

import _ "example.com/plugin"

这是空白导入,目的是触发初始化副作用。两者的用途完全不同。

测试启动行为

可以把插件注册中心写成普通包测试:

package registry

import "testing"

func TestNamesIsSorted(t *testing.T) {
	got := Names()
	for i := 1; i < len(got); i++ {
		if got[i-1] > got[i] {
			t.Fatalf("names are not sorted: %v", got)
		}
	}
}

运行:

go test ./...
go test -race ./...

包的 init 会在测试函数执行之前运行,所以测试不应该依赖多个测试文件之间的初始化顺序,也不应该让测试包的 init 连接真实数据库或启动真实端口。

如果需要在不同测试用例中使用不同插件集合,可以把注册中心改成显式创建的 Registry 对象,而不是依赖全局 map:

type Registry struct {
	mu        sync.RWMutex
	factories map[string]Factory
}

func New() *Registry {
	return &Registry{factories: make(map[string]Factory)}
}

全局注册表适合固定的编译期插件目录;可变的测试和多租户场景更适合显式对象。

总结

main 和 init 都是 Go 程序启动阶段的重要函数,但职责不同:

  • main 是 main 包的程序入口,只能有一个,无参数、无返回值;
  • init 可以存在于任意包,一个包可以有多个;
  • 包级变量先初始化,再执行当前包的 init;
  • 依赖包先于导入它的包初始化,每个包只初始化一次;
  • init 不能被显式调用,也不能返回错误;
  • _ 导入用于触发包的初始化副作用,. 导入用于省略包名前缀,别名导入用于改变当前文件的访问名称;
  • init 适合做纯注册和局部准备,不适合隐藏网络连接、配置加载和后台任务;
  • 高可用项目应当把资源连接、错误处理、超时和优雅关闭放进显式生命周期函数。

最实用的设计通常是:让 init 完成“把能力登记进目录”,让 main 完成“读取配置、构造应用、启动服务、处理退出”。这样既能使用 Go 的包初始化能力,又不会把关键业务状态藏在导入顺序里。

官方资料

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐