Go语言goroutine(轻量级线程)、锁及线程同步| 青训营

316 阅读6分钟

goroutine介绍

在 Go 语言中,通常使用 Goroutine(协程)来实现并发。Goroutine 是 Go 语言提供的一种轻量级线程,它可以让你在程序中同时执行多个函数或方法,从而实现并发编程。与传统的操作系统线程相比,Goroutine 更加高效,它可以在单个线程上运行成千上万个 Goroutine,而不会导致线程创建的资源开销过大。以下是 Go 协程的原理和优势的详细介绍:

原理:

  • 轻量级线程:Go 协程是一种轻量级的执行单元,它不需要像传统的操作系统线程一样占用大量内存资源,因此可以在一个进程中同时运行成千上万个 Goroutine,而不会导致系统资源过度消耗。
  • 用户态线程:Go 协程是在用户态(User Space)实现的,并不依赖于操作系统内核的线程调度。这意味着创建和销毁 Goroutine 的开销非常小,因此 Goroutine 的启动速度非常快。
  • 自动调度:Go 语言的运行时系统(runtime)负责在多个 Goroutine 之间进行自动调度,将它们映射到实际的操作系统线程上执行。这种自动调度和 Goroutine 的轻量级特性一起,使得在 Go 中进行并发编程变得非常简单。

优势:

  • 简单易用:使用 Goroutine 只需在函数或方法调用前面加上关键字 go,就可以实现并发执行,而不需要手动创建、管理线程等复杂的操作。这简化了并发编程的实现和维护,使得开发人员可以更专注于业务逻辑。
  • 并发性能:Go 协程的轻量级和自动调度特性,使得并发程序可以高效地利用系统资源,避免了传统多线程编程中线程切换的开销。
  • 通信同步:Go 语言提供了通道(Channel)作为 Goroutine 之间通信的主要方式。通道可以实现 Goroutine 之间的同步和数据传递,避免了传统共享内存并发编程中可能出现的竞态条件和死锁问题,使得并发编程更加安全和稳定。
  • 抽象层级:在 Go 中,Goroutine 的抽象层级非常高,它隐藏了底层操作系统线程的细节,使得并发编程更加简单和可控。开发人员可以专注于业务逻辑的设计,而不必过于关注底层的线程管理和同步问题。
  • 可扩展性:由于 Goroutine 只是一个轻量级的执行单元,因此在大规模并发应用中,可以轻松地创建成千上万个 Goroutine 来处理任务,而不会造成资源的浪费。

Goroutine案例

使用 Goroutine 非常简单,只需在函数或方法调用前面加上关键字 go 即可。当程序遇到 go 关键字时,会立即启动一个 Goroutine 来执行对应的函数或方法,而不会阻塞当前的执行流程。

以下是使用 Goroutine 的基本示例:

package main

import (
    "fmt"
    "time"
)

func printNumbers() {
    for i := 0; i < 5; i++ {
        fmt.Println(i)
        time.Sleep(time.Millisecond * 500) // 模拟一些耗时操作
    }
}

func printMessage(message string) {
    for i := 0; i < 3; i++ {
        fmt.Println(message)
        time.Sleep(time.Millisecond * 700)  // 模拟一些耗时操作
    }
}

func main() {
    fmt.Println("Main Goroutine Start")

    go printNumbers() // 启动第一个Goroutine

    message := "Hello, from Goroutine!"
    go printMessage(message) // 启动第二个Goroutine,并传递 message 参数

    time.Sleep(time.Second * 5) // 等待一段时间,以便 Goroutine 有足够时间执行
    fmt.Println("Main Goroutine End")
}

输出如下:

Main Goroutine Start
Hello, from Goroutine!
0
1
Hello, from Goroutine!
2
Hello, from Goroutine!
3
4
Main Goroutine End
  • 注意,为了让主 Goroutine(main 函数)有足够的时间来观察其他 Goroutine 的输出,我在主 Goroutine 中添加了一段 time.Sleep 来等待一段时间。
  • 值得注意的是,Goroutine 是并发执行的,它们的执行顺序是不确定的,因此可能会在每次运行程序时看到不同的输出顺序。

线程同步和锁

在 Go 语言中,锁(Lock)和线程同步(WaitGroup)是用于处理并发编程中的资源共享和协调问题的两种重要机制。

线程同步(WaitGroup):

线程同步是一种用于等待多个 Goroutine 完成工作的机制。在 Go 语言中,可以使用 sync.WaitGroup 来实现线程同步。WaitGroup 提供了三个方法:Add()Done()Wait()Add() 用于增加等待的 Goroutine 数量,Done() 用于表示一个 Goroutine 已经完成,Wait() 用于阻塞当前 Goroutine,直到所有等待的 Goroutine 都完成。

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

package main

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

func process(id int, wg *sync.WaitGroup) {
    defer wg.Done()
    fmt.Printf("Goroutine %d is processing...\n", id)
    time.Sleep(time.Second)  // 模拟一些耗时操作
    fmt.Printf("Goroutine %d is done.\n", id)
}

func main() {
    var wg sync.WaitGroup

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

    wg.Wait()
    fmt.Println("All Goroutines are done.")
}

在上面的例子中,我们使用 sync.WaitGroup 来等待三个 Goroutine 完成工作。每个 Goroutine 都会睡眠一秒钟,然后打印出一条消息表示自己已经完成。wg.Add(1) 增加了等待的 Goroutine 数量,wg.Done() 表示一个 Goroutine 已经完成,wg.Wait() 用于阻塞 main 函数,直到所有等待的 Goroutine 都完成。

image.png

通过使用 sync.WaitGroup,我们可以轻松地等待多个 Goroutine 完成,并确保在它们都完成之前不继续执行后续代码。这在一些并发任务中非常有用,特别是当我们需要等待一组 Goroutine 完成后再进行后续处理时。

锁(Lock):

锁是一种同步机制,用于保护共享资源在同一时刻只能被一个 Goroutine 访问,从而避免竞态条件(Race Condition)的发生。在 Go 语言中,最常用的锁是互斥锁(Mutex)。互斥锁提供了两个重要的方法:Lock()Unlock()Lock() 方法用于获取锁,如果锁已经被其他 Goroutine 获取,则当前 Goroutine将阻塞,直到锁被释放;Unlock() 方法用于释放锁。

不加锁出错的例子:

package main

import (
    "fmt"
    "sync"
)

var counter int

func incrementWithoutLock() {
    counter++
}

func main() {
    var wg sync.WaitGroup

    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func() {
                defer wg.Done()
                incrementWithoutLock()
        }()
    }

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

定义了一个全局变量 counter 作为共享资源,并创建了一个 sync.WaitGroup 用于等待所有 Goroutine 完成。然后,我们启动了100个 Goroutine 来调用 incrementWithoutLock 函数对 counter 进行递增操作。

由于 counter++ 并不是原子操作,当多个 Goroutine 同时对 counter 进行写操作时,就会导致竞态条件。运行多次以上代码,你可能会得到不同的结果,如:

image.png

加锁正确的例子:

为了避免并发访问问题,我们可以使用互斥锁(Mutex)来保护共享资源,从而确保同一时刻只有一个 Goroutine 能够对其进行写操作。以下是加锁正确的例子:

package main

import (
    "fmt"
    "sync"
)

var counter int
var mutex sync.Mutex // 互斥锁

func incrementWithLock() {
    mutex.Lock() // 获取锁
    counter++
    mutex.Unlock() // 释放锁
}

func main() {
    var wg sync.WaitGroup

    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func() {
                defer wg.Done()
                incrementWithLock()
        }()
    }

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

在上面的例子中,我们使用互斥锁 mutex 来保护对 counter 变量的写操作。通过在 incrementWithLock 函数中调用 mutex.Lock()mutex.Unlock() 来确保在同一时刻只有一个 Goroutine 能够递增 counter,避免了并发访问问题。