包皮过长有什么影响| 什么叫压缩性骨折| 血糖高会有什么症状| 咽炎咳嗽吃什么药| 血糖高吃什么水果好能降糖| 读书与吃药是什么生肖| 留低是什么意思| 贴水是什么意思| 颈部ct能检查出什么| 下下签是什么意思| f4什么意思| 烧包是什么意思| 黄体酮不足吃什么| 中国地图像什么| 蚕吃什么| 腋窝下疼痛是什么原因| 为什么喉咙总感觉有东西堵着| 浅表性胃炎伴糜烂用什么药| 灬是什么意思| 破釜沉舟的釜是什么意思| 低血糖要吃什么| 女人腰疼是什么原因引起的| 蛇年五行属什么| 月球表面的坑叫什么| 炎热的夏天风儿像什么| 舌苔发白吃什么药| 唐玄宗为什么叫唐明皇| 外向孤独症是什么意思| 溶血是什么意思| 5月8日是什么星座| 西红柿和什么搭配最好| 经血是什么血| 宝宝便秘吃什么| 皮肤暗黄是什么原因造成的| lr是什么| 吃什么食物下奶快而且奶多| 结界是什么意思| 小舅子是什么意思| 性激素检查是查什么| 11月28日是什么星座| 梦见桥塌了有什么预兆| 咽喉炎是什么症状| 玉米吃多了有什么坏处| 什么是血癌| 士大夫什么意思| 脚气是什么样的| 内痔疮用什么药治最好效果最快| 长期大便不成形是什么原因造成的| 老汉推车什么意思| 不宁腿综合症是什么原因引起的| 26岁属什么生肖| 什么是商k| 定坤丹什么时候吃最好| 老是咳嗽挂什么科| 梦见吃饭是什么预兆| 什么叫阴虚| 下午5点半是什么时辰| 维脑路通又叫什么| 细菌感染吃什么药| 隐翅虫皮炎用什么药膏| 反胃是什么原因引起的| 非布司他片是什么药| 宫颈肥大需要注意什么| 一个丝一个鸟读什么| 每天泡脚对身体有什么好处| c k是什么牌子| 奴才是什么意思| 千千结是什么意思| 饭后胃胀吃什么药| 阑尾炎有什么症状表现| 吃饺子是什么节日| gd什么意思| 蒸鱼豉油什么时候放| 墨菲定律什么意思| 1月29日是什么星座| 交是什么结构的字| 发高烧是什么原因引起的| 菩提是什么材质| 吃牛肉不能吃什么| 检查神经做什么检查| 宫腔内囊性结构是什么意思| 什么的草地| 属牛的五行属性是什么| 1月4号是什么星座| 1988年属什么今年多大| 巩固是什么意思| 全身发烫但不发烧是什么原因| 减肥期间可以吃什么零食| 霉菌是什么病| 食粉是什么粉| 胃寒是什么原因引起的| 民族是什么意思| 11月24日是什么星座| 结痂是什么意思| 犟嘴是什么意思| 扫墓是什么意思| 手足口病吃什么药好得快| B2B什么意思| 什么是粉尘螨过敏| 为什么短信验证码收不到| 1988年是什么命| 心计是什么意思| 排卵期之后是什么期| 山竹为什么叫山竹| 舌苔厚黄是什么原因| 六月二十五号是什么星座| 盐酸舍曲林片治疗什么程度的抑郁| 脱脂乳粉是什么| 各自安好是什么意思| 双重人格是什么意思| 大圈是什么意思| 女生读什么技校好| 阳萎吃什么药| 木变石是什么| 舌头上有溃疡是什么原因| 女予念什么| 呼吸道感染吃什么药| 扶苏是什么意思| 什么叫唐氏综合症| 什么的被子| hbsab阳性是什么意思| 奶黄包的馅是什么做的| 歼灭是什么意思| 挂是什么意思| 喝什么茶降血糖| 吃什么能让阴茎更硬| grader是什么意思| 女性尿液发黄是什么原因| 肚脐是什么部位| 低压高吃什么药效果好| 老实人为什么总被欺负| 女生什么时候绝经| 什么兔子最好养| 拉屎有血是什么原因| 早熟是什么意思| 山药补什么| 排卵期有什么症状| 钼靶检查是什么意思| 嘴唇开裂是什么原因| 专业术语是什么意思| 杜建英是宗庆后什么人| 什么样的伤口需要打破伤风针| 夏天喝什么饮料好| 国资委主任是什么级别| 1935年属什么生肖| 肾结石术后吃什么食物最好| 蚊子最怕什么植物| 儿童回春颗粒主要治什么| 传说中的狮身人面像叫什么名字| 屁多又臭是什么原因| 牙龈经常发炎是什么原因| 小暑节气吃什么| 女朋友过生日送什么最好| 血压高压高是什么原因| 5月22是什么星座| 12月21日是什么星座| 孕吐是什么原因造成的| 1202是什么星座| 血压什么时候最高| 什么也别说| 免疫五项检查是什么| 颇有是什么意思| 什么石什么鸟| 什么眼霜去皱效果好| 吃东西就吐是什么原因| 聊胜于无什么意思| 郑州有什么玩的| 手脚出汗是什么原因| 压寨夫人是什么意思| 孕妇为什么不能吃韭菜| mama是什么意思| 桃子不能和什么一起吃| 湿气重吃什么药最好| 新生儿湿疹抹什么药膏| 廿读什么| hpv是什么意思| 露酒是什么酒| 子宫痉挛是什么症状| 炫是什么意思| 右眼跳是什么兆头| 尿频尿急尿不尽吃什么药效果最好| 十一月六号是什么星座| 为什么想吐| 星是什么意思| 恍恍惚惚什么意思| 什么是黄疸| 小腿发黑是什么原因| 什么是地震| 又什么又什么的花朵| 气口是什么意思| 据说是什么意思| 大便真菌阳性说明什么| 人加三笔是什么字| 决心是什么意思| 湿气重是什么引起的| 购物狂是什么心理疾病| 蓝桉什么意思| 艾字五行属什么| 经常拉肚子是什么原因引起的| 降甘油三酯吃什么食物最好| skg是什么品牌| 胸闷气短挂什么科室| 红豆和什么搭配最好| 误机是什么意思| 尖锐湿疣吃什么药| 眩晕吃什么药| od值是什么意思| 妇乐颗粒的功效能治什么病| 疣是什么意思| zara属于什么档次| 吃什么水果对肝好| 吃什么水果降血压| 宝宝老是摇头是什么原因| 白化病是什么遗传| 空调自动关机是什么原因| 急性鼻窦炎吃什么药| a型熊猫血是什么血型| 耳朵听不清楚是什么原因| 不知道饿是什么原因| 月经不来挂什么科| 舌裂纹是什么原因| 钾高了会出现什么症状| 贵州有什么特产| 什么水果泡酒最好喝| 甘草长什么样| 小暑节气吃什么| 白细胞高吃什么药| 花椒有什么功效与作用| 不生孩子的叫什么族| 脖子疼什么原因| 心腹是什么意思| 数九寒天是什么意思| 五道杠是什么牌子| 冷暖自知上一句是什么| 女人三十如狼四十如虎什么意思| 长疱疹是什么原因| 朋友圈提到了我是什么意思| 发来贺电是什么意思| 宋江属什么生肖| gigi 是什么意思| 为什么梦不到死去的亲人| 蜂蜜可以做什么美食| 皮蛋是什么蛋| 疤痕贴什么时候用最佳| 后背出汗是什么原因| 小孩为什么经常流鼻血| 脑梗塞用什么药效果好| 膀胱充盈欠佳是什么意思| 发生什么事了| 巨细胞病毒抗体阳性是什么意思| 月痨病是什么病| 14k金是什么意思| 胆管堵塞有什么症状| 88年什么命| 眼前的苟且是什么意思| 千古一帝指什么生肖| 66年属什么| 梅雨季节什么时候结束| 良善是什么意思| 纤维化是什么意思| 脑供血不足吃什么食物好| ch2o是什么物质| 假牛肉干是什么做的| 坐卧针毡是什么生肖| 百度
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >Go 语言中的并发编程

吃什么解油腻

原创
作者头像
叫我阿杰好了
发布于 2025-08-07 12:23:32
发布于 2025-08-07 12:23:32
百度 再比如,针对缺乏配套养老设施用地、用房的老旧社区,政府可以出资或向社会募资,从居民手中购买一些住宅,改造成“老年活动室”。 710
举报
文章被收录于专栏:Go入门到入土Go入门到入土

1、Go 并发编程出体验

大家好,欢迎来到本章节。今天,我们将深入探讨 Go 语言的核心魅力之一:并发编程。

许多开发者选择 Go,正是看中了它简洁而强大的并发模型,能够轻松编写出高性能的并发程序。与 Java、Python 或 PHP 等传统语言主要依赖多线程实现并发不同,Go 语言提供了一种更为轻量且高效的范式。

1、从线程到协程:并发模型的演进

在传统的多线程编程中,我们面临两个主要挑战:

  1. 内存消耗:操作系统级别的线程(OS Thread)通常会预分配较大的栈空间,例如每个线程可能占用几兆字节的内存。
  2. 调度成本:线程的创建、销毁和切换(上下文切换)都由操作系统内核直接管理,这是一个相对昂贵的操作。当线程数量增多时,调度开销会显著影响系统性能。

随着 Web 2.0 时代的到来,高并发场景日益普遍,传统的多线程模型在某些情况下开始显得力不从心。为了应对这一挑战,用户级线程(User-level Thread)应运而生。它有多种称谓,如“绿色线程”(Green Thread)、“轻量级线程”,但目前最通用的叫法是 “协程”(Coroutine)

如今,许多主流语言都在积极拥抱协程:

  • Pythonasyncio
  • PHPSwoole
  • Java 社区的 Project LoomNetty

协程的核心优势在于:

  • 内存占用极小:一个 Go 协程(Goroutine)的初始栈大小仅为 2KB。
  • 切换速度极快:协程的调度在用户态完成,由语言的运行时(Runtime)管理,避免了昂贵的内核态切换。可以将其理解为函数级别的切换,效率远高于线程。

2、Goroutine:Go 语言的并发基石

Go 语言作为一门为并发而生的现代语言,从设计之初就彻底摒弃了传统的线程概念,直接将协程作为其并发的唯一实现,并称之为 Goroutine

这种设计的最大好处在于生态的统一性。在 Go 中,开发者无需在线程和协程之间做选择,所有标准库和第三方库都原生支持 Goroutine。这避免了其他语言中两种并发模型并存所带来的生态割裂和开发混淆问题。

Go 的另一个显著特点是其极致的简洁性。启动一个 Goroutine 非常简单,我们马上就会看到。

快速上手:启动你的第一个 Goroutine

让我们通过代码来直观感受 Goroutine 的魅力。

假设我们有一个简单的打印函数:

image.png
image.png

main 函数中,我们想异步执行它,只需在函数调用前加上 go 关键字。

运行这段代码,我们会发现一个问题:程序只打印了 Hello from main!,而 Hello from Goroutine! 并没有出现。

这是因为 main 函数本身也运行在一个主 Goroutine 中。当主 Goroutine 执行完毕,整个程序就会立即退出,而此时我们新启动的子 Goroutine 可能还未来得及被调度执行。这引出了一个重要原则:主死从随

为了看到效果,我们可以简单地让主 Goroutine 等待片刻:

PixPin_2025-08-07_10-20-30.gif
PixPin_2025-08-07_10-20-30.gif

再次运行,你会看到 Hello from main! 先被打印,随后才是 Hello from Goroutine!,这清晰地证明了 asyncPrint 是被异步执行的。

匿名函数与 Goroutine

在实际开发中,许多一次性的并发任务并不需要定义一个具名函数。这时,匿名函数就显得非常方便。

image.png
image.png

这种写法在 Go 代码中非常普遍,它将并发逻辑内联,使代码更紧凑。

启动多个 Goroutine 与闭包陷阱

Go 可以轻松启动成千上万个 Goroutine。让我们尝试在一个循环中启动 100 个 Goroutine,并让每个 Goroutine 打印自己的序号。

一个常见的错误写法是这样的:

image.png
image.png

运行后,你会发现输出结果非常混乱,可能会出现大量相同的数字(通常是循环结束后的值,如 10)。

原因在于闭包(Closure)和循环变量的重用go 关键字只会立即启动 Goroutine,但 Goroutine 内部的代码何时被调度执行是不确定的。当 Goroutine 真正执行 fmt.Println(i) 时,它引用的是外层 for 循环的变量 i。而此时,for 循环很可能已经执行完毕,i 的值已经变成了 10。所有的 Goroutine 都共享同一个 i 变量的内存地址,因此它们最终打印出来的值,取决于它们被调度时 i 的瞬时值。

为了解决这个问题,我们需要在每次循环时,为 Goroutine 复制一份循环变量的副本。

方法一:使用临时变量
image.png
image.png

temp 是每次循环的局部变量,每个 Goroutine 捕获的是不同的 temp 实例。

方法二:通过函数参数传递(更推荐)
image.png
image.png

这是更优雅和地道的 Go 写法。通过参数传递,Go 会在调用时对 i 进行值拷贝,确保每个 Goroutine 得到的是循环迭代时的正确值。

2、协程与线程调度原理解析

1、传统线程模型的局限

在探讨 Go 语言的并发模型之前,我们首先需要理解传统多线程编程的机制及其固有的局限性。在大多数编程语言中,例如 Java 或 C++,我们创建的线程通常与操作系统的内核线程(Kernel Thread)直接对应。

当我们启动一个新线程时,本质上是请求操作系统创建一个对应的内核线程。这个过程涉及与操作系统的深度交互。无论是 Windows 还是 Linux,操作系统都需要为这个线程分配和管理一系列资源,包括独立的栈空间、寄存器状态等。这个内核级的线程结构体相对庞大,创建成本较高。

更关键的是 线程切换(Context Switching) 的开销。当多个线程并发执行时,CPU 需要在它们之间快速切换,以实现宏观上的并行。每次切换,操作系统都必须执行一系列重量级的操作:

  1. 保存现场:将当前线程的寄存器状态、程序计数器等信息保存到内存中。
  2. 调度决策:由操作系统调度器决定下一个要运行的线程。
  3. 恢复现场:加载新线程的上下文信息到寄存器中。

这个过程直接涉及到从用户态到内核态的转换,带来了显著的性能损耗。因此,在传统模型下,创建和切换大量线程的代价非常高昂,这严重限制了应用程序的并发能力。

2、Go 语言的解决方案:Goroutine 与 GMP 调度模型

Go 语言从设计之初就旨在解决高并发的痛点。它引入了 Goroutine(协程) 的概念,这是一种比线程更轻量级的 用户级线程。开发者可以轻松创建成千上万甚至上百万个 Goroutine,而不会耗尽系统资源。

其核心在于 Go 的运行时(Runtime)内置了一个高效的 调度器,它实现了著名的 GMP 模型,将大量的 Goroutine 映射到少量的内核线程上执行。

GMP 模型包含三个核心组件:

  • G (Goroutine):代表一个 Goroutine。它拥有自己的栈空间、指令指针和状态。G 是执行任务的基本单位,创建和销毁的成本极低。
  • M (Machine):代表一个内核线程,由操作系统管理。M是真正执行代码的实体。
  • P (Processor):代表一个逻辑处理器,是 G 和 M 之间的“协调者”。P 拥有一个本地的 Goroutine 队列(Local Run Queue),它从队列中取出 G,并将其交给一个 M 来执行。
GMP 的协作流程
  1. M:N 映射:GMP 模型实现了 M 个内核线程执行 N 个 Goroutine 的调度。通常情况下,P 的数量默认等于 CPU 的核心数,确保计算资源得到充分利用。
  2. 本地队列与锁竞争:一个朴素的想法是让所有 G 放入一个全局队列,然后由所有 M 去争抢。但这会引入全局锁,导致严重的性能瓶颈。GMP 的精妙之处在于,每个 P 都有自己的 本地队列。当一个 P 绑定一个 M 时,M 会优先执行 P 本地队列中的 G。这极大地减少了锁竞争,提升了并行效率。
  3. 任务窃取(Work Stealing):如果一个 P 的本地队列为空,而其他 P 的队列中仍有待处理的 G,它会“窃取”其他 P 队列中的一部分 G 来执行,从而实现负载均衡,避免 M 闲置。

3、调度器如何应对阻塞?

并发编程中一个常见且棘手的问题是 阻塞。如果一个 Goroutine 因为执行了系统调用(Syscall),如文件 I/O 或网络请求,而导致其所在的 M 被阻塞,该怎么办?

Go 的调度器对此有智能的处理机制:

  • 阻塞型系统调用:当一个 G 即将执行阻塞型系统调用时,Go 运行时会将该 G 所在的 P 与 M 解绑。M 会继续等待系统调用返回,而 P 则会寻找一个空闲的 M(或者创建一个新的 M)来继续执行其本地队列中的其他 G。这样,单个 G 的阻塞就不会影响整个程序的执行。
  • 非阻塞型网络 I/O:对于网络轮询等操作,Go 采用了基于 netpoller 的非阻塞 I/O 模型。当 G 发起网络请求时,它不会阻塞 M,而是被注册到 netpoller 中并转为等待状态。M 可以继续执行其他 G。当网络 I/O 就绪时,netpoller 会通知调度器,对应的 G 会被重新放回 P 的队列中等待执行。

这种机制保证了即使在涉及大量 I/O 操作的场景下,CPU 资源也能得到高效利用。

4、总结

通过 GMP 调度模型,Go 语言巧妙地将线程管理的复杂性从开发者手中移交给了运行时。它用轻量级的 Goroutine 替代了昂贵的内核线程,并通过高效的调度策略,实现了以下优势:

  • 极低的创建成本:Goroutine 的栈空间可以动态伸缩,初始大小仅为几 KB。
  • 高效的上下文切换:Goroutine 的切换在用户态即可完成,无需陷入内核,速度远快于线程切换。
  • 卓越的并发能力:智能的调度和任务窃取机制,使得 Go 能够轻松驾驭海量并发任务,成为构建高并发服务的理想选择。

理解 GMP 模型,是深入掌握 Go 并发编程、写出高性能 Go 程序的基础。

3、使用 WaitGroup 实现优雅同步

1、问题的提出:time.Sleep 的局限性

之前我们使用 time.Sleep 来强制主 goroutine 等待,以确保子 goroutine 有足够的时间执行完毕。虽然这种方法能暂时解决问题,但它显然不够优雅,甚至可以说是不可靠的。

核心问题在于:我们无法预知所有子 goroutine 完成任务究竟需要多长时间。

image.png
image.png

这种硬编码的等待时间导致了两个主要问题:

  1. 时间过短:子 goroutine 可能没有全部执行完,导致程序提前退出。
  2. 时间过长:主 goroutine 会不必要地空闲等待,浪费系统资源。

我们需要一种更精确的同步机制,让主 goroutine 能够明确地知道:所有它派生出去的子 goroutine 是否已经全部执行完毕

2、解决方案:sync.WaitGroup

Go 语言在 sync 包中为我们提供了 WaitGroup,它正是为解决上述问题而设计的。WaitGroup 可以等待一组 goroutine 全部完成。它内部维护着一个计数器,通过这个计数器来实现精确的同步。

WaitGroup 的使用非常直观,主要涉及三个核心方法:

  1. Add(delta int):将计数器的值增加 delta。通常我们在启动一个新的 goroutine 之前调用 Add(1)
  2. Done():将计数器的值减 1。每个被等待的 goroutine 在执行结束时,都必须调用此方法。
  3. Wait():阻塞调用此方法的 goroutine,直到 WaitGroup 的计数器值变为 0。

3、WaitGroup 的基本用法

让我们重写之前的代码,这次使用 WaitGroup 来实现同步。

image.png
image.png

代码解析

  1. wg.Add(100):在循环开始前,我们将计数器设置为 100。
  2. wg.Wait():在 main 函数的末尾调用。它会阻塞 main goroutine,直到 WaitGroup 的计数器减少到 0。
  3. defer wg.Done():这是每个子 goroutine 中的关键部分。defer 关键字确保了无论函数后续逻辑如何,wg.Done() 都会在函数退出前被执行。这是一种健壮的写法,可以有效防止忘记调用 Done() 而导致死锁。

运行这段代码,你会发现程序会稳定地等待所有 100 个 goroutine 执行完毕后才优雅地退出,不再需要猜测等待时间。

4、常见陷阱与最佳实践

陷阱:忘记调用 Done() 导致死锁

如果在 goroutine 中忘记调用 wg.Done()WaitGroup 的计数器将永远无法归零。这会导致 wg.Wait() 无限期地阻塞下去,最终程序会因为所有 goroutine 都处于等待状态而崩溃,并抛出 fatal error: all goroutines are asleep - deadlock! 的错误。

最佳实践:使用 defer

为了避免忘记调用 Done(),最佳实践是在 goroutine 的第一行就使用 defer wg.Done()。这不仅能保证 Done() 被调用,也让代码意图更加清晰。

image.png
image.png
Add() 的另一种用法

如果 goroutine 的数量在运行时才能确定,也可以在每次循环迭代时调用 wg.Add(1)

代码语言:go
复制
for i := 0; i < taskCount; i++ {
    // 在每次启动 goroutine 前加 1
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        // ...
    }(i)
}

这两种 Add 的使用方式效果相同,但如果任务总数是固定的,一次性 Add(taskCount) 会显得更为简洁。

5、总结

WaitGroup 是 Go 并发编程中用于 goroutine 同步的基础且强大的工具。它通过简单的计数器机制,提供了一种优雅而可靠的方式来替代 time.Sleep,确保主程序能精确地等待一组异步任务全部完成。

使用 WaitGroup 时,请牢记以下核心原则:

  • Add()Done() 必须成对出现Add 的总增量必须等于 Done 的总调用次数。
  • 优先使用 defer wg.Done():将其作为 goroutine 的第一条语句,以保证其执行并避免死锁。

4、互斥锁(Mutex)

在 Go 语言中,锁(Lock)的机制相比于 Java 等语言要简洁得多。Go 语言的设计哲学并不推荐通过共享内存的方式进行通信,而是提倡“通过通信来共享内存”。因此,其内置的同步原语相对较少。

本节,我们将深入探讨 Go 中最基础的同步原语之一:互斥锁(sync.Mutex,并解决并发编程中一个经典的问题——资源竞争(Race Condition)

1、资源竞争的实例

当多个 Goroutine 同时对一个共享变量进行读写操作时,如果没有适当的同步机制,就会产生资源竞争,导致程序的结果出现不可预期的错误。

让我们通过一个例子来直观感受一下。假设我们有一个全局变量 total,我们启动两个 Goroutine:一个对其执行 10 万次加法,另一个执行 10 万次减法。

image.png
image.png

从逻辑上看,一个加 10 万,一个减 10 万,最终 total 的值应该为 0。但当我们运行这段代码时,会发现结果几乎每次都不同,且 rarely 为 0

image.png
image.png
image.png
image.png

为什么会出现这种情况?

2、 问题的根源:非原子操作

问题的关键在于 total++total-- 这样的操作在底层并非 原子性(Atomic) 的。它实际上由三个独立的步骤组成:

  1. 加载(Load):从内存中读取 total 的当前值到 CPU 寄存器。
  2. 计算(Compute):在寄存器中对值进行加一或减一。
  3. 写入(Store):将寄存器中的新值写回内存。

由于 Goroutine 可以在任何时刻被调度器中断和切换,就可能出现以下场景:

  1. add Goroutine 加载 total 的值(假设为 0)到自己的寄存器。
  2. 此时发生上下文切换,sub Goroutine 开始执行。
  3. sub Goroutine 也加载 total 的值(仍然是 0)到自己的寄存器。
  4. sub 计算 0 - 1 = -1,并将 -1 写回内存。现在 total-1
  5. 再次切换回 add Goroutine。它基于自己之前加载的 0 进行计算,0 + 1 = 1,然后将 1 写回内存。

最终,total 的值是 1,而不是预期的 0。一次减法操作被完全覆盖了。当这种竞争发生数万次时,最终的结果就变得完全不可预测。

3. 使用互斥锁(sync.Mutex)解决问题

要解决这个问题,我们必须确保在任一时刻,只有一个 Goroutine 能够执行 total 的读改写操作。这块需要被保护起来的代码区域,我们称之为 临界区(Critical Section)

Go 语言的 sync 包提供了 Mutex(互斥锁)来实现这个目标。

image.png
image.png

通过在修改 total 之前调用 lock.Lock() 并在之后调用 lock.Unlock(),我们确保了 total++ 这段临界区代码的原子性。当一个 Goroutine 持有锁时,其他任何试图获取该锁的 Goroutine 都会被阻塞,直到锁被释放。

现在,无论运行多少次,最终结果都将稳定地为 0

重要提示:锁(sync.Mutex)是值类型,但绝对不能被复制。一旦复制,新的副本和原始锁将是两个独立的锁,无法实现互斥效果。

4. 更高效的选择:原子操作(sync/atomic

对于像整数加减这样简单的场景,使用互斥锁虽然能解决问题,但其开销相对较大。Go 语言的 sync/atomic 包为此类操作提供了更高效、硬件级别的原子指令。

我们可以用 atomic.AddInt32 来重写上面的例子:

image.png
image.png

Mutex vs atomic

  • atomic:性能更高,适用于保护简单的、原生的数据类型(如 int32, int64 等)的单一操作。
  • Mutex:更通用、更灵活。它可以保护一个复杂的代码块,比如一个包含多行代码的逻辑单元,或者对一个结构体进行多个字段的修改。

总结

在并发编程中,只要存在对共享资源的写操作,就必须考虑同步问题。

  • 使用 sync.Mutex 是保护临界区、防止数据竞争的通用方法。
  • 对于简单的计数器等场景,使用 sync/atomic 包能获得更好的性能。

掌握锁的使用是编写健壮并发程序的基石。

5、读写锁(RWMutex)

1、锁的本质与性能瓶颈

在探讨读写锁之前,我们首先需要回顾一下锁的本质。无论是何种锁,其核心作用都是将并发环境中并行的代码执行流强制串行化,以此来保证共享资源在同一时刻只被一个 Goroutine 访问,从而确保数据的一致性。

然而,这种串行化也带来了不可避免的性能代价。一旦加锁,原本可以并行执行的任务被迫排队等待,这无疑会降低程序的整体吞吐量。因此,在并发编程中,我们的一个核心原则是:在保证数据安全的前提下,应尽可能地减少锁的粒度和持有时间,最大限度地维持程序的并行度。

2、"读多写少" 场景的优化需求

在实际的开发场景中,我们经常遇到“读多写少”的情况。例如,一个电商系统的商品详情页,其读取操作的频率远远高于编辑和更新操作的频率。同样,系统的配置信息、基础数据等也属于典型的读多写少资源。

对于这类场景,如果我们依然使用标准的互斥锁 (sync.Mutex),就意味着即使是多个并发的“读”操作,也必须像“写”操作一样排队等待,这显然是一种性能浪费。因为“读”操作本身并不会修改数据,理论上它们之间可以安全地并行执行。

为了优化这种情况,读写锁 (sync.RWMutex) 应运而生。它的设计目标非常明确:

  • 读与读:可以并行。多个读操作可以同时持有读锁,互不影响。
  • 读与写 / 写与写:必须互斥。写操作会阻塞其他所有读操作和写操作,确保在修改数据时不会被读取到中间状态,也不会被其他写操作干扰。

这种策略使得读写锁在“读多写少”的场景下,能够显著提升程序的性能。

3. sync.RWMutex 的使用

Go 语言标准库 sync 提供了 RWMutex 类型来实现读写锁。它提供了两对核心方法:

  • 写操作
    • Lock(): 获取写锁。如果锁已被其他读锁或写锁持有,则阻塞。
    • Unlock(): 释放写锁。
  • 读操作
    • RLock(): 获取读锁。如果锁已被写锁持有,则阻塞;否则,增加读锁计数,允许并发读取。
    • RUnlock(): 释放读锁。

下面,我们通过一个具体的例子来演示 RWMutex 的工作机制。

4. 代码示例

在这个示例中,我们将创建一个“写” Goroutine 和多个“读” Goroutine,来观察它们在读写锁下的行为。

image.png
image.png
image.png
image.png

4、运行结果分析

运行上述代码,你会观察到类似以下的输出顺序:

  1. 初始并发读取:前 5 个 Reader 会几乎同时获取到读锁,并开始读取初始数据(0)。这证明了“读-读”可以并行。
  2. 写操作阻塞:当 Writer 尝试获取写锁时,它会等待所有已持有的读锁被释放。一旦释放完毕,Writer 立即获取写锁,此时后启动的 5 个 Reader 会被阻塞,无法获取读锁。
  3. 写操作独占:Writer 在持有写锁的 2 秒内,是独占资源的。所有 Reader 都在排队等待。
  4. 写后并发读取:当 Writer 释放写锁后,所有等待的 Reader 会立即(几乎同时)获取到读锁,并读取到被修改后的新数据(100)。

这个过程清晰地展示了 RWMutex 如何在保证写操作原子性的同时,极大地提升了读操作的并发能力。

6、总结

读写锁 (sync.RWMutex) 是 Go 并发编程中针对“读多写少”场景的性能优化利器。它通过允许多个读操作并发执行,显著提高了程序的吞吐量。在设计并发系统时,准确识别数据访问模式并选择合适的锁策略(MutexRWMutex),是构建高性能服务的关键一步。

6、Channel 通信机制

在探讨了 Go 语言中的并发同步机制之后,本节我们将聚焦于另一个核心主题:Goroutine 之间的通信。Goroutine 间的通信是 Go 并发编程的基石,其设计哲学和实现方式独树一帜。

1、设计哲学:通过通信共享内存

Go 语言推崇一句经典格言:

不要通过共享内存来通信,而要通过通信来实现内存共享。

这句话深刻地揭示了 Go 与其他主流编程语言(如 Java, Python, PHP)在并发模型上的核心差异。在许多语言中,多线程通信最常见的方式是在内存中声明一个全局变量。一个线程修改该变量,另一个线程通过轮询或加锁的方式读取,以此进行协作。

虽然消息队列(Queue)作为生产者-消费者模型的实现,在其他语言中也广泛应用,但 Go 语言将其提升到了语言设计的核心地位。Go 鼓励开发者首选队列模式进行消息传递,并为此内置了强大的原生工具——Channel(通道),通过简洁的语法糖,让这一模式的使用变得异常简单和高效。

2、Channel 的基本概念与使用

我们可以将 Channel 理解为连接两个 Goroutine 的桥梁或管道。当一个 Goroutine(例如 G1)需要向另一个 Goroutine(G2)发送数据时,它只需将数据放入这个预先建立好的通道中。G2 则在通道的另一端接收数据。这个过程可以是阻塞的,一旦数据送达,接收方会立刻感知。

1. 定义与初始化

在 Go 这种静态类型语言中,Channel 在定义时必须指定其允许传输的数据类型。

代码语言:go
复制
// 定义一个用于传输 string 类型数据的 Channel
var messages chan string

slicemap 类似,一个未经初始化的 Channel 的零值为 nil。对 nil Channel 的任何读写操作都会导致永久阻塞。因此,在使用前必须对其进行初始化。初始化通常使用 make 函数:

代码语言:go
复制
// 初始化一个 Channel
messages = make(chan string)
2. 发送与接收

Go 为 Channel 操作提供了优雅的语法糖 <-

  • 发送数据:将值放在 <- 符号的右侧,Channel 变量放在左侧。// 将字符串 "bobby" 发送到 messages 通道中 messages <- "bobby"
  • 接收数据:将 <- 符号放在 Channel 变量的右侧,通常用于赋值。
代码语言:go
复制
// 从 messages 通道接收一个值,并存入 data 变量
data := <-messages

3、缓冲 Channel 与无缓冲 Channel

make 函数在初始化 Channel 时可以接受第二个可选参数,用于指定 Channel 的缓冲区大小。这个参数决定了 Channel 的行为模式。

代码语言:go
复制
// 初始化一个无缓冲的 Channel (缓冲区大小为 0)
unbufferedChan := make(chan int, 0)

// 初始化一个有缓冲的 Channel (缓冲区大小为 10)
bufferedChan := make(chan int, 10)

无缓冲 Channel (Unbuffered Channel)

当缓冲区大小为 0 时,我们称之为无缓冲 Channel。它的特点是:

  • 发送操作会阻塞,直到另一个 Goroutine 准备好从该 Channel 接收数据。
  • 接收操作会阻塞,直到另一个 Goroutine 向该 Channel 发送数据。

这种同步特性意味着发送和接收操作必须同时准备就绪。如果在同一个 Goroutine 中对无缓冲 Channel 先写后读,或先读后写,都会导致死锁(deadlock)

image.png
image.png

正确用法:要解决无缓冲 Channel 的死锁问题,必须在不同的 Goroutine 中执行发送和接收操作。

image.png
image.png

Go 语言的 Happens-Before 内存模型保证了对无缓冲 Channel 的一次成功通信(发送与接收配对),会建立一个同步点,确保发送操作之前的代码对接收操作之后可见。这使得我们能安全地使用无缓冲 Channel 进行 Goroutine 间的同步和数据交换。

缓冲 Channel (Buffered Channel)

当缓冲区大小大于 0 时,我们称之为缓冲 Channel。它的特点是:

  • 发送操作仅在缓冲区满时阻塞。只要缓冲区还有空间,发送操作会立刻完成,数据被放入缓冲区。
  • 接收操作仅在缓冲区为空时阻塞

缓冲 Channel 提供了一个异步的通信方式,解耦了发送和接收操作的时间依赖。

image.png
image.png

4、应用场景与选择

理解不同 Channel 的特性后,关键在于根据具体需求选择合适的类型。

无缓冲 Channel:强同步与事件通知

无缓冲 Channel 主要用于实现两个 Goroutine 之间的紧密同步。由于发送和接收必须同时准备就绪,它非常适合以下场景:

  • 事件通知:当 Goroutine B 需要在第一时间确切地知道 Goroutine A 是否已完成某个特定任务时,无缓冲 Channel 是最佳选择。A 完成任务后向 Channel 发送一个信号,B 会立刻被唤醒。这种即时性是无缓冲的核心优势。
有缓冲 Channel:解耦与流量控制

有缓冲 Channel 则主要用于解耦生产者和消费者,尤其是在两者处理速度不匹配的情况下。它适用于:

  • 生产者-消费者模型:例如,一个网络爬虫程序,生产者 Goroutine 快速地生成待抓取的 URL 并放入缓冲 Channel,而多个消费者 Goroutine 则以自己的节奏从 Channel 中取出 URL 进行处理。缓冲区起到了平滑流量、提高系统整体吞吐量的作用。Channel 的更广泛应用

在 Go 的并发实践中,Channel 的应用远不止于此。它是构建复杂并发模式的基础,常见应用包括:

  • 消息传递与过滤:在多个 Goroutine 之间可靠地传递数据。
  • 信号广播:一个 Goroutine 向多个订阅者 Goroutine 广播事件。
  • 任务分发:将任务单元通过 Channel 分配给一组工作 Goroutine。
  • 结果汇总:从多个并发任务中收集处理结果。
  • 并发控制:利用 Channel 的缓冲区来限制并发执行的 Goroutine 数量。
  • 实现同步与异步:根据 Channel 的类型(有无缓冲)灵活切换通信模式。

5、小结与注意事项

Channel 是 Go 并发编程的利器,但在使用时需特别注意,以避免死锁。

常见的死锁场景

  1. sync.WaitGroup:当 Add 的计数值与 Done 的调用次数不匹配时(例如忘记调用 Done),Wait 方法会永久阻塞。
  2. 无缓冲 Channel:在单个 Goroutine 中进行读写,或所有参与通信的 Goroutine 都陷入阻塞状态(例如都在等待接收或发送)。

正确理解并区分缓冲与无缓冲 Channel 的行为模式,并根据应用场景做出恰当选择,是编写健壮、高效的 Go 并发程序的关键。

7、使用 for range 优雅地消费与关闭

在之前的讨论中,我们掌握了 Go channel 的基本数据收发操作。但在真实的并发场景中,生产者(Producer)会持续地向 channel 发送数据流,而消费者(Consumer)则需要相应地、不间断地从中读取和处理。

显然,通过一次次手动调用接收操作 <-ch 的方式来消费数据,不仅代码冗余,而且难以管理。如果 channel 中没有数据,调用还会阻塞。那么,我们如何才能实现一种更优雅、更自动化的连续消费机制呢?

1、使用 for range 循环消费 Channel

Go 语言为此提供了一个非常强大的语法糖:for range 循环。就像遍历切片(slice)或映射(map)一样,for range 可以直接应用于 channel。它会智能地处理接收逻辑:

  • 当 channel 中有数据时,for range 会读取该数据并执行一次循环体。
  • 当 channel 中没有数据时,for range 会自动阻塞,等待新的数据到来。
  • 当 channel 被关闭(closed)并且其中已无数据时,循环会自动结束。

让我们来看一个例子。假设我们有一个缓冲大小为 2 的 channel,生产者向其中放入了两个整数。

image.png
image.png

我们发现程序会打印出 12,然后就卡住了。这是因为 for range 在消费完所有数据后,并不知道生产者是否还会发送新的数据,于是它会永远等待下去,最终导致 main goroutine 和子 goroutine 全部阻塞,程序 panic。

2、使用 close() 优雅地关闭 Channel

这就引出了 channel 的一个核心概念:关闭

当生产者确认所有数据都已发送完毕时,它有责任通过调用内置的 close() 函数来关闭 channel。这个关闭操作就像一个广播信号,通知所有正在等待的消费者:“数据已经全部发送完毕,不会再有新值了。”

一旦 channel 被关闭,for range 循环在读取完所有已缓冲的数据后,就会自动检测到关闭状态并安全退出。

让我们完善上面的例子:

image.png
image.png

现在再次运行,你会看到程序在消费完 12 之后,消费者的 for range 循环会正常退出,wg.Wait() 解除阻塞,整个程序优雅地结束。

3、已关闭 Channel 的特性

关于关闭后的 channel,有两条至关重要的规则需要牢记:

  1. 禁止向已关闭的 channel 发送数据。 这是一种严重的运行时错误,会立即引发 panic: panic: send on closed channel。因此,必须确保只有数据的发送方(通常是唯一的生产者)负责关闭 channel,并且在关闭后不再尝试发送。
代码语言:go
复制
    ch := make(chan int, 1)
    ch <- 1
    close(ch)
    ch <- 2 // 这里会引发 panic
  1. 可以从已关闭的 channel 持续接收数据。 这个行为非常特别:
    • 如果 channel 中仍有缓冲的数据,接收操作会依次取出这些值。
    • 当所有数据都被取完后,任何后续的接收操作都不会阻塞,而是会立即返回该 channel 类型的零值(例如 int 的零值是 0string"",指针是 nil)。
代码语言:go
复制
    ch := make(chan int, 2)
    ch <- 10
    ch <- 20
    close(ch)
    
    fmt.Println(<-ch) // 输出: 10
    fmt.Println(<-ch) // 输出: 20
    fmt.Println(<-ch) // 输出: 0 (channel 已空,返回 int 的零值)
    fmt.Println(<-ch) // 输出: 0 (仍然可以接收,仍然返回零值)

这个特性保证了消费者可以安全地排空 channel 中的所有数据,而不用担心在 channel 关闭的瞬间丢失数据。

总结
  • 使用 for range 是循环消费 channel 数据的首选方式,它能极大地简化代码并自动处理阻塞。
  • 使用 close() 函数是实现生产者与消费者之间优雅通信、避免死锁和 goroutine 泄漏的关键。它明确地标志了数据流的结束。

正确理解并熟练运用 for rangeclose(),是掌握 Go 并发编程、编写出健壮、高效的并发程序的基石。

8、单向Channel

在 Go 语言的并发编程中,Channel 是一个核心且强大的工具。默认情况下,我们创建的 Channel 都是 双向的(Bidirectional),这意味着我们可以通过同一个 Channel 发送数据,也可以从中接收数据。

然而,在许多实际场景中,尤其是在将 Channel作为函数参数传递时,我们往往希望对其功能进行限制,以增强代码的健壮性和可读性。例如,一个函数可能只被设计为数据的生产者,我们希望它只能向 Channel 发送数据;而另一个函数作为消费者,我们希望它只能从 Channel 读取数据。

为了满足这种需求,Go 语言提供了 单向 Channel(Unidirectional Channel) 的概念。

1、为什么需要单向 Channel?

设想一个场景:我们有一个用于处理数据的函数,它需要从一个 Channel 中读取数据。虽然它的设计初衷是读取,但由于传入的是一个双向 Channel,调用者在函数内部依然拥有写入该 Channel 的权限。

image.png
image.png

这种不受约束的权限可能会引入难以追踪的 bug。通过将 Channel 的使用方向限制为单向,我们可以在编译层面就杜绝这类错误,让函数签名本身就成为一种清晰的文档,明确其行为。

2、定义与使用单向 Channel

单向 Channel 的定义非常直观,通过在 chan 关键字旁边添加 <- 箭头来表示数据流动的方向。

定义与使用单向 Channel

单向 Channel 的定义非常直观,通过在 chan 关键字旁边添加 <- 箭头来表示数据流动的方向。

  1. 只写 Channel (Send-only):箭头指向 chan,表示只能向其发送数据。
代码语言:go
复制
    // ch 是一个只能写入 float64 类型的 Channel
    var ch chan<- float64
  1. 只读 Channel (Receive-only):箭头远离 chan,表示只能从其接收数据。
代码语言:go
复制
    // ch 是一个只能读取 int 类型的 Channel
    var ch <-chan int

类型转换

Go 语言允许我们将一个双向 Channel 转换为任意一种单向 Channel,但反之则不行。这个特性是保证类型安全的关键。

image.png
image.png

重要提示:你无法将一个单向 Channel 转换回双向 Channel。这种限制是设计上的,它强制我们遵循更严格的编程模型。

代码语言:go
复制
// 尝试将单向转回双向,会导致编译错误
// var bidirectionalChan chan int = sendOnly // cannot convert sendOnly (type chan<- int) to type chan int

3、实战:生产者与消费者模型

单向 Channel 在 生产者-消费者 模型中表现得尤为出色。生产者只需要写入数据,而消费者只需要读取数据。

下面是一个完整的示例:

  • producer 函数:作为生产者,它接受一个只写的 Channel (chan<- int),负责生产数据并发送到 Channel,完成后关闭 Channel。
  • consumer 函数:作为消费者,它接受一个只读的 Channel (<-chan int),负责从 Channel 中读取并处理数据。
image.png
image.png

main 函数中,我们创建了一个双向 Channel ch。当我们将它传递给 producerconsumer 时,Go 会根据函数签名自动进行类型转换。这使得 producer 内部无法读取 ch,而 consumer 内部无法写入 ch,从而在编译期就保证了操作的安全性。

4、总结

单向 Channel 是 Go 语言并发模型中一个精妙的设计。它通过类型系统为我们提供了一种强大的约束机制,使得代码意图更清晰、逻辑更安全、维护性更高。在设计并发程序时,尤其是在构建复杂的 Channel 协作流程时,善用单向 Channel 是一个非常值得推荐的最佳实践。

9、Go Channel面试题:交替打印数字和字母

本节内容,我们来讲解一道常见的 Go 语言 Channel 面试题:如何使用两个 goroutine 交替打印数字和字母序列,最终实现 12AB34CD... 这样的效果。

这个问题的核心在于“交替”,即一个 goroutine 在打印完两个数字后,需要通知另一个 goroutine 开始打印两个字母,反之亦然。这种 goroutine 间的通信和同步,正是 Channel 的理想应用场景。

核心思路与实现

为了实现交替通知,我们需要创建两个 Channel,一个用于触发数字打印,另一个用于触发字母打印。

代码语言:go
复制
// numberChan 用于通知打印数字的 goroutine
var numberChan = make(chan bool)
// letterChan 用于通知打印字母的 goroutine
var letterChan = make(chan bool)

我们选择无缓冲的布尔类型 Channel,因为我们只关心“通知”这个事件本身,而不需要传递具体的数据。

1. 打印数字的 Goroutine

我们首先实现打印数字的 goroutine。它在一个 for 循环中,每次递增 2,并打印 ii+1

关键在于,在每次打印之前,它必须等待来自 numberChan 的信号。这个等待是通过 <-numberChan 实现的,该操作会阻塞当前 goroutine,直到接收到信号。打印完成后,它会向 letterChan 发送一个信号,通知字母打印 goroutine 开始工作。

image.png
image.png

2. 打印字母的 Goroutine

打印字母的 goroutine 逻辑非常相似。它同样在一个 for 循环中,通过索引遍历一个包含所有字母的字符串。

在打印前,它会阻塞并等待 letterChan 的信号。打印完两个字母后,它再通过 numberChan 发出通知,将执行权交还给数字打印 goroutine。

image.png
image.png

注意: 此处增加了一个 if i >= len(letterStr) 的边界检查。如果不加检查,当字母打印完毕后,letterStr[i] 会导致索引越界(index out of range)的 panic。

3. 启动与调度

main 函数中,我们创建并启动这两个 goroutine。为了启动整个交替打印的流程,我们需要发送第一个信号。因为要求先打印数字,所以我们向 numberChan 发送一个初始信号。

最后,为了防止主 goroutine 退出导致整个程序提前结束,我们需要让主 goroutine 等待。这里可以使用 time.Sleep 或更优雅的方式如 sync.WaitGroup 来实现。

image.png
image.png

总结

运行代码后,可以看到控制台正确地交替打印出了 12AB34CD... 的序列。

这个例子清晰地展示了 Channel 在 Go 并发编程中的核心价值:通过通信来共享内存(Don't communicate by sharing memory, share memory by communicating)。它能够优雅地实现 goroutine 间的同步与协作,这也是 Go 语言所推崇的并发模型。在处理需要多任务协作的问题时,应当优先考虑使用 Channel 来构建清晰、安全的通信机制。

10、Select语句

在 Go 语言的并发编程模型中,channel 用于在 goroutine 之间传递数据,是实现通信和同步的关键。然而,当我们需要同时处理多个 channel 时,select 语句就成为了不可或缺的利器。它的语法结构与 switch 语句相似,但其核心功能是实现多路 channel 的并发控制。

本文将深入探讨 select 语句的工作原理、核心特性以及在实际开发中的典型应用场景,例如如何优雅地实现超时控制。

1. 问题提出:如何监控多个Goroutine?

假设我们有这样一个需求:启动了两个执行时间不同的 goroutine,我们希望在主 goroutine 中能够第一时间知道哪一个先执行完毕。

初始方案:共享变量与锁

一个直接的想法是使用全局共享变量和互斥锁(Mutex)来实现。

image.png
image.png

这种方法虽然能实现功能,但存在明显缺陷:

  • 效率低下:主 goroutine 需要通过 for 循环不断轮询检查 done 标志位,即使加入了 time.Sleep 来减少 CPU 消耗,这种“忙等”模式依然不够高效。
  • 违背Go的设计哲学:Go 提倡“不要通过共享内存来通信,而要通过通信来共享内存”。上述方法正是通过共享内存(done 变量)和锁来同步,这在 Go 中通常是不被推荐的。

改进方案:使用Channel

遵循 Go 的设计哲学,我们可以用 channel 来重构代码。Channel 本身是并发安全的,非常适合在 goroutine 间传递信号。

我们可以创建一个 channel,当任意一个 goroutine 完成任务后,就向该 channel 发送一个值。主 goroutine 只需阻塞等待从该 channel 接收值即可。

image.png
image.png

这个方案比前一个优雅得多,它利用 channel 的阻塞特性实现了高效的等待,避免了 CPU 的空转。

2. select登场:处理多个独立的Channel

上面的方案虽然不错,但它依赖于多个 goroutine 共享同一个 channel。在更复杂的场景中,不同的 goroutine 可能有各自独立的 channel 用于通信。这时,我们又该如何监控呢?

如果尝试按顺序读取每个 channel,代码会阻塞在第一个读取操作上,无法响应后面可能先就绪的 channel。

代码语言:go
复制
ch1 := make(chan string)
ch2 := make(chan string)

// ... 启动goroutine向ch1和ch2写数据 ...

// 错误的做法:如果ch2先就绪,程序仍会阻塞在等待ch1
result1 := <-ch1 
result2 := <-ch2

这正是 select 语句的用武之地。select 可以同时等待多个 channel 操作,一旦其中某个 channel 准备就绪(即可读或可写),select 就会执行对应的 case 分支。

image.png
image.png

select 会阻塞,直到 ch1ch2 中有一个可以接收数据,然后执行相应的 case

3. select的核心规则

规则一:就绪执行

select 会在所有 case 中选择一个已经就绪的 channel 操作来执行。如果所有 case 中的 channel 都未就绪,select 将会阻塞。

规则二:随机选择

这是一个非常重要的特性:如果 select 发现有多个 case 同时就绪,它会随机选择一个来执行。 我们通过一个例子来验证这一点。在下面的代码中,我们在 select 语句执行前就确保 ch1ch2 都已经有数据,即两个 case 都已就绪。

image.png
image.png

多次运行上述代码,你会发现输出的顺序是随机的,有时先输出 g1,有时先输出 g2

这种随机性并非偶然,而是 Go 设计者有意为之。其目的是为了防止饥饿(Starvation)。如果 select 总是按固定的顺序(如代码中的书写顺序)检查 case,那么排在前面的 case 可能会被频繁触发,导致排在后面的 case 即使已经就绪也得不到执行的机会。随机选择保证了每个 channel 都有公平的被选中的机会。

4. select的实战应用

应用一:非阻塞操作与 default

如果 select 语句中包含一个 default 分支,那么该 select 语句将永远不会阻塞。当所有 case 都不满足执行条件时,select 会直接执行 default 分支。

image.png
image.png

default 常被用于实现非阻塞的 channel 发送或接收。

应用二:超时控制与 time.Timer

在网络编程或需要与外部系统交互的场景中,为操作设置超时时间至关重要,这可以防止程序因等待一个永远不会到来的响应而无限期阻塞。select 结合 time 包可以非常优雅地实现超时控制。

time.NewTimer(duration) 会创建一个定时器,它在指定的 duration 之后会向其内部的 channel(可以通过 timer.C 访问)发送一个当前时间。我们可以将这个 channel 作为 select 的一个 case

image.png
image.png

在上面的例子中,select 会同时等待 ch1 的数据和2秒的定时器。由于定时器会先于 ch1 就绪,程序会执行超时分支并退出,从而有效地避免了过长的等待。

总结

select 语句是 Go 并发编程中一个强大而优雅的工具,它赋予了我们同时处理多个 channel 的能力。其核心要点如下:

  1. 多路复用select 用于在多个 channel 上进行非阻塞或阻塞的等待。
  2. 随机性保公平:当多个 channel 同时就绪时,select 的随机选择机制可以有效防止 goroutine 饥饿,保证系统的健壮性。
  3. 超时控制:结合 time.Timertime.Afterselect 可以简洁地实现强大的超时机制,这是构建高可用系统的必备功能。
  4. 非阻塞操作:通过 default 分支,可以实现对 channel 的非阻塞读写。

掌握 select 的用法和原理,是编写高质量、高效率 Go 并发程序的关键一步。在 select 的基础上,Go 语言还提供了 context 包,用于实现更复杂的并发控制,例如跨多个 goroutine 的取消、超时和数据传递,我们将在后续进行探讨。

11、Context

在Go的并发编程模型中,context包是一个至关重要的标准库。它允许我们在不同的Goroutine之间传递请求范围的数据、取消信号以及超时和截止日期。对于任何一位Go开发者来说,深入理解并熟练运用context是编写健壮、可维护并发程序的必备技能。

我们通过一个渐进式的实例,从问题的源头出发,逐步揭示为何需要context,并最终展示其优雅的解决方案。

1. 问题的提出:如何优雅地控制Goroutine?

让我们从一个简单的需求开始:编写一个程序来持续监控系统的CPU信息。

一个直接的实现方式是启动一个Goroutine,在其中使用一个无限循环来模拟监控任务,每隔一段时间打印一次信息。

image.png
image.png

这段代码中的cpuInfo Goroutine会无限运行下去,导致wg.Wait()永远无法返回,主程序也会被永久阻塞。

现在,我们的需求升级了:如何在主程序中主动通知cpuInfo Goroutine停止工作并优雅退出?

2. 方案一:共享变量(反模式)

许多开发者首先想到的可能是使用一个共享的布尔标志位。

image.png
image.png

虽然这个方案能工作,但它是一种反模式。在并发环境中,多个Goroutine对共享变量的读写必须使用互斥锁等同步机制来保护,否则会引发数据竞争(data race)。这种方式违背了Go语言所推崇的“通过通信共享内存,而不是通过共享内存来通信”的核心理念。

3. 方案二:使用Channel进行通信

一个更符合Go语言风格的方案是使用channel来传递“停止”信号。

image.png
image.png

在这个版本中,我们创建了一个stop channel。cpuInfoWithChannel函数中的select语句会同时监听stop channel和定时器。当main函数关闭stop channel时,case <-stop:会立即被选中,从而使Goroutine安全退出。

这种方式远优于共享变量,它代码更清晰,且天生就是并发安全的。将channel作为参数传递,而不是作为全局变量,也使得函数更加独立和可测试。

4. 终极方案:Context

尽管使用channel已经相当不错,但Go语言为这类场景提供了更通用、更强大的标准工具:contextcontext专门用于处理取消(cancellation)超时(timeout)和传递请求范围的值

context.Context是一个接口,它定义了四个方法:

代码语言:go
复制
type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}

其中,Done()方法返回一个channel,当Context被取消或超时时,该channel会被关闭。这正是我们需要的。

让我们用context来重构代码:

image.png
image.png

context.WithCancel(parentContext)函数会返回一个新的context实例和一个cancel函数。当cancel()被调用时,它所关联的contextDone() channel就会被关闭。

Context的传递性与优势

context最强大的特性之一是其传递性。Context对象形成一棵树状结构,当一个父Context被取消时,所有由它派生出来的子Context也会被自动取消。

设想一个复杂的调用链:main -> goroutine A -> goroutine B

image.png
image.png

main函数调用cancel()时,这个取消信号会沿着Context树向下传播,routineAroutineB中的ctx.Done()都会收到信号。这使得在复杂的微服务或API调用中,能够轻松地实现全链路的超时控制和请求取消。

最佳实践

  1. Context作为函数的第一个参数:如果一个函数可能需要被外部控制(如取消或超时),那么应该接受一个context.Context作为其第一个参数,通常命名为ctx
  2. 不要将Context存储在结构体中Context应该在函数调用链中显式传递,而不是作为结构体的字段。
  3. 使用context.Background()作为根:只在程序的最高层(如main函数或HTTP服务器的请求处理入口)使用context.Background()context.TODO()创建根Context。
  4. cancel函数必须被调用:即使程序提前返回,也应该确保调用context.WithCancel返回的cancel函数,以释放相关资源。通常使用defer cancel()来保证。

除了WithCancelcontext包还提供了WithTimeoutWithDeadline用于超时控制,以及WithValue用于在Goroutine间传递请求范围的数据。这些功能共同构成了Go并发编程中不可或缺的利器。

12、 Context:从取消、超时到传值

context 包为在 API 边界、长时间运行的任务以及跨多个 goroutine 的操作中,提供了一种标准化的方式来处理取消信号超时控制传递请求范围的数据

现在我们将深入剖析 context 包提供的四种核心工具函数,帮助你更好地理解其设计哲学,并编写出更健壮、更可控的并发程序。

context.WithCancel:主动控制生命周期

context.WithCancel 函数用于创建一个可以被手动取消的 context。它返回一个新的 context 实例和一个 CancelFunc 函数。当我们需要中断某个或某组关联的操作时,只需调用这个 CancelFunc 即可。

这个机制非常适合需要由外部事件或条件(例如用户点击“取消”按钮)来主动停止的场景。调用 CancelFunc 后,所有从该 context 派生出的子 context 都会收到取消信号,相关的 goroutine 可以通过监听 ctx.Done() channel 来优雅地退出,从而释放资源。

image.png
image.png

context.WithTimeout:实现超时自动取消

在网络请求或耗时计算中,超时控制是保证系统稳定性的关键。context.WithTimeout 为此提供了极大的便利。它接收一个父 context 和一个 time.Duration 类型的超时时长,返回一个在指定时间后会自动取消的 context。

image.png
image.png

WithCancel 相比,WithTimeout 将超时的判断逻辑内置,我们无需再手动管理计时器。当设定的时间耗尽,与该 context 关联的所有 goroutine 都会收到取消信号。

context.WithDeadline:在指定时间点取消

context.WithDeadline 的功能与 WithTimeout 类似,但它不是设置一个相对的“超时时长”,而是指定一个未来的具体时间点time.Time)作为截止日期。当系统时间到达或超过这个截止点时,context 会被自动取消。

实际上,WithTimeout(parent, duration) 的内部实现正是通过 WithDeadline(parent, time.Now().Add(duration)) 来完成的。因此,选择哪个函数取决于你的具体需求:是需要一个相对的持续时间,还是一个绝对的截止时间。

context.WithValue:跨 Goroutine 传递请求范围数据

在复杂的系统中,尤其是在微服务架构下,我们经常需要在整个请求链路中传递一些与业务逻辑本身解耦的数据,例如 Trace ID、用户身份信息等。如果为每个函数都显式添加这些参数,会使代码变得冗余且难以维护。

context.WithValue 提供了一种优雅的解决方案。它允许我们将键值对数据附加到 context 中,并在整个调用链中向下传递,而无需修改中间环节的函数签名。

image.png
image.png

一个重要的特性是,使用 WithValue 创建的新 context 会继承父 context 的所有特性,包括取消和超时信号。这意味着,你可以在一个带有超时的 context 基础上再附加值,而不会影响其原有的取消机制。这种设计使得 context 成为一个功能强大且高度可组合的工具。

核心优势:对业务代码的零侵入

context 最强大的地方之一在于,无论是实现取消、超时还是传值,业务逻辑函数本身几乎不需要任何修改。我们只需遵循约定,将 context 作为函数的第一个参数,就可以在不改变其核心功能的情况下,为其注入强大的控制能力,这体现了 Go 语言推崇的“正交”设计原则。

总结

context 包通过 WithCancelWithTimeoutWithDeadlineWithValue 四个核心函数,为 Go 语言的并发控制和数据传递提供了标准化的解决方案。

  • WithCancel: 用于需要手动触发的取消操作。
  • WithTimeout: 用于为操作设置一个相对的超时时长。
  • WithDeadline: 用于为操作设置一个精确的截止时间点。
  • WithValue: 用于在请求范围内安全、无侵入地传递数据。

熟练掌握 context 的使用,是每一位 Go 开发者编写高质量、高可靠性服务的必备技能。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1、Go 并发编程出体验
    • 1、从线程到协程:并发模型的演进
    • 2、Goroutine:Go 语言的并发基石
      • 快速上手:启动你的第一个 Goroutine
      • 匿名函数与 Goroutine
      • 启动多个 Goroutine 与闭包陷阱
  • 2、协程与线程调度原理解析
    • 1、传统线程模型的局限
    • 2、Go 语言的解决方案:Goroutine 与 GMP 调度模型
    • 3、调度器如何应对阻塞?
    • 4、总结
  • 3、使用 WaitGroup 实现优雅同步
    • 1、问题的提出:time.Sleep 的局限性
    • 2、解决方案:sync.WaitGroup
    • 3、WaitGroup 的基本用法
    • 4、常见陷阱与最佳实践
    • 5、总结
  • 4、互斥锁(Mutex)
    • 1、资源竞争的实例
    • 2、 问题的根源:非原子操作
    • 3. 使用互斥锁(sync.Mutex)解决问题
    • 4. 更高效的选择:原子操作(sync/atomic)
    • 总结
  • 5、读写锁(RWMutex)
    • 1、锁的本质与性能瓶颈
    • 2、"读多写少" 场景的优化需求
    • 3. sync.RWMutex 的使用
    • 4. 代码示例
    • 4、运行结果分析
    • 6、总结
  • 6、Channel 通信机制
    • 1、设计哲学:通过通信共享内存
    • 2、Channel 的基本概念与使用
    • 3、缓冲 Channel 与无缓冲 Channel
      • 无缓冲 Channel (Unbuffered Channel)
      • 缓冲 Channel (Buffered Channel)
    • 4、应用场景与选择
    • 5、小结与注意事项
  • 7、使用 for range 优雅地消费与关闭
    • 1、使用 for range 循环消费 Channel
    • 2、使用 close() 优雅地关闭 Channel
    • 3、已关闭 Channel 的特性
  • 8、单向Channel
    • 1、为什么需要单向 Channel?
    • 2、定义与使用单向 Channel
      • 定义与使用单向 Channel
      • 类型转换
    • 3、实战:生产者与消费者模型
    • 4、总结
  • 9、Go Channel面试题:交替打印数字和字母
    • 核心思路与实现
    • 总结
  • 10、Select语句
    • 1. 问题提出:如何监控多个Goroutine?
      • 初始方案:共享变量与锁
      • 改进方案:使用Channel
    • 2. select登场:处理多个独立的Channel
    • 3. select的核心规则
      • 规则一:就绪执行
      • 规则二:随机选择
    • 4. select的实战应用
      • 应用一:非阻塞操作与 default
      • 应用二:超时控制与 time.Timer
    • 总结
  • 11、Context
    • 1. 问题的提出:如何优雅地控制Goroutine?
    • 2. 方案一:共享变量(反模式)
    • 3. 方案二:使用Channel进行通信
    • 4. 终极方案:Context
      • Context的传递性与优势
      • 最佳实践
  • 12、 Context:从取消、超时到传值
    • context.WithCancel:主动控制生命周期
    • context.WithTimeout:实现超时自动取消
    • context.WithDeadline:在指定时间点取消
    • context.WithValue:跨 Goroutine 传递请求范围数据
    • 核心优势:对业务代码的零侵入
    • 总结
相关产品与服务
消息队列
腾讯云消息队列 TDMQ 是腾讯云自主研发的消息中间件产品系列,作为分布式系统中的关键组件,具备稳定可靠、高弹性、低成本的特性,提供异步通信的基础能力,通过应用解耦降低系统复杂度,提升系统可用性和可扩展性。兼容开源主流协议,包含 CKafka、RocketMQ、RabbitMQ、Pulsar、MQTT 五大子产品,覆盖在线(电商交易、社交直播等)和离线场景(大数据、日志监控等),满足金融、互联网、教育、物流、能源等不同行业和场景的需求。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
内含是什么意思 一岁宝宝吃什么 肾上腺增生是什么意思 梵高的星空表达了什么 头发变棕色是什么原因
缺锌有什么症状 一念之间什么意思 生肖龙和什么生肖相冲 熠五行属什么 特应性皮炎用什么药膏
12月29号是什么星座 梦见自己生男孩是什么意思 什么样的降落伞 人言可畏是什么意思 上日下文念什么
女孩月经不规律是什么原因 登革热吃什么药 乳腺结节和乳腺增生有什么区别 拉杆箱什么材质好 什么蛋不能吃脑筋急转弯
醒酒器有什么作用hcv7jop5ns0r.cn 无伤大雅是什么意思hcv8jop4ns0r.cn 胃疼吃什么药最管用hcv8jop4ns7r.cn 胃溃疡能吃什么hcv8jop5ns8r.cn 化合物是什么hcv9jop1ns3r.cn
女人做春梦预示着什么hcv9jop7ns1r.cn 强势是什么意思hcv8jop2ns0r.cn 无缘无故吐血是什么原因hcv7jop9ns4r.cn 白醋加盐洗脸有什么好处hcv9jop4ns0r.cn 山竹里面黄黄的是什么可以吃吗hcv9jop8ns1r.cn
月子期间可以吃什么水果hcv8jop2ns7r.cn 小孩腿抽筋是什么原因引起的bjhyzcsm.com 蜂窝织炎用什么抗生素hcv8jop6ns3r.cn 棱角是什么意思hcv7jop5ns5r.cn 一九九八年属什么生肖hcv9jop2ns0r.cn
什么牌子的空调好xinmaowt.com 凭什么是什么意思hcv8jop3ns9r.cn 甲胎蛋白偏高说明什么hcv8jop0ns6r.cn 隐形眼镜半年抛是什么意思hcv8jop7ns1r.cn 贝字旁与什么有关hcv8jop8ns0r.cn
百度