10 Go 并发编程

Go 并发编程

选择 Go 编程的原因可能是看中它简单且强大,那么你其实可以选择C语言;除此之外,我看中 Go 的地方还有原生支持并发编程,对于开发网络编程有着一定的优势,实际上很多地方也谈到,Go 目前是作为云编程的最为流行的编程语言。Go 从语法层面支持并发编程,这可能是其他语言不多见的地方。其实,无所谓孰优孰劣,关键是你如何应用。

并发编程

在谈并发编程之前,似乎需要知道什么是并发编程,为什么要并发编程? 并发程序指立即可以对多个任务进行的程序,注意这里是立即而非同时,同时处理多个任务,通常叫并行。如何理解立即呢?假定A、B、C三个人在快餐店排队点餐准备吃饭,如果快餐店接到A的订单,告诉A受到订单,并不理会B,C,而是等A的订单完成后,对A结账处理,再依次服务B和C。这种模式叫顺序式或者说独占式。服务窗口仅会对当前用户服务,而实际上准备订单是后厨的人,但服务窗口只能等待后厨准备后之后,再服务客人,服务窗口有很多时间等在等待,并没有做任何事情,这样的效率非常低下。假如,服务窗口接受A订单后,告知A,你的订单已经接受,你可以去旁边取餐口等待一下,准备好快餐会告知你领取,那么服务窗口就可以接受B和C的订单,依次往前,而不用让后面的客人长时间排队等待,而不知道任何事情。这里的立即,就是这样的含义,一个一个处理告知,可能并不能立即提供结果,但有结果一定会告知。同样,还有另外一种情况,就是食堂情况,当A,B,C同时排队等待,服务窗口,立即开启3个,同时对A,B,C进行服务,互不相关,这样情况,叫做并行。但并行一定会快吗?有时候也不一定?因为无论开设多少窗口,如果后厨只有一名厨师,那么客户等待时间其实并没有减少,反而需要服务人员,那么增加后厨厨师不就可以了吗?如果后厨有多名厨师,但多名厨师需要共享某一个设备时,其实效率也不会,从系统来说,总会有一些资源处于非独立的共享状态,只要涉及资源共享,就必然存在竞争环境,而竞争环境就会导致一定的不确定性,因为无法保证竞争的后果。

Go 为了解决上面的情况,原生支持的是并发编程,即按照顺序立即处理而非等待,也并不是同时执行。让后厨一直在忙,而非要求增加窗口服务和后厨人员数量。Go 使用 Go 协程(Goroutine)和信道(Channel)来处理并发问题。语法是非常简单的,以至于2个关键字就可以解决:go, channel。但什么是协程,什么是信道?并没有说清楚,还需要补充一些东西。

协程,是用户空间的并发运行的一些函数或方法。在曾经的电脑,有很多程序要执行,操作系统会将他们标记为一个一个的进程,每一个进程有自己的独立的空间和资源,并接受系统内核调度。具体来讲操作系统的内核按照某种优先级关系从进程中选择一个,让它执行,如果它处于等待状态(例如等待网络用户连接或者IO读取等等),内核就将其休眠,并打包放入栈中,然后再让另一个程序从休眠中唤醒执行(这样的过程叫做上下文切换),由于进程非常有自己的各种资源,如果程序是网络服务或者IO密集型,上下文切换非常频繁,系统对上下文切换的开销就会非常大(可以看作是一种无用功)。所以随着技术演进就出现线程,线程就是一种轻量级的进程,是程序执行的最小单元,一个标准的线程由线程ID,当前指令指针(PC),寄存器集合和堆栈组 成。另外,线程是进程中的一个实体,是被系统独立调度和分派的基本单位,线程自己不拥有系统资源,只拥有一点儿在运行中必不可少的资源,但它可与同属一个 进程的其它线程共享进程所拥有的全部资源。这样,系统调度线程会快很多。线程是程序中一个单一的顺序控制流程。进程内一个相对独立的、可调度的执行单元,是系统独立调度和分派CPU的基本单位指运行中的程序的调度单位。在单个程序中同时运行多个线程完成不同的工作,称为多线程。协程是一个更小的单位,如同线程一样,有自己的上下文,切换受系统控制;协程也相对独立,有自己的上下文,但是其切换由用户控制而非系统,所以称之为用户态并发函数或方法。具体可以参见该 博文

借用别人一段话原文 Go 协程相比于线程的优势:

相比线程而言,Go 协程的成本极低。堆栈大小只有若干 kb,并且可以根据应用的需求进行增减。而线程必须指定堆栈的大小,其堆栈是固定不变的。Go 协程会复用(Multiplex)数量更少的 OS 线程。即使程序有数以千计的 Go 协程,也可能只有一个线程。如果该线程中的某一 Go 协程发生了阻塞(比如说等待用户输入),那么系统会再创建一个 OS 线程,并把其余 Go 协程都移动到这个新的 OS 线程。所有这一切都在运行时进行,作为程序员,我们没有直接面临这些复杂的细节,而是有一个简洁的 API 来处理并发。 Go 协程使用信道(Channel)来进行通信。信道用于防止多个协程访问共享内存时发生竞态条件(Race Condition)。信道可以看作是 Go 协程之间通信的管道。

协程

再谈更多内容之前,先看几个实例。如何开启协程。

package main

import (
    "fmt"
)

func hello(name string) {
    fmt.Println("Hello", name)
}

func main() {
    SomePeople := []string{"Jack", "Perter", "John"}
    for _, v := range SomePeople {
        go hello(v) // 用go 关键字开启了一个新的协程,这个循环开启了三个协程
    }
    fmt.Println("There is no nobody!")
    //time.Sleep(1 * time.Second)
}

如果你按照上述代码运行,那么很可能你只能看到 >>There is no nobody! 的提示,运气好,可能看到像某个People打招呼的情况。这里为了看的直观,将time一行去掉注释再次运行,即可看到类似如下的结果。

//-------result-------------------
There is no nobody!
Hello Jack
Hello Perter
Hello John
//-----another result------
Hello Jack
There is no nobody!
Hello John
Hello Perter

如何按照顺序执行的理念,OhmyGod,这怎么可能?循环是按照"Jack", "Perter", "John"的顺序逐个开启协程的,然后打印 nobody,为什么?

首先需要知道的是,我们通过 main() 函数的一个循环,通过 go 关键字创建了三个 Hello() 的协程,然后打印 There is no nobody!。由于 main() 是其他三个协程的创建者,如果 main() 结束,其他协程则结束。 从之前谈到的概念,可以知道协程一旦创建,就相当于已经下发任务,不需要等任务完成后再返回执行,所以 main 函数自己通过循环创建完三个协程后,马上就执行打印没有人的语句了,而不会等协程打印完再打印。同理,main()执行完打印就退出,如果没有延时1s的退出的语句,我们很可能什么打招呼过程都看到。而加上延时后,不同次执行的结果也可能不完全一样,这是由于系统或Goruntime调度的原因,我们无法确定哪个协程会被系统先执行,只知道会被执行,所以There is no nobody!可能发生在任何时候,向"Jack", "Perter", "John"打招呼的顺序也可能是任何顺序。这就是协程。

这时候,也许该发生一点点担忧,因为我们通常编写的代码和程序需要需要匹配到现实世界的某一个确定过程,例如把大象关进冰箱,首先需要打开冰箱门、把大象放进去、关上冰箱门。这三个步骤必须按照既定顺序执行,不能够错乱,否则大象不可能被关进冰箱。那么就需要一个模型来指导协程之间如何配合工作。

Go 的原生并发模型叫“顺序通信进程” (communicating sequential processes) 或被简称为 CSP。常见与之区别较大的另一个并发模型叫共享内存,关于共享内存模型可以参考博文。CSP并发模型是在1970年左右提出的概念,属于比较新的概念(提出一个崭新的概念是多么的不容易),不同于传统的多线程通过共享内存来通信,CSP 讲究的是 Do not communicate by sharing memory; instead, share memory by communicating。更多关于并发编程了解可以阅读 并发模型比较 或者《七周七并发模型》。

CSP 模型

communicating sequential processes

CSP 描述这样一种并发模型:多个Process 使用一个 Channel 进行通信, 这个 Channel 连结的 Process 通常是匿名的,消息传递通常是同步的(可以异步,但有别于 Actor Model)。CSP 最早是由 Tony Hoare 在 1977 年提出,据说老爷子至今仍在更新这个理论模型,有兴趣的朋友可以自行查阅电子版本:http://www.usingcsp.com/cspbook.pdf。严格来说,CSP 是一门形式语言(类似于 ℷ calculus),用于描述并发系统中的互动模式,也因此成为一众面向并发的编程语言的理论源头,并衍生出了 Occam/Limbo/Golang...

而具体到编程语言,如 Golang,其实只用到了 CSP 的很小一部分,即理论中的 Process/Channel(对应到语言中的 goroutine/channel):这两个并发原语之间没有从属关系, Process 可以订阅任意个 Channel,Channel 也并不关心是哪个 Process 在利用它进行通信;Process 围绕 Channel 进行读写,形成一套有序阻塞和可预测的并发模型。

引用自 莫邪博客

What is a goroutine? It’s an independently executing function, launched by a go statement. It has its own call stack, which grows and shrinks as required. It’s very cheap. It’s practical to have thousands, even hundreds of thousands of goroutines. It’s not a thread. There might be only one thread in a program with thousands of goroutines. Instead, goroutines are multiplexed dynamically onto threads as needed to keep all the goroutines running. But if you think of it as a very cheap thread, you won’t be far off. ― Rob Pike

goroutine 是什么?它就是由 go func()语句发起的一个独立执行的函数。它由自己的栈,并且栈的大小是可以随着需求增大或收缩。它资源开销非常廉价,成千上万的 goroutine 并发运行已经被实践。它不是线程,因为程序中的一个线程就可能包含上千个 goroutine,并且实际上,goroutine 是随需求被线程动态多路复用的,使他们总可以被执行。但如果你认为它是一个开销非常小的线程,你可能就真的理解它了。----Rob Pike

CSP_G.png

如图所示,方框中是一个个的协程 Goroutine,协程之间通过一个约定的通道来传递消息或数据。理论上通道是双向的,Goroutine_A 和 Goroutine_B 都可以随时向对方发送消息和数据,但为了数据流处理的简单,降低软件处理的模型复杂度和实现难度,通常会是单向通道。

channel 的声明如同其他一样var chan type向通道中发送或接收数据就可以用下面这个样子

ch := make(chan int)
data := <- ch // 读取int信道 ch  
ch <- data // 写入int信道 ch

Golang 调度器

在 Golang 中,任何代码都是运行在 goroutine里,即便没有显式的 go func(),默认的 main 函数也是一个 goroutine。但 goroutine 不等于操作系统的线程,它与系统线程的对应关系,牵涉到 Golang 运行时的调度器:

调度器 由三方面实体构成:

  1. M:物理线程,类似于 POSIX 的标准线程;
  2. G:goroutine,它拥有自己的栈、指令指针和维护其他调度相关的信息;
  3. P:代表调度上下文,可将其视为一个局部调度器,使Golang代码跑在一个线程上
MGP

三者对应关系:

MGP对应关系

上图有2个 物理线程 M,每一个 M 都拥有一个上下文(P),每一个也都有一个正在运行的goroutine(G)。P 的数量可由 runtime.GOMAXPROCS() 进行设置,它代表了真正的并发能力,即可有多少个 goroutine 同时运行。调度器为什么要维护多个上下文P 呢?因为当一个物理线程 M 被阻塞时,P 可以转而投奔另一个 OS 线程 M(即 P 带着 G 连茎拔起,去另一个 M 节点下运行)。

无缓存的通道通讯

channel 的声明如同其他一样var chan type向通道中发送或接收数据就可以用下面这个样子

ch := make(chan int)
data := <- ch // 读取int信道 ch  
ch <- data // 写入int信道 ch
close(ch) // 关闭通道

type 类型决定ch通断可以传输何种类型的数据,如果数据类型不匹配,那么编译时就会报错。
因为通道是负责不同协程之间通讯的,如果在某一协程中,向同一个通道中既读取数据又写入数据会造成panic。其实这种情况就是死锁,因为数据发送方始终无法将数据送出,因为自己一旦发送就会造成拥塞,但没有其他地方接收数据,所以一直拥塞,自己内部的数据读取则根本机组执行。

func {
    ch := make(chan int)
    ch <- 2 //错误的示例
    x := <-ch //错误的示例
    log.Println(x)
}()

改造一下上面的打招呼,一个协程负载给出名单,另一个协程负责打招呼

func hello(ch chan string) {
    for {
        v, ok := <-ch
        if ok == false {
            break
        }
        fmt.Println("Hello", v)
    }
    fmt.Println("Hello is over!")
}

func listPeople(ch chan string) {
    SomePeople := []string{"Jack", "Perter", "John"}
    for _, v := range SomePeople {
        ch <- v
    }
    
    fmt.Println("People List is Over")
    close(ch)
}

func main() {
    ch := make(chan string)
    go hello(ch)
    go listPeople(ch)
    time.Sleep(1 * time.Second)
    fmt.Println("There is no nobody!")
}
/* -------------result -----------
Hello Jack
Hello Perter
Hello John
People List is Over
Hello is over!
There is no nobody!
*/

上述例子声明了两个以 ch 通道为参数的的函数,其中 listPeople() 为列出人清单,发送给通道;hello() 则从通道中取出值,负责打招呼。其实如果有多个函数向 ch 丢人名,hello() 也不会介意,它只关注通道,而不关注通道的源头是哪里。

从结果可以看出,两个协程拥塞,listPeople() 每扔出一个人名,hello() 打印一个人名。传递完人名后,打印People List is Over,并且关闭通道。hello() 检查到通道关闭后,跳出循环,打印 Hello is over! 在 main() sleep 拥塞完后,打印结束。

协程拥塞

从上图可以看到,通道是拥塞的,无论读写都会造成拥塞,写入通道会等待读取后才会继续执行。这对高IO读取或者网络服务造成非常大的影响,因为IO读取和网络通讯相对CPU速度非常缓慢,CPU等待过程属于浪费,而并发就是想将这种浪费利用起来。那么有没有办法不拥塞呢?可以试试带有缓存的通道。

带缓存的通道

带有缓存的通道声明和普通类似,ch:=make(chan type, cap),带有缓存并不意味着不拥塞,如果通道缓存容量大于首发数据值,那么就不用拥塞,数据都被丢到缓存中,否则缓存被填满之后依然会拥塞。直接再看例子。

func hello(ch chan string) {
    for {
        v, ok := <-ch
        if ok == false {
            break
        }
        fmt.Println("Hello", v)
    }
    fmt.Println("Hello is over!")
}

func listPeople(ch chan string) {
    SomePeople := []string{"Jack", "Perter", "John"}
    for _, v := range SomePeople {
        ch <- v
    }

    fmt.Println("People List is Over")
    close(ch)
    fmt.Println("关闭拥塞")
}

func main() {
    ch := make(chan string, 5)
    go hello(ch)
    go listPeople(ch)
    time.Sleep(1 * time.Second)
    fmt.Println("There is no nobody!")
}
/* ------------- ch with cache 5 -----------
People List is Over
关闭拥塞
Hello Jack
Hello Perter
Hello John
Hello is over!
There is no nobody!

------  ch with cache 1 ----------
Hello Jack
Hello Perter
Hello John
People List is Over
关闭拥塞
Hello is over!
There is no nobody!

当声明了一个带有5个缓存的通道时,可以发现 listPeople() 直接就执行完成了,将所有数据都丢入通道缓存,并关闭了缓存,执行完毕。然后Hello()捡起各个名字,并问候,检测到没有数据后也退出。此时Main() 经过一定时间等待也完成了自己的任务退出。但带有1个缓存的通道并没有那么幸运,和同步通道(就是没有缓存的通道)几乎一样,逐步执行,这并不能说明缓存没有作用,后面的实例会讲明缓存的效果。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342

推荐阅读更多精彩内容