当前位置: 首页 > 科技观察

Go语言为你带来的并发前言

时间:2023-03-12 06:23:36 科技观察

并发在学习Go的并发之前,先回顾一下操作系统的基础知识。并发和并行让我们首先了解并发和并行之间的区别。并行性:是指在同一时间,多个程序一起运行在不同的CPU上,它们之间不存在CPU资源的竞争。比如我看书的时候,左手翻书,右手做笔记,两者可以同时进行。并发性:如果系统只有一个CPU,有多个程序要运行,系统只能把CPU时间分成多个时间片,然后分配给不同的程序。比如我看书的时候,只有用右手翻书,才能腾出手做笔记。但是很明显,并发≠并行,但只要CPU运行得足够快,每个时间片的划分足够小,就会给人一种计算机在同时做多件事情的错觉。进程、线程和协程进程是程序执行的过程,是系统进行资源分配和调度的基本单位。简单的说,进程就是在我们的电脑上独立运行的程序。线程是系统可以调度的最小单位。它包含在流程中,是流程中的实际操作单元。一个进程可以包含多个线程。一个进程可以理解为一个工厂,工厂里的工人就是线程。就像工厂里必须有一个工人在工作一样,每个进程中也必须有一个线程在工作。比如JavaScript被称为单线程语言,就是说JavaScript工厂里只有一个worker,这个worker就是工头,叫做主线程。多线程进程中也会有一个主线程,主线程一般是随着进程一起创建和销毁的。进程和线程都是操作系统上的概念。如果要在程序中切换进程或线程,需要保存当前线程的状态,然后在切换过程中恢复另一个线程的状态,这需要时间,如果是进程切换,也可能会跨CPU,CPU缓存无法利用,导致进程比线程切换开销更大。因此,除了系统级的内核线程外,一些程序中还创建了用户线程。这样可以减少与操作系统的交互,控制程序内的线程切换。这个用户模式线程称为协程。.用户线程的切换完全由程序控制。实际上只使用了一个内核线程,内核线程和用户线程是一对多的关系。虽然这样可以减少线程上下文切换带来的开销,但是无法避免阻塞的问题。一旦一个用户线程被阻塞,内核线程就会被阻塞,用户线程无法切换,所以整个进程就会被挂起。协程Go语言中的线程模型既不是内核线程也不是完整的用户线程。它是一种混合线程模型。用户线程和内核线程是多对多的对应关系,用户线程和内核线程是动态关联的。当一个线程被阻塞时,可以动态切换到另一个内核线程。上面的G-P-M模型只是Go语言中抽象层次的线程模型。具体如何进行线程调度,我们看Go语言的代码。funclog(msgstring){fmt.Println(msg)}funcmain(){log("hello")golog("world")}在上一篇文章中提到,Go程序运行时,main函数作为默认入口点。main函数中运行的代码会在一个goroutine中运行。如果我们在被调用的函数前加上一个go关键字,那么这个函数就会在另一个goroutine中运行。这里所说的goroutine就是Go语言中的用户线程,即coroutine。Go语言运行时,会建立一个G-P-M模型,负责goroutines的调度。G:gotoutine(用户线程);P:处理器(逻辑处理器);M:machine(机器资源);每个goroutine都会放在一个goroutine队列中,因为是用户创建的,上下文切换成本极低。P(processor)的主要作用是管理用户线程,在内核线程上合理安排goroutines,也就是这个模型的M。通常情况下,G的数量远远多于M。Goroutine如果你运行了上面的代码,你会发现go关键字后面的函数并没有真正执行。funclog(msgstring){fmt.Println(msg)}funcmain(){log("hello")golog("world")}运行后,终端只输出hello,不输出world。这是因为main函数会运行在主goroutine中,类似于主线程,每条go语句都会启动一个新的goroutine。启动的goroutine不会直接执行,而是放到一个G队列中,等待分配P。但是maingoroutine结束后,就代表程序结束了,G队列中的goroutine还没有等待执行时间。所以go语句后面的函数是一个异步函数。go语句调用后,会立即执行后面的语句,无需等待go语句后面的函数执行完毕。如果我们要输出世界,我们可以在main函数后面加一个sleep来延长maingoroutine的执行时间。import("fmt""time")funclog(msgstring){fmt.Println(msg)}funcmain(){fmt.Println()log("hello")golog("world")time.Sleep(time.Millisecond*500)}通道多线程编程,由于线程间需要共享数据,一般采用共享内存方案。但是这样做,势必会出现多个线程同时修改同一个数据的情况。为了保证数据的安全,需要对数据进行锁定,处理起来比较麻烦。所以Go语言社区有一句名言:不要通过共享内存来通信,而是通过通信来共享内存。这里所说的创建通道的通信方式就是Go语言中的通道。channel是Go语言中的一种特殊类型,需要通过make方法创建channel。ch:=make(chanint)//创建一个int类型的通道在创建通道的时候需要添加一个type来表示通道传输的数据类型。您还可以通过指定一个空接口来创建一个可以传输任意数据的通道。ch:=make(chaninterface{})创建的通道分为非缓冲通道和缓存通道。make方法的第二个参数表示buffer的个数(传入0则和不传入效果一样)。ch:=make(chanstring,0)//没有缓存通道,传入ch:=make(chanstring,1)发送和接收数据通道创建后,通过<-符号接收和发送数据。ch:=make(chanstring)ch<-"helloworld"//发送一个字符串msg:=<-ch//接收发送的字符串在实际运行这段代码之前,会提示错误。致命错误:所有goroutinesareas睡眠死锁!表示当前goroutine处于挂起状态,以后不会有任何响应,只能直接中断程序。因为这里创建了一个非缓冲的channel,channel在发送数据后不会在channel中缓存数据,导致后面查找channel获取数据时无法正常从channel中获取数据。我们可以把通道的缓存设置为1,这样通道就可以在里面缓存一个数据。ch:=make(chanstring,1)ch<-"helloworld"//发送一个字符串msg:=<-ch//接收之前发送的字符串fmt.Println(msg)但是如果发送的数据超过了缓存的数量,或者接收数据时,缓存中没有数据,仍然会报错。ch:=make(chanstring,1)ch<-"helloworld"ch<-"helloworld"//fatalerror:allgoroutinesareasleep-deadlock!ch:=make(chanstring,1)ch<-"helloworld"<-ch<-ch//fatalerror:allgoroutinesareasleep-deadlock!协程中使用了通道,那么在无缓冲的通道中应该如何发送和接收数据呢?这就需要通道和协程的结合,也就是Go语言常用的并发开发模式。非缓冲通道在发送和接收数据时,由于一次只能同步发送一个数据,所以会在两个goroutine之间反复跳转。当通道接收到数据时,它会阻塞当前的goroutine,直到通道在另一个goroutine中发送数据。ch:=make(chanstring)//创建一个无缓冲通道temp:="IamonEarth"gofunc(){//接收一个字符串ch<-"helloworld"temp="Enteredadifferentdimension"}()//这里运行会被阻塞//直到通道在另一个goroutine中发送数据数据阻塞,我们可以在前面加一个temp变量,然后在另一个goroutine中修改这个变量,看看最后输出的值有没有被修改,以此来证明接收数据时通道是否被阻塞。运行结果证明,当channel接收到数据时,maingoroutine的执行被阻塞了。除了主动从channel中逐条获取数据外,还可以通过range循环获取数据。ch:=make(chanstring)gofunc(){fori:=0;i<5;i++{ch<-fmt.Sprintf("data%d",i)}close(ch)}()fordata:=range{fmt.Println("receive=>",data)}如果使用range循环读取通道中的数据,发送数据时需要调用close(ch)关闭通道。实战了解了前面的基础知识后,我们就可以通过协程+通道的方式编写一个爬虫来练习Go语言的并发能力。首先,确定爬虫需要爬取的网站。由于我个人喜欢看电影,所以决定爬取豆瓣的电影TOP榜。它的域名是https://movie.douban.com/top250,翻到第二页后,域名是https://movie.douban.com/top250?start=25,第三页的域名page为https://movie.douban.com/top250?start=50,表示TOP列表每页会有25部电影,每翻页start参数会加25。constlimit=25//每页个数为25consttotal=100//爬取列表前100部电影constpage=total/limit//要爬取的页数funcmain(){varstartintvarurlstringfori:=0;i