理解 goroutine 的并发

理解 goroutine 的并发

线程(Thread)对于语言的重要性不言而喻,每个语言都要“发明”自己最高效的线程库以说明自己的厉害,go 也不能免俗的。但 Go 语言却不认为自己是线程,叫 goroutine? Why? 本文通过案例加深对goroutine 的本质理解,在此基础上介绍 goroutine 编程涉及的内容以及四个编程准则。

如果你只是使用 goroutine,直接将 goroutine 理解为轻量级的线程,配合 chan 做通讯,足以应付多数工程项目。如果你要做线程池、或控制 goroutine ,例如 kill,stop,resume 等典型的线程操作,那你就的深入理解 goroutine 的并发,也需要你具备更多 OS 的知识。 本文会告诉你 kill 等想法是不实际的。

用一个不太确当的比喻,有一个病人得了阑尾炎,你作为医生可能会有如下选择:

  • 合格的医学博士(MD):手术前,给出“手术知情通知”
  • 会开刀,但不太了解各种后果:手术前,给出“病危通知”
  • 不会开刀:给出一个保守治疗方案,让病人自己调理

你可能期望自己选择第一项,但你必须经过严格、长时间的教育和训练。go 的理念是简单,选择的第三项。这合理吗? 答案是合理!

就线程来说,kill 一个线程的需求是似乎“合理”的。但是 java 的 API 在后面的版本也取消了它(但可用),因为强制线程退出会导致涉及资源的锁异常,导致后续程序难以正常获取资源,除非你能控制所有可能的后果。 go 设计者应该这样思考,既然一般程序员不能控制 kill 线程的后果,与其让它产生不确定后果,不如给个简单、高效、安全的 API。

一、预备知识

1.1 进程、线程、协程

进程(Process):在内存中的程序。有自己独立的独占的虚拟 CPU 、虚拟的 Memory、虚拟的 IO devices。

OS 直接支持并调度。进程之间只能通过系统提供的 IO 机制通讯。共享内存(变量)是不可能的!

  • (1) 每一进程占用独立的地址空间。
    • 此处的地址空间包括代码、数据及其他资源。
  • (2) 进程间的通信开销较大且受到许多限制。
    • 对象(或函数)接口、通信协议、…
  • (3) 进程间的切换开销也较大。
    • 又称Context Switch。
    • 上下文包括代码、数据、堆栈、处理器状态、资源、…

线程(Thread):轻量级进程。在现代操作系统中,是进程中程序执行流的最小单元。一个标准的线程由线程ID,当前指令指针(PC),寄存器集合和堆栈组成。

一个进程由若干线程组成,它们共享进程的计算、存储、IO资源。因此,程序员必须使用系统提供的同步、消息机制,处理资源的竞争和消息的通讯。

  • (1) 多个线程共享进程的地址空间(代码、数据、其他资源等)。
    • 线程也需要自己的资源,如程序计数器、寄存器组、调用栈等。
  • (2) 线程间的通信开销较少且比较简单。
    • 因为共享而减少了需要通信的内容。
    • 但也因为充分共享而无法对共享资源进行保护。
  • (3) 线程间的切换开销也较小。
    • 只需保存每一线程的程序计数器、寄存器组、堆栈等空间。
    • 不必切换或复制整个地址空间,从而成本大为降低(约1/10)

线程有分为两大类:

  • 操作系统管理的线程(Core Thread),通常根据 CPU 资源决定线程的数量,一般为 CPU 数量的两倍。
  • 语言提供的线程库管理的线程(User Thread),它执行时映射到系统线程,按任务类型(计算密集型,IO密集型)决定线程池的管理方式与数量。

协程(coroutine/fiber):轻量级线程。 是可以并发执行的函数,由编译或用户指定位置将控制权交给协程调度程序执行的方式。它是非抢占式的,可以避免反复系统调用,还有进程切换造成的开销,给你上几千个逻辑流,也称用户级别线程。

在单线程模式下,协程不需要自己上下文,可以大大减少资源竞争的情况。例如,读写map的项时,不需要锁整个表。在 JavaScript、python等单进程单线程、数据驱动(流式)的应用中,协程比线程更有效率;结合回调函数,更高效的处理 IO 请求。

https://www.zhihu.com/question/20511233

1.2 绿色线程(green thread)

知道这个名词的 java 程序员,一定不一般的。一句话就是可管理、可移植的用户线程。

二、“失控” 的 goroutine

启动一个 goroutine 仅需要 go func(...) 就 OK 了。Go 的表达这个函数是并发执行的,反正这之后它的命运只能由 runtime 库管理,程序员什么也不能做(失控了),甚至你无法直接知道它什么时候结束。它是 Thread 或 Coroutine ? 反正谷歌不解释,直说 goroutine。goroutine 中写个死循环如何?会向线性那样不影响其他 goroutine 吗?如何 kill 它? 这些都是常见问题。

程序猿的探求精神这时无比强大,以下代码主要来自 如何得到goroutine 的 id?

package main

import (
    "fmt"
    "runtime"
    "strconv"
    "strings"
    "sync"
)

func GoID() int {
    var buf [64]byte
    n := runtime.Stack(buf[:], false)
    idField := strings.Fields(strings.TrimPrefix(string(buf[:n]), "goroutine "))[0]
    id, err := strconv.Atoi(idField)
    if err != nil {
        panic(fmt.Sprintf("cannot get goroutine id: %v", err))
    }
    return id
}

func main() {
    fmt.Println("main", GoID())
    runtime.GOMAXPROCS(4)
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        i := i
        wg.Add(1)
        go func() {
            defer wg.Done()
            fmt.Println(i, GoID())
        }()
    }
    fmt.Println("++++++++++++++")
    for i:=1<<10; i>0; i-- {
      i++
      fmt.Print("/")
      i--
    } 
    fmt.Println("")
    fmt.Println("++++++++++++++")
    wg.Wait()
}

运行它,得到结论:

  • 第一,每个 goroutine 有自己独立的栈
  • 第二,main.main 是应用的第一个 goroutine
  • 第三,goroutine 是并发的

至于取 goroutine ID 就作为一个必须忘记的小技巧。的确我们没有太多理由需要取 goroutine ID。

三、goroutine 的执行 M-G/MPG 模型

goroutine 由 runtime 库管理。 现在任务是修改 runtime.GOMAXPROCS(4) 中的 4 改为 1。

结果变了吗? Why?

关于 goroutine 的原理和未来是没有官方的资料的,也是为未来的修改留下伏笔。但就目前实践情况,goroutine 是成功的。

最靠谱的资料 Analysis of the Go runtime scheduler

M: 操作系统的核心线程。runtime.GOMAXPROCS(4) 就是设置核心线程的数目
G:goroutine

当 M = 1 时,我们观察到 main.main goroutine 执行时,其他 goroutine 不会得到 M 的执行,直到 main.main 的 wg.wait 而交出执行权。
当 M > 1 时,更多的 goroutine 得到执行,产生并发效果

P: M 执行 G 时的上下文

因此:

  • (1)M 在 Go 中的含义是 machine 的意思。
    • 一个 goroutine 独占一个 M,除非 goroutine 主动交出控制权(非抢占式调度)
  • (2)线程?协程?
    • M = 1 , goroutine 就是协程
    • M > 1 , goroutine 是(轻量级)线程
  • (3) goroutine 是轻量级,主要表现在调度成本低
    • 不采用分时调度(抢占式),避免了频繁的线程上下文切换成本
    • goroutine 没有线程所谓的优先级、层次。是简单的平面结构
    • 通过对 M 的优化,如 goroutine 缓存,降低 goroutine 上下文切换成本
    • 采用了用户级别的同步与通讯机制,提升了执行效率
  • (4)用户必须保证:
    • 避免多个长耗时 goroutine 耗尽 M
    • 按线程要求同步与通讯,除非你设计 corountine 程序

参考链接:
* golang的goroutine是如何实现的?
* Golang runtime 浅析
* Is there a better way to stop an infinite goroutine in Go?

四、goroutine 编程相关的库

4.1 尽可能避免使用 runtime 库的函数和方法

尽管你可以使用以下函数,但不建议你使用。

  • GOMAXPROCS 设置 M 数量,除非你打算写 coroutine
  • Goexit 退出当前 goroutine, 用 close(c) 或 select 通知 goroutine 退出
  • GoroutineProfile 除非 debug
  • Gosched 放弃 yield 当前的 M , 用 time.sleep(1) 更稳健
  • NumGoroutine 当前 goroutine 数量
  • LockOSThread/UnLockOSThread 强制 goroutine 独占 M 直到 Unlock, 除非你打算做客户化函数的调度

4.2 最常用的类型与库

  • channel 类型与通讯:

    • 读、写 channel,阻塞 goroutine
    • select case,或 select case default 读多个 channel 阻塞 goroutine (见 gotour concurrency)
    • close channel
    • 消息(数据)驱动编程!
  • time 库

    • sleep() : Sleep pauses the current goroutine。这是最重要的语句,要及时让 runtime 调度 goroutine
    • Tick(d Duration): 定周期产生时间消息
    • After(d Duration): 定时产生消息
    • Timer: 类型
package main

import (
    "fmt"
    "time"
)

func main() {
    tick := time.Tick(100 * time.Millisecond)
    boom := time.After(500 * time.Millisecond)
    for {
        select {
        case t := <-tick:
            fmt.Println("tick.", t)
        case t := <-boom:
            fmt.Println("BOOM!")
            return
//      default:
//          fmt.Println("    .")
//          time.Sleep(50 * time.Millisecond)
        }
    }
}

加 default 和 不加 default 的区别?…

时间事件是 goroutine 的最重要事件源之一!

  • sync 库
    • Mutex :互斥量。
    • WaitGroup:信号量
    • Cond:带锁的通知量
    • Once 确保函数线程安全地仅只执行一次。(做单实例的神器)

4.3 集合类型都不是线程安全的

  • slice
  • map

五、 goroutine 编程基本准则

5.1 友好线程调度

友好线程调度的准则:不让一个函数长期占用计算资源

代码模板:

    for i:=1<<10; i>0; i-- {
      time.sleep(...) // 阻塞该函数
      doSomeThingInLittleTime(...)
    } 

为了将控制权交给调度,你需要以下方法:

  • sleep 一下
  • <- chan
  • Lock(locker)
  • IO reader

5.2 关注资源竞争

在使用 xorm 时,以下两段代码是等价的吗?

engine.Where(...).Get(...)
等价于?
engine.Where(...)
engine.Get(...)

如果一样,则 engine 内部必须保持 Where(…) 执行后的状态,供 Get(…) 使用。这导致 engine 不是线程安全的,即另一个线程会在 Get 前修改该状态。这时就必要一个锁,确保 Where(…) Get(…) 不被打断,而产生了资源竞争。事实上,engine.Where(…) 返回的是 Session类(sqlbuilder),以下代码是线程安全的,其中 sqlBuilder 是线程局部变量。

sqlBuilder := engine.Where(...)
sqlBuilder.Get(...)

为了简化资源竞争的管理,人们提出模板化解决资源冲突的方法(管程):

type Resource struct {
    locker Locker
    resouce ...
}

func (r CResource) AOperation {
    r.locker.Lock()
    defer r.locker.Unlocker()
    ...
}

这样,确保共享资源访问时,只有一个操作在执行。但这样会导致 AOperation 调用 BOperation 死锁,即“不可重入”。怎么解决?

5.3 线程初始化参数必须是 final 的!

即线程的参数要么是一个值,要么是一个 final 的引用,否则程序行为将不可预测。 例如:

    for _, req := range requests {
        go func(&req)
    }

java 或 c++ 在编译时是禁止这样的行为(编译错误)。由于 go 没有 final 机制,所以要特别注意!!!

5.4 构建消息驱动(Date Driven/Event Driven)的 goroutine

目前,go 是默认你是熟悉消息驱动机制的高手的! 或者说,你会使用 Channel,Locker,Cond 解决各种同步、通讯场景。

这是一个大的话题,需要你阅读大量的优秀源代码并做总结。以下只是一些常见模式:

接受数据,让一个线程处理一个数据:

   connection := Accept()
   ctx := prepareContext(connection)
   go messageHandler(ctx, ...)

让线程处理消息循环:

func processer(ch) {
    for {
        message, ok := <-ch
        if !ok {
           return
        }
        ...
        go messageHandler(...)
    }
}

异步(函数或接口回调):

  messageHandler(yourFunc())

六、考验你消息驱动编程的能力

HTTP Reactive Client 是一个典型的消息(事件)驱动的案例。

  • Motivation for Reactive Client

练习要求:

  • 依据文档图6-1,用中文描述 Reactive 动机
  • 使用 go HTTPClient 实现图 6-2 的 Naive Approach
  • 为每个 HTTP 请求设计一个 goroutine ,利用 Channel 搭建基于消息的异步机制,实现图 6-3
  • 对比两种实现,用数据说明 go 异步 REST 服务协作的优势
  • 思考: 是否存在一般性的解决方案?

七、goroutine 的并发模式

如果你看到 java 的 Reactive X 然后思考如何实现,就大错特错了。你必须重新回顾函数是第一类成员的理念!建立函数式并发思维去思考通用的异步并发,其中的关键是并发模式:

7.1 基本编程模式与应用

请阅读:Go语言并发的设计模式和应用场景

要点:

  • 生成器(Generator): 如何生成事件序列。tick 等函数都是案例。基础的基础
  • 服务化
  • 多路复合
  • select监听信道
  • 结束标志
  • 菊花链(Daisy-chain):最基本的并行模型
  • 定时器

7.2 并发应用模型

请阅读:入门goroutine并发设计模式以及goroutine可视化工具

要点:

  • Daisy-chain(菊花链):如何实现过滤器
  • Ping-pong(乒乓):注意人家化的重点,等待队列
  • Fan-In(扇入): 信息汇聚(join)
  • Fan-Out(扇出): 信息分发(fork),负载均衡(balance)
  • Servers(服务器):

7.3 一些常见的应用模式

建议阅读:RabbitMQ Tutorials

它的六个图,如何用 goroutine + channel 实现?

八、小结

goroutine 是比较难且实用的话题,在理解 goroutine 原理的基础上,进一步了解多线程程序的准则以及消息驱动的编程模式,将是 go 分布式编程的利器!

你可能感兴趣的:(golang)