Go 并发编程 - Goroutine 基础 (一)
基础概念
进程与线程
进程是一次程序在操作系统执行的过程,需要消耗一定的CPU、时间、内存、IO等。每个进程都拥有着独立的内存空间和系统资源。进程之间的内存是不共享的。通常需要使用 IPC 机制进行数据传输。进程是直接挂在操作系统上运行的,是操作系统分配硬件资源的最小单位。
线程是进程的一个执行实体,一个进程可以包含若干个线程。线程共享进程的内存空间和系统资源。线程是 CPU 调度的最小单位。因为线程之间是共享内存的,所以它的创建、切换、销毁会比进程所消耗的系统资源更少。
举一个形象的例子:一个操作系统就相当于一支师级编制的军队作战,一个师会把手上的作战资源独立的分配各个团。而一个团级的编制就相当于一个进程,团级内部会根据具体的作战需求编制出若干的营连级,营连级会共享这些作战资源,它就相当于是计算机中的线程。
什么是协程
协程是一种更轻量的线程,被称为用户态线程。它不是由操作系统分配的,对于操作系统来说,协程是透明的。协程是程序员根据具体的业务需求创立出来的。一个线程可以跑多个协程。协程之间切换不会阻塞线程,而且非常的轻量,更容易实现并发程序。Golang 中使用 goroutine 来实现协程。
并发与并行
可以用一句话来形容:多线程程序运行在单核CPU上,称为并发;多线程程序运行在多核CPU上,称为并行。
Goroutine
Golang 中开启协程的语法非常简单,使用 Go 关键词即可:
func main() {
go hello()
fmt.Println("主线程结束")
}
func hello() {
fmt.Println("hello world")
}
// 结果
主线程结束
程序打印结果并非是我们想象的先打印出 “hello world”,再打印出 “主线程结束”。这是因为协程是异步执行的,当协程还没有来得及打印,主线程就已经结束了。我们只需要在主线程中暂停一秒就可以打印出想要的结果。
func main() {
go hello()
time.Sleep(1 * time.Second) // 暂停一秒
fmt.Println("主线程结束")
}
// 结果
hello world
主线程结束
这里的一次程序执行其实是执行了一个进程,只不过这个进程就只有一个线程。
在 Golang 中开启一个协程是非常方便的,但我们要知道并发编程充满了复杂性与危险性,需要小心翼翼的使用,以防出现了不可预料的问题。
编写一个简单的并发程序
用来检测各个站点是否能响应:
func main() {
start := time.Now()
apis := []string{
"https://management.azure.com",
"https://dev.azure.com",
"https://api.github.com",
"https://outlook.office.com/",
"https://api.somewhereintheinternet.com/",
"https://graph.microsoft.com",
}
for _, api := range apis {
_, err := http.Get(api)
if err != nil {
fmt.Printf("响应错误: %s\n", api)
continue
}
fmt.Printf("成功响应: %s\n", api)
}
elapsed := time.Since(start) // 用来记录当前进程运行所消耗的时间
fmt.Printf("主线程运行结束,消耗 %v 秒!\n", elapsed.Seconds())
}
// 结果
成功响应: https://management.azure.com
成功响应: https://dev.azure.com
成功响应: https://api.github.com
成功响应: https://outlook.office.com/
响应错误: https://api.somewhereintheinternet.com/
成功响应: https://graph.microsoft.com
主线程运行结束,消耗 5.4122892 秒!
我们检测六个站点一个消耗了5秒的时间,假设现在需要对一百个站点进行检测,那么这个过程就会耗费大量的时间,这些时间都被消耗到了 http.Get(api) 这里。
在 http.get(api) 还没有获取到结果时,主线程会等待请求的响应,会阻塞在这里。这时候我们就可以使用协程来优化这段代码,将各个网络请求的检测变成异步执行,从而减少程序响应的总时间。
func main() {
...
for _, api := range apis {
go checkApi(api)
}
time.Sleep(3 * time.Second) // 等待三秒,不然主线程会瞬间结束,导致协程被杀死
...
}
func checkApi(api string) {
_, err := http.Get(api)
if err != nil {
fmt.Printf("响应错误: %s\n", api)
return
}
fmt.Printf("成功响应: %s\n", api)
}
// 结果
响应错误: https://api.somewhereintheinternet.com/
成功响应: https://api.github.com
成功响应: https://graph.microsoft.com
成功响应: https://management.azure.com
成功响应: https://dev.azure.com
成功响应: https://outlook.office.com/
主线程运行结束,消耗 3.0013905 秒!
可以看到,使用 goroutine 后,除去等待的三秒钟,程序的响应时间产生了质的变化。但美中不足的是,我们只能在原地傻傻的等待三秒。那么有没有一种方法可以感知协程的运行状态,当监听到协程运行结束时再优雅的关闭主线程呢?
sync.waitgroup
sync.waitgroup 可以完成我们的”优雅小目标“。 sync.waitgroup 是 goroutine 的一个“计数工具”,通常用来等待一组 goroutine 的执行完成。当我们需要监听协程是否运行完成就可以使用该工具。sync.waitgroup 提供了三种方法:
Add(n int):添加 n 个goroutine 到 WaitGroup 中,表示需要等待 n 个 goroutine 执行完成。
Done():每个 goroutine 执行完成时调用 Done 方法,表示该 goroutine 已完成执行,相当于把计数器 -1。
Wait():主线程调用 Wait 方法来等待所有 goroutine 执行完成,会阻塞到所有的 goroutine 执行完成。
我们来使用 sync.waitgroup 来优雅的结束程序:
package main
import (
"fmt"
"net/http"
"sync"
"time"
)
func main() {
var (
start = time.Now()
apis = []string{
"https://management.azure.com",
"https://dev.azure.com",
"https://api.github.com",
"https://outlook.office.com/",
"https://api.somewhereintheinternet.com/",
"https://graph.microsoft.com",
}
wg = sync.WaitGroup{} // 初始化WaitGroup
)
wg.Add(len(apis)) // 表示需要等待六个协程请求
for _, api := range apis {
go checkApi(api, &wg)
}
wg.Wait() // 阻塞主线程,等待 WaitGroup 归零后再继续
elapsed := time.Since(start) // 用来记录当前进程运行所消耗的时间
fmt.Printf("线程运行结束,消耗 %v 秒!\n", elapsed.Seconds())
}
func checkApi(api string, wg *sync.WaitGroup) {
defer wg.Done() // 标记当前协程执行完成,计数器-1
_, err := http.Get(api)
if err != nil {
fmt.Printf("响应错误: %s\n", api)
return
}
fmt.Printf("成功响应: %s\n", api)
}
// 结果
响应错误: https://api.somewhereintheinternet.com/
成功响应: https://api.github.com
成功响应: https://management.azure.com
成功响应: https://graph.microsoft.com
成功响应: https://dev.azure.com
成功响应: https://outlook.office.com/
线程运行结束,消耗 0.9718695 秒!
可以看到,我们优雅了监听了所有协程是否执行完毕,且大幅度缩短了程序运行时间。但同时我们的打印响应信息也是无序的了,这代表了我们的协程确确实实异步的请求了所有的站点。
Channel
channel 也可以完成我们的”优雅小目标“。 channel 的中文名字被称为“通道”,是 goroutine 的通信机制。当需要将值从一个 goroutine 发送到另一个时,可以使用通道。Golang 的并发理念是:“通过通信共享内存,而不是通过共享内存通信”。channel 是并发编程中的一个重要概念,遵循着数据先进先出,后进后出的原则。
声明 Channel
声明通道需要使用内置的 make() 函数:
ch := make(chan <type>) // type 代表数据类型,如 string、int
Channel 发送数据和接收数据
创建好 channle 后可以使用 <- 来发送/接受数据:
func main() {
ch := make(chan int)
go func() {
ch <- 1 // 发送
}()
a := <-ch // 接收
fmt.Println(a)
close(ch) // 关闭通道
}
// 结果
1
每个发送数据都必须有正确的接受方式,否则会编译错误。编译错误比误用 channel 更好!
接收 channel 中的发来的数据时, a := <-ch 这里是处于阻塞状态的。我们可以利用这点来监听协程是否执行完成,还是上文的例子,但这次我们不使用 sync.waitgroup:
func main() {
var (
start = time.Now()
apis = []string{
"https://management.azure.com",
"https://dev.azure.com",
"https://api.github.com",
"https://outlook.office.com/",
"https://api.somewhereintheinternet.com/",
"https://graph.microsoft.com",
}
ch = make(chan string)
)
for _, api := range apis {
go checkApi(api, ch)
}
// 因为我们一共有六个请求,所以我们要接收六次
for i := 0; i < 6; i++ {
fmt.Println(<-ch)
}
elapsed := time.Since(start) // 用来记录当前进程运行所消耗的时间
fmt.Printf("线程运行结束,消耗 %v 秒!\n", elapsed.Seconds())
}
func checkApi(api string, ch chan string) {
_, err := http.Get(api)
if err != nil {
ch <- fmt.Sprintf("响应错误: %s", api)
return
}
ch <- fmt.Sprintf("成功响应: %s", api)
}
// 结果
成功响应: https://api.github.com
成功响应: https://management.azure.com
成功响应: https://graph.microsoft.com
成功响应: https://outlook.office.com/
成功响应: https://dev.azure.com
线程运行结束,消耗 0.9013927 秒!
可以看到,我们利用通道接收数据的阻塞特性,达到了和使用 sync.waitgroup 一样的效果。
有缓冲 Channel
默认情况下,我们创建的 channel 是无缓冲的,意味着有接收数据,就一定要有对应的发送数据,否则就会永久阻塞程序。有缓冲的 channel 则可以避免这种限制。创建一个有缓冲的 channel:
ch := make(chan <type>, <num>) // num 代表有缓冲通道的大小
有缓冲 channel 有点类似与队列,它不限制发送数据和接收数据,实现了接发 channel 的解耦。每次向 channel 中发送数据不用管有没有接收方,直接放入这个“队列”中。当有接收方从队列中取走数据时,就会从“队列”中删除这个值。当 channel 满时,发送数据会被阻塞,直到 channel 有空;当 channel 为空时,接收数据会被阻塞,直到 channel 有数据过来。
func main() {
ch := make(chan int, 3)
fmt.Printf("当前通道长度:%d\n", len(ch))
send(ch, 1)
send(ch, 2)
send(ch, 3)
fmt.Println("所有数据已经已经放入通道")
fmt.Printf("当前通道长度:%d\n", len(ch))
for i := 0; i < 3; i++ {
fmt.Println(<-ch)
}
fmt.Println("主线程结束")
}
// 结果
当前通道长度:0
所有数据已经已经放入通道
当前通道长度:3
1
2
3
主线程结束
这里并没有什么不同的操作,程序也在正常运行,但我们把通道大小改成比 3 更小时,编译就会出错,提示 fatal error: all goroutines are asleep - deadlock!这是因为我们在主线程中连续的执行send,最终超出了通道的限制。
func main() {
ch := make(chan int, 2) // 把通道改小
...
}
// 结果
当前通道长度:0
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.send(...)
E:/project/gotest/main.go:8
main.main()
E:/project/gotest/main.go:16 +0xfe
尝试使用协程来运行 send 函数:
func main() {
...
go send(ch, 1)
go send(ch, 2)
go send(ch, 3)
...
}
// 结果
当前通道长度:0
所有数据已经已经放入通道
当前通道长度:2
1
2
3
主线程结束
channel 与 goroutine 有着千丝万缕的关系,在使用 channel 时一定要使用 goroutine。

Channel 方向
channel 有一个很有意思的功能,当通道作为函数的参数时,可以限制该通道是发送数据还是接收数据。当程序变的复杂后,这可以有效的记住每个通道的意图。一个简单的例子:
func send(ch chan<- string, message string) {
fmt.Printf("发送: %#v\n", message)
ch <- message
}
func read(ch <-chan string) {
fmt.Printf("接收: %#v\n", <-ch)
}
func main() {
ch := make(chan string, 1)
send(ch, "Hello World!")
read(ch)
}
// 结果
发送: "Hello World!"
接收: "Hello World!"
当错误使用通道时,编译会不通过:
# command-line-arguments
.\main.go:11:32: invalid operation: cannot receive from send-only channel ch (variable of type chan<- string)
多路复用
什么是多路复用
在实际的业务场景中,有时候需要根据不同的数据来处理不同的结果。举一个白话例子:
例如现在你有一个物流中心,这个物流中心具备一个这样的功能:从青岛、北海、舟山等城市接收海鲜,从朔州、大同等城市接受煤矿,从广州、苏州等城市接受制造业产出的生活用品。接收到这些物资后,根据物资种类发送到各个需要这些物资的地方,比如海鲜发往成渝做火锅,煤矿发往长三角发电,生活用品发往陕甘供生活使用。
Golang 提供 select 关键词来实现多路复用, select 就类似于这个物流中心,它用来接收多个 channel 中的数据,并做出不同的处理。select 的语法类似与 switch,都具备 case 和 default 但是 select 只适用于 channel。
func seafood(ch chan<- string) {
time.Sleep(2 * time.Second)
ch <- "海鲜已经送达"
}
func coal(ch chan<- string) {
time.Sleep(5 * time.Second)
ch <- "煤矿已经送达"
}
func goods(ch chan<- string) {
time.Sleep(8 * time.Second)
ch <- "生活用品已经送达"
}
func main() {
var (
text string
seafoodCh = make(chan string)
coalCh = make(chan string)
goodsCh = make(chan string)
tick = time.NewTicker(1 * time.Second)
)
go seafood(seafoodCh)
go coal(coalCh)
go goods(goodsCh)
for _ = range tick.C {
select {
case text = <-seafoodCh:
fmt.Println(text)
case text = <-coalCh:
fmt.Println(text)
case <-goodsCh:
fmt.Println("检测到有生活用品,但是不做处理")
default:
fmt.Println("什么都没来")
}
}
}
// 结果
什么都没来
海鲜已经送达
什么都没来
什么都没来
煤矿已经送达
检测到有生活用品,但是不做处理
什么都没来
什么都没来
什么都没来
select 在运行时,如果所有 case 都不满足,就会选择 default 执行,如果没有 default ,select 会一直阻塞等待,直到至少有一个 case 满足被执行。如果同时有多个 case 被满足,则 select 会随机选择一个执行。
使用多路复用来实现 channel 超时
Golang 没有直接提供 channel 的超时机制,但是我们可以使用多路复用来实现:
func goods(ch chan<- string) {
time.Sleep(8 * time.Second)
ch <- "生活用品已经送达"
}
func main() {
var (
goodsCh = make(chan string)
)
go goods(goodsCh)
fmt.Println("开始等待")
select {
case <-goodsCh:
fmt.Println("生活用品送到了")
case t := <-time.After(3 * time.Second): // 三秒后 channel 发出当前时间
fmt.Println("没有等到生活用品")
fmt.Println(t.Format("2006-01-02 15:04:05"))
break
}
fmt.Println("主线程运行结束")
}
// 结果
开始等待
没有等到生活用品
2023-04-08 14:51:36
主线程运行结束
本系列文章:
Go 并发编程 - Goroutine 基础 (一)的更多相关文章
- java并发编程 线程基础
java并发编程 线程基础 1. java中的多线程 java是天生多线程的,可以通过启动一个main方法,查看main方法启动的同时有多少线程同时启动 public class OnlyMain { ...
- golang并发编程goroutine+channel(一)
go语言的设计初衷除了在不影响程序性能的情况下减少复杂度,另一个目的是在当今互联网大量运算下,如何让程序的并发性能和代码可读性达到极致.go语言的并发关键词 "go" go dos ...
- 为什么TCP比UDP可靠真正原因,以及并发编程的基础问题
一 为什么TCP协议比UDP协议传输数据可靠: 我们知道在传输数据的时候,数据是先存在操作系统的缓存中,然后发送给客户端,在客户端也是要经过客户端的操作系统的,因为这个过程涉及到计算机硬件,也就是物 ...
- Java并发编程之美之并发编程线程基础
什么是线程 进程是代码在数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,线程则是进程的一个执行路径,一个进程至少有一个线程,进程的多个线程共享进程的资源. java启动main函数其实就 ...
- Golang并发编程——goroutine、channel、sync
并发与并行 并发和并行是有区别的,并发不等于并行. 并发 两个或多个事件在同一时间不同时间间隔发生.对应在Go中,就是指多个 goroutine 在单个CPU上的交替运行. 并行 两个或者多个事件在同 ...
- Java 并发编程实践基础 读书笔记: 第三章 使用 JDK 并发包构建程序
一,JDK并发包实际上就是指java.util.concurrent包里面的那些类和接口等 主要分为以下几类: 1,原子量:2,并发集合:3,同步器:4,可重入锁:5,线程池 二,原子量 原子变量主要 ...
- Java 并发编程实践基础 读书笔记: 第一章 JAVA并发编程实践基础
1.创建线程的方式: /** * StudySjms * <p> * Created by haozb on 2018/2/28. */ public class ThreadDemo e ...
- 《Java Concurrency》读书笔记,Java并发编程实践基础
1. 基本概念 程序,是一组有序的静态指令,是一种静态的概念.程序的封闭性是指程序一旦运行,其结果就只取决于程序本身:程序的再现性是指当机器在同一数据集上重复执行同一程序时,机器内部的动作系列完全相同 ...
- 【Java_多线程并发编程】基础篇——synchronized关键字
1. synchronized同步锁的原理 当我们调用某对象的synchronized方法或代码块时,就获取了该对象的同步锁.例如,synchronized(obj)就获取了“obj这个对象”的同步锁 ...
- 【Java_多线程并发编程】基础篇—线程状态及实现多线程的两种方式
1.Java多线程的概念 同一时间段内,位于同一处理器上多个已开启但未执行完毕的线程叫做多线程.他们通过轮寻获得CPU处理时间,从而在宏观上构成一种同时在执行的假象,实质上在任意时刻只有一个线程获得C ...
随机推荐
- exclude查询时出掉或排除某个条件的信息
exclude查询时出掉或排除某个条件的信息 print(Student.objects.all().exclude(nickname='A')
- SpringMVC使用注解开发
1.编写web.xml(模板代码) 2.导入springmvc的context和mvc两个依赖,通过context标签可以自动扫描识别包"com.lian.controller"下 ...
- CSS文本过长时设置省略号
写页面时很多时候会遇到一个容器中文本内容超出,使用overflow : hidden;,但是跟用户体验不太友好,设置overflow : scroll; 会出现滚动看着也不爽,所以我就想使用css 设 ...
- postman接口关联-token值
背景: 在测试工作中,测试鉴权的接口需要用到登录接口的token,需要我们先调用登录接口,获得token,然后把即时获得的token填入请求中发送请求,我们可以用设置全局变量的办法解决这个问题 实 ...
- .net Assembly.Load重复加载程序集造成执行异常
最近ET做热更重载dll的时候,返回登陆会重新检测新的dll,首次登录之前已经Assembly.Load()过一次dll,第二次返回登陆再次load dll到内存中,Invoke执行方法的时候,异常了 ...
- 记录一些不知道哪里冒出来的 idea(可能已存在)
2022/7/4 给定一张 \(n\) 个点的有权无向图和 \(m\) 个关键点,求其生成树满足每个点到离它最近的关键点的距离之和最小. 输出该生成树边权和. 加强一下: 给出一个 \(N\) 个点 ...
- RStuido Server 选择不同的 R 版本(conda 中的不同 R 版本)
自从上一次服务器重装系统之后,总感觉缺少了一些东西,安装R包很多依赖库报错,也可以解决,但总是存在,烦. 一天,一个同事问我说ggpubr包安装不成功,我就自己试了一下,真的是--安装不成功. 当你到 ...
- Python-Loguru:让记录日志更装13
" 今天勇哥来介绍一款让日志记录在 Python 中变得更加轻松愉快的库--Loguru.它提供了强大的功能和简洁的接口,使我们能够以更加灵活和直观的方式记录和管理日志信息,据说比loggi ...
- 论c++实现sql连接
寻找关于c++ 对 sql连接的过程非常艰辛. 今天要做一个简单项目,要求在远程sql上实现对数据的实时模拟,每五分钟进行一次随机产生数据并写入. 在此之前我并没有用过代码实现sql连接的经历,在翻阅 ...
- 尚医通day16-网站怎么接入微信扫码支付?
第01章-准备工作 1.微信支付产品介绍 参考资料:产品中心 - 微信支付商户平台 (qq.com) 付款码支付.JSAPI支付.小程序支付.Native支付.APP支付.刷脸支付 1.1.付款码支付 ...