协程 以及进程和线程

背景与场景

在Golang语言学习中,对协程的理解必不可少。似乎我们是通过Golang才知道了“协程”的概念,不过协程并不只存在于Golang中,是一个由来已久的设计。据Knuth(计算机界最著名大佬之一)说,1958年Melvin Conway就提出了协程的概念,且已经被广泛应用于典型的重量级语言C#,Erlang,Golang;以及各种轻量级语言python,lua,JavaScript,ruby;包括函数式语言scala,scheme。相比于2007年才开始设计的Golang,已经属于爷爷辈技术了。

当初的协程被设计用于完成协作式的多任务,又被形象地称为“用户态线程”(这和它背后的实现密切相关)。常见的应用场景包括实现状态机,实现并发的“演员模型”(通常被用在制作游戏中),实现生成器,实现通信顺序过程等。

协程定义

回到协程的定义,协程,coroutine,cooperation routine,协作的例程,维基百科的定义为:通过生成灵活的挂起和恢复的子例程,以实现协作式多任务(非抢占式多任务)的一类计算机程序组件。

在这个定义中,有必要澄清一下“子例程”,subroutine的概念,同样来自维基百科定义,子例程是计算机编程中用于实现一组特定任务的编程指令。与整个程序相比,它有比较明显的独立性和内聚性,函数,过程,方法都可以是一类子例程,更熟悉的名词是子程序,一种高级语言中的概括性术语。

通过伪代码定义一个简单的协程,如下所示:

# producer coroutine
loop
while queue is not full
  create some new items
  add the items to queue
yield to consumer
 
# consumer coroutine
loop
while queue is not empty
  remove some items from queue
  use the items
yield to producer

两个协程,生产者和消费者,生产者产生商品放入队列,然后通过“yield”通知消费者。消费者从队列中拿出商品并消耗,再“yield”生产者。就这样两组程序互相合作,在恰当的时机让出CPU的代码执行权,默契友好。

那在Go语言中,又是如何利用协程的呢?Go中协程术语为Goroutine,使用关键字“go”来启动。Go by Example的例子如下:

package main
 
import (
    "fmt"
    "time"
)
 
func f(from string) {
    for i := 0; i < 3; i++ {
        fmt.Println(from, ":", i)
    }
}
 
func main() {
 
    f("direct")
 
    go f("goroutine")
 
    go func(msg string) {
        fmt.Println(msg)
    }("going")
 
    time.Sleep(time.Second)
    fmt.Println("done")
}

和进程,线程的对比,以及纤程

我们比较熟悉的是进程和线程的概念,协程与之有什么区别呢?首先回顾一下:

进程:指计算机中已执行的程序,是操作系统资源调度的基本单位,在今天这个多核时代,进程已不再是执行的基本单位,转而变为了线程的容器。

线程:操作系统能够进行运算调度的最小单位,一个进程可以并发多个线程,多线程在多核CPU中可以并行执行不同的任务。

进程的概念应该很容易区别,那么线程和协程之间的区别是什么呢?我们在协程定义中可以很明确地看到:协程是非抢占式多任务,而线程就是典型的抢占式多任务。实际实现中,一个线程也可以拥有多个协程,协程是一种用户级线程的实现。

也有一种说法将协程称作纤程(Fiber),查阅了一下资料,纤程似乎是存在于Windows中的类比协程的概念。这里不再展开。

如何工作

怎样理解协程的“非抢占式”,和“用户级”呢?这里稍微回顾一下操作系统相关的知识:

首先看一下进程切换时,操作系统在干什么:

进程是操作系统中资源调度的基本单位,不同进程间资源不共享,最关键的资源就是共享内存。因此在进行进程切换时必须进行内存内数据的切换,也就是我们常说的“进程上下文切换”。例如A进程切换到B进程,中断发生时:

  • 首要的就是把通用寄存器中所有的数据保存到A进程的内核栈中,尤其是PC(程序计数器),指向了当前程序运行的地方。
  • 进行地址空间切换,进程能看到的是一套虚拟内存地址,实际映射向真实的物理地址。切换地址空间也就是把将运行的进程地址空间存入到特殊的页表基址寄存器中。
  • 进行硬件上下文切换,恢复进程B的各类寄存器状态,仿佛回到了进程B上次中断退出前的状态。

在这个过程中,对于各种寄存器的保存恢复等都会进入操作系统的内核空间进行。

而在线程切换时,又分为内核线程和用户线程切换。

  • 对于内核线程,不需要切换地址空间,因为所有任务共享内核地址空间。
  • 对于用户线程,则会区分切换前后两个线程是否属于同一个进程,如果是同一个进程共享地址空间的情况下,同样不需要切换地址空间。不同进程下的线程则会走完整的进程切换过程。
  • 需要注意的是,即使是同进程下的线程切换,调度过程仍然会触发中断,涉及到用户态与内核态之间的转换。过程中会做线程上下文的切换。

对于协程呢?

  • 首先,协程的上下文(函数栈状态和寄存器的值)是直接放在堆中的。(有待从更权威的资料确认各语言的实现方式)
  • 其次,协程需要主动让出CPU控制权,也就是执行yield,这个动作在不同语言中有不同的实现方法。
  • 这两点保证了协程的切换由程序员掌控,而非通过系统中断,也就因此不需要进入内核态,节省了切换开销。

优势与劣势

进程切换最大的开销在于刷新页表,会大致大量的cache miss,同时还会进行内核态用户态切换。而同进程的线程切换规避了地址空间的切换开销,但内核态与用户态的切换仍然花费了不少时间。协程切换避免了进入内核态,但如何发挥这个优势仍然需要注意。根据大家的总结:

  1. 计算密集型的程序频繁使用协程切换并没有太大意义,此时的切换更加类似于函数调用。来回切换还需要保存协程状态,降低性能。
  2. IO性操作则可以充分利用IO wait时间进行协程切换提升并发,实现异步编程。

当然如果协程调用了阻塞IO,无法主动进行切换,那么操作系统仍然会执行线程切换。有效的使用场景是让协程在IO等待时间切换执行任务,当IO操作结束后再自动回调,协程+异步能发挥最大作用。协程也成为了用同步写法写异步代码的一种良好实践。

作为实践,可以参考一下Python中代码的例子

async def main():
    task1 = asyncio.create_task(
        say_after(1, 'hello'))
 
    task2 = asyncio.create_task(
        say_after(2, 'world'))
 
    print(f"started at {time.strftime('%X')}")
 
    # Wait until both tasks are completed (should take
    # around 2 seconds.)
    await task1
    await task2
 
    print(f"finished at {time.strftime('%X')}")

task1会等待1秒后输出hello,task2会等待2s后输出world,asynio.create_task会创建协程异步操作,整个程序执行完的时间是2s。

参考资料

你可能感兴趣的:(golang后端)