说一说协程

作者: 似水牛年 | 来源:发表于2018-06-05 15:42 被阅读26次

    首先,我们了解一下进程,线程和协程三个概念之间的区别

    进程,线程,协程区别

    进程 拥有自己独立的堆和栈,既不共享堆,亦不共享栈,进程由操作系统调度。
    线程 拥有自己独立的栈和共享的堆,共享堆,不共享栈,线程亦由操作系统调度。
    协程 和线程一样共享堆,不共享栈,协程由程序员在协程的代码里显示调度。

    进程,线程,协程

    协程的优势

    实现协程的语言,主要以python和go语言为主,当然java也有协程的第三方库,但生产环境使用的不多,典型的协程实现还是以go语言为代表的,下面我们以go语言来说明协程的优势

    • 内存消耗:每个 goroutine (协程) 默认占用内存远比 Java 、C 的线程少。
      我们知道,线程是有固定的栈的,基本都是2MB,当然,不同系统可能大小不太一样,但是的确都是固定分配的。这个栈用于保存局部变量,用于在函数切换时使用。但是对于goroutine这种轻量级的协程来说,一个大小固定的栈可能会导致资源浪费:比如一个协程里面只print了一个语句,那么栈基本没怎么用;当然,也有可能嵌套调用很深,那么可能也不够用。
      所以go采用了动态扩张收缩的策略:初始化为2KB,最大可扩张到1GB。

    • 切换调度开销方面: goroutine 远比线程小
      协程和线程的区别在于:线程切换需要陷入内核,然后进行上下文切换,而协程在用户态由协程调度器完成,不需要陷入内核,这代价就小了;另外,协程的切换时间点是由调度器决定的,而不是系统内核决定的。

    多线程编程的槽点

    线程,是操作系统的内核对象,多线程编程时,如果线程数过多,就会导致频繁的上下文切换,这些 cpu 时间是一个额外的耗费。所以在一些高并发的网络服务器编程中,使用一个线程服务一个 socket 连接是很不明智的。于是操作系统提供了基于事件模式的异步编程模型。用少量的线程来服务大量的网络连接和I/O操作。但是采用异步和基于事件的编程模型,复杂化了程序代码的编写,非常容易出错。因为线程穿插,也提高排查错误的难度。

    协程,是在应用层模拟的线程,他避免了上下文切换的额外耗费,兼顾了多线程的优点。简化了高并发程序的复杂度。举个例子,一个高并发的网络服务器,每一个socket连接进来,服务器用一个协程来对他进行服务。代码非常清晰。而且兼顾了性能。

    协程底层实现原理

    协程(Coroutine)是在1963年由Melvin E. Conway USAF, Bedford, MA等人提出的一个概念,而且协程的概念是早于线程(Thread)提出的,它是一种非抢占式的线程调度。【参考 线程的调度 · 协同式调度

    协程和线程的原理是一样的,当 a线程 切换到 b线程 的时候,需要将 a线程 的相关执行进度压入栈,然后将 b线程 的执行进度出栈,进入 b线程 的执行序列。协程只不过是在 应用层 实现这一点。但是,协程并不是由操作系统调度的,而且应用程序也没有能力和权限执行 cpu 调度。怎么解决这个问题?

    答案是,协程是基于线程的。内部实现上,维护了一组数据结构和 n 个线程,真正的执行还是线程,协程执行的代码被扔进一个待执行队列中,由这 n 个线程从队列中拉出来执行。这就解决了协程的执行问题。那么协程是又是怎么切换的呢?

    golang 对各种 io函数 进行了封装,这些封装的函数提供给应用程序使用,而其内部调用了操作系统的异步 io函数,当这些异步函数返回 busy 或 bloking 时,golang 利用这个时机将现有的执行序列压栈,让线程去拉另外一个协程的代码来执行,基本原理就是这样,利用并封装了操作系统的异步函数。包括 linux 的 epoll、select 和 windows 的 iocp、event 等。

    尽管,在任务调度上,协程是弱于线程的。但是在资源消耗上,协程则是极低的。一个线程的内存在 MB 级别,而协程只需要 KB 级别。而且线程的调度需要内核态与用户的频繁切入切出,资源消耗也不小。

    至此,我们把协程的基本特点归纳为:

    1. 协程调度机制无法实现公平调度
    2. 协程的资源开销是非常低的,一台普通的服务器就可以支持百万协程

    Golang 协程的应用

    我们知道,协程(coroutine)是Go语言中的轻量级线程实现,由Go运行时(runtime)管理。

    go 关键字

    go 关键字用来创建 goroutine (协程),是实现并发的关键。go 关键字的用法如下:

    //go 关键字放在方法调用前新建一个 goroutine 并让他执行方法体
    go GetThingDone(param1, param2);
    
    //上例的变种,新建一个匿名方法并执行
    go func(param1, param2) {
    }(val1, val2)
    
    //直接新建一个 goroutine 并在 goroutine 中执行代码块
    go {
        //do someting...
    }
    

    在一个函数调用前加上go关键字,这次调用就会在一个新的goroutine中并发执行。当被调用的函数返回时,这个goroutine也自动结束。需要注意的是,如果这个函数有返回值,那么这个返回值会被丢弃。

    先看一下下面的程序代码:

    func Add(x, y int) {
        z := x + y
        fmt.Println(z)
    }
     
    func main() {
        for i:=0; i<10; i++ {
            go Add(i, i)
        }
    }
    

    执行上面的代码,会发现屏幕什么也没打印出来,程序就退出了。

    对于上面的例子,main()函数启动了10个goroutine,然后返回,这时程序就退出了,而被启动的执行 Add() 的 goroutine 没来得及执行。我们想要让 main() 函数等待所有 goroutine 退出后再返回,但如何知道 goroutine 都退出了呢?这就引出了多个goroutine之间通信的问题。

    在工程上,有两种最常见的并发通信模型:共享内存消息传递【参考:并发编程模型的分类 】;Go 语言主要使用消息机制 channel 来作为通信模型

    channel

    消息机制认为每个并发单元是自包含的、独立的个体,并且都有自己的变量,但在不同并发单元间这些变量不共享。每个并发单元的输入和输出只有一种,那就是消息。

    channel 是 Go 语言在语言级别提供的 goroutine 间的通信方式,我们可以使用 channel 在多个 goroutine 之间传递消息。channel是进程内的通信方式,因此通过 channel 传递对象的过程和调用函数时的参数传递行为比较一致,比如也可以传递指针等。channel 是类型相关的,一个 channel 只能传递一种类型的值,这个类型需要在声明 channel 时指定。


    channel

    CSP模型

    要想理解 channel 要先知道 CSP 模型。CSP 是 Communicating Sequential Process 的简称,中文可以叫做通信顺序进程,是一种并发编程模型,由 Tony Hoare 于 1977 年提出。简单来说,CSP 模型由并发执行的实体(线程或者进程)所组成,实体之间通过发送消息进行通信,这里发送消息时使用的就是通道,或者叫 channel。CSP 模型的关键是关注 channel,而不关注发送消息的实体。Go 语言实现了 CSP 部分理论,goroutine 对应 CSP 中并发执行的实体,channel 也就对应着 CSP 中的 channel。

    channel典型用法

    • channel的声明形式为:
      var chanName chan ElementType

    • 声明一个传递int类型的channel:
      var ch chan int

    • 使用内置函数 make() 定义一个channel:
      ch := make(chan int)

    • 在channel的用法中,最常见的包括写入和读出:

    // 将一个数据value写入至channel,这会导致阻塞,直到有其他goroutine从这个channel中读取数据
    ch <- value
    
    // 从channel中读取数据,如果channel之前没有写入数据,也会导致阻塞,直到channel中被写入数据为止
    value := <-ch
    

    默认情况下,channel的接收和发送都是阻塞的,除非另一端已准备好。

    • 我们还可以创建一个带缓冲的channel:
    ch := make(chan int, 1024)
    
    // 从带缓冲的channel中读数据
    for i:=range ch {
      ...
    }
    

    此时,创建一个大小为1024的int类型的channel,即使没有读取方,写入方也可以一直往channel里写入,在缓冲区被填完之前都不会阻塞。

    无缓冲channel 有缓冲channel
    • 可以关闭不再使用的channel:
      close(ch)
      应该在生产者的地方关闭channel,如果在消费者的地方关闭,容易引起panic;

    一个非阻塞简单示例

    阻塞的意思是调用方在被调用的代码返回之前必须一直等待,不能处理别的事情。而非阻塞调用则不用等待,调用之后立刻返回。那么返回值如何获取呢?Node.js 使用的是回调的方式,Golang 使用的是 channel。

    /**
     * 每次调用方法会新建一个 channel : resultChan,
     * 同时新建一个 goroutine 来发起 http 请求并获取结果。
     * 获取到结果之后 goroutine 会将结果写入到 resultChan。
     */
    func UnblockGet(requestUrl string) chan string {
        resultChan := make(chan string)
        go func() {
            request := httplib.Get(requestUrl)
            content, err := request.String()
            if err != nil {
                content = "" + err.Error()
            }
            resultChan <- content
        } ()
        return resultChan
    }
    
    
    
    fmt.Println(time.Now())
    resultChan1 := UnblockGet("http://127.0.0.1/test.php?i=1")
    resultChan2 := UnblockGet("http://127.0.0.1/test.php?i=2")
    
    fmt.Println(<-resultChan1)
    fmt.Println(<-resultChan1)
    fmt.Println(time.Now())
    

    上面两个 http 请求是在两个 goroutine 中并行的。总的执行时间小于 两个请求时间和。

    相关文章

      网友评论

        本文标题:说一说协程

        本文链接:https://www.haomeiwen.com/subject/niwtsftx.html