《快学 Go 语言》第 11 课 —— 千军万马跑协程

简介:
ac544371a7738f50281eb3e22d409597d391cd0a

协程和通道是 Go 语言作为并发编程语言最为重要的特色之一,初学者可以完全将协程理解为线程,但是用起来比线程更加简单,占用的资源也更少。通常在一个进程里启动上万个线程就已经不堪重负,但是 Go 语言允许你启动百万协程也可以轻松应付。如果把协程比喻成小岛,那通道就是岛屿之间的交流桥梁,数据搭乘通道从一个协程流转到另一个协程。通道是并发安全的数据结构,它类似于内存消息队列,允许很多的协程并发对通道进行读写。

0c2b7d28e76277f60b8a8d18def6c16b9dac3ed7图片

Go 语言里面的协程称之为 goroutine,通道称之为 channel。

协程的启动

Go 语言里创建一个协程非常简单,使用 go 关键词加上一个函数调用就可以了。Go 语言会启动一个新的协程,函数调用将成为这个协程的入口。

package main

import "fmt"
import "time"

func main() {
fmt.Println("run in main goroutine")
go func() {
fmt.Println("run in child goroutine")
go func() {
fmt.Println("run in grand child goroutine")
go func() {
fmt.Println("run in grand grand child goroutine")
}()
}()
}()
time.Sleep(time.Second)
fmt.Println("main goroutine will quit")
}

-------
run in main goroutine
run in child goroutine
run in grand child goroutine
run in grand grand child goroutine
main goroutine will quit


main 函数运行在主协程(main goroutine)里面,上面的例子中我们在主协程里面启动了一个子协程,子协程又启动了一个孙子协程,孙子协程又启动了一个曾孙子协程。这些协程之间似乎形成了父子、子孙、关系,但是实际上协程之间并不存在这么多的层级关系,在 Go 语言里只有一个主协程,其它都是它的子协程,子协程之间是平行关系。

值得注意的是这里的 go 关键字语法和前面的 defer 关键字语法是一样的,它后面跟了一个匿名函数,然后还要带上一对(),表示对匿名函数的调用。

上面的代码中主协程睡眠了 1s,等待子协程们执行完毕。如果将睡眠的这行代码去掉,将会看不到子协程运行的痕迹

-------------
run in main goroutine
main goroutine will quit


这是因为主协程运行结束,其它协程就会立即消亡,不管它们是否已经开始运行。

子协程异常退出

在使用子协程时一定要特别注意保护好每个子协程,确保它们正常安全的运行。因为子协程的异常退出会将异常传播到主协程,直接会导致主协程也跟着挂掉,然后整个程序就崩溃了。

package main

import "fmt"
import "time"

func main() {
fmt.Println("run in main goroutine")
go func() {
fmt.Println("run in child goroutine")
go func() {
fmt.Println("run in grand child goroutine")
go func() {
fmt.Println("run in grand grand child goroutine")
panic("wtf")
}()
}()
}()
time.Sleep(time.Second)
fmt.Println("main goroutine will quit")
}

---------
run in main goroutine
run in child goroutine
run in grand child goroutine
run in grand grand child goroutine
panic: wtf

goroutine 34 [running]:
main.main.func1.1.1()
/Users/qianwp/go/src/github.com/pyloque/practice/main.go:14 +0x79
created by main.main.func1.1
/Users/qianwp/go/src/github.com/pyloque/practice/main.go:12 +0x75
exit status 2


我们看到主协程最后一句打印语句没能运行就挂掉了,主协程在异常退出时会打印堆栈信息。从堆栈信息中可以了解到是哪行代码引发了程序崩溃。

为了保护子协程的安全,通常我们会在协程的入口函数开头增加 recover() 语句来恢复协程内部发生的异常,阻断它传播到主协程导致程序崩溃。

go func() {
if err := recover(); err != nil {
// log error
}
// do something
}()

启动百万协程

Go 语言能同时管理上百万的协程,这不是吹牛,下面我们就来编写代码跑一跑这百万协程,读者们请想象一下这百万大军同时奔跑的感觉。

package main

import "fmt"
import "time"

func main() {
fmt.Println("run in main goroutine")
i := 1
for {
go func() {
for {
time.Sleep(time.Second)
}
}()
if i % 10000 == 0 {
fmt.Printf("%d goroutine started\n", i)
}
i++
}
}


上面的代码将会无休止地创建协程,每个协程都在睡眠,为了确保它们都是活的,协程会 1s 钟醒过来一次。在我的个人电脑上,这个程序瞬间创建了 200w 个协程,观察发现内存占用在 4G 多,这意味着每个协程的内存占用大概 2000 多字节。协程还在继续创建,电脑开始变的卡顿,应该是程序开始使用交换分区,CPU 占用率持续走高。再继续压榨下去已经没有了意义。

协程死循环

前面我们通过 recover() 函数可以防止个别协程的崩溃波及整体进程。但是如果有个别协程死循环了会导致其它协程饥饿得到不运行么?下面我们来做一个实验

package main

import "fmt"
import "time"

func main() {
fmt.Println("run in main goroutine")
n := 3
for i:=0; i<n; i++ {
go func() {
fmt.Println("dead loop goroutine start")
for {} // 死循环
}()
}
for {
time.Sleep(time.Second)
fmt.Println("main goroutine running")
}
}


通过调整上面代码中的变量 n 的值可以发现一个有趣的现象,当 n 值大于 3 时,主协程将没有机会得到运行,而如果 n 值为 3、2、1,主协程依然可以每秒输出一次。要解释这个现象就必须深入了解协程的运行原理

协程的本质

一个进程内部可以运行多个线程,而每个线程又可以运行很多协程。线程要负责对协程进行调度,保证每个协程都有机会得到执行。当一个协程睡眠时,它要将线程的运行权让给其它的协程来运行,而不能持续霸占这个线程。同一个线程内部最多只会有一个协程正在运行。

72d732580d33ce3b799790bad3ef538ad8f81d5b图片


线程的调度是由操作系统负责的,调度算法运行在内核态,而协程的调用是由 Go 语言的运行时负责的,调度算法运行在用户态。

协程可以简化为三个状态,运行态、就绪态和休眠态。同一个线程中最多只会存在一个处于运行态的协程,就绪态的协程是指那些具备了运行能力但是还没有得到运行机会的协程,它们随时会被调度到运行态,休眠态的协程还不具备运行能力,它们是在等待某些条件的发生,比如 IO 操作的完成、睡眠时间的结束等。

39ba8415648d83f39b6ee3149302226ae4d2637e图片

操作系统对线程的调度是抢占式的,也就是说单个线程的死循环不会影响其它线程的执行,每个线程的连续运行受到时间片的限制。

Go 语言运行时对协程的调度并不是抢占式的。如果单个协程通过死循环霸占了线程的执行权,那这个线程就没有机会去运行其它协程了,你可以说这个线程假死了。不过一个进程内部往往有多个线程,假死了一个线程没事,全部假死了才会导致整个进程卡死。

每个线程都会包含多个就绪态的协程形成了一个就绪队列,如果这个线程因为某个别协程死循环导致假死,那这个队列上所有的就绪态协程是不是就没有机会得到运行了呢?Go 语言运行时调度器采用了 work-stealing 算法,当某个线程空闲时,也就是该线程上所有的协程都在休眠(或者一个协程都没有),它就会去其它线程的就绪队列上去偷一些协程来运行。也就是说这些线程会主动找活干,在正常情况下,运行时会尽量平均分配工作任务。

设置线程数

默认情况下,Go 运行时会将线程数会被设置为机器 CPU 逻辑核心数。同时它内置的 runtime 包提供了 GOMAXPROCS(n int) 函数允许我们动态调整线程数,注意这个函数名字是全大写,Go 语言的设计者就是这么任性,该函数会返回修改前的线程数,如果参数 n <=0 ,就不会产生修改效果,等价于读操作。

package main

import "fmt"
import "runtime"

func main() {
// 读取默认的线程数
fmt.Println(runtime.GOMAXPROCS(0))
// 设置线程数为 10
runtime.GOMAXPROCS(10)
// 读取当前的线程数
fmt.Println(runtime.GOMAXPROCS(0))
}

--------
4
10


获取当前的协程数量可以使用 runtime 包提供的 NumGoroutine() 方法

package main

import "fmt"
import "time"
import "runtime"

func main() {
fmt.Println(runtime.NumGoroutine())
for i:=0;i<10;i++ {
go func(){
for {
time.Sleep(time.Second)
}
}()
}
fmt.Println(runtime.NumGoroutine())
}

------
1
11

协程的应用

在日常互联网应用中,Go 语言的协程主要应用在HTTP API 应用、消息推送系统、聊天系统等。

在 HTTP API 应用中,每一个 HTTP 请求,服务器都会单独开辟一个协程来处理。在这个请求处理过程中,要进行很多 IO 调用,比如访问数据库、访问缓存、调用外部系统等,协程会休眠,IO 处理完成后协程又会再次被调度运行。待请求的响应回复完毕后,链接断开,这个协程的寿命也就到此结束。

在消息推送系统中,客户端的链接寿命很长,大部分时间这个链接都是空闲状态,客户端会每隔几十秒周期性使用心跳来告知服务器你不要断开我。在服务器端,每一个来自客户端链接的维持都需要单独一个协程。因为消息推送系统维持的链接普遍很闲,单台服务器往往可以轻松撑起百万链接,这些维持链接的协程只有在推送消息或者心跳消息到来时才会变成就绪态被调度运行。

聊天系统也是长链接系统,它内部来往的消息要比消息推送系统频繁很多,限于 CPU 和 网卡的压力,它能撑住的连接数要比推送系统少很多。不过原理是类似的,都是一个链接由一个协程长期维持,连接断开协程也就消亡。

在后面的高级内容部分,我将会教读者使用协程来实现上面这三个系统。下一章节我们开讲通道,因为通道的使用比较复杂,知识点较多,所以需要单独一节来讲。


原文发布时间为: 2018-12-03
本文作者:老钱
本文来自云栖社区合作伙伴“ 码洞”,了解相关信息可以关注“码洞”。

相关文章
|
2月前
|
存储 监控 算法
员工上网行为监控中的Go语言算法:布隆过滤器的应用
在信息化高速发展的时代,企业上网行为监管至关重要。布隆过滤器作为一种高效、节省空间的概率性数据结构,适用于大规模URL查询与匹配,是实现精准上网行为管理的理想选择。本文探讨了布隆过滤器的原理及其优缺点,并展示了如何使用Go语言实现该算法,以提升企业网络管理效率和安全性。尽管存在误报等局限性,但合理配置下,布隆过滤器为企业提供了经济有效的解决方案。
92 8
员工上网行为监控中的Go语言算法:布隆过滤器的应用
|
2天前
|
Go C语言
Go语言入门:分支结构
本文介绍了Go语言中的条件语句,包括`if...else`、`if...else if`和`switch`结构,并通过多个练习详细解释了它们的用法。`if...else`用于简单的条件判断;`if...else if`处理多条件分支;`switch`则适用于基于不同值的选择逻辑。特别地,文章还介绍了`fallthrough`关键字,用于优化重复代码。通过实例如判断年龄、奇偶数、公交乘车及成绩等级等,帮助读者更好地理解和应用这些结构。
29 14
|
17天前
|
存储 监控 算法
内网监控系统之 Go 语言布隆过滤器算法深度剖析
在数字化时代,内网监控系统对企业和组织的信息安全至关重要。布隆过滤器(Bloom Filter)作为一种高效的数据结构,能够快速判断元素是否存在于集合中,适用于内网监控中的恶意IP和违规域名筛选。本文介绍其原理、优势及Go语言实现,提升系统性能与响应速度,保障信息安全。
25 5
|
26天前
|
算法 安全 Go
Go语言中的加密和解密是如何实现的?
Go语言通过标准库中的`crypto`包提供丰富的加密和解密功能,包括对称加密(如AES)、非对称加密(如RSA、ECDSA)及散列函数(如SHA256)。`encoding/base64`包则用于Base64编码与解码。开发者可根据需求选择合适的算法和密钥,使用这些包进行加密操作。示例代码展示了如何使用`crypto/aes`包实现对称加密。加密和解密操作涉及敏感数据处理,需格外注意安全性。
41 14
|
26天前
|
Go 数据库
Go语言中的包(package)是如何组织的?
在Go语言中,包是代码组织和管理的基本单元,用于集合相关函数、类型和变量,便于复用和维护。包通过目录结构、文件命名、初始化函数(`init`)及导出规则来管理命名空间和依赖关系。合理的包组织能提高代码的可读性、可维护性和可复用性,减少耦合度。例如,`stringutils`包提供字符串处理函数,主程序导入使用这些函数,使代码结构清晰易懂。
73 11
|
26天前
|
存储 安全 Go
Go语言中的map数据结构是如何实现的?
Go 语言中的 `map` 是基于哈希表实现的键值对数据结构,支持快速查找、插入和删除操作。其原理涉及哈希函数、桶(Bucket)、动态扩容和哈希冲突处理等关键机制,平均时间复杂度为 O(1)。为了确保线程安全,Go 提供了 `sync.Map` 类型,通过分段锁实现并发访问的安全性。示例代码展示了如何使用自定义结构体和切片模拟 `map` 功能,以及如何使用 `sync.Map` 进行线程安全的操作。
|
2天前
|
监控 关系型数据库 MySQL
【01】客户端服务端C语言-go语言-web端PHP语言整合内容发布-优雅草网络设备监控系统-硬件设备实时监控系统运营版发布-本产品基于企业级开源项目Zabbix深度二开-分步骤实现预计10篇合集-自营版
【01】客户端服务端C语言-go语言-web端PHP语言整合内容发布-优雅草网络设备监控系统-硬件设备实时监控系统运营版发布-本产品基于企业级开源项目Zabbix深度二开-分步骤实现预计10篇合集-自营版
11 0
|
1月前
|
监控 安全 算法
深度剖析核心科技:Go 语言赋能局域网管理监控软件进阶之旅
在局域网管理监控中,跳表作为一种高效的数据结构,能显著提升流量索引和查询效率。基于Go语言的跳表实现,通过随机化索引层生成、插入和搜索功能,在高并发场景下展现卓越性能。跳表将查询时间复杂度优化至O(log n),助力实时监控异常流量,保障网络安全与稳定。示例代码展示了其在实际应用中的精妙之处。
41 9
|
2月前
|
算法 安全 Go
Go 语言中实现 RSA 加解密、签名验证算法
随着互联网的发展,安全需求日益增长。非对称加密算法RSA成为密码学中的重要代表。本文介绍如何使用Go语言和[forgoer/openssl](https://github.com/forgoer/openssl)库简化RSA加解密操作,包括秘钥生成、加解密及签名验证。该库还支持AES、DES等常用算法,安装简便,代码示例清晰易懂。
64 12
|
2月前
|
监控 算法 安全
解锁企业计算机监控的关键:基于 Go 语言的精准洞察算法
企业计算机监控在数字化浪潮下至关重要,旨在保障信息资产安全与高效运营。利用Go语言的并发编程和系统交互能力,通过进程监控、网络行为分析及应用程序使用记录等手段,实时掌握计算机运行状态。具体实现包括获取进程信息、解析网络数据包、记录应用使用时长等,确保企业信息安全合规,提升工作效率。本文转载自:[VIPShare](https://www.vipshare.com)。
36 1