在 Go 项目里,很多人默认认为日志“天然线程安全”,所以在多个 goroutine 中直接复用同一个日志实例也不再多想。这个判断只对了一半:如果日志过程牵涉共享文件、数据库连接或初始化逻辑,并发问题依然会冒出来。下面按常见风险拆开讲,分别看什么时候该加锁、什么时候该用 sync.Once,以及什么时候更适合把写日志这件事单独交给一个 goroutine。
共享资源写日志时,先考虑用互斥锁保护
如果你的日志调用最终会落到某个共享资源上,比如同一个文件句柄、同一个数据库连接,风险点就不再只是 log.Println 这一步,而是后面的资源访问过程。多个 goroutine 同时进入时,最直接的问题就是写入顺序交错、状态覆盖,严重时还可能引发竞态条件。

这类场景最稳妥的做法,就是用 sync.Mutex 或 sync.RWMutex 把关键区包起来,保证同一时刻只有一个 goroutine 操作共享对象。对于单纯写日志的流程,互斥锁往往已经够用;只有在同一资源上读多写少,并且读写路径明确区分时,读写锁才更有意义。
import (
"log"
"sync"
)
var mu sync.Mutex
func logWithMutex(message string) {
mu.Lock()
defer mu.Unlock()
log.Println(message)
}
这段写法的重点不在“给日志库加锁”,而在“给共享访问路径加锁”。如果你的日志函数里还附带了额外操作,比如写文件、更新状态、拼装共享缓冲区,那么这些步骤也应该一起纳入锁保护范围,否则问题只是从日志输出挪到了别的地方。
初始化阶段并发进入,用 sync.Once 收口
日志系统的另一个高发问题出现在初始化阶段。比如配置日志格式、创建日志记录器、打开日志文件,这些操作通常只该执行一次;但在并发程序里,多个 goroutine 可能会在第一次使用日志时同时触发初始化逻辑。

如果这里没有保护,常见后果包括重复打开文件、重复设置配置,甚至出现初始化状态不一致。sync.Once 的价值就在于把这类“一次性动作”收口,无论多少 goroutine 同时调用,初始化函数都只会执行一次。
import (
"log"
"sync"
)
var once sync.Once
func initializeLogger() {
// 初始化日志记录器
}
func getLogger() *log.Logger {
once.Do(initializeLogger)
return &log.Logger{}
}
适合交给 sync.Once 的,不只是创建 logger 实例,还包括读取配置、打开目标日志文件、注册输出目标等启动阶段动作。它解决的是“多次初始化”的问题,而不是“并发写入”的问题,所以它常常和其他手段配合使用,而不是单独承担所有并发控制。
高并发写入场景,更适合用 channel 串行化
如果你的程序里日志量比较大,或者业务 goroutine 数量多,频繁加锁可能会让日志路径变得拥挤。这时候,把日志消息发送到 channel,再由一个专门的 goroutine 统一落盘,通常是更清晰也更容易扩展的方案。

这种模式的核心思路是职责分离:业务侧只负责生产日志消息,后台 worker 负责顺序消费并写入文件。因为真正操作文件的是单个 goroutine,所以天然避免了多个执行流同时写一个目标带来的冲突。
import (
"log"
"os"
"sync"
)
type LogEntry struct {
Message string
}
func loggerWorker(entries chan LogEntry, wg *sync.WaitGroup) {
defer wg.Done()
logFile, err := os.OpenFile("logfile.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
log.Fatal(err)
}
defer logFile.Close()
logger := log.New(logFile, "", log.LstdFlags)
for entry := range entries {
logger.Println(entry.Message)
}
}
func main() {
var wg sync.WaitGroup
entryChan := make(chan LogEntry, 100)
wg.Add(1)
go loggerWorker(entryChan, &wg)
// 在程序中使用entryChan发送日志消息
entryChan <- LogEntry{Message: "Hello, World!"}
close(entryChan)
wg.Wait()
}
这里的几个细节值得注意。第一,示例里用了带缓冲的 channel,可以减少业务 goroutine 因日志写入而被立刻阻塞的概率。第二,写入 goroutine 负责打开和关闭 logfile.log,资源生命周期更集中。第三,通过 close(entryChan) 和 wg.Wait() 收尾,可以保证程序退出前把 channel 里的日志处理完。
这种方式很适合日志集中落盘、异步输出和后续扩展,例如未来要增加批量写入、按级别分流、统一格式化时,往往比散落在业务代码里的锁更容易维护。不过代价也很明确:你需要额外管理 channel 容量、worker 退出时机,以及高峰期消息积压的问题。
三种方案怎么选,关键看冲突点在哪
把这三种手段放在一起看,其实它们分别对应三类不同问题。
适合用锁的情况
当冲突来自“多个 goroutine 同时操作同一个共享资源”时,sync.Mutex 或 sync.RWMutex 是最直接的办法。实现成本低,接入也快,适合改造已有代码。
适合用 sync.Once 的情况
当问题集中在“初始化只能发生一次”时,sync.Once 最合适。它不负责后续写入顺序,只负责把启动阶段的不确定性压平。
适合用 channel 的情况
当你想把高并发日志写入彻底从业务路径中拆开,并且希望获得更清晰的串行处理模型时,channel 加专用 worker 往往更优雅。它尤其适合写文件这类天然顺序敏感的输出目标。
总结来看,Go 自带日志库的线程安全,只能说明“基础打印动作通常没问题”,并不等于你的整条日志链路就一定安全。只要日志流程碰到了共享资源、一次性初始化或高并发写入,就应该有针对性地补上控制手段。互斥锁、sync.Once 和 channel 这三种方法,基本能覆盖大多数日志并发场景,实际选择时看的是架构形态、资源类型和你对吞吐与复杂度的取舍。













