当前位置: 首页 > Linux

【Go语言踩坑系列(七)】Goroutine(上篇)

时间:2023-04-06 19:40:31 Linux

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