市北区大型网站建设,管理咨询师证书,网页紧急升级,建站之星和凡科摘要#xff1a;本文由葡萄城技术团队于CSDN原创并首发。转载请注明出处#xff1a;葡萄城官网#xff0c;葡萄城为开发者提供专业的开发工具、解决方案和服务#xff0c;赋能开发者。 前言
本文主要介绍Go语言、进程、线程、协程的出现背景原因以及Go 语言如何解决协程的… 摘要本文由葡萄城技术团队于CSDN原创并首发。转载请注明出处葡萄城官网葡萄城为开发者提供专业的开发工具、解决方案和服务赋能开发者。 前言
本文主要介绍Go语言、进程、线程、协程的出现背景原因以及Go 语言如何解决协程的问题和并发编程的实现本文的阅读时长约在15-20分钟左右请合理的分配您的阅读时间。
1.Go的前世今生
1.1.Go语言诞生的过程
话说早在 2007 年 9 月的一天Google 工程师 Rob Pike 和往常一样启动了一个 C项目的构建按照他之前的经验这个构建应该需要持续 1 个小时左右。这时他就和 Google公司的另外两个同事 Ken Thompson 以及 Robert Griesemer 开始吐槽并且说出了自己想搞一个新语言的想法。当时 Google 内部主要使用 C构建各种系统但 C复杂性巨大并且原生缺少对并发的支持使得这三位大佬苦恼不已。 第一天的闲聊初有成效他们迅速构想了一门新语言能够给程序员带来快乐能够匹配未来的硬件发展趋势以及满足 Google 内部的大规模网络服务。并且在第二天他们又碰头开始认真构思这门新语言。第二天会后Robert Griesemer 发出了如下的一封邮件 可以从邮件中看到他们对这个新语言的期望是**在 C 语言的基础上修改一些错误删除一些诟病的特性增加一些缺失的功能。**比如修复 Switch 语句加入 import 语句增加垃圾回收支持接口等。而这封邮件也成了 Go 的第一版设计初稿。
在这之后的几天Rob Pike 在一次开车回家的路上为这门新语言想好了名字Go。在他心中”Go”这个单词短小容易输入并且可以很轻易地在其后组合其他字母比如 Go 的工具链goc 编译器、goa 汇编器、gol 连接器等并且这个单词也正好符合他们对这门语言的设计初衷简单。
1.2.逐步成型
在统一了 Go 的设计思路之后Go 语言就正式开启了语言的设计迭代和实现。2008 年C语言之父大佬肯·汤普森实现了第一版的 Go 编译器这个版本的 Go 编译器还是使用C语言开发的其主要的工作原理是将Go编译成C之后再把C编译成二进制文件。到2008年中Go的第一版设计就基本结束了。这时同样在谷歌工作的伊恩·泰勒Ian Lance Taylor为Go语言实现了一个gcc的前端这也是 Go 语言的第二个编译器。伊恩·泰勒的这一成果不仅仅是一种鼓励也证明了 Go 这一新语言的可行性 。有了语言的第二个实现对Go的语言规范和标准库的建立也是很重要的。随后伊恩·泰勒以团队的第四位成员的身份正式加入 Go 语言开发团队后面也成为了 Go 语言设计和实现的核心人物之一。罗斯·考克斯Russ Cox是Go核心开发团队的第五位成员也是在2008年加入的。进入团队后罗斯·考克斯利用函数类型是“一等公民”而且它也可以拥有自己的方法这个特性巧妙设计出了 http 包的 HandlerFunc 类型。这样我们通过显式转型就可以让一个普通函数成为满足 http.Handler 接口的类型了。不仅如此罗斯·考克斯还在当时设计的基础上提出了一些更泛化的想法比如 io.Reader 和 io.Writer 接口这就奠定了 Go 语言的 I/O 结构模型。后来罗斯·考克斯成为 Go 核心技术团队的负责人推动 Go 语言的持续演化。到这里Go 语言最初的核心团队形成Go 语言迈上了稳定演化的道路。
1.3.正式发布
2009年10月30日罗伯·派克在Google Techtalk上做了一次有关 Go语言的演讲这也是Go语言第一次公之于众。十天后也就是 2009 年 11 月 10 日谷歌官方宣布 Go 语言项目开源之后这一天也被 Go 官方确定为 Go 语言的诞生日。 Go语言吉祥物Gopher
1.4.Go安装指导
1Go语言安装包下载
Go 官网https://golang.google.cn/ 选择对应的安装版本即可建议选择.msi文件。
2.查看是否安装成功 环境是否配置成功
打开命令行win R 打开运行框输入 cmd 命令打开命令行窗口。 命令行输入 go version 查看安装版本显示下方内容即为安装成功。 2.进程、线程与协程
在互联网云时代几乎所有叫的上名字的组件都是用 Go 开发的比如 Docker、Kubernetes、Dapr 等等根本都列不完但是如果就仅凭这些简单的语法是不可能捕获那些大厂的心的。Go 能在互联网时代这么火热肯定是有自己的立命之本的没错就是 Go 的并发编程功能Go 语言的最大杀器 Goroutine。
Goroutine 是一个合成词取自原词Coroutine意为协程。
Goroutine 就是协程在 Go 中的一个实现那协程究竟是什么呢为啥最近几年才火起来呢C#没有协程这个概念不是一样可以做 Web 开发么为啥大厂纷纷从 Java 等大众的语言都投身有协程的 Go 呢带着上述这些个问题我开始从头了解协程究竟是个什么东西。
2.1.进程的出现
我是很早就听说过协程这个概念的并且几次三番都想搞懂这个协程到底是个什么意思但是由于前置知识的缺失无法形成有用的知识链不是看不懂就是刚理解一点就忘。所以我觉得应该从头来理解协程这个概念的诞生和为什么目前这么火热才能最终把知识变为自己的经验。
因为要从头说起那我觉得就应该从最开始为什么会有进程这个概念讲起。
话说在很早以前计算机还是单道批处理的一个机器程序员或者可以称之为打孔员将自己写好的程序通过纸袋打入计算机中计算机计算完毕最终会把结果返回给用户。在这个时期其实是没有进程的概念的。
随着技术的不断发展计算机也从最原始的样子逐渐进化到多道批处理系统。这个时候计算机已经可以并发的执行多个程序了同时也出现了操作系统的概念。这个时候人们发现单单用程序这个概念已经不能成功描述一个正在计算机内执行的程序了因为一份程序可以多次并发地执行那对于计算机来说这些代码相同但是并发执行的程序就分别表示的是不同的程序因此聪明的脑袋就发明了进程这个概念。
在那个时期进程是一个程序存在的唯一标识其不仅作为程序的执行调度单元同样也是程序信息的保存单元每个进程都有一个进程控制块PCB内部存放的是这个进程的一些信息比如页表、寄存器、程序计数器、堆、栈等等。
除此之外进程和进程之间是相互隔离的它们自身有着自己独立的 PCB 和内存等几个进程之间互不干扰完全独立地在计算机系统中运行着。
这个时期一切都在相安无事的完美运行着。
2.2.线程的出现
但是随着技术进一步的发展人们发现单单用进程已经解决不了一些问题了比如播放一个 MP3 音频文件。
一个 MP3 音频文件播放的伪代码大致如下
main() {while(true) {/*IO读取音频流*/Read();/*解压音频流*/Decompress();/*播放*/Play();}}Read() {...}Decompress() {...}Play() {...}这段代码在进程中执行就存在一个很严重的问题了无法让三个函数并发执行。因为在调用Read函数时用户态代码就发出一个系统调用进行 IO 操作。对于 IO 密集型的操作操作系统通常情况下都是会将其进程直接阻塞的当 IO 操作完成触发一个中断操作系统才会激活之前被阻塞的进程继续执行。
那这就有问题了在我没有将文件全部读完全部解码完我这个程序是无法播放音频文件的。给用户带来的直接后果就是播放的声音是一段一段的。为什么是一段一段的因为 IO 操作是有 buffer 的每次可能只会 IO 一个 buffer 的数据按照程序的逻辑会把 buffer 中的数据解码然后再播放。然后再 IO…那用户听到的就是一段一段的音乐了。
那有没有可能用多个进程实现呢就是一个进程 IO一个进程解码一个进程播放。这看起来好像是一个解决方案但是依然存在问题即进程间通信。上一节我们知道了进程间的内存是相互隔离的三个进程直接是无法直接访问对方的内容的这就需要 IO 的进程执行完需要想办法把自己的数据告诉解码的进程。且不说进程间通讯的性能消耗是巨大的就连三个进程完美地协同工作其实都很难做到。
所以我们知道了单独的进程无法做到在共享内存的前提下并发执行多个不同的程序。因此线程这个概念就出现了。
为了解决上述问题聪明的脑袋将进程的资源管理模块和调度模块进行了更细致的拆分创建出了线程这个概念。这时进程依然是一个程序的所有资源控制中心但是程序的执行已经不是进程来做的了而是丢个了自己内部的多个线程来完成。这些线程是共享内存的并且又可以被 CPU 调度并发地执行多个不同的程序上述这类问题就被线程完美地解决了。
虽然进程内的线程是共享内存的但是线程的执行时相互独立的因此每个线程就需要有自己的寄存器和程序计数器堆栈等资源。因此和进程控制块 PCB 相同线程也有自己的线程控制块 TCB来记录上述的一些自己独享的资源。进程和线程的模型如下图 2.3.互联网时代
线程这个概念一直平稳运行到了互联网时代这时新的问题又出现了
在互联网高速发展的现在高并发已经是每个互联网企业必须要面对的问题了因为有了高并发才有流量有了流量才有自己企业的立命之本。而在高并发时代下线程已经很难满足需求了。
如果一台服务器 1 秒中的并发量可达 10000 个那么对应的服务器就需要开启至少 1 万个线程去服务这些并发请求。而线程的创建也是需要资源的以 Linux 为例一个 POSIX Thread 的创建成本是1-8MB 不等那 1 秒 10000 个请求就需要在 1 秒内消耗掉10-80GB的内存资源这个数量是十分恐怖的。
并且CPU 从一个线程调度到另一个线程是需要线程上下文切换的这也是一个性能损耗点。什么是上下文切换我们上面说了线程同样也有自己的 TCB 去记录自己的堆栈和程序计数器寄存器等。操作系统在进行线程调度时需要从一个线程的 TCB 中将上述的所有资源加载到 CPU 执行的寄存器和内存中才能执行。当一个线程的时间片结束切换到另一个线程时操作系统同样需要将上一个程序的最终资源信息记录回之前的线程 TCB 中然后再加载新线程的 TCB 中的资源。这个过程被称为线程的上下文切换这个开销一般在3-5us 左右。
除此之外目前的互联网请求大多数都是需要读取数据的并且返回给用户展示的。那数据的读取就需要 IO 操作IO 操作时操作系统又会将对应的线程阻塞因此有人做过测试高并发情况下有80%的线程其实都是阻塞状态的它们只占资源缺不干活白白占用了系统资源。并且如果内存不足操作系统可能会挂起进程从而频繁地触发缺页中断给了本就不宽裕的IO带宽更大的压力形成了更严重的恶性循环。
早期的 Web 服务器 Apache 就是通过多线程响应的模型来处理 web 请求的。但是现在几乎没有人用 Apache 这个服务器了因为那种模型无法解决上述的问题。
这时聪明的脑袋又想到了新的解决方案IO 多路复用技术。Nginx就是使用这种技术处理高并发请求的。那什么是 IO 多路复用呢
IO 多路复用就是和一个请求开一个线程不同Nginx 这类服务器是通过一个死循环的线程去监听所有的 Web 请求当有请求到来时 Linux 的一些 IO 多路复用技术select,poll,epoll,kqueue可以通过一个阻塞的系统调用同时监听多个文件描述符一旦其中有任何一个文件描述符准备好进行读写操作就会通知程序进行相应的处理从而实现高效的事件驱动编程。并且这些请求的执行线程都是非阻塞的 IO 操作也就是遇到 IO 操作它们不是等而是停下来去干别的事这样就大大降低了服务器的压力。
但是这却又有一个新的问题产生了即 IO 多路复用技术下多个请求的响应是事件回调机制的而处理这些程序的程序员很难去找到回调的时机这让程序开发人员增加了无限高的心智压力代码非常难写。
2.4.协程的出现
为了解决以上的种种问题我们的主角协程就出现了。
协程的本质其实就是用户态的线程。这里又来了一个新的概念啥是用户态目前市面上的所有 CPU 指令集其实都是分级别的一般分为 ring0~ring3 一共 4 个级别离 ring0 越近获得的 CPU 指令的权限就越大相应能干的事就越多但是对应的不安全的风险也就越高。由此我们可以知道用户态和内核态其实是完全隔离的。
因此现在的操作系统都是分用户态和内核态的。以 Linux 为例ring0 是内核态ring3 即是用户态。我们日常开发的所有程序都是用户态程序在用户态程序我们仅能操作计算机很小一部分的功能大部分功能比如 IO 读写内存分配和各种硬件交互等等都是由内核程序完成的。
这时肯定有同学问了哎不对啊我的代码同样可以读文件并且把各种信息写在屏幕上啊那是怎么实现的
这些功能其实都是用户态程序向内核态程序发送各种不同的系统调用实现的。而发送一次系统调用就会触发一次用户态到内核态的上下文切换这同样也会带来一次性能损耗。
去年网上有一个阿里巴巴的二面面试题问为什么 RocketMQ 和 Kafka 的速度那么快其实是因为 RocketMQ 和 Kafka 在进行 IO 操作的时候都用到了 linux 中的一个零拷贝技术 mmap让数据读写过程中少一次系统调用切换带来的内存拷贝而是映射到相同的一块内存区域从而达到的加速。对这个问题感兴趣的同学可以去看 什么是 mmap。
因此在线程这个概念出现之前就已经出现了用户态线程的概念即用户态的程序内部自己模拟多线程的调度操作系统仅仅是调度了对应的进程却感知不到对应进程内的线程协程带来的直接好处是不需要创建 TCB 了可以节省下线程创建时对应的内存开销。
不过如果这么看来协程就又是一个和 Docker一样旧瓶装新酒的技术还真不是。
早期的用户态线程虽然有一定的性能优势但是还是解决不了一个问题无法感知系统中断。我们知道现在的操作系统都是抢占式的操作系统会默认优先执行高优先级的程序如果目前正在调度一个低优先级的程序那操作系统会触发一个中断让高优先级的程序抢占它。抢占是操作系统通过发送系统中断实现的然而操作系统感知不到协程的存在所以协程自身是无法处理抢占的中断事件的。此外如果一个用户态线程进行了 IO 操作那操作系统会把整个线程阻塞对应的没有调用 IO 的协程也会被阻塞。最后由于操作系统的调度单位是进程所以每次时间片分配到各个协程就更少了所以 CPU 算力也是需要解决的一个问题。也是因为这些问题吧各个操作系统才进一步推出自己的系统级线程。
3.Goroutine
Goroutine其实是Go为了解决上述普通协程的问题而做出的更高层的封装。
Go 的作者 Rob Pike 是这样描述 Goroutine 的Goroutine 是一个与其他 Goroutine 并行运行在同一地址空间的 Go 函数或方法。一个运行的程序由一个或多个 Goroutine 组成。它于线程、协程、进程等不同。它就是一个 Goroutine。
对于上一节提到的早期协程基本和线程的匹配模型都是N:1的即一个线程需要同时维护多个协程。这样的构建模型就无法解决上述出现的问题。为此 Go 在语言提供了一种 GM 模型后期逐渐演化为 GMP 模型总体来说就是让用户态线程即 Goroutine(G) 和真正的线程 Machine(M) 成为一个N:M的模型如下图所示 可以看到 Goroutine 是依附在系统线程运行的。它们统一由 Go Runtime 管理所以 Go 的核心其实就是它的 runtimego runtime 内统一管理了 Goroutine 的创建销毁和会统一给它们分配内存响应系统调用等等其内部包括了内存管理进程管理设备管理的主要功能而真正的操作系统其实也就这么点功能可以说 go runtime 就已经是一个小型的操作系统了。
Github 上有一个大佬是百度的 Go 工程师他自己用 Go 写了一个小操作系统eggos并且成功把它装到了一个没有操作系统的裸机上并且还能在里面玩超级马里奥。感兴趣的同学可以去学习了解一下这种事在.net 平台几乎是无法实现的。 上图是早期Go1.2 版本Go 对 Goroutine 的调度模型。我们程序中新创建的 Goroutine 其实最开始是被加入了global queue这个队列中然后程序真正的执行器 M 就会从这个队列中捞出待调度的 Goroutine 来运行。当运行中的 Goroutine 触发了 IO 等系统调度时runtime 会重新把它移回到 global queue 中。同样的如果运行中的 Goroutine 内创建了新的 Goroutine那同样也会把 Goroutine 放入 global queue 中等待调度。此外runtime 还会启动一个监控线程监控这些运行中的 Goroutine如果超过规定的时间片那这些 Goroutine 就会被重新移回 global queue 中。
可以看到GM 模型其实也是存在很多问题的比如统一使用了一个全局锁Goroutine 的调度依赖全局队列程序执行器和 Goroutine 没有强依赖导致很多情况下不满足局部性原理M 的内存分配和扩展等等。因此 Go 团队后期又进一步进化到了 GMP 模型加入了一个 Processor感兴趣的同学去学习了解GMP 模型。
综上Goroutine 和线程相比具有以下优势
很低的初始资源。一个 Goroutine 的创建成本直接从线程的 1-8MB降到了默认 2KB由于不需要创建 TCBGoroutine 只需要创建一个程序计数器的指针记录自己当前运行的函数栈位置即可。几乎没有上下文切换开销。一个 Goroutine 由 GoRuntime 调度调度开销完全就是入队出队的操作不需要切换上下文。和线程的 3.5us 相比Goroutine 的切换开销大概在 100ns 左右。运行线程不会被阻塞。当 Goroutine 发出系统调用的时其自身会被 runtime 直接调离 M对应的 M 又会继续去执行新的 Goroutine 程序极大地提高了线程利用率。
同时结合 IO 多路复用技术和 runtime 调度解决了早期协程一些严重性的问题从而顺利从互联网时代突围出来成为了各个大厂以及底层组件的主力语言。
4.talk is cheap
好了知道了 Goroutine 的各种优点最后我们来看看一个 Go 的并发编程模型是如何实现的。
func say(s string) {for i : 0; i 5; i {time.Sleep(100 \* time.Millisecond)fmt.Println(s)}}func sayHello() {go say(hello)say(world)}这是 Go 官方文档中的 Goroutine 部分的一段示例。可以看到运行一段 Goroutine 的语法非常简单就只需要一个 go 关键字即可。上述的例子最终会输出”hello world”。和 C#的传染性不同Go 代码从外部是完全看不到代码是不是异步实现的这就给开发者降低了一些心理压力。
我们知道C#设计这套复杂的async/await模型其实就是为了解决异步方法 callback 难以获取的问题。所以加入了 await 关键字对异步状态机的结果监听最终返回异步线程上下文中的结果。然而 Go 没有 await那是如何进行上下文同步的呢
func calN(n int, ch chan- int) {// 模拟复杂的N计算time.Sleep(time.Second)// 返回结果ch - n \ n}func main() {ch : make(chan int, 1)go calN(12345, ch)select {case ans : -ch: {fmt.Printf(answer is: %d, ans)}default {time.Sleep(time.Millisecond \ 100)}}}这里我们终于学到了最后一个关键字 select 以及最后一个引用类型 chan 了。
chan
先来说 chanchan 是 channel 的意思是多个 goroutine 进行数据传递的通道其作用类似于 C#中的 Pipe相当于是再多个并发执行的 goroutine 中掏了个洞洞用来传递数据。
chan 和指针一样是由类型的是一个引用类型通过make()函数初始化第二个参数是通道的 size。由此可知通道其实就是一个双端队列多个 goroutine 都可以往通道中读写数据当通道 buffer 被写满后写通道的 goroutine 就会被阻塞。
写通道的语法特别简单就是一个箭头符号-注意只有左箭头唯一一种没有右箭头ch-代表想通道写数据-ch代表从通道读出数据。
select
再来看 select 关键字这里的 select 其实就是 linux 操作系统的 IO 多路复用技术的一个指令其目的就是当接收到异步事件时轮询每一个事件结果。
Go 实现了语言级别的 select 功能它的作用和 linux 的 select 类似就是阻塞当前 goroutine等待 chan 的返回。
通常 select 会配合 case 和 default 使用使用方式类似于 switch-case 语句满足哪一个就触发哪一个。上述代码中当我使用select关键字读取通道内的数据时由于刚开始caN函数还没有返回所以 main 的 goroutine 进入了 default 会睡 100 毫秒。之后再次循环之前的操作直至某个 case 中有 return 等退出的语句。
如果同一时间同时满足了多个 case那 Go Runtime 会随机选择一个 case 去执行。通常情况下Goroutine 的超时都是自己写一个超时函数实现的比如下列代码
func makeNum(ch chan- int) {time.Sleep(5 * time.Second)ch - 10}func timeout(ch chan- int) {time.Sleep(3 time.Second)ch - 0}func chanBlock() {ch : make(chan int, 1)timeoutCh : make(chan int, 1)go makeNum(ch)go timeout(timeoutCh)select {case -ch:fmt.Println(ch)case -timeoutCh:fmt.Println(timeout)}}生产者-消费者模型
好了为了更熟练地理解 Goroutine 的编程风格最后让我们用 Goroutine 实现一个操作系统同步互斥问题中比较经典的生产者-消费者模型
// 需求创建生产者消费者模型其中生产者和消费者分别是N和M个// 生产者每隔一段时间生产X产品消费者同样也每隔一段时间消费Y产品// 生产者如果将产品容器填满应该被阻塞多次阻塞之后将会退出// 每个消费者需要消费满Z个产品才能退出否则就要一直消费产品const (ProducerCount 3 // 生产者数量ConsumerCount 5 // 消费者数量FullCount 15 // 消费者需求数量消费者吃够了应该回家TimeFactor 5 // 时间间隔因子每生产/消费一个产品需要休息一段时间QuitTimes 3 // 生产者退出次数如果生产者阻塞了多次则会下班SleepFactor 3 // 睡眠时间因子如果生产者被阻塞应该睡眠一段时间)var (waitGroup sync.WaitGroup{})func producer(n int, ch chan- int) {defer waitGroup.Done()times : createFactor()asleepTimes : 0for true {p : createFactor()select {case ch - p:{t : time.Duration(times) \ time.Secondfmt.Printf(Producer: %d produced a %d, then will sleep %d s\\n, n, p, times)time.Sleep(t)}default:{time.Sleep(time.Second \ SleepFactor)asleepTimesfmt.Println(I need consumers!)if asleepTimes QuitTimes {fmt.Printf(Producer %d will go home\\n, n)return}}}}}func consumer(n int, ch chan int) {waitGroup.Done()s : make([]int, 0, FullCount)times : createFactor()for len(s) FullCount {select {case c : -ch:{s append(s, c)fmt.Printf(Consumer: %d consume a %d, remains %d, then will sleep %d s\\n, n, c, FullCount-len(s), times)time.Sleep(time.Duration(times) \ time.Second)}default:{fmt.Println(Producers need to hurry up, Im hungry!)time.Sleep(time.Second \ SleepFactor)}}}fmt.Printf(Consumer: %d already full\\n, n)}func createFactor() int {times : 0for times 0 {times rand.Intn(TimeFactor)}return times}func main() {rand.Seed(time.Now().UnixNano())ch : make(chan int, FullCount)waitGroup.Add(ProducerCount)for i : 0; i \ ProducerCount; i {go producer(i, ch)}waitGroup.Add(ConsumerCount)for i : 0; i \ ConsumerCount; i {go consumer(i, ch)}waitGroup.Wait()}这里用到了 Goroutine 中另一个比较常见的包sync上例中用到了一个 WaitGroup其目的类似于 C#中的Task.WaitAll用来等待所有的 goroutine 执行结束。可以看到它基本是基于信号量实现的所以每次创建 goroutine 时都需要执行 Add 函数。
最后提供一个我自己纯手工每一行代码都是自己实现的Toy-Web。这个练手的小项目也花了我很大的精力才完成真正想要自己实现其实也需要一点算法功底比如路由应该使用 Trie 树匹配通配符需要 BFS 和 DFS 的知识如果需要路由节点扩展则需要会回溯的算法。总之万丈高楼平地起加油吧各位新生的 Gopher 们祝我们都有一个美好的未来。
扩展链接
Spring Boot框架下实现Excel服务端导入导出
项目实战在线报价采购系统React SpreadJSEcharts
Svelte 框架结合 SpreadJS 实现纯前端类 Excel 在线报表设计