MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Gogoroutine的基本使用

2024-09-016.8k 阅读

1. Go 语言并发编程简介

在现代软件开发中,并发编程是一个至关重要的话题。随着多核处理器的普及,程序需要利用多个核心的计算能力来提高性能和响应速度。Go 语言从诞生之初就将并发编程作为其核心特性之一,通过 goroutinechannel 这两个关键组件,为开发者提供了一种简洁且高效的并发编程模型。

goroutine 是 Go 语言中实现并发的轻量级执行单元。与传统线程相比,goroutine 的创建和销毁成本极低,使得我们可以轻松创建数以万计的并发任务。而 channel 则用于在 goroutine 之间进行安全的数据传递和同步,避免了共享内存带来的竞态条件等问题。

2. 启动一个 Goroutine

2.1 简单示例

要启动一个 goroutine,只需要在函数调用前加上 go 关键字。下面是一个简单的示例:

package main

import (
    "fmt"
    "time"
)

func say(s string) {
    for i := 0; i < 5; i++ {
        time.Sleep(100 * time.Millisecond)
        fmt.Println(s)
    }
}

func main() {
    go say("world")
    say("hello")
}

在这个例子中,我们定义了一个 say 函数,它会循环打印传入的字符串。在 main 函数中,我们使用 go 关键字启动了一个新的 goroutine 来执行 say("world"),同时主线程继续执行 say("hello")。这两个 goroutine(主线程也可以看作是一个特殊的 goroutine)并发执行。

注意,在实际运行中,你可能会发现输出的顺序是不确定的。这是因为 goroutine 的调度是由 Go 运行时系统(Goruntime)负责的,它会根据系统资源和任务状态动态调度 goroutine 的执行。

2.2 带参数的 Goroutine

goroutine 启动的函数可以接受参数,就像普通函数调用一样。例如:

package main

import (
    "fmt"
    "time"
)

func printNumbers(start, end int) {
    for i := start; i <= end; i++ {
        time.Sleep(100 * time.Millisecond)
        fmt.Printf("Number: %d\n", i)
    }
}

func main() {
    go printNumbers(1, 5)
    go printNumbers(10, 15)

    time.Sleep(1 * time.Second)
}

在这个示例中,我们定义了 printNumbers 函数,它接受两个整数参数 startend,并在指定范围内打印数字。在 main 函数中,我们启动了两个 goroutine,分别传递不同的参数范围。最后,通过 time.Sleep 让主线程等待一段时间,确保两个 goroutine 有足够的时间执行完毕。

3. Goroutine 的调度

3.1 M:N 调度模型

Go 语言采用的是 M:N 调度模型,即多个 goroutine 映射到多个操作系统线程上。传统的线程模型通常是 1:1 映射(一个用户线程对应一个操作系统线程),这种模型在创建大量线程时会消耗大量系统资源。而 Go 的 M:N 调度模型可以在少量操作系统线程上高效调度大量的 goroutine

在 Go 运行时系统中,有三个重要的概念:M(操作系统线程)、Ggoroutine)和 P(处理器)。P 表示逻辑处理器,它包含了运行 goroutine 的资源,如 goroutine 队列等。每个 M 必须绑定到一个 P 才能运行 goroutine。多个 G 可以被分配到不同的 P 上,由对应的 M 来执行。

3.2 协作式调度

goroutine 采用协作式调度(Cooperative Scheduling),也称为非抢占式调度。这意味着 goroutine 只有在遇到某些特定的操作(如系统调用、I/O 操作、time.Sleepchannel 操作等)时,才会主动让出执行权,让其他 goroutine 有机会运行。这种调度方式避免了抢占式调度带来的上下文切换开销和复杂的同步问题,使得 goroutine 的调度更加高效。

例如,在前面的 say 函数示例中,我们使用了 time.Sleep,这就是一个会让出执行权的操作。当一个 goroutine 执行到 time.Sleep 时,它会暂停执行,Go 运行时系统会调度其他可运行的 goroutine

4. 匿名函数与 Goroutine

4.1 简单匿名函数 Goroutine

我们可以使用匿名函数来启动 goroutine,这在一些场景下非常方便,尤其是当我们的逻辑比较简单,不需要单独定义一个函数时。例如:

package main

import (
    "fmt"
    "time"
)

func main() {
    go func() {
        for i := 0; i < 5; i++ {
            time.Sleep(100 * time.Millisecond)
            fmt.Println("Anonymous Goroutine:", i)
        }
    }()

    for i := 0; i < 5; i++ {
        time.Sleep(150 * time.Millisecond)
        fmt.Println("Main Goroutine:", i)
    }
}

在这个例子中,我们使用匿名函数启动了一个 goroutine。匿名函数内部循环打印一些信息,主线程(也是一个 goroutine)也在循环打印信息。由于两个 goroutine 并发执行,输出结果的顺序是不确定的。

4.2 带参数的匿名函数 Goroutine

匿名函数也可以接受参数,如下所示:

package main

import (
    "fmt"
    "time"
)

func main() {
    message := "Hello, Goroutine!"
    go func(msg string) {
        for i := 0; i < 3; i++ {
            time.Sleep(100 * time.Millisecond)
            fmt.Println(msg, i)
        }
    }(message)

    time.Sleep(500 * time.Millisecond)
}

在这个示例中,我们定义了一个字符串变量 message,然后使用匿名函数启动 goroutine,并将 message 作为参数传递给匿名函数。匿名函数在循环中打印传入的消息和循环变量。

5. 等待 Goroutine 完成

5.1 使用 time.Sleep

在前面的一些示例中,我们使用了 time.Sleep 来等待 goroutine 完成。这种方法虽然简单,但并不精确,而且可能会导致等待时间过长或过短。例如,如果 goroutine 的执行时间不确定,使用固定的 time.Sleep 时间可能无法确保所有 goroutine 都执行完毕。

5.2 使用 sync.WaitGroup

sync.WaitGroup 是 Go 标准库中提供的一种同步机制,用于等待一组 goroutine 完成。它有三个主要方法:

  • Add(delta int):增加等待组的计数器。
  • Done():减少等待组的计数器,相当于 Add(-1)
  • Wait():阻塞当前 goroutine,直到等待组的计数器为 0。

下面是一个使用 sync.WaitGroup 的示例:

package main

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

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done()
    fmt.Printf("Worker %d started\n", id)
    time.Sleep(200 * time.Millisecond)
    fmt.Printf("Worker %d finished\n", id)
}

func main() {
    var wg sync.WaitGroup
    numWorkers := 3

    for i := 1; i <= numWorkers; i++ {
        wg.Add(1)
        go worker(i, &wg)
    }

    wg.Wait()
    fmt.Println("All workers have finished")
}

在这个例子中,我们定义了一个 worker 函数,它接受一个 id 和一个指向 sync.WaitGroup 的指针。在 worker 函数内部,首先使用 defer wg.Done() 来确保函数结束时减少等待组的计数器。在 main 函数中,我们创建了一个 sync.WaitGroup,并通过循环启动多个 goroutine,每次启动前调用 wg.Add(1) 增加计数器。最后调用 wg.Wait() 等待所有 goroutine 完成。

5.3 结合匿名函数使用 sync.WaitGroup

我们也可以结合匿名函数来使用 sync.WaitGroup,使代码更加简洁。例如:

package main

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

func main() {
    var wg sync.WaitGroup
    tasks := []string{"task1", "task2", "task3"}

    for _, task := range tasks {
        wg.Add(1)
        go func(t string) {
            defer wg.Done()
            fmt.Printf("Starting %s\n", t)
            time.Sleep(100 * time.Millisecond)
            fmt.Printf("Finished %s\n", t)
        }(task)
    }

    wg.Wait()
    fmt.Println("All tasks completed")
}

在这个示例中,我们通过匿名函数启动 goroutine 来处理不同的任务,每个匿名函数内部都使用 defer wg.Done() 来标记任务完成。

6. Goroutine 与内存共享

6.1 竞态条件问题

当多个 goroutine 同时访问和修改共享内存时,就可能会出现竞态条件(Race Condition)。竞态条件会导致程序出现不可预测的行为,因为多个 goroutine 的执行顺序是不确定的。

例如,下面是一个简单的示例,展示了竞态条件的问题:

package main

import (
    "fmt"
    "sync"
)

var counter int

func increment(wg *sync.WaitGroup) {
    defer wg.Done()
    for i := 0; i < 1000; i++ {
        counter++
    }
}

func main() {
    var wg sync.WaitGroup
    numRoutines := 10

    for i := 0; i < numRoutines; i++ {
        wg.Add(1)
        go increment(&wg)
    }

    wg.Wait()
    fmt.Println("Final counter value:", counter)
}

在这个例子中,我们定义了一个全局变量 counter,多个 goroutine 通过 increment 函数对其进行递增操作。由于多个 goroutine 同时访问和修改 counter,可能会出现竞态条件,导致最终的 counter 值并非预期的 1000 * numRoutines

6.2 使用互斥锁(Mutex)解决竞态条件

为了避免竞态条件,我们可以使用互斥锁(Mutex,即 Mutual Exclusion 的缩写)。互斥锁用于保护共享资源,确保在同一时间只有一个 goroutine 可以访问共享资源。

Go 标准库中的 sync.Mutex 提供了互斥锁的实现。下面是使用 sync.Mutex 改进上述示例的代码:

package main

import (
    "fmt"
    "sync"
)

var counter int
var mu sync.Mutex

func increment(wg *sync.WaitGroup) {
    defer wg.Done()
    for i := 0; i < 1000; i++ {
        mu.Lock()
        counter++
        mu.Unlock()
    }
}

func main() {
    var wg sync.WaitGroup
    numRoutines := 10

    for i := 0; i < numRoutines; i++ {
        wg.Add(1)
        go increment(&wg)
    }

    wg.Wait()
    fmt.Println("Final counter value:", counter)
}

在这个改进后的代码中,我们定义了一个 sync.Mutex 类型的变量 mu。在 increment 函数中,每次对 counter 进行操作前,先调用 mu.Lock() 锁定互斥锁,操作完成后调用 mu.Unlock() 解锁互斥锁。这样就确保了在同一时间只有一个 goroutine 可以修改 counter,从而避免了竞态条件。

6.3 使用读写锁(RWMutex)优化读操作

在一些场景中,读操作远远多于写操作。如果使用普通的互斥锁,会导致所有读操作也需要等待锁的释放,这会降低程序的性能。此时,我们可以使用读写锁(sync.RWMutex)。

读写锁允许同一时间有多个读操作同时进行,但写操作必须独占。下面是一个使用 sync.RWMutex 的示例:

package main

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

var data int
var rwmu sync.RWMutex

func read(wg *sync.WaitGroup) {
    defer wg.Done()
    rwmu.RLock()
    fmt.Printf("Read data: %d\n", data)
    rwmu.RUnlock()
}

func write(wg *sync.WaitGroup) {
    defer wg.Done()
    rwmu.Lock()
    data++
    fmt.Println("Write data:", data)
    rwmu.Unlock()
}

func main() {
    var wg sync.WaitGroup

    for i := 0; i < 5; i++ {
        wg.Add(1)
        go read(&wg)
    }

    time.Sleep(100 * time.Millisecond)

    for i := 0; i < 2; i++ {
        wg.Add(1)
        go write(&wg)
    }

    wg.Wait()
}

在这个示例中,我们定义了 read 函数用于读操作,使用 rwmu.RLock()rwmu.RUnlock() 来获取和释放读锁;定义了 write 函数用于写操作,使用 rwmu.Lock()rwmu.Unlock() 来获取和释放写锁。通过这种方式,在写操作较少的情况下,可以提高读操作的并发性能。

7. 基于 Channel 的通信

7.1 Channel 简介

channel 是 Go 语言中用于在 goroutine 之间进行通信和同步的重要机制。它可以看作是一个类型安全的管道,数据可以从一端发送到另一端。channel 的使用避免了共享内存带来的竞态条件问题,使得并发编程更加安全和简洁。

创建一个 channel 使用 make 函数,例如:

ch := make(chan int)

这里创建了一个类型为 intchannelchannel 有两种主要操作:发送(<-)和接收(<-)。例如:

ch <- 10 // 发送数据 10 到 channel
value := <-ch // 从 channel 接收数据并赋值给 value

7.2 无缓冲 Channel

无缓冲 channel 是指在创建 channel 时没有指定缓冲区大小的 channel,例如 make(chan int)。无缓冲 channel 的发送和接收操作是同步的,即发送操作会阻塞直到有另一个 goroutine 在该 channel 上执行接收操作,反之亦然。

下面是一个无缓冲 channel 的示例:

package main

import (
    "fmt"
)

func sender(ch chan int) {
    ch <- 42
    fmt.Println("Data sent")
}

func receiver(ch chan int) {
    value := <-ch
    fmt.Println("Received data:", value)
}

func main() {
    ch := make(chan int)
    go sender(ch)
    go receiver(ch)

    select {}
}

在这个例子中,sender 函数向 channel 发送数据 42receiver 函数从 channel 接收数据。由于 channel 是无缓冲的,sender 函数会阻塞在 ch <- 42 这一行,直到 receiver 函数执行 <-ch 接收操作。main 函数中使用 select {} 来防止主线程退出,确保两个 goroutine 有机会执行。

7.3 有缓冲 Channel

有缓冲 channel 是指在创建 channel 时指定了缓冲区大小的 channel,例如 make(chan int, 5) 表示创建了一个缓冲区大小为 5 的 int 类型 channel。有缓冲 channel 的发送操作只有在缓冲区满时才会阻塞,接收操作只有在缓冲区为空时才会阻塞。

下面是一个有缓冲 channel 的示例:

package main

import (
    "fmt"
)

func main() {
    ch := make(chan int, 3)

    ch <- 1
    ch <- 2
    ch <- 3

    fmt.Println("Data sent to channel")

    value1 := <-ch
    fmt.Println("Received data:", value1)

    ch <- 4

    value2 := <-ch
    fmt.Println("Received data:", value2)
}

在这个例子中,我们创建了一个缓冲区大小为 3 的 channel。前三次发送操作不会阻塞,因为缓冲区有足够的空间。当接收操作执行后,缓冲区有了空闲空间,再次发送操作又可以继续。

7.4 Channel 的关闭

使用 close 函数可以关闭 channel。关闭 channel 后,就不能再向其发送数据,但仍然可以接收数据,直到缓冲区中的数据被全部接收完。接收操作在 channel 关闭且缓冲区为空时,会收到对应类型的零值。

下面是一个关闭 channel 的示例:

package main

import (
    "fmt"
)

func main() {
    ch := make(chan int, 3)

    ch <- 1
    ch <- 2
    ch <- 3

    close(ch)

    for value := range ch {
        fmt.Println("Received data:", value)
    }
}

在这个例子中,我们关闭 channel 后,使用 for... range 循环从 channel 接收数据。for... range 会自动检测 channel 是否关闭,当 channel 关闭且缓冲区为空时,循环结束。

8. 单向 Channel

8.1 发送方单向 Channel

有时候,我们希望限制 channel 的使用方向,只允许发送数据或只允许接收数据。发送方单向 channel 只允许向其发送数据,例如:

func sender(ch chan<- int) {
    ch <- 42
}

这里的 ch 类型是 chan<- int,表示这是一个只允许发送 int 类型数据的单向 channel

8.2 接收方单向 Channel

接收方单向 channel 只允许从其接收数据,例如:

func receiver(ch <-chan int) {
    value := <-ch
    fmt.Println("Received data:", value)
}

这里的 ch 类型是 <-chan int,表示这是一个只允许接收 int 类型数据的单向 channel

在实际使用中,通常是在函数参数中使用单向 channel 来明确 channel 的使用方向,避免错误的操作。例如:

package main

import (
    "fmt"
)

func producer(ch chan<- int) {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch)
}

func consumer(ch <-chan int) {
    for value := range ch {
        fmt.Println("Received:", value)
    }
}

func main() {
    ch := make(chan int)
    go producer(ch)
    consumer(ch)
}

在这个示例中,producer 函数接受一个发送方单向 channelconsumer 函数接受一个接收方单向 channel,这样可以清晰地界定数据的流向,提高代码的可读性和安全性。

9. Select 语句

9.1 Select 基本用法

select 语句用于在多个 channel 操作(发送或接收)之间进行选择。它会阻塞直到其中一个 channel 操作可以继续执行。如果有多个 channel 操作可以执行,select 会随机选择其中一个执行。

下面是一个简单的 select 示例:

package main

import (
    "fmt"
)

func main() {
    ch1 := make(chan int)
    ch2 := make(chan int)

    go func() {
        ch1 <- 10
    }()

    go func() {
        ch2 <- 20
    }()

    select {
    case value := <-ch1:
        fmt.Println("Received from ch1:", value)
    case value := <-ch2:
        fmt.Println("Received from ch2:", value)
    }
}

在这个例子中,我们创建了两个 channel ch1ch2,并通过两个 goroutine 分别向它们发送数据。select 语句会阻塞,直到其中一个 channel 有数据可读。当有数据可读时,select 会选择对应的 case 分支执行。

9.2 Select 与 Default 分支

select 语句可以包含一个 default 分支,用于在没有任何 channel 操作可以立即执行时执行。这使得 select 不会阻塞,而是直接执行 default 分支的代码。

例如:

package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan int)

    select {
    case value := <-ch:
        fmt.Println("Received:", value)
    default:
        fmt.Println("No data available yet")
    }

    time.Sleep(1 * time.Second)

    ch <- 42

    select {
    case value := <-ch:
        fmt.Println("Received:", value)
    default:
        fmt.Println("No data available yet")
    }
}

在第一个 select 中,由于 channel 中没有数据,default 分支会被执行。在等待一秒后,向 channel 发送数据,第二个 selectcase 分支会被执行,因为此时 channel 中有数据可读。

9.3 Select 用于超时控制

通过结合 time.Afterselect,我们可以实现超时控制。time.After 函数会返回一个 channel,在指定的时间后,该 channel 会接收到一个当前时间的值。

例如:

package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan int)

    go func() {
        time.Sleep(2 * time.Second)
        ch <- 42
    }()

    select {
    case value := <-ch:
        fmt.Println("Received:", value)
    case <-time.After(1 * time.Second):
        fmt.Println("Timeout")
    }
}

在这个例子中,我们启动一个 goroutine,它会在两秒后向 channel 发送数据。而 select 中的 time.After(1 * time.Second) 表示如果在一秒内没有从 channel 接收到数据,就会执行对应的 case 分支,输出 Timeout

10. 总结与实践建议

在 Go 语言中,goroutine 是实现并发编程的核心组件,它以轻量级的方式为我们提供了高效的并发执行能力。通过结合 channelsync 包中的同步工具,我们可以安全、简洁地编写并发程序。

在实践中,以下是一些建议:

  1. 尽量使用 channel 进行通信而不是共享内存:通过 channelgoroutine 之间传递数据,可以避免共享内存带来的竞态条件等复杂问题,使代码更易于理解和维护。
  2. 合理使用 sync.WaitGroup 等待 goroutine 完成:避免过度依赖 time.Sleep,使用 sync.WaitGroup 可以更精确地控制等待 goroutine 完成的时机。
  3. 注意 channel 的缓冲区大小:根据实际需求选择合适的 channel 类型(无缓冲或有缓冲),合理设置缓冲区大小可以提高程序性能。
  4. 谨慎使用共享内存和同步工具:如果必须使用共享内存,要正确使用互斥锁、读写锁等同步工具,防止竞态条件的发生。
  5. 使用 select 进行多路复用select 语句在处理多个 channel 操作时非常有用,可以实现高效的多路复用和超时控制。

通过深入理解和熟练运用 goroutine 的基本使用方法,你将能够编写出高性能、高并发的 Go 语言程序,充分发挥多核处理器的优势。在实际项目中,不断实践和优化,逐渐掌握并发编程的技巧和最佳实践,提升自己的编程能力。同时,要注意并发编程可能带来的复杂性,通过良好的代码结构和注释,提高代码的可读性和可维护性。