说说Golang goroutine并发那些事儿


摘要:今天我们一起盘点一下Golang并发那些事儿。

Golang、Golang、Golang 真的够浪,今天我们一起盘点一下Golang并发那些事儿,准确来说是goroutine,关于多线程并发,咱们暂时先放一放(主要是俺现在还不太会,不敢出来瞎搞)。关于golang优点如何,咱们也不扯那些虚的。反正都是大佬在说,俺只是个吃瓜群众,偶尔打打酱油,逃~。

说到并发,等等一系列的概念就出来了,为了做个照顾一下自己的菜,顺便复习一下

基础概念

进程

进程的定义

进程(英语:process),是指计算机中已运行的程序。进程曾经是`分时系统的基本运作单位。在面向进程设计的系统(如早期的UNIX,Linux 2.4及更早的版本)中,进程是程序的基本执行实体;在面向线程设计的系统(如当代多数操作系统、Linux 2.6及更新的版本)中,进程本身不是基本运行单位,而是线程的容器。

程序本身只是指令、数据及其组织形式的描述,相当于一个名词,进程才是程序(那些指令和数据)的真正运行实例,可以想像说是现在进行式。若干进程有可能与同一个程序相关系,且每个进程皆可以同步或异步的方式独立运行。现代计算机系统可在同一段时间内以进程的形式将多个程序加载到存储器中,并借由时间共享(或称时分复用),以在一个处理器上表现出同时平行性运行的感觉。同样的,使用多线程技术(多线程即每一个线程都代表一个进程内的一个独立执行上下文)的操作系统或计算机体系结构,同样程序的平行线程,可在多CPU主机或网络上真正同时运行(在不同的CPU上)。

进程的创建

操作系统需要有一种方式来创建进程。

以下4种主要事件会创建进程

  1. 系统初始化 (简单可理解为关机后的开机)
  2. 正在运行的程序执行了创建进程的系统调用(例如:朋友发了一个网址,你点击后开启浏览器进入网页中)
  3. 用户请求创建一个新进程(例如:打开一个程序,打开QQ、微信)
  4. 一个批量作业的初始化

进程的终止

进程在创建后,开始运行与处理相关任务。但并不会永恒存在,终究会完成或退出。那么以下四种情况会发生进程的终止

  1. 正常退出(自愿)
  2. 错误退出(自愿)
  3. 崩溃退出(非自愿)
  4. 被其他杀死(非自愿)

正常退出:你退出浏览器,你点了一下它

错误退出:你此时正在津津有味的看着电视剧,突然程序内部发生bug,导致退出

崩溃退出:你程序崩溃了

被其他杀死:例如在windows上,使用任务管理器关闭进程

进程的状态

  1. 运行态(实际占用CPU)
  2. 就绪态(可运行、但其他进程正在运行而暂停)
  3. 阻塞态(除非某种外部的时间发生,否则进程不能运行)

前两种状态在逻辑上是类似的。处于这两种状态的进程都可以运行,只是对于第二种状态暂时没有分配CPU,一旦分配到了CPU即可运行

第三种状态与前两种不同,处于该状态的进程不能运行,即是CPU空闲也不行。

如有兴趣,可进一步了解进程的实现、多进程设计模型

进程池

进程池技术的应用至少由以下两部分组成:

资源进程

预先创建好的空闲进程,管理进程会把工作分发到空闲进程来处理。

管理进程

管理进程负责创建资源进程,把工作交给空闲资源进程处理,回收已经处理完工作的资源进程。

资源进程跟管理进程的概念很好理解,管理进程如何有效的管理资源进程,分配任务给资源进程,回收空闲资源进程,管理进程要有效的管理资源进程,那么管理进程跟资源进程间必然需要交互,通过IPC,信号,信号量,消息队列,管道等进行交互。

进程池:准确来说它并不实际存在于我们的操作系统中,而是IPC,信号,信号量,消息队列,管道等对多进程进行管理,从而减少不断的开启、关闭等操作。以求达到减少不必要的资源损耗

线程

定义

线程(英语:thread)是操作系统能够进行运算调度的最小单位。大部分情况下,它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。在Unix System V及SunOS中也被称为轻量进程(lightweight processes),但轻量进程更多指内核线程(kernel thread),而把用户线程(user thread)称为线程。

线程是独立调度和分派的基本单位。线程可以为操作系统内核调度的内核线程

同一进程中的多条线程将共享该进程中的全部系统资源,如虚拟地址空间,文件描述符信号处理等等。但同一进程中的多个线程有各自的调用栈(call stack),自己的寄存器环境(register context),自己的线程本地存储(thread-local storage)。

一个进程可以有很多线程来处理,每条线程并行执行不同的任务。如果进程要完成的任务很多,这样需很多线程,也要调用很多核心,在多核或多CPU,或支持Hyper-threading的CPU上使用多线程程序设计的好处是显而易见的,即提高了程序的执行吞吐率。以人工作的样子想像,核心相当于人,人越多则能同时处理的事情越多,而线程相当于手,手越多则工作效率越高。在单CPU单核的计算机上,使用多线程技术,也可以把进程中负责I/O处理、人机交互而常被阻塞的部分与密集计算的部分分开来执行,编写专门的workhorse线程执行密集计算,虽然多任务比不上多核,但因为具备多线程的能力,从而提高了程序的执行效率。

线程池

线程池(英语:thread pool):一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。 例如,线程数一般取cpu数量+2比较合适,线程数过多会导致额外的线程切换开销。

任务调度以执行线程的常见方法是使用同步队列,称作任务队列。池中的线程等待队列中的任务,并把执行完的任务放入完成队列中。

线程池模式一般分为两种:HS/HA半同步/半异步模式、L/F领导者与跟随者模式。

  • 半同步/半异步模式又称为生产者消费者模式,是比较常见的实现方式,比较简单。分为同步层、队列层、异步层三层。同步层的主线程处理工作任务并存入工作队列,工作线程从工作队列取出任务进行处理,如果工作队列为空,则取不到任务的工作线程进入挂起状态。由于线程间有数据通信,因此不适于大数据量交换的场合。
  • 领导者跟随者模式,在线程池中的线程可处在3种状态之一:领导者leader、追随者follower或工作者processor。任何时刻线程池只有一个领导者线程。事件到达时,领导者线程负责消息分离,并从处于追随者线程中选出一个来当继任领导者,然后将自身设置为工作者状态去处置该事件。处理完毕后工作者线程将自身的状态置为追随者。这一模式实现复杂,但避免了线程间交换任务数据,提高了CPU cache相似性。在ACE(Adaptive Communication Environment)中,提供了领导者跟随者模式实现。

线程池的伸缩性对性能有较大的影响。

  • 创建太多线程,将会浪费一定的资源,有些线程未被充分使用。
  • 销毁太多线程,将导致之后浪费时间再次创建它们。
  • 创建线程太慢,将会导致长时间的等待,性能变差。
  • 销毁线程太慢,导致其它线程资源饥饿。

协程

协程,英文叫作 Coroutine,又称微线程、纤程,协程是一种用户态的轻量级线程。

协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。因此协程能保留上一次调用时的状态,即所有局部状态的一个特定组合,每次过程重入时,就相当于进入上一次调用的状态。

协程本质上是个单进程,协程相对于多进程来说,无需线程上下文切换的开销,无需原子操作锁定及同步的开销,编程模型也非常简单。

串行

多个任务,执行完毕后再执行另一个。

例如:吃完饭后散步(先坐下吃饭、吃完后去散步)

并行

多个任务、交替执行

例如:做饭,一会放水洗菜、一会吸收(菜比较脏,洗下菜写下手,傲娇~)

并发

共同出发

边吃饭、边看电视

阻塞与非阻塞

阻塞

阻塞状态指程序未得到所需计算资源时被挂起的状态。程序在等待某个操作完成期间,自身无法继续处理其他的事情,则称该程序在该操作上是阻塞的。

常见的阻塞形式有:网络 I/O 阻塞、磁盘 I/O 阻塞、用户输入阻塞等。阻塞是无处不在的,包括 CPU 切换上下文时,所有的进程都无法真正处理事情,它们也会被阻塞。如果是多核 CPU 则正在执行上下文切换操作的核不可被利用。

非阻塞

程序在等待某操作过程中,自身不被阻塞,可以继续处理其他的事情,则称该程序在该操作上是非阻塞的。

非阻塞并不是在任何程序级别、任何情况下都可以存在的。仅当程序封装的级别可以囊括独立的子程序单元时,它才可能存在非阻塞状态。

非阻塞的存在是因为阻塞存在,正因为某个操作阻塞导致的耗时与效率低下,我们才要把它变成非阻塞的。

同步与异步

同步

不同程序单元为了完成某个任务,在执行过程中需靠某种通信方式以协调一致,我们称这些程序单元是同步执行的。

例如购物系统中更新商品库存,需要用“行锁”作为通信信号,让不同的更新请求强制排队顺序执行,那更新库存的操作是同步的。

简言之,同步意味着有序。

异步

为完成某个任务,不同程序单元之间过程中无需通信协调,也能完成任务的方式,不相关的程序单元之间可以是异步的。

例如,爬虫下载网页。调度程序调用下载程序后,即可调度其他任务,而无需与该下载任务保持通信以协调行为。不同网页的下载、保存等操作都是无关的,也无需相互通知协调。这些异步操作的完成时刻并不确定。

可异步与不可异步

经过以上了解,又是进程、又是线程、等等一系列的东西,那是真的难受。不过相信你已经有个初步的概率,那么这里我们将更加深入的去了解可异步与不可异步。

在此之前先总结一下,以上各种演进的路线,其实加速无非就是一句话,提高效率。(废话~)

那么提高效率的是两大因素,增加投入以求增加产出、尽可能避免不必要的损耗(例如:减少上下文切换等等)。

如何区分它是可异步代码还是不可异步呢,其实很简单那就是,它是否能够自主完成不需要我们参与的部分。

我们从结果反向思考,

例如我们发送一个网络请求,这之间拥有网络I/O阻塞,那么测试我们将它挂起、转而去做其他事情,等他响应了,我们在进行此阶段的下一步的操作。那么这个是可异步的

另外:写作业与上洗手间,我此时正在写着作业,突然,我想上洗手间了,走。上完洗手间后又回来继续写作业,在我去洗手间这段时间作业是不会有任何进展,所以我们可以理解为这是非异步

goroutine

东扯一句,西扯一句,终于该上真家伙了,废话不多说。

如何实现只需定义很多个任务,让系统去帮助我们把这些任务分配到CPU上实现并发执行。

Go语言中的goroutine就是这样一种机制,goroutine的概念类似于线程,但 goroutine是由Go的运行时(runtime)调度和管理的。Go程序会智能地将 goroutine 中的任务合理地分配给每个CPU。Go语言之所以被称为现代化的编程语言,就是因为它在语言层面已经内置了调度和上下文切换的机制。

在Go语言编程中你不需要去自己写进程、线程、协程,你的技能包里只有一个技能–goroutine,当你需要让某个任务并发执行的时候,你只需要把这个任务包装成一个函数,开启一个goroutine去执行这个函数就可以了

goroutine与线程

可增长的栈

OS线程(操作系统线程)一般都有固定的栈内存(通常为2MB),一个goroutine的栈在其生命周期开始时只有很小的栈(典型情况下2KB),goroutine的栈不是固定的,他可以按需增大和缩小,goroutine的栈大小限制可以达到1GB,虽然极少会用到这么大。所以在Go语言中一次创建十万左右的goroutine也是可以的。

goroutine模型

GPM是Go语言运行时(runtime)层面的实现,是go语言自己实现的一套调度系统。区别于操作系统调度OS线程。

  • G很好理解,就是个goroutine的,里面除了存放本goroutine信息外 还有与所在P的绑定等信息。
  • P管理着一组goroutine队列,P里面会存储当前goroutine运行的上下文环境(函数指针,堆栈地址及地址边界),P会对自己管理的goroutine队列做一些调度(比如把占用CPU时间较长的goroutine暂停、运行后续的goroutine等等)当自己的队列消费完了就去全局队列里取,如果全局队列里也消费完了会去其他P的队列里抢任务。
  • M(machine)是Go运行时(runtime)对操作系统内核线程的虚拟, M与内核线程一般是一一映射的关系, 一个groutine最终是要放到M上执行的;

P与M一般也是一一对应的。他们关系是: P管理着一组G挂载在M上运行。当一个G长久阻塞在一个M上时,runtime会新建一个M,阻塞G所在的P会把其他的G 挂载在新建的M上。当旧的G阻塞完成或者认为其已经死掉时 回收旧的M。

P的个数是通过runtime.GOMAXPROCS设定(最大256),Go1.5版本之后默认为物理线程数。 在并发量大的时候会增加一些P和M,但不会太多,切换太频繁的话得不偿失。

单从线程调度讲,Go语言相比起其他语言的优势在于OS线程是由OS内核来调度的,goroutine则是由Go运行时(runtime)自己的调度器调度的,这个调度器使用一个称为m:n调度的技术(复用/调度m个goroutine到n个OS线程)。 其一大特点是goroutine的调度是在用户态下完成的, 不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池, 不直接调用系统的malloc函数(除非内存池需要改变),成本比调度OS线程低很多。 另一方面充分利用了多核的硬件资源,近似的把若干goroutine均分在物理线程上, 再加上本身goroutine的超轻量,以上种种保证了go调度方面的性能。

GOMAXPROCS

Go运行时的调度器使用GOMAXPROCS参数来确定需要使用多少个OS线程来同时执行Go代码。默认值是机器上的CPU核心数。例如在一个8核心的机器上,调度器会把Go代码同时调度到8个OS线程上(GOMAXPROCS是m:n调度中的n)。

Go语言中可以通过runtime.GOMAXPROCS()函数设置当前程序并发时占用的CPU逻辑核心数。

Go1.5版本之前,默认使用的是单核心执行。Go1.5版本之后,默认使用全部的CPU逻辑核心数。

goroutine的创建

使用goroutine非常简单,只需要在调用函数的时在函数名前面加上go关键字,就可以为一个函数创建一个goroutine。

一个goroutine必定对应一个函数,当然也可以创建多个goroutine去执行相同的函数。

语法如下

func main() {
    go 函数()[普通函数和匿名函数即可]
}

如果你此时兴致勃勃的想立马试试,我只想和你说,“少侠,请稍等~”,我话还没说完。以上我只说了如何创建goroutine,可没说这样就是这样用的。嘻嘻~

首先我们先看看不用goroutine的代码,示例如下

# example
package main

import (
    "fmt"
    "time"
)

func example(i int) {
    //fmt.Println("HelloWord~, stamp is", i)
    time.Sleep(time.Second)
}

// normal
func main() {
    startTime := time.Now()
    for i := 0; i < 10; i++ {
        example(i)
    }
    fmt.Println("Main~")
    spendTime := time.Since(startTime)
    fmt.Println("Spend Time:", spendTime)
}

输入结果如下

那么我们来使用goroutine,运行

示例代码如下:

package main

import (
    "fmt"
    "time"
)

func example(i int) {
    fmt.Println("HelloWord~, stamp is", i)
    time.Sleep(time.Second)
}

// normal
func main() {
    startTime := time.Now()
    // 创建十个goroutine
    for i := 0; i < 10; i++ {
        go example(i)
    }
    fmt.Println("Main~")
    spendTime := time.Since(startTime)
    fmt.Println("Spend Time:", spendTime)
}

输出如下

乍一看,好家伙速度提升了简直不是一个量级啊,秒啊~

仔细看你会发现,7,9 跑去哪儿呢?不见了,盯~

谜底在下一篇揭晓~

期待下一篇,盘点Golang并发那些事儿之二,goroutine并发控制得心应手

本文分享自华为云社区《盘点Golang并发那些事儿之一》,原文作者:PayneWu。

 

点击关注,第一时间了解华为云新鲜技术~

  • 分享:
评论
还没有评论
    发表评论 说点什么