声明
本系列文章并不会停留在Go语言的语法层面,更关注语言特性、学习和使用中出现的问题以及引起的一些思考。
进程,线程到协程的发展
计算机发展至今有几个至关重要的时期:
1. 单任务时代
这个时代主要标志为批处理。我们都知道早期的计算机就是穿孔打卡来运行的,需要人工去做输入输出的处理工作,计算机只进行了计算,而且每次都只能执行一个流程,然后循环往复,这个大流程下计算机很大程度上是没有执行的,所以。为了解决这个问题,出现了批处理,它把之前一次执行的任务聚合起来,统一传输给计算机处理,计算机做统一的输出,减少了人与机器的交互过程,计算机资源自然而然的利用起来了。整个过程好比之前我们喝水是每次渴了的时候就拿水杯从池塘去打杯水喝,而现在是你每次渴了的时候就拿两个桶去挑一担一样,挑回来的水每次都够喝一段时间,节省了你很多次去打水的过程,这样我们只需要保证桶里一直有水就可以了。具体的流程图如下图所示,注意差别在单个和多个上。
2. 多进程时代
单任务时代的批处理虽然提高了计算机整体资源利用的问题,但是这个时代一台计算器只能干一件事情,进行读取或写入的时候就不能进行计算,进行计算的时候就不能进行读写的IO,也就是说是串行的。因为IO和CPU计算速度存在着巨大的差异,这样就造成了CPU所花的计算时间非常少,而大部分的时间都在等待IO的结束。
所以为了更合理的利用CPU资源,前辈们就把计算机内存划分为多个块,不同的任务拥有自己的内存空间,任务之间互不干涉,这里单独的任务也就单独分为了一个进程,CPU可以在多个进程之间切换这执行,当一个进程需要进行磁盘IO的时候CPU就切换到另外一个进程去执行指令(这块有个问题,磁盘IO难道就不占用CPU么?有兴趣的可以去了解一下DMA(Direct Memory Access),这样就让CPU的资源更合理的运用起来,这个时候随着内存的加大,可划分的块也越来越多,这样能“同时”运行的进程也越来越多,CPU在不同的进程之间切换执行,任务多的时候它会一直处于工作状态。这样极大的提升了计算机的工作效率。
这里每个进程都有自己的独立内存空间,由于进程比较重,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,在切换时他需要保存自己的上下文,栈寄存器等信息(这样才能切换回来),但相对比较稳定安全。
3. 多线程时代
CPU基于进程的调度极大的提高了CPU的利用率,但是存在一个问题,如果一个进程内存在阻塞的动作,这个进程就会总得不到CPU,而且当一个进程工作的时候别的进程是无法获取CPU的,想象一下,我们在写代码的时候不能同时听野狼Disco了还会激情满满么。 当然,如果计算机说可以给每个进程都分配一个CPU,那也可以,但是这是后话了。
所以我们的CPU选择了基于粒度更小的线程来调度执行任务。一个进程可以创建很多个线程来执行任务,没有了进程的界限区分,CPU会在线程之间来回切换的工作, CPU的时间片分的更细,以至他在多个线程之间切换执行,我们并没有明显的感知,这样的话我们多个进程也变得可以“同时”工作了,同时CPU等待IO的几率又更加小了。
此时线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。因为它是进程的一个实体,它可与同属一个进程的其他的线程共享进程所拥有的全部资源,当然除了在运行中必不可少的资源(如程序计数器,一组寄存器和栈),这就导致线程上下文切换很快,资源开销比较少,但因为它和其他线程拥有共享资源,所以相比进程不够稳定容易丢失数据。
4. 多核CPU
之前的时代都是一个CPU来执行,之前我们一直怕不能充分利用CPU,但是随着计算机的高速发展,我们发现CPU不够用了。因此就出现了现在的双核,四核,八核等CPU,出现了多核CPU之后,计算机拥有了同一时间处理不同线程的能力,也就是真正意义的并行了。
5. 协程的出现
协程不能说是我们这个时代出现的产物,但是它是这个时代兴起的产物,它和进程和线程不太一样,它是用户态线程,也就是说,它的调用是用户自己来控制的。那么出现协程的背景是什么呢?现在通常会面临下边这种情况,一个网络服务器有很多客户,那么整个服务器就充斥着大量并行的 IO 请求。而为了处理这种情况,也出现了很多种解决的办法,其中最著名的就是epoll(Linux)或 IOCP(Windows)机制,这两个机制颇为类似,都是在需要 IO 时登记一个 IO 请求,然后统一在某个线程查询谁的 IO 先完成了,谁先完成了就让谁处理。
从系统调用次数的角度,epoll 或 IOCP 都是产生了更多次数的系统调用。从内存拷贝来说也没有减少。所以真正最有意义的事情是:减少了线程的数量。
那么为什么要减少线程的数量?这就需要从时间,空间成本上来考虑了,首先说空间成本,默认情况下 Linux 线程在数 MB 左右,其中最大的成本是堆栈(虽然,线程的堆栈大小是可以设置的,但是出于线程执行安全性的考虑,线程的堆栈不能太小)。我们可以想象如一个线程1MB,一千个有多大?时间成本,线程之间的切换也不可忽略。调度成本,执行体之间的同步与互斥成本,也是一个不可忽略的成本。虽然单位成本看起来还好,但是盖不住次数实在太多。
而协程的出现恰恰可以解决这个痛点,切换上下文快,没有锁同步的开销以及占用的资源很少。然而协程只是解决的单核CPU下的并发,如果以计算为主且是多核的话,它可能没有线程性能要好,这也是它的一大弊端。当然,协程的工作模式导致了擅长在IO密集型场景中,具体的协程原理我们在下一篇文章中会做详细阐述。
协程是一种用户态的轻量级线程,协程的调度完全由用户控制。我们可以认为协程是线程内的某一段,拥有自己的寄存器上下文和栈,此时的栈空间在不同的设计上会比线程更优,例如Go,它的协程的栈空间是按需扩展的,开始只有4k。 协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈所以基本没有内核切换的开销,它可以不加锁的访问全局变量,所以上下文的切换非常块。
Goroutine经典案例浅谈
我们具体来看一道在面试中经常遇到的编程题。
func main() {
for i := 0; i < 10; i++ {
go func() {
fmt.Println(i)
}()
}
}
问,这段代码会输出什么?此刻读者请思考一下。
这道题的典型回答是:不会有任何内容被打印出来。但是,如果你尝试去跑这段代码你就会知道它输出的可能是10个10,不会有任何内容被打印出来又或是“打印出乱序的0到9等等。那么这是为什么呢?
首先,我们需要知道一个与主 goroutine 有关的重要特性,即:一旦主 goroutine 中的代码(也就是main函数中的那些代码)执行完毕,当前的 Go 程序就会结束运行。
其次,为什么会出现这么多种结果呢?原因就是GO所拥有的调度方法以及GPM模型的神秘之处。
我们在此先说明一下简单的流程,在执行一条go语句的时候,Go 语言的运行时系统(runtime),会先试图从某个存放空闲的 G(也就是 goroutine) 的队列中获取一个 G(它只有在找不到空闲 G 的情况下才会去创建一个新的 G),在拿到了一个空闲的 G 之后,Go 语言运行时系统会用这个 G 去包装当前的那个go函数(或者说该函数中的那些代码),然后再把这个 G 追加到某个存放可运行的 G 的队列中。这类队列中的 G 总是会按照先入先出的顺序,很快地由运行时系统内部的调度器安排运行(具体实现下篇会讲解)。经过这么多的运算,go函数的执行时间总是会明显滞后于它所属的go语句的执行时间。然而只要go语句本身执行完毕,Go 程序完全不会等待go函数的执行,它会立刻去执行后边的语句,等到所有的语句执行完毕,Go程序就会结束运行,它也不会去等待go函数执行完毕。
所以我们再来看上述的代码的问题,在for语句中我们会依次的去执行go语句,但是go函数什么时候执行是我们控制不了的,除非我们使用了某种 Go 语言提供的方式进行了人为干预。
当然,在此我们只是简单的说明了这个现象,如果你想要对GO的GPM模型和调度方法有更多的了解,相信你会在我们下篇的文章里有所收获。
扩展
什么是用户态和内核态?
内核态:当一个任务(进程)执行系统调用而陷入内核代码中执行时,我们就称进程处于内核运行态(或简称为内核态)。
用户态就是提供应用程序运行的空间,为了使应用程序访问到内核管理的资源例如CPU,内存,I/O。也就是说是内核态的一层封装的空间。
为什么会有用户态和内核态?
由于开发和维护内核的复杂性,只有最基本的和性能关键的代码才放在内核中。其他东西,如GUI、管理和控制代码,通常被编程为用户空间应用程序常见,这种是Linux中很常见的做法。
用户态线程和内核态线程有什么关系?
所谓用户态线程就是把内核态的线程在用户态实现了一遍而已,目的是更轻量化(更少的内存占用、更少的隔离、更快的调度)和更高的可控性(可以自己控制调度器)。用户态所有东西内核态都「看得见」,只是对于内核而言「用户态线程」只是一堆内存数据而已。
下期预告
【Go语言踩坑系列(七)】Goroutine(下)
关注我们
欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。