头图

并行、并发

并行和并发的区别:

  • 并行:两个或多个程序在同一时刻执行。
  • 并发:两个或多个程序在同一个时间段内执行。

并行执行的程序,在同一时刻,是真真正正的有多个程序在 CPU 上执行,这也就需要 CPU 提供多核计算的能力。而并发执行的程序,只是在宏观的角度观察到有多个程序在 CPU 上执行,微观上是它们在 CPU 上被快速轮换执行。

对于进程、线程、协程,并发、并行,在我之前的文章中讲并发掌握时也有介绍过,感兴趣的可以过去瞅一眼。传送门在此Go通关09:并发掌握,goroutine和channel声明与使用!

Go 的 MPG 线程模型

之所以 Go 被认为是高性能开发语言,在于它在原生态支持协程并发。协程是一种用户线程,是轻量级线程。协程的调度完全取决于用户空间的代码控制。

协程拥有自己的寄存器上下文和栈,并存储在用户空间,协程在切换时无需切换到内核态来访问内核空间,切换速度极快。开发人员需要在用户空间处理协程切换时候的上下文信息的保存和恢复、栈空间大小的管理等技术问题。

Go语言采用了一种特殊的两级线程模型,即 MPG 线程模型:

  • M,即 machine,相当于内核线程在 Go 进程中的映射,它与内核线程一一对应,代表真正执行计算的资源。M 的生命周期内,只会与一个内核线程相关联。
  • P,即 processor,代表 Go 代码片段执行所需的上下文环境。 M 和 P 的结合可以为 G 提供有效的运行环境。它们之间的结合关系不是固定的。P 的最大数量决定了 Go 程序的并发规模,由 runtime.GOMAXPROCS 变量来决定。
  • G,即 goroutine,是一种轻量级的用户线程,是对代码片段的封装,拥有执行时的栈、状态和代码片段等信息。

在实际执行过程中,多个可执行的 G 会顺序挂载在 P 的可执行 G 队列下面,等待调度和指向。当 G 中存在一些 I/O 系统调用阻塞了 M 时,P 会断开 M 的联系,从调度器空闲 M 队列中获取一个 M 或创建一个新的 M 进行组合执行,从而保证 P 中可执行 G 队列中其他 G 得到执行,由于程序中并行执行的 M 数量没有变,所以程序的 CPU 有很高的利用率。

1. select 多路复用

select 可以实现多路复用,即同时监听多个 channel。

  • 发现哪个 channel 有数据产生,就执行相应的 case 分支
  • 如果同时有多个 case 分支可以执行,则会随机选择一个
  • 如果一个 case 分支都不可执行,则 select 会一直等待

示例:

package main

import (
    "fmt"
)

func main() {
    ch := make(chan int, 1)
    for i := 0; i < 10; i++ {
        select {
        case x := <-ch:
            fmt.Println(x)
        case ch <- i:
            fmt.Println("--", i)
        }
    }
}

运行结果:

-- 0
0
-- 2
2
-- 4
4
-- 6
6
-- 8
8

2. Context 上下文

当需要在多个 goroutine 中传递上下文信息时,可以使用 Context 实现。Context 除了用来传递上下文信息,还可以用于传递终结执行子任务的相关信号,中止多个执行子任务的 goroutine。Context 中提供以下接口:

type Context interface {
    //返回 Context 被取消的时间,即完成工作的截止日期
    Deadline() (deadline time.Time, ok bool)
    
    //1.返回一个 channel,这个 channel 会在当前工作完成或者上下文被取消之后关闭
    //2.多次调用 Done 方法会返回同一个 channel;
    Done() <-chan struct{}
    
    //1.返回 Context 结束的原因,只会在 Done 返回的 channel 被关闭时才会返回非空的值
    //2.如果 Context 被取消,会返回 Canceled 错误
    //3.如果 Context 超时,会返回 DeadlineExceeded 错误
    Err() error
    
    //用于从 Context 中获取传递的键值信息
    Value(key interface{}) interface{}

}

在实际工作中,一个 Web 请求可能需要启动多个 goroutine 协同工作, goroutine 之间可能需要共享请求的信息,且当请求被取消或者执行超时时,该请求启动的所有 goroutine 都需要结束,释放资源,这时就需要使用 Context 来解决这些问题。

示例:

package main

import (
    "context"
    "fmt"
    "time"
)

const DB_ADDRESS  = "db_address"
const CALCULATE_VALUE  = "calculate_value"

func readDB(ctx context.Context, cost time.Duration)  {
    fmt.Println("db address is", ctx.Value(DB_ADDRESS))
    select {
    case <- time.After(cost): //  模拟数据库读取
        fmt.Println("read data from db")
    case <-ctx.Done():
        fmt.Println(ctx.Err()) // 任务取消的原因

        // 一些清理工作

    }

}

func calculate(ctx context.Context, cost time.Duration)  {
    fmt.Println("calculate value is", ctx.Value(CALCULATE_VALUE))
    select {
    case <- time.After(cost): //  模拟数据计算
        fmt.Println("calculate finish")
    case <-ctx.Done():
        fmt.Println(ctx.Err()) // 任务取消的原因
    
        // 一些清理工作

    }

}

func main()  {
    ctx := context.Background(); // 创建一个空的上下文
    // 添加上下文信息
    ctx = context.WithValue(ctx, DB_ADDRESS, "localhost:10086")
    ctx = context.WithValue(ctx, CALCULATE_VALUE, 1234)

    // 设定子 Context 2s 后执行超时返回
    ctx, cancel := context.WithTimeout(ctx, time.Second * 2)

    defer cancel()

    // 设定执行时间为 4 s
    go readDB(ctx, time.Second * 4)
    go calculate(ctx, time.Second * 4)

    // 充分执行
    time.Sleep(time.Second * 5)
}

运行结果:

calculate value is 1234
db address is localhost:10086
context deadline exceeded
context deadline exceeded

在例子中,我们模拟了一个请求中同时进行数据库访问和逻辑计算的操作,在请求执行超时时,关闭尚未执行结束 goroutine。

  1. 首先通过 context.WithValue 方法为 context 添加上下文信息,Context 在多个 goroutine 中是并发安全的。
  2. 接着使用 context.WithTimeout 方法设定了 Context 的超时时间为 2s,并传递给 readDB 和 calculate 两个 goroutine 执行子任务。
  3. 在 readDB 和 calculate 方法中,使用 select 语句对 Context 的 Done 通道进行监控。由于我们设定了子 Context 将在 2s 之后超时,所以它将在 2s 之后关闭 Done 通道;然而预设的子任务执行时间为 4s,对应的 case 语句尚未返回,执行被取消,进入到清理工作的 case 语句中,结束掉当前的 goroutine 所执行的任务。

微客鸟窝
37 声望3 粉丝