第一章:Go并发编程入门

1.1 Go语言并发模型概述

说到Go语言,最让我着迷的就是它的并发模型。你想想看,传统的并发编程有多痛苦?Java里搞个线程池,写个Future,还得小心翼翼处理共享内存。Go呢?它直接换了个思路。

Go的并发模型,核心就是一句话:不要通过共享内存来通信,而要通过通信来共享内存。这句话我刚开始学的时候觉得挺玄乎的,后来在项目中真正用起来,才明白它的妙处。

这个模型基于CSP(Communicating Sequential Processes)理论,说白了就是让不同的执行单元通过channel来传递消息。每个Goroutine专注于自己的事,需要数据了?从channel里拿。处理完了?往channel里丢。这样一来,锁的问题就少了很多。

核心要点:

  • Goroutine是轻量级的执行单元,不是操作系统线程
  • Channel是Goroutine之间的通信桥梁
  • select机制可以同时处理多个channel
  • Go运行时负责调度Goroutine到系统线程上

我在项目中遇到过这样一个场景:需要同时从多个数据源拉取信息,然后合并结果。如果用传统的线程模型,我得自己管理线程池、处理超时、还要小心死锁。用Go的Goroutine+channel,代码量直接砍了一半,而且逻辑清晰得多。

1.2 Goroutine基础

Goroutine是什么?说白了就是一个轻量级的"线程"。但它比系统线程轻量太多了——初始栈大小只有几KB,创建和销毁的开销也极小。你可以在一个Go程序里轻松创建成千上万个Goroutine,这在传统线程模型里是想都不敢想的。

创建一个Goroutine有多简单?看代码:

package main

import (
    "fmt"
    "time"
)

func sayHello() {
    fmt.Println("Hello from Goroutine!")
}

func main() {
    // 启动一个Goroutine
    go sayHello()
    
    // 主Goroutine继续执行
    fmt.Println("Hello from main!")
    
    // 等一下,不然主Goroutine退出了,子Goroutine还没执行
    time.Sleep(time.Second)
}

嗯,这里要注意:go关键字后面跟一个函数调用,就启动了一个新的Goroutine。主函数和这个新Goroutine是并发执行的。但有个坑——如果主Goroutine退出了,所有子Goroutine都会被强制结束。所以我上面加了个time.Sleep,实际项目中可不能这么干,得用WaitGroup或者channel来同步。

小技巧:我个人习惯用匿名函数启动Goroutine,这样可以把上下文直接传进去:

go func(msg string) {
    fmt.Println(msg)
}("Hello from anonymous Goroutine!")

我曾经犯过一个低级错误:在循环里启动Goroutine时,直接引用了循环变量。结果所有Goroutine打印出来的都是最后一个值。为什么会这样?因为Goroutine启动时捕获的是变量的引用,而不是当时的快照。正确的做法是把变量作为参数传进去。

1.3 Go调度器简介

Go的调度器,我愿称之为"幕后英雄"。你写的go func(),操作系统并不知道有Goroutine这回事。它只看到几个系统线程在跑。真正把Goroutine调度到系统线程上执行的,是Go运行时自带的调度器。

这个调度器基于GMP模型:

组件 全称 作用
G Goroutine 代表一个待执行的任务
M Machine 代表操作系统线程
P Processor 代表执行Goroutine所需的上下文

你想想看,M个系统线程,要调度N个Goroutine(N通常远大于M)。Go调度器是怎么做到的?它维护了一个全局的Goroutine队列,每个P也有自己的本地队列。当一个Goroutine发生阻塞(比如等待I/O),P就会从队列里取一个新的Goroutine来执行。这个过程对开发者是完全透明的。

我记得有一次排查性能问题,发现程序明明开了很多Goroutine,但CPU利用率就是上不去。后来发现是GOMAXPROCS设置得太小了。这个参数控制的是P的数量,也就是可以并行执行Goroutine的系统线程数。默认值是CPU核心数,但如果你跑在容器里,可能检测到的核心数不对。

避坑指南:我曾经在Kubernetes容器里部署Go服务,没设置GOMAXPROCS,结果调度器检测到了宿主机的所有核心,导致大量线程争抢有限的CPU配额。后来用uber-go/automaxprocs这个库自动适配容器限制,问题才解决。

1.4 并发与并行的区别

这个问题,我面试别人的时候经常问。很多人张口就来:"并发是逻辑上的同时执行,并行是物理上的同时执行。"说得没错,但太抽象了。

我换个说法:并发是处理多个事情的能力,并行是同时做多个事情的能力

举个例子:你一边写代码一边听音乐,这是并发。你同时写代码、听音乐、还吃零食,这是并行。但你的大脑只有一个(单核CPU),你只能快速切换——写几行代码,切过去听两句音乐,再切回来。这就是并发。如果你有两个大脑(多核CPU),一个负责写代码,一个负责听音乐,这才是并行。

在Go里:

  • 并发:多个Goroutine在单个系统线程上轮流执行。调度器负责切换。
  • 并行:多个Goroutine在多个系统线程上同时执行。需要多核CPU支持。

看个直观的例子:

package main

import (
    "fmt"
    "runtime"
    "sync"
)

func main() {
    // 设置使用1个核心,此时是并发
    runtime.GOMAXPROCS(1)
    
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            fmt.Printf("Goroutine %d\n", id)
        }(i)
    }
    wg.Wait()
    
    // 设置使用所有核心,此时可能并行
    runtime.GOMAXPROCS(runtime.NumCPU())
    // ... 同样的代码
}

GOMAXPROCS=1时,所有Goroutine都在一个线程上跑,是并发。当GOMAXPROCS=4时(假设你有4核),最多有4个Goroutine可以同时执行,是并行。

记住这个:

  • 并发是关于结构的——你如何组织代码来处理多个任务
  • 并行是关于执行的——你的硬件能否同时做多件事
  • Go的并发模型让你可以轻松写出并发代码,但并行需要硬件支持

我个人习惯在设计系统时先考虑并发结构,把任务拆分成独立的Goroutine,用channel串联起来。至于能不能并行,那是部署时考虑的事。你想想看,如果你的代码结构本身就是并发的,那放到多核机器上自然就并行起来了。这就是Go的设计哲学——让你专注于并发结构,并行是自然而然的事。

好了,第一章的内容就到这里。并发编程的入门概念我们已经聊完了。下一章我们会深入Goroutine的生命周期管理,包括如何优雅地启动、停止和同步Goroutine。到时候见。