goroutine原理分析

进程和线程

在讲解goroutine之前,先来熟悉一下进程和线程的概念,因为只有通过概念之前对比,才能更加理解这些概念。

进程-——拥有资源的人

计算机的使用,大都是以进程为单位来管理的,比如我打开电脑版微信,桌面启动一个微信程序,本质上计算机启动了一个为微信进程,打开浏览器、播放器等等类似,当然有的应用软件不只启动一个进程。

在windows下,可以通过任务管理器看到启动的进程;在linux下,可以通过ps -aux命令,看到所有的进程。

由此,可以大致了解到,所谓进程就是运行的程序

如果安装完QQ软件,你不运行QQ,那么QQ就是一堆静态的躺在硬盘上的文件,当你一旦点击运行,那么计算机便创建了一个QQ进程,这个时候,就可以输入账号和密码登录QQ了,这就是和QQ进程交互的过程。

到这里,可以知道,进程是CPU和内存有关系的,因为程序的运行需要CPU和内存,更专业的说法就是进程就是程序在内存中的镜像,因为只有把程序载入内存才能运行。

上面讲了那么多废话,无非就是引入进程的以下特征:

  • 进程是计算机资源管理的基本单位,例如内存分配、描述符分配、环境变量等等。
  • 进程之间相互独立,互不干扰。

由于第一点资源上的独立,第二点也就是自然成立了。

线程——真正干活的人

由于进程从宏观的上就可以看到,所以容易理解,但是线程是比进程更小的单位,貌似就不容易那么理解了。世间万事都是如此,更微观的现象,了解的成本的就越高,了解的人就越少。首先,线程是由进程创建,并且可以创建多个线程,正确的说是,线程的创建、运行、销毁都是由进程控制。

第一点:进程的拥有的资源,所有线程的线程都可以”看见“,都可以访问到。

第二点:线程是CPU执行的基本单位。

一点不能理解,既然进程分配资源的基本单位,拥有资源,那么这些资源给谁用呢?就是线程,线程来使用这些资源,所有的线程都可以使用到。线程使用这些资源干嘛呢?干活。

这里需要补充一点,CPU每次只能执行一个线程,那么其他的线程只能排队等待,至于这个线程要”霸占“CPU多久,取决于系统的线程调度算法,一般是执行一定的时间就让出CPU,然后排队等待。

下面做一个简单的比喻:

进程就好比一个公司,拥有很多资源,包括办公室、电脑、食堂、班车等等,而且公司与公司之间相互独立,互不影响。那么线程就是公司里的员工,每天都要工作,每个人都是基本的人力单位,所有的员工都可以使用公司的资源。所以进程与进程的独立性很强,基本不受对方的影响。但是一个进程的所有线程,就不那么独立了,因为使用同一个进程的资源,有时候就产生”矛盾“了,你看一个公司的所有员工之前经常发生摩擦和争吵。

多线程和多协程

多线程——多个人干多件事

刚才说了,一个进程拥有多个线程,就好比一家公司有好多员工,每个员工的工作任务不同,有的人写代码、有的人设计UI、有的人负责运营、有的人负责人事,他们的工作基本上是并行的。我之所以说基本上是并行的,是因为有时候他们之间也需要相互等待,比如软件没有开发完,就不能让测试进行测试,更不能发布到线上。

整体而言,一个公司的人越多,做事情的速度越快。但是不是全然正确,因为《人月神话》,因为一件工作的粒度不能无限细分下去,举个极端的例子,一车砖头,10个人1小时搬完,请问10万个人搬需要多少小时?是1万分之小时吗?也就是0.36秒?算了吧,10万个人排队就超过1小时,这时候人多反而降低效率了。当然,我举的这个例子很极端,只是为了说明问题。

一个小案例

之前做过这样一个需求:有一个目录下会生成大量的文件,需要及时转移到另外一个目录下,文件的大小2KB—2GB之间,我最开始的做法是配置多个线程来转移文件,因为多个线程读取同一个目录,所以必须采用互斥锁。

线程的具体做法是:

//配置8个线程
lock();
filelist =GetFiles();//获取50个文件
unlock();
Move(filelist);//开始转移

经过测试就发现一个问题,假如这某一个时候,生成的文件都比较大,都是2GB,那么文件大,文件的数量就少,只能少量线程可以搬运文件,有的线程都空闲着,根本无法发挥多线程的优势。

另一种情况时,生产的文件都很小,只有1KB那么大,但是生成的速度很快,几秒钟便可以生成近百万个文件,如果这个时候,每个线程每次只获取50个文件,那么线程就需要多次访问互斥锁,性能反而降低,怎么办?改进方法:每个线程每次获取1万个文件,性能可以提高不少。

这个案例就涉及到任务粒度划分的问题,第一种情况,每个文件2G,任务粒度很大,一个文件只能由一个线程来处理,多线程没啥优势。第二种情况,每个文件1KB,任务粒度太小,每个线程根本没有饱和,造成线程争夺资源损耗性能。

总结:

  • 多线程的优势显而易见,可以同时执行多种任务。
  • 多线程合作执行同一任务时,其执行效果和任务的粒度有关系。

多协程——一个人干多件事

协程,是一个线程更小的单位,由线程创建,由于线程是CPU调度执行的最小单元,那么第一个结论就是:

一个线程里协程,是不能并发的,因此协程之间不用加锁。

一个人每天早上上班后,开始投入工作,认真写代码,此时主协程在工作,代码写了250行,突然上级让TA过去开会,这时候放下手头的工作,创建一个开会的协程,开始进入会议模式,记下100行会议纪要,突然TA的电话响了,停止会议,创建接电话的协程,进入接电话模式,5分钟后电话结束,接电话的协程结束,回到刚才的切换的协程,即开会的协程,然后继续接着100行会议纪要继续记录,等会议结束,会议协程结束,回到刚才切换的协程,即写代码的协程,继续接着250行代码继续写。

以上的过程,大概就是协程切换的过程,是一个人串行的干多件事,干完一件事,就回到上一件事继续接着干。

本质

其实协程的切换就是函数栈帧的切换,以为线程的结构就是栈帧、程序指针、各种寄存器等等,CPU拿到这些东西就可以执行一个线程了。

思考:

一个线程应该至少包含哪些东西?

CPU是如何通过机器指令执行程序的。所谓的栈,只是空间,但是在机器指令中,都是地址,变量、函数都是地址。

goroutine的原理

最近学习和使用golang已经有半年多了,对于一个C/C++程序员来说,golang只是一门语言而已,并没有什么神奇之处,正如侯捷所说:

代码面前,了无秘密。

一门编程语言,最终还是要依赖操作系统实现各种功能,你看golang对应每个操作系统,都有一个版本,windows的话,你就需要下载windows版本的golang ,linux系统就要下载linux版本的golang。

说到这里,就不得不说golang的一个重要功能,就是协程——goroutine,利用关键go就可以轻易的开启协程,编写并发程序,利用chan就可以实现协程之间的通信。

上面,我们说了,真正的协程是不能并发的,因为一个协程在线程内部,而线程又是CPU执行的最小单位。但是goroutine是天生可以并发的,在语言层面获得支持,所以golang的强大,其实是golang的运行时干了太多的事情。

package main

import (
	"fmt"
	"time"
)

func main() {
	fmt.Println("hello world")
	time.Sleep(time.Second * 100)
} // hello.go

编译运行,查看这个进程的线程数量,执行:

top -H -p pidof hello

54605 KentZhan  20   0  3100 1072  588 S  0.0  0.0   0:00.00 hello   
54606 KentZhan  20   0  3100 1072  588 S  0.0  0.0   0:00.00 hello                                 
54607 KentZhan  20   0  3100 1072  588 S  0.0  0.0   0:00.00 hello                                 
54608 KentZhan  20   0  3100 1072  588 S  0.0  0.0   0:00.00 hello                                 
54609 KentZhan  20   0  3100 1072  588 S  0.0  0.0   0:00.00 hello 

结果发现,一个简单的hello.go运行后,竟然开启了5个线程(包括主线程),那么goroutine之所以能轻松并发,是这些线程的支持,这些线程来执行应用层goroutine的任务。

GM模型

由上面可知,go的运行时启动多个线程来执行多个goroutine任务,最开始go的调度器是GM模型。

G:表示goroutine,应用层开启的任务。

M:表示golang运行时开启的线程(machine),刚才在我的机器看到的是5个hello线程,当然这些线程的个数和CPU的核数,以及当前的goroutine的数量有关系,和当前的goroutine的行为有一定的关系。

这样的话,多个G相当于是任务队列,多个M构成线程池,然后每个M取出一定量的任务来执行,看起来很完美,但是实际中存在一些问题。

  • 任务队列需要加锁,参考多线程可知,加锁在一定的任务粒度下会损耗性能。
  • 假如M上正在执行任务阻塞,比如调用系统调用,那么这个M上的其他任务得不到执行。我之前在想,当goroutine调用系统调用的时候,M不能把当前的G切出去吗?执行下一个G,等系统调用返回,再继续执行。通过读libco源码才知道,系统调用只能阻塞,只不过libco采用了hook,改写了read函数,在新的read函数里,先epoll,等有数据了,在调用系统调用。所以,系统调用只能阻塞并且等待返回结果。
  • M频繁地调用系统调用阻塞,就把自己其他任务传递给其他的M,造成的一定的性能损耗。

GPM模型

GPM

于是在G和M之间引入P,

P:是M调度G的一个中间层,可以理解为是对CPU的抽象,因为它的个数是由CPU的核数确定,可以由runtime.GOMAXPROCS(num)指定,程序运行后不会再改变。

G要想到M上执行,必须先绑定一个P,然后P在M上执行,所以我说P是G和M的中间层,P的数量决定了,同时最多有几个G在执行,P数数量小于等于CPU的核数。P可以控制整个程序的并发程度。

由P来完成一部分M的任务,之前是M从任务队列取任务,现在是P从任务对列取任务,放到自己的本地队列,当M上执行的G阻塞时,P与M分离,这个阻塞的G仍然和M绑在一起继续阻塞等待系统调用返回。那么P就可以继续和其他的M结合,你看M和G就解耦了,解决了GM模型存在的第二和第三个问题。此时,M只执行任务,P只分发任务,解耦了之前的M执行任务,又要管理任务的耦合。

这时候,M面对的不是G了,M只需找到一个P去结合,然后执行P中的G。

关于goroutine底层的线程的数量

测试程序一

package main

import (
	"fmt"
	"os"
	"time"
)

func WriteFile(num int) {
	file := fmt.Sprintf("%d.txt", num)
	fp, err := os.OpenFile(file, os.O_CREATE|os.O_RDWR, 0666)
	if nil != err {
		fmt.Printf("openFile failed, err:%s\n", err.Error())
		return
	}
	data := "Hello"
	for {
		fp.Write([]byte(data))
	}
}

func main() {
	for i := 0; i < 30; i++ {
		go WriteFile(i)
	}
	time.Sleep(time.Second * 60)
} //writefile.go 启动30个协程不断地写文件。						

测试结果如下:我的机器8核,64位系统,golang 1.11

Tasks: 384 total,   2 running, 382 sleeping,   0 stopped,   0 zombie
Cpu(s):  1.0%us,  0.8%sy,  0.0%ni, 98.2%id,  0.0%wa,  0.0%hi,  0.0%si,  0.0%st
Mem:  132118792k total, 126218820k used,  5899972k free,  2641128k buffers
Swap: 32767996k total,   920348k used, 31847648k free, 105824260k cached

  PID USER      PR  NI  VIRT  RES  SHR S %CPU %MEM    TIME+  COMMAND                                                                    
24480 KentZhan  20   0  5736 1684  604 S 640.4  0.0   0:38.15 writefile                                                                  
44955 KentZhan  20   0  225m  11m  10m R 65.2  0.0   2:56.84 smbd                                                                        
23999 KentZhan  20   0 20140 1816 1204 R  1.9  0.0   0:00.01 top                
[KentZhang@LOCAL-192-168-97-2 ~]$ pstree -p 24480|wc -l
34

这个进程CPU占有率640%,一共启动34个线程,加上主线程就是35个,业务层代码启动了30个协程。

测试程序二

package main

import "time"

func sleep() {
	for {
		time.Sleep(1 * time.Second)
	}
}

func main() {

	for i := 0; i < 30; i++ {
		go sleep()
	}

	time.Sleep(60 * time.Second)
} // sleep.go  开启30个协程,每个线程不断sleep

[KentZhang@LOCAL-192-168-97-2 ~]$ pstree -p 28276
sleep(28276)─┬─{sleep}(28277)
             ├─{sleep}(28278)
             ├─{sleep}(28279)
             ├─{sleep}(28280)
             ├─{sleep}(28281)
             ├─{sleep}(28282)
             ├─{sleep}(28283)
             ├─{sleep}(28284)
             ├─{sleep}(28285)
             ├─{sleep}(28286)
             ├─{sleep}(28330)
             └─{sleep}(28332)
[KentZhang@LOCAL-192-168-97-2 ~]$ pstree -p 28276|wc -l
12

CPU占有率很少,因为斗都在休眠中,后台线程12个,加上主线程一共13个,这个程序也是启动30个协程。

测试程序三

package main

import (
	"fmt"
	"os"
	"time"
)

func WriteFile(num int) {
	file := fmt.Sprintf("%d.txt", num)
	fp, err := os.OpenFile(file, os.O_CREATE|os.O_RDWR, 0666)
	if nil != err {
		fmt.Printf("openFile failed, err:%s\n", err.Error())
		return
	}
	data := "Hello"

	for i := 0; i < 200000; i++ {
		fp.Write([]byte(data))
	}

}

func main() {
	for i := 0; i < 30; i++ {
		go WriteFile(i)
	}
	time.Sleep(time.Second * 20000)
}//sleep.go  修改了写文件的次数为20万次,之后协程退出。

但是30个协程任务执行完后,全部退出,主协程休眠中,但是底层34的个线程依然还在,没有销毁。

结论

加上上面的hello.go,一共也就三个测试程序,按理说,我是得不到什么结论的,但是阅读了其他资料,在上自己的推论,可得到一下我自己的结论。

1、执行线程的数量是不定的,根据需要创建,我的机器上最少是6个。

2、协程越多,执行线程未必越多,取决于于协程是否忙碌,忙碌的协程越多,执行线程就越多。

3、执行线程根据任务繁忙程度来创建,任务执行完,这些线程依然还在,没有销毁。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值