Skip to content

《Go语言实战》入门实战系列 06:并发模型详解——goroutine、竞争检测与通道通信

约 3534 字大约 12 分钟

《Go语言实战》入门实战系列云原生

2026-04-01

开篇引导

现代计算机早已进入多核时代,但传统编程语言在并发编程上依然困难重重——线程管理复杂、锁机制易错、共享数据难以同步。Go语言从设计之初就将并发作为核心特性,通过轻量级的goroutine和通道(channel)提供了一种全新的并发编程模型。在本篇中,我们将深入Go的并发世界,学习如何创建和管理goroutine,理解调度器的运行机制,掌握同步共享数据的正确方式,并熟练运用通道在goroutine之间安全传递数据。学完本篇,你将能够编写高效、安全且易于维护的并发程序。

【本篇核心收获】

  • 理解并发与并行的区别,掌握Go调度器的工作原理
  • 学会使用go关键字创建goroutine,并利用sync.WaitGroup同步等待
  • 掌握竞争状态的检测与修复,使用原子函数和互斥锁保护共享资源
  • 深入理解无缓冲通道与有缓冲通道的行为差异
  • 通过网球比赛和接力赛等案例,掌握通道在goroutine间的同步与通信
  • 学会使用有缓冲通道实现工作池模式

1. 并发与并行

在深入了解Go的并发机制前,我们需要先理清并发(concurrency)和并行(parallelism)这两个核心概念。

1.1 操作系统线程与进程

当运行一个应用程序时,操作系统会为其创建一个进程。进程是一个容器,包含了程序运行所需的各种资源(内存地址空间、文件句柄、线程等)。每个进程至少有一个主线程,线程是操作系统调度执行的最小单元。当主线程终止时,整个应用程序也会终止。

图1:一个运行的应用程序的进程和线程的简要描绘

1.2 Go调度器与逻辑处理器

Go的运行时调度器在操作系统线程之上构建了**逻辑处理器(Logical Processor)**的概念。每个逻辑处理器绑定到一个操作系统线程,而goroutine则在逻辑处理器上运行。Go 1.5版本之后,默认会为每个可用的物理处理器(CPU核心)分配一个逻辑处理器。

图2:Go调度器如何管理goroutine

当创建一个goroutine时,它会被放入调度器的全局运行队列。随后调度器会将goroutine分配给某个逻辑处理器的本地运行队列,等待该逻辑处理器执行。当某个goroutine执行阻塞的系统调用(如文件读取)时,线程和goroutine会从逻辑处理器上分离,该线程继续阻塞,而调度器会创建新线程绑定到该逻辑处理器,继续执行其他goroutine。网络I/O调用则通过集成网络轮询器实现非阻塞等待。

1.3 并发 vs 并行

并发(Concurrency):同时管理很多事情,但可能只在一个物理处理器上交替执行。
并行(Parallelism):同时做很多事情,需要多个物理处理器同时执行。

图3:并发和并行的区别

Go语言通过调度器实现了高效的并发,但要让goroutine真正并行运行,必须使用多个逻辑处理器(通常设为CPU核心数)。但盲目增加逻辑处理器并不一定提升性能,需要根据实际情况测试。

模块小结:Go的并发模型构建在操作系统线程之上,通过逻辑处理器和调度器将goroutine高效地映射到有限的线程上。理解并发与并行的区别有助于设计更合理的程序结构。

2. goroutine——轻量级线程

goroutine是Go并发的基本单位,使用go关键字即可启动一个新的goroutine。下面通过几个示例来观察goroutine的调度行为。

2.1 单逻辑处理器上的并发

代码清单1:单逻辑处理器上并发运行两个goroutine

package main

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

func main() {
    // 分配一个逻辑处理器
    runtime.GOMAXPROCS(1)

    var wg sync.WaitGroup
    wg.Add(2)

    fmt.Println("Start Goroutines")

    go func() {
        defer wg.Done()
        for count := 0; count < 3; count++ {
            for char := 'a'; char < 'a'+26; char++ {
                fmt.Printf("%c", char)
            }
        }
    }()

    go func() {
        defer wg.Done()
        for count := 0; count < 3; count++ {
            for char := 'A'; char < 'A'+26; char++ {
                fmt.Printf("%c", char)
            }
        }
    }()

    fmt.Println("Waiting To Finish")
    wg.Wait()
    fmt.Println("\nTerminating Program")
}

输出示例:

Start Goroutines
Waiting To Finish
ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzTerminating Program

可以看到,由于只有一个逻辑处理器,两个goroutine交替运行,但由于第一个goroutine完成得很快,几乎全部大写字母输出后才开始输出小写字母。调度器会在合适的时机切换goroutine。

2.2 调度器的时间片切换

为了让调度器更明显地展示切换,我们可以让goroutine执行耗时操作,例如计算素数。

代码清单2:计算素数展示调度器切换

package main

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

var wg sync.WaitGroup

func main() {
    runtime.GOMAXPROCS(1)
    wg.Add(2)

    fmt.Println("Create Goroutines")
    go printPrime("A")
    go printPrime("B")

    fmt.Println("Waiting To Finish")
    wg.Wait()
    fmt.Println("Terminating Program")
}

func printPrime(prefix string) {
    defer wg.Done()

next:
    for outer := 2; outer < 5000; outer++ {
        for inner := 2; inner < outer; inner++ {
            if outer%inner == 0 {
                continue next
            }
        }
        fmt.Printf("%s:%d\n", prefix, outer)
    }
    fmt.Println("Completed", prefix)
}

输出片段(每次运行可能不同):

B:2
B:3
...
B:4591
A:3    // 切换goroutine
A:5
...
A:4567
B:4603 // 切换回来
...

图4:goroutine在逻辑处理器的线程上进行交换

2.3 多逻辑处理器并行

通过设置runtime.GOMAXPROCS(runtime.NumCPU()),可以让每个物理处理器上运行一个逻辑处理器,实现真正的并行。

runtime.GOMAXPROCS(runtime.NumCPU())

在并行模式下,两个goroutine可能同时运行,输出中大小写字母会混合出现。

模块小结:goroutine的创建和调度非常轻量,通过runtime.GOMAXPROCS可以控制并发级别。理解调度器的切换行为有助于编写可预测的并发程序。

3. 竞争状态

当多个goroutine同时访问同一个共享资源,且至少有一个进行写操作时,就会产生竞争状态。竞争状态会导致数据不一致或程序崩溃。

3.1 竞争状态示例

代码清单3:存在竞争状态的程序

package main

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

var (
    counter int
    wg      sync.WaitGroup
)

func main() {
    wg.Add(2)
    go incCounter(1)
    go incCounter(2)
    wg.Wait()
    fmt.Println("Final Counter:", counter)
}

func incCounter(id int) {
    defer wg.Done()
    for count := 0; count < 2; count++ {
        value := counter   // 读取
        runtime.Gosched()  // 让出时间片
        value++
        counter = value    // 写入
    }
}

输出:

Final Counter: 2

期望结果是4,实际却是2。原因在于两个goroutine交替执行时,读取的值可能已被另一个goroutine修改,导致覆盖。

图5:竞争状态下程序行为的图像表达

3.2 检测竞争状态

Go提供了内置的竞争检测器,在编译时加上-race标志即可:

go build -race
./program

输出会明确指出发生数据竞争的代码行和涉及的goroutine。

模块小结:竞争状态是并发程序中最常见的错误之一。通过go build -race可以轻松检测,但根本上需要在设计时避免共享数据的无序访问。

4. 锁住共享资源

Go提供了两种主要方式来同步对共享资源的访问:原子操作和互斥锁。

4.1 原子函数

sync/atomic包提供了一系列原子操作函数,用于安全地操作整型变量和指针。

代码清单4:使用原子函数修正竞争状态

package main

import (
    "fmt"
    "runtime"
    "sync"
    "sync/atomic"
)

var (
    counter int64
    wg      sync.WaitGroup
)

func main() {
    wg.Add(2)
    go incCounter(1)
    go incCounter(2)
    wg.Wait()
    fmt.Println("Final Counter:", counter)
}

func incCounter(id int) {
    defer wg.Done()
    for count := 0; count < 2; count++ {
        atomic.AddInt64(&counter, 1)
        runtime.Gosched()
    }
}

输出:

Final Counter: 4

atomic.AddInt64保证了对counter的加法操作是原子的。类似的还有LoadInt64StoreInt64,可用于实现安全标志位。

代码清单5:使用原子标志关闭goroutine

var shutdown int64

// 在goroutine中检查
if atomic.LoadInt64(&shutdown) == 1 {
    break
}

// 在主goroutine中设置
atomic.StoreInt64(&shutdown, 1)

4.2 互斥锁

互斥锁(sync.Mutex)可以在代码中定义一个临界区,保证同一时间只有一个goroutine进入。

代码清单6:使用互斥锁修正竞争状态

package main

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

var (
    counter int
    wg      sync.WaitGroup
    mutex   sync.Mutex
)

func main() {
    wg.Add(2)
    go incCounter(1)
    go incCounter(2)
    wg.Wait()
    fmt.Printf("Final Counter: %d\n", counter)
}

func incCounter(id int) {
    defer wg.Done()
    for count := 0; count < 2; count++ {
        mutex.Lock()
        {
            value := counter
            runtime.Gosched()
            value++
            counter = value
        }
        mutex.Unlock()
    }
}

输出:

Final Counter: 4

互斥锁将读、修改、写这三步操作包装成一个临界区,确保其他goroutine无法在此期间访问counter

模块小结:原子函数适用于简单的整型操作,互斥锁则能保护任意代码段。两者都能消除竞争状态,但通道往往能提供更简洁的解决方案。

5. 通道——goroutine间的通信

通道(channel)是Go语言中实现CSP(通信顺序进程)模型的核心。它允许一个goroutine向另一个goroutine发送数据,并自动处理同步。

5.1 创建与使用通道

// 无缓冲通道
unbuffered := make(chan int)

// 有缓冲通道,缓冲区大小10
buffered := make(chan string, 10)

// 发送数据
buffered <- "Gopher"

// 接收数据
value := <-buffered

5.2 无缓冲通道

无缓冲通道在接收前无法保存任何值,要求发送和接收必须同时准备好,否则会阻塞。这种特性天然实现了同步。

网球比赛模拟:使用无缓冲通道在两位“选手”之间传递球。

代码清单7:网球比赛模拟

package main

import (
    "fmt"
    "math/rand"
    "sync"
    "time"
)

var wg sync.WaitGroup

func init() {
    rand.Seed(time.Now().UnixNano())
}

func main() {
    court := make(chan int)
    wg.Add(2)
    go player("Nadal", court)
    go player("Djokovic", court)

    court <- 1 // 发球
    wg.Wait()
}

func player(name string, court chan int) {
    defer wg.Done()
    for {
        ball, ok := <-court
        if !ok {
            fmt.Printf("Player %s Won\n", name)
            return
        }

        n := rand.Intn(100)
        if n%13 == 0 {
            fmt.Printf("Player %s Missed\n", name)
            close(court)
            return
        }

        fmt.Printf("Player %s Hit %d\n", name, ball)
        ball++
        court <- ball
    }
}

图6:使用无缓冲的通道在goroutine之间同步

接力比赛模拟:使用无缓冲通道传递接力棒。

代码清单8:接力比赛模拟

package main

import (
    "fmt"
    "sync"
    "time"
)

var wg sync.WaitGroup

func main() {
    baton := make(chan int)
    wg.Add(1)
    go Runner(baton)
    baton <- 1
    wg.Wait()
}

func Runner(baton chan int) {
    var newRunner int
    runner := <-baton
    fmt.Printf("Runner %d Running With Baton\n", runner)

    if runner != 4 {
        newRunner = runner + 1
        fmt.Printf("Runner %d To The Line\n", newRunner)
        go Runner(baton)
    }

    time.Sleep(100 * time.Millisecond)

    if runner == 4 {
        fmt.Printf("Runner %d Finished, Race Over\n", runner)
        wg.Done()
        return
    }

    fmt.Printf("Runner %d Exchange With Runner %d\n", runner, newRunner)
    baton <- newRunner
}

输出示例:

Runner 1 Running With Baton
Runner 1 To The Line
Runner 1 Exchange With Runner 2
Runner 2 Running With Baton
Runner 2 To The Line
Runner 2 Exchange With Runner 3
Runner 3 Running With Baton
Runner 3 To The Line
Runner 3 Exchange With Runner 4
Runner 4 Running With Baton
Runner 4 Finished, Race Over

5.3 有缓冲通道

有缓冲通道可以存储一个或多个值,只有当缓冲区满时发送才会阻塞,缓冲区空时接收才会阻塞。

工作池模式:固定数量的goroutine从有缓冲通道中领取任务。

代码清单9:工作池模拟

package main

import (
    "fmt"
    "math/rand"
    "sync"
    "time"
)

const (
    numberGoroutines = 4
    taskLoad         = 10
)

var wg sync.WaitGroup

func init() {
    rand.Seed(time.Now().Unix())
}

func main() {
    tasks := make(chan string, taskLoad)

    wg.Add(numberGoroutines)
    for gr := 1; gr <= numberGoroutines; gr++ {
        go worker(tasks, gr)
    }

    for post := 1; post <= taskLoad; post++ {
        tasks <- fmt.Sprintf("Task: %d", post)
    }

    close(tasks)
    wg.Wait()
}

func worker(tasks chan string, worker int) {
    defer wg.Done()
    for {
        task, ok := <-tasks
        if !ok {
            fmt.Printf("Worker: %d : Shutting Down\n", worker)
            return
        }

        fmt.Printf("Worker: %d : Started %s\n", worker, task)
        sleep := rand.Int63n(100)
        time.Sleep(time.Duration(sleep) * time.Millisecond)
        fmt.Printf("Worker: %d : Completed %s\n", worker, task)
    }
}

输出片段:

Worker: 1 : Started Task: 1
Worker: 2 : Started Task: 2
Worker: 3 : Started Task: 3
Worker: 4 : Started Task: 4
Worker: 1 : Completed Task: 1
Worker: 1 : Started Task: 5
...
Worker: 4 : Shutting Down

图7:使用有缓冲的通道在goroutine之间同步数据

模块小结:无缓冲通道强制同步,有缓冲通道提供异步缓冲。选择哪种取决于业务是否需要即时同步。通道的使用让代码更加清晰,减少了锁的复杂性。

6. 本篇核心知识点速记

  • goroutine:轻量级线程,使用go关键字启动。调度器将goroutine映射到逻辑处理器上执行。
  • 并发 vs 并行:并发是同时管理多个任务,并行是同时执行多个任务。Go默认使用并发,可通过runtime.GOMAXPROCS设置并行级别。
  • 竞争状态:多个goroutine同时读写共享资源导致数据不一致。使用go build -race检测。
  • 原子操作sync/atomic包提供AddInt64LoadInt64StoreInt64等,用于安全操作整型变量。
  • 互斥锁sync.MutexLock/Unlock定义临界区,保证同一时间只有一个goroutine进入。
  • 通道make(chan T)创建无缓冲通道,make(chan T, capacity)创建有缓冲通道。使用<-发送/接收数据。
  • 无缓冲通道:同步交换,发送和接收必须同时就绪。
  • 有缓冲通道:异步通信,缓冲区满时发送阻塞,缓冲区空时接收阻塞。
  • WaitGroupAdd设置计数,Done递减,Wait阻塞直到计数归零,用于等待一组goroutine完成。

文末小结

本篇我们系统学习了Go语言的并发模型。从goroutine的创建与调度,到竞争状态的产生与检测,再到原子函数、互斥锁以及通道的使用,我们逐步构建了编写并发程序所需的知识体系。Go通过轻量级的goroutine和优雅的通道机制,让并发编程变得简单、安全且高效。

在实际开发中,应优先考虑使用通道进行通信,而不是通过共享内存来同步。通道鼓励“通过通信来共享内存”,而不是“通过共享内存来通信”。这种设计使得并发代码更易于理解与维护。