文章目录
进程和线程
在讲解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模型
于是在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、执行线程根据任务繁忙程度来创建,任务执行完,这些线程依然还在,没有销毁。