0%

盘点go并发那些事儿

盘点Golang并发那些事儿之一

一生很短,Let’s Go

人生苦短,我用Python

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

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

基础概念

进程

进程的定义

进程(英语:process),是指计算机中已运行的程序。进程曾经是`分时系统的基本运作单位。在面向进程设计的系统(如早期的UNIXLinux 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 VSunOS中也被称为轻量进程(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去执行相同的函数。

语法如下

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 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,运行

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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并发控制得心应手