Go语言 —— Go routine学习 | 豆包MarsCode AI刷题

84 阅读7分钟

2. 协程:Goroutine

2.1. 基本概念

在 Go 语言中,协程(Goroutine)是一种轻量级的线程实现,可以帮助程序并发执行。它的实现原理和一般的操作系统线程不同,更加节省资源。

  • 轻量级:Goroutine 比线程更轻量,启动一个 Goroutine 的开销远小于线程的开销。每个 Goroutine 在创建时的栈内存非常小(通常是几 KB),并且可以动态扩展。这与操作系统的线程不同,操作系统线程的栈是固定大小的。
  • 并发:Goroutine 允许 Go 程序在同一时间运行多个任务,提供了自然的并发编程支持。通过 go 关键字,可以轻松地将函数作为协程启动。
  • 调度器:Go 语言运行时包含了自己的调度器,将 Goroutine 映射到系统线程上执行。调度器会在 Goroutine 之间公平分配 CPU 时间。
  • 通道(Channel) :Goroutine 常常与通道一起使用,用来在不同的 Goroutine 之间传递数据并同步。这种模式可以帮助开发者避免复杂的锁定机制,编写更加简单和安全的并发代码。
  • 阻塞和非阻塞:当一个 Goroutine 需要等待某个资源或操作时,它可以自动让出 CPU,使其他 Goroutine 可以继续执行。这种机制使得 Goroutine 可以实现高效的资源利用。
  • 错误处理:虽然 Goroutine 简化了并发,但在编写 Goroutine 时仍需注意错误处理和资源清理,避免内存泄漏或意外的程序崩溃。
package main

import (
    "fmt"
    "time"
)

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

func sayGoodbye() {
    for i := 0; i < 3; i++ {
        fmt.Println("Goodbye")
        time.Sleep(500 * time.Millisecond)
    }
}

func main() {
    // 启动两个 Goroutine
    go sayHello()
    go sayGoodbye()

    // 主 Goroutine 也可以进行其他操作
    time.Sleep(2 * time.Second) // 等待 Goroutine 执行完成
    fmt.Println("Main function completed")
}


//  输出的顺序可能会因为 Goroutine 的调度而不同,但通常会看到类似以下的输出:
// Hello
// Goodbye
// Hello
// Goodbye
// Hello
// Goodbye
// Main function completed

2.2. 协程通信

2.2.1. 通过通信共享内存,而不是通过共享内存实现通信

传统共享内存的缺点

在传统的并发编程中,多线程通常通过共享内存(如共享变量、全局变量)来交换信息和状态。然而这种方式存在以下问题:

  • 数据竞争(Race Condition) :多个线程同时读写共享内存可能导致数据竞争,需要复杂的锁定机制(如互斥锁)来保证一致性。
  • 死锁和活锁:加锁和解锁机制很容易导致死锁和活锁,编写和调试变得非常困难。
  • 可读性差:共享内存的代码难以理解,因为要频繁地关注资源加锁和解锁操作。

Go 的通信机制:通道(Channel)

Go 通过通道实现了 协程安全的数据交换,也就是鼓励开发者在 Goroutine 之间直接传递数据,而不是依赖共享内存。通道是一种先进的 消息传递机制,具有以下特点:

  • 数据独立性:通过通道,数据直接从一个 Goroutine 传递给另一个 Goroutine,不需要共享内存,也不需要担心并发读写的冲突。
  • 同步:无缓冲通道会自动同步发送和接收操作,保证数据被读取后发送方才继续。这种同步机制在设计上更直观、简单。
  • 死锁避免:通道的通信方式避免了传统锁定带来的死锁风险,因为它天然具备阻塞和解阻塞的特性

2.2.2. 通道:Channel

在 Go 语言中,协程(Goroutine)之间的通信主要通过 通道(Channel) 来完成。通道是 Go 中的一种特殊类型,专门用于在 Goroutine 之间传递数据和同步协程操作。通道又分为无缓冲通道有缓冲通道

通道可以通过 make 函数创建,并且需要指定通道中传递的数据类型。例如:

ch1 := make(chan int) // 创建一个传递 int 类型数据的通道,无缓冲
ch2 := make(chan int, 2) // 创建一个缓冲容量为2的通道
  • 无缓冲通道: 同步通道,只能在发送和接收同时发生时传递数据。这种通道用于确保两个 Goroutine 同步,发送方会等待直到数据被接收。
  • 有缓冲通道: 可以存储多个数据,不要求发送和接收同时进行。 是一种生产者-消费者模型

2.2.3. 通道的使用

发送数据:使用 ch <- value 将数据发送到通道。

接收数据:使用 value := <-ch 从通道接收数据。

// Gorountine 通道的使用

package main

func CalSquare() {
	// 创建通道
	src := make(chan int)     // 无缓冲通道
	dest := make(chan int, 3) // 有缓冲通道,考虑消费者的处理能力会慢一些,所以设置为3,避免生产者一直阻塞

	// 开启goroutine将0~9发送到src中
	go func() {
		defer close(src) // 协程结束后关闭src通道
		for i := 0; i < 10; i++ {
			src <- i // 写入数据到src通道
		}
	}()

	// 开启goroutine计算平方数
	go func() {
		defer close(dest)
		// 从src中取出半径计算平方数
		for i := range src { // 读取数据从src通道,一次一个
			dest <- i * i
		}
	}()

	// 主协程从dest中取出平方数并打印
	for i := range dest { // 读取数据从dest通道,一次一个
		println(i)
	}
}

func main() {
	CalSquare()
}

2.2.4. 并发安全 —— 加锁

虽然通道非常适合在 Goroutine 之间传递数据,并可以避免大部分加锁,但在以下场景下可能还是需要锁:

  • 复杂状态管理:如果多个 Goroutine 需要频繁访问和修改同一个复杂的数据结构(例如,树、图等),锁可能更有效。
  • 非协作性操作:当无法通过通道直接传递数据,或数据来源不便于传入通道时,加锁可能更合适。
  • 性能:通道在并发高、操作频繁时可能会带来一些性能开销,在这种情况下,锁可能表现更优。
// 共享内存要注意并发安全问题
// 通过加锁的方式来保证并发安全示例

package main

import (
	"sync"
	"time"
)

// 定义锁和全局变量
var x int
var lock sync.Mutex

// 加锁的自增操作
func addWithLock() {
	for i := 0; i < 2000; i++ {
		// 加锁
		lock.Lock()
		// 自增操作
		x += 1
		// 解锁
		lock.Unlock()
	}
}

// 不加锁的自增操作
func addWithoutLock() {
	for i := 0; i < 2000; i++ {
		// 自增操作
		x += 1
	}
}

// 加法操作
func Add() {
	x = 0 // 初始化全局变量

	// 开启 5 个 goroutine 执行加锁的自增操作
	for i := 0; i < 5; i++ {
		go addWithLock()
	}
	time.Sleep(time.Second) // 主程序不会等待 Goroutine 结束,通过sleep等待保证 Goroutine 结束

	// 输出结果
	println("加锁的自增操作结果:", x)

	// 开启 5 个 goroutine 执行不加锁的自增操作
	x = 0
	for i := 0; i < 5; i++ {
		go addWithoutLock()
	}
	time.Sleep(time.Second) // 等待所有 goroutine 执行完毕

	// 输出结果,结果可能不一致
	println("不加锁的自增操作结果:", x)
}

func main() {
	Add()
}

2.2.5. 协程阻塞

time.Sleep 是一种不优雅的等待方法,特别是在并发编程中,因为我们没办法准确知道子协程的执行时间。 使用 sync.WaitGroup 可以更优雅地等待多个 Goroutine 完成而不依赖于硬编码的延迟时间。WaitGroup 提供了一种可靠的方式来阻塞主程序,直到所有 Goroutine 完成,避免了不确定的等待时间。 sync.WaitGroup 提供了三个主要的方法:

  • Add(n int) :设置等待的 Goroutine 数量,通常在启动 Goroutine 之前调用。
  • Done() :当一个 Goroutine 完成时调用,用于减少 WaitGroup 的计数。
  • Wait() :阻塞调用它的 Goroutine(通常是主 Goroutine),直到 WaitGroup 计数器变为 0。
// 共享内存要注意并发安全问题
// 通过加锁的方式来保证并发安全示例

package main

import (
	"sync"
)

// 定义锁和全局变量
var x int
var lock sync.Mutex
var wg sync.WaitGroup // 等待组,实现阻塞,等待所有协程执行完毕后再继续执行主程序

// 加锁的自增操作
func addWithLock() {
	defer wg.Done() // 协程执行完毕后调用,等待组减一

	for i := 0; i < 2000; i++ {
		// 加锁
		lock.Lock()
		// 自增操作
		x += 1
		// 解锁
		lock.Unlock()
	}
}

// 不加锁的自增操作
func addWithoutLock() {
	defer wg.Done() // 协程执行完毕后调用,等待组减一

	for i := 0; i < 2000; i++ {
		// 自增操作
		x += 1
	}
}

// 加法操作
func Add() {
	x = 0 // 初始化全局变量

	// 开启 5 个 goroutine 执行加锁的自增操作
	for i := 0; i < 5; i++ {
		wg.Add(1)
		go addWithLock()
	}
	// time.Sleep(time.Second) // 主程序不会等待 Goroutine 结束,通过sleep等待保证 Goroutine 结束
	wg.Wait() // 等待所有 goroutine 执行完毕

	// 输出结果
	println("加锁的自增操作结果:", x)

	// 开启 5 个 goroutine 执行不加锁的自增操作
	x = 0
	for i := 0; i < 5; i++ {
		wg.Add(1)
		go addWithoutLock()
	}
	// time.Sleep(time.Second) // 等待所有 goroutine 执行完毕
	wg.Wait()

	// 输出结果,结果可能不一致
	println("不加锁的自增操作结果:", x)
}

func main() {
	Add()
}