Go 语言入门进阶 | 青训营X豆包MarsCode 技术训练营

92 阅读9分钟

进程、线程、进程

Go 语言以其简洁的语法和强大的并发支持而闻名。Go 的并发模型基于 goroutines 和 channels,这使得编写高并发程序变得相对容易。但在此学习Go的并发之前先了解什么是进程、协程和线程

1.进程(Process)

1.1 定义

进程是一个执行中的程序,每个进程都有自己独立的地址空间,它包含了独立的代码和数据空间。这意味着一个进程的数据对其他进程来说是不可见的。

1.2 特点

  • 独立性:每个进程都有自己的内存空间,互不影响。
  • 资源分配:进程是资源分配的基本单位,包括 CPU 时间、内存、文件等。
  • 通信:进程间通信(IPC)需要通过特定的方式实现,如管道、套接字或共享内存等。
  • 开销:创建和销毁进程的开销较大,上下文切换也相对较慢。

1.3 适用场景

  • 独立任务:适合需要较高隔离度的任务,例如不同的用户进程或服务。
  • 资源管理:需要独立资源管理的场景。

2. 线程 (Thread)

2.1 定义

线程是进程中的一个实体,是CPU调度和分派的基本单位。它是比进程更小的独立运行单位。它属于内核态,栈是MB级别。一个进程中可以有多个线程,这些线程共享进程的资源,比如内存地址空间、文件描述符等。

2.2 特点

  • 轻量级:相对于进程,创建和销毁线程的成本较低。
  • 共享资源:同一进程下的所有线程共享该进程的资源。
  • 并发性:多线程可以在多核处理器上实现真正的并发执行,提高程序效率。
  • 开销:线程的上下文切换比进程快,但仍然有一定的开销。

2.3 适用场景

  • 并发任务:适合需要并行处理多个任务的场景,例如 Web 服务器、数据库服务器等。
  • 资源共享:需要共享资源的场景。

3. 协程 (Coroutine)

3.1 定义

协程是一种通用的控制流结构,它允许多个执行序列(即多个子程序或函数)在同一个线程内协作式地运行。它属于用户态,栈是KB级别。协程可以在执行过程中暂停并恢复,从而实现非抢占式的多任务处理。

3.2 特点

  • 协作式多任务处理:协程之间的切换是显式控制的,通常由程序员决定何时暂停和恢复。
  • 轻量级:创建和销毁协程的成本远低于操作系统线程。
  • 状态保存:协程在暂停时会保存其状态,包括局部变量和执行位置,这样在恢复时可以从上次暂停的地方继续执行。
  • 资源共享:同一进程内的所有协程共享内存和其他资源,这使得通信和同步更加方便。
  • 开销:协程的上下文切换非常快,几乎可以忽略不计。

3.3 适用场景

  • 异步 I/O:适合处理网络请求、文件读写等 I/O 操作,避免阻塞主线程。
  • 并发任务:适合处理多个独立的任务,而无需为每个任务创建一个新的线程。
  • 生成器:可以用来实现生成器,生成器是一种可以逐步生成数据的函数,每次调用时返回一个值并暂停,直到下一次调用。

并发、并行

了解完进程、线程、协程等,能更好地了解并发与并行

4. 并发(Concurrency)

4.1 定义

并发是指多个任务在同一时间段内交错执行的能力。这些任务可能在不同的时间点上暂停和恢复,从而给人一种“同时”执行的错觉。实际上,这些任务可能并没有真正同时执行,而是通过快速的上下文切换在同一个 CPU 上交替运行。

4.2 特点

  • 任务交错执行:多个任务在一段时间内交替执行,而不是同时执行。
  • 资源共享:任务之间共享资源,如内存、文件等。
  • 上下文切换:任务之间的切换需要保存和恢复上下文,这可能会带来一定的开销。
  • 协作式或多任务调度:可以通过操作系统或语言运行时来管理任务的调度。

5. 并行(Parallelism)

5.1 定义

并行是指多个任务在同一时刻真正同时执行的能力。这些任务通常在多个 CPU 核心或多个计算机上并行运行,从而加速任务的完成。也能说是并发的一种手段。

5.2 特点

  • 任务同时执行:多个任务在不同的 CPU 核心上同时执行。
  • 硬件支持:需要多核处理器或多个计算机的支持。
  • 减少执行时间:通过并行执行,可以显著减少任务的总执行时间。
  • 负载均衡:需要合理分配任务到不同的核心或计算机,以避免负载不均。

而go实现了并发的极高调度模型,通过调度充分发挥多核优势,实现高效运行

go的进阶:goroutines 和 channels。

6. Goroutines

6.1 定义

Goroutines 是 Go 语言中的轻量级线程。它们是由 Go 运行时管理的,可以在同一个操作系统线程中高效地运行多个 goroutines。启动一个 goroutine 非常简单,只需在函数调用前加上 go 关键字即可。

6.2 特点

  • 轻量级:创建和销毁 goroutine 的成本远低于操作系统线程。
  • 调度:由 Go 运行时自动管理调度,可以根据可用的 CPU 核心数动态调整。
  • 并发:多个 goroutine 可以在不同的 CPU 核心上并行执行,充分利用多核处理器的优势。
func Hello(i int) {
	println("hello:" + fmt.Sprintln(i))
}

func main() {
	for i := 0; i < 5; i++ {
		go Hello(i)
	}
	time.Sleep(2 * time.Second)
}

image.png

7. Goroutines

7.1 定义

Channels 是 Go 语言中用于 goroutines 之间通信和同步的工具。通过 channels,goroutines 可以安全地传递数据,并且可以阻塞发送或接收操作,从而实现同步。

7.2 特点

  • 类型安全:每个 channel 都有一个明确的数据类型,只能传递该类型的值。
  • 同步:发送和接收操作可以阻塞,直到另一端准备好。
  • 方向:channel 可以是双向的(默认),也可以是单向的(通过类型声明指定)

7.3 创建(有缓存的可以提供一定的缓存区以避免阻塞)

ch := make(chan int) // 创建一个 int 类型的 channel
ch := make(chan int, 3) // 创建一个缓冲大小为 3 的 channel

7.4 发送和接收

  • 发送ch <- value 将值发送到 channel。
  • 接收value := <-ch 从 channel 接收值。
func sum(s []int, c chan int) {
    sum := 0
    for _, v := range s {
        sum += v
    }
    c <- sum // 将结果发送到 channel
}

func main() {
    s := []int{7, 2, 8, -9, 4, 0}

    c := make(chan int)
    go sum(s[:len(s)/2], c)
    go sum(s[len(s)/2:], c)
    x, y := <-c, <-c // 从 channel 接收结果

    fmt.Println(x, y, x+y)
}

7.5 单向 Channels

有时只想让某个 goroutine 只能发送或接收数据,可以使用单向 channel。

func serve(c chan<- int) {
    c <- 42 // 只能发送
}

func main() {
    ch := make(chan int)
    serve(ch)
    fmt.Println(<-ch)
}

8. 并发安全Lock

在并发编程中,多个线程或协程同时访问和修改共享资源时,可能会导致数据不一致、竞态条件(race condition)等问题。为了确保数据的一致性和完整性,需要使用并发安全机制,其中最常用的一种机制就是锁(Lock)。锁可以确保在同一时间只有一个线程或协程能够访问共享资源,从而避免竞态条件。

8.1 锁的作用

  1. 互斥访问
    • 锁确保在同一时间只有一个线程或协程能够访问共享资源。这防止了多个线程同时修改同一数据,从而避免数据不一致的问题。
  2. 防止竞态条件
    • 竞态条件发生在多个线程或协程以不可预测的顺序访问和修改共享资源时,导致不正确的结果。锁可以确保对共享资源的访问是有序的,从而防止竞态条件。
  3. 保证原子性
    • 锁可以确保一系列操作作为一个原子操作执行,即要么全部执行,要么全部不执行。这在处理复杂的业务逻辑时非常重要。

8.2 常见的锁类型

  1. 互斥锁(Mutex)
    • 互斥锁是最基本的锁类型,确保在同一时间只有一个线程或协程能够持有锁。
    • 在 Go 语言中,可以使用 sync.Mutex 来实现互斥锁。
  2. 读写锁(RWMutex)
    • 读写锁允许多个读操作同时进行,但写操作必须独占。这在读多写少的场景中非常有用,可以提高并发性能。
    • 在 Go 语言中,可以使用 sync.RWMutex 来实现读写锁。
  3. 自旋锁(Spin Lock)
    • 自旋锁在尝试获取锁时会不断循环检查锁的状态,直到锁可用为止。适用于锁竞争不激烈且持有时间短的场景。
    • 自旋锁在某些低级别的并发控制中使用较多,但不常见于高级语言中。

8.3 sync.WaitGroup

sync.WaitGroup 是 Go 语言标准库中的一个同步原语,用于等待一组 Goroutine 完成。它提供了一种简单而有效的方法来同步主 Goroutine 和其他子 Goroutine 的执行。sync.WaitGroup 通过计数器来跟踪需要等待的 Goroutine 数量,当所有被跟踪的 Goroutine 都完成时,计数器归零,主 Goroutine 可以继续执行。

主要方法

sync.WaitGroup 提供了以下几个主要方法:

  1. Add(delta int)
    • 增加 WaitGroup 的内部计数器。通常在启动一个新的 Goroutine 之前调用 Add(1) 来增加计数器。
    • 注意:delta 必须是一个正整数。如果传入负数,会导致 panic。
  2. Done()
    • 减少 WaitGroup 的内部计数器。通常在 Goroutine 完成其任务后调用 Done()
    • 这个方法等价于 Add(-1)
  3. Wait()
    • 阻塞当前 Goroutine,直到 WaitGroup 的内部计数器归零。这意味着所有被跟踪的 Goroutine 都已完成。
以下是 读写锁 和 WaitGroup 的实例
package main

import (
    "fmt"
    "sync"
)

var (
    data map[string]int
    rwMu sync.RWMutex
)

func read(key string, wg *sync.WaitGroup) {
    defer wg.Done()
    rwMu.RLock()
    value, exists := data[key]
    if exists {
        fmt.Printf("Read %s: %d\n", key, value)
    } else {
        fmt.Printf("%s does not exist\n", key)
    }
    rwMu.RUnlock()
}

func write(key string, value int, wg *sync.WaitGroup) {
    defer wg.Done()
    rwMu.Lock()
    data[key] = value
    fmt.Printf("Write %s: %d\n", key, value)
    rwMu.Unlock()
}

func main() {
    data = make(map[string]int)
    var wg sync.WaitGroup

    // 启动多个读操作
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go read(fmt.Sprintf("key%d", i), &wg)
    }

    // 启动多个写操作
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go write(fmt.Sprintf("key%d", i), i*10, &wg)
    }

    wg.Wait()
}