go并发处理业务

2023-09-13 17:45:52

引言

实际上,在服务端程序开发和爬虫程序开发时,我们的大多数业务都是IO密集型业务,什么是IO密集型业务,通俗地说就是CPU运行时间只占整个业务执行时间的一小部分,而剩余的大部分时间都在等待IO操作。

IO操作包括http请求、数据库查询、文件读取、摄像设备录音设备的输入等等。这些IO操作会引起中断,使业务线程暂时放弃cpu,暂时停止运行,等待IO操作完成后在重新获得cpu、继续运行下去。

比如下面这段代码,我执行了一个很简单的IO操作,就是请求百度的主页面。在IO操作之后我又执行了一个一千万数量级的循环,用来模拟cpu计算业务。

func main() {
	t := time.Now().UnixMilli()

	// 发起http请求病接收响应
	res, _ := http.Get("https://www.baidu.com")
	fmt.Printf("https请求结束,耗时%dms\n", time.Now().UnixMilli()-t)

	// 进行1e7次计算操作,用来模拟业务处理时cpu计算内容
	body, _ := io.ReadAll(res.Body)
	res.Body.Close()
	for i := 1; i <= 1e7; i++ {
		_ = i * i
		_ = i + i
	}
	
	fmt.Printf("程序运行结束,总耗时%dms, 数据长度为%d", time.Now().UnixMilli()-t, len(body))
}

这段程序在我的电脑上的输出结果是:

https请求结束,耗时250ms
程序运行结束,总耗时256ms, 数据长度为227

不难看出,向百度发起请求耗费了250ms,而一千万次cpu计算仅耗费6ms。(实际情况中,大多数业务的cpu计算量甚至远远达不到1e7级别)

我们为什么要引入并发呢,正如操作系统课程上讲的那样,目的就是为了提高cpu的利用率,如果某个线程正处在等待IO的状态,此时cpu是空闲的,那么我们就应该用cpu去执行别的线程,而不是傻傻的等待这个进程结束再去进行别的操作。

并发样例

假设现在有一个爬虫项目,它的业务流程如下图所示,我大致描述一下流程:

  1. 首先要爬取A和B的页面,并解析页面结果
  2. 然后根据B页面的解析结果,设定好相关参数,对C、D、F页面进行爬取;同样的根据A页面的解析结果,设定好相关参数,对E页面进行爬取
  3. 接下来根据C、D页面的解析结果,设定好相关参数,对G页面进行爬取
  4. 最后,对E、F、G三个页面进行解析,得到我们需要的最终数据

虽然只是假设,但类似的业务流程在爬虫项目中是很常见的。
除此之外,类似的业务流程在后端程序开发中也很常见,对于前端的一个请求,后端很可能需要多次访问数据库、向下游服务器发送请求、访问内存外的缓存数据等等。
在这里插入图片描述
每个页面爬取耗费的时间如图中所示,由于cpu计算耗费时间很短,所以在这里就忽略不计。我们用sleep函数来模拟发起请求耗费的时间,代码如下图所示,每个task函数代表爬取一个页面:

func taskA() string {
	time.Sleep(time.Millisecond * 40)
	return "AAA"
}
func taskB() string {
	time.Sleep(time.Millisecond * 30)
	return "BBB"
}
func taskC(resultOfB string) string {
	time.Sleep(time.Millisecond * 30)
	return "CCC"
}
func taskD(resultOfB string) string {
	time.Sleep(time.Millisecond * 30)
	return "DDD"
}
func taskF(resultOfB string) string {
	time.Sleep(time.Millisecond * 30)
	return "FFF"
}
func taskE(resultOfA string) string {
	time.Sleep(time.Millisecond * 30)
	return "EEE"
}
func taskG(resultOfC, resultOfD string) string {
	time.Sleep(time.Millisecond * 30)
	return "GGG"
}

串行

串行,也就是不使用并发的代码如下所示:
串行的代码非常好写,从前往后把task函数顺序执行就行了,之所以在这里把串行代码和运行结果列出了,主要是为了和下面的并发做对比。


func main() {
	t := time.Now().UnixMilli()

	// 按照任务执行的先后要求,顺序执行所有任务
	resultOfA := taskA()
	fmt.Printf(" %dms: A over\n", time.Now().UnixMilli()-t)

	resultOfB := taskB()
	fmt.Printf(" %dms: B over\n", time.Now().UnixMilli()-t)

	resultOfC := taskC(resultOfB)
	fmt.Printf(" %dms: C over\n", time.Now().UnixMilli()-t)

	resultOfD := taskD(resultOfB)
	fmt.Printf(" %dms: D over\n", time.Now().UnixMilli()-t)

	resultOfE := taskE(resultOfA)
	fmt.Printf(" %dms: E over\n", time.Now().UnixMilli()-t)

	resultOfF := taskF(resultOfB)
	fmt.Printf(" %dms: F over\n", time.Now().UnixMilli()-t)

	resultOfG := taskG(resultOfC, resultOfD)
	fmt.Printf(" %dms: G over\n", time.Now().UnixMilli()-t)
	
	// 打印E、F、G的运行结果,至此程序就运行结束了,打印程序运行所耗费的时间
	fmt.Printf(" %dms: all over, %s, %s, %s\n", time.Now().UnixMilli()-t, resultOfE, resultOfF, resultOfG)
}

程序的执行结果如下所示,可以看到效率很慢很慢,总执行时间等于所有任务执行时间之和。

41ms: A over
73ms: B over
118ms: C over
164ms: D over
194ms: E over
240ms: F over
285ms: G over
286ms: all over, EEE, FFF, GGG

并发

本文的重点来了,如何并发地执行上面假设的爬虫程序?怎样才能让cpu的利用效率最高?

go为我们提供了非常好用的goroutine和chan,前者叫做协程也可以简单地认为是小型线程,能够以极小的开销和极快的速度启动一个并发任务;后者叫做通道,也可以叫管道,是协程和协程间通信的工具,不仅能够传递数据,还能够阻塞和唤醒协程从而实现协程间的同步。

在下面的代码中,我们使用通道来进行任务与任务之间的通信,我们为每一个任务都开一个协程,如果该任务没有前置依赖,那么就之间执行,然后把执行结果放到对应的通道中;如果该任务有前置依赖任务,那么先从通道中读取自己所需要的数据,然后再执行相应任务。

代码如下所示,逻辑很简单,主要是体现了一种并发的思想和并发执行任务的思路,现实情况中并发业务的代码肯定要复杂得多。

func main() {
	t := time.Now().UnixMilli()

	// AtoE代表A把自己的运行结果交给E的所经过的通道,下面同理
	AtoE := make(chan string, 1)
	BtoC := make(chan string, 1)
	BtoF := make(chan string, 1)
	BtoD := make(chan string, 1)
	CtoG := make(chan string, 1)
	DtoG := make(chan string, 1)
	GtoEnd := make(chan string, 1)
	FtoEnd := make(chan string, 1)
	EtoEnd := make(chan string, 1)

	// 每个go func代表着给某个任务开一个协程
	// 为A任务开个协程
	go func() {
		resultOfA := taskA()
		AtoE <- resultOfA
		fmt.Printf(" %dms: A over\n", time.Now().UnixMilli()-t)
	}()

	// 为B任务开个协程
	go func() {
		resultOfB := taskB()
		BtoF <- resultOfB
		BtoC <- resultOfB
		BtoD <- resultOfB
		fmt.Printf(" %dms: B over\n", time.Now().UnixMilli()-t)
	}()

	// 为C任务开个协程
	go func() {
		resultOfB := <-BtoC
		resultOfC := taskC(resultOfB)
		CtoG <- resultOfC
		fmt.Printf(" %dms: C over\n", time.Now().UnixMilli()-t)
	}()

	// 为D任务开个协程
	go func() {
		resultOfB := <-BtoD
		resultOfD := taskC(resultOfB)
		DtoG <- resultOfD
		defer fmt.Printf(" %dms: D over\n", time.Now().UnixMilli()-t)
	}()

	// 为F任务开个协程
	go func() {

		resultOfB := <-BtoF
		resultOfF := taskF(resultOfB)
		FtoEnd <- resultOfF
		fmt.Printf(" %dms: F over\n", time.Now().UnixMilli()-t)
	}()

	// 为E任务开个协程
	go func() {
		resultOfA := <-AtoE
		resultOfE := taskC(resultOfA)
		EtoEnd <- resultOfE
		fmt.Printf(" %dms: E over\n", time.Now().UnixMilli()-t)
	}()

	// 为G任务开个协程
	go func() {
		resultOfC := <-CtoG
		resultOfD := <-DtoG
		resultOfG := taskG(resultOfC, resultOfD)
		GtoEnd <- resultOfG
		fmt.Printf(" %dms: G over\n", time.Now().UnixMilli()-t)
	}()

	// 接收E、F、G的运行结果,至此程序就运行结束了,打印程序运行所耗费的时间
	resultOfE, resultOfF, resultOfG := <-EtoEnd, <-FtoEnd, <-GtoEnd
	fmt.Printf(" %dms: all over, %s, %s, %s\n", time.Now().UnixMilli()-t, resultOfE, resultOfF, resultOfG)
}

程序的执行结果如下所示,可以看到程序运行中花费了105ms,很接近关键路径的长度90ms,说明我们这个程序的并发性很好,在最大程度上实现了cpu的高效利用。(PS:关键路径begin → B→ C → G → end)

43ms: A over
44ms: B over
74ms: E over
74ms: C over
74ms: D over
74ms: F over
105ms: G over
105ms: all over, CCC, FFF, GGG

更多推荐

SpringMVC学习|JSON讲解、Controller返回JSON数据、Jackson、JSON乱码处理、FastJson

JSON讲解JSON(JavaScriptObjectNotation,JS对象标记)是一种轻量级的数据交换格式,目前使用特别广泛。采用完全独立于编程语言的文本格式来存储和表示数据。简洁和清晰的层次结构使得JSON成为理想的数据交换语言。易于人阅读和编写,同时也易于机器解析和生成,并有效地提升网络传输效率。在JavaS

【案例教学】华为云API图像搜索ImageSearch的快捷性—AI帮助您快速归类图片

云服务、API、SDK,调试,查看,我都行阅读短文您可以学习到:人工智能AI同类型的相片合并归类1IntelliJIDEA之API插件介绍API插件支持VSCodeIDE、IntelliJIDEA等平台、以及华为云自研CodeArtsIDE,基于华为云服务提供的能力,帮助开发者更高效、便捷的搭建应用。API插件关联华为

区块链(2):区块链的应用分类和诞生的故事

1区块链的应用分类第一类:数字资产第一类是数字资产,分为一般数字资产和主打匿名应用场景的匿名数字资产。一般数字资产包括我们非常熟悉的比特币【https://bitcoin.org】、莱特币,在前面我们已经学习过,除此之外还有新经币NEM(NewEconomyMovement)、Decred(中文名暂无)、狗狗币Doge

Redis群集

1、redis群集三种模式redis群集有三种模式,分别是主从同步/复制、哨兵模式、Cluster,下面会讲解一下三种模式的工作方式,以及如何搭建cluster群集●主从复制:主从复制是高可用Redis的基础,哨兵和集群都是在主从复制基础上实现高可用的。主从复制主要实现了数据的多机备份,以及对于读操作的负载均衡和简单的

redisson使用过程常见问题汇总

文章目录常见报错1.配置方式使用错误2.版本差异报错3.配置文件中配置了密码或者配置错误4.字符集和序列化方式配置问题5.Redisson的序列化问题6.连接池问题:7.Redisson的高可用性问题:8.Redisson的并发问题9.Redisson的性能问题2.参考文档常见报错1.配置方式使用错误Redisson提

redis设计规范

部分内容参考:阿里redis开发规范同时,结合shigen在实习中的实践经验总结。key的名称设计可读性和管理性业务名:表名:idpro:user:1001简洁性控制key的长度,可以用缩写transaction->tras拒绝bigkey防止网卡流量、慢查询,string类型控制在10KB以内,hash、list、s

Redis 高性能设计之epoll和IO多路复用深度解析

I/O多路复用模型是什么I/O:网络I/O多路:多个客户端连接(连接就是套接字描述符,即socket或者channel),指的是多条TCP连接复用:用一个进程来处理多条的连接,使用单进程就能的够实现同时处理多个客户端的连接一句话:实现了用一个进程来处理大量的用户连接,IO多路复用类似一个规范和接口落地实现:可以分sel

深度对话|Sui在商业技术堆栈中的地位

近日,我们采访了MystenLabs的商业产品总监LolaOyelayo-Pearson,共同探讨了区块链技术如何为企业提供商业服务,以及为什么Sui特别适合这些用例。1.请您简要介绍一下自己、您的角色以及您是如何开始涉足Web3领域的?目前,我领导MystenLabs的商业产品团队。通常来说,商业涵盖了一切,它可能是

山石网科国产化入侵防御系统,打造全生命周期的安全防护

随着互联网的普及和网络安全的威胁日益增加,botnet感染成为了企业面临的重要问题之一。botnet是一种由分散的客户端(或肉鸡)组成的网络,这些客户端被植入了bot程序,受控于攻击者。攻击者通过这些客户端的bot程序,利用C&C服务器对这些客户端进行管理和控制,以达到非法牟利的目的。被感染攻击的企业不仅会面临公司和个

GET和POST的区别,java模拟postman发post请求

目录一、先说一下get和post1、看一下人畜无害的w3schools怎么说:2、问一下文心你言哥,轻轻松松给你一个标准答案:3、卧槽,懂了,好像又没懂二、让我们扒下GET和POST的外衣,坦诚相见吧!三、我们的大BOSS还等着出场呢四、java模拟post请求1、弯了?那就给它掰回来。2、HttpURLConnect

GraphQL基础知识与Spring for GraphQL使用教程

文章目录1、数据类型1.1、标量类型1.2.高级数据类型基本操作2、SpringforGraphQL实例2.1、项目目录2.2、数据库表2.3、GraphQL的schema.graphql2.4、Java代码3、运行效果3.1、添加用户3.2、添加日志3.3、查询所有日志3.4、查询指定用户日志3.5、数据订阅4、总结

热文推荐