一、操作系统提供的并发基础
1、进程
进程是在并发环境下,程序的一次动态执行过程。它由进程控制块(PCB)、程序和数据三部分组成,进程在它的生命周期内可能处于执行、就绪、阻塞三种基本状态。
在多任务操作系统中,多个进程可以并发执行,而且进程是系统资源分配的基本单位。系统中每个进程都有自已的内存映像区,且互不影响,所以管理简单,但缺点是系统开销大。所以,系统能同时创建的进程数量是有限的,不能太多。
2、线程
由于进程的系统开销大,操作系统的设计者又提出了更小的能独立运行的单位一一线程,试图用它来提高系统内程序并发执行的程度,从而进一步提高系统的吞吐量。
在操作系统中,线程是由进程创建的,所以它继承了进程的部分资源,且具有进程的一些基本特征。所以多个线程之间也可以并发执行,且比进程的系统开销小。但是,和进程一样,线程依然是由系统内核管理的,所以在高并发模式下,系统能创建的线程数量依然有限,效率也并不高。
3、协程
协程本质上是一种用户态线程,不需要操作系统进行抢占式调度,而且在真正的实现中寄存于线程中。因此,协程系统开销极小,可以有效提高线程任务的并发性,避免高并发模式下线程的缺点。协程的最大优势在于其“轻量级”,可以轻松创建上百万个而不会导致系统资源衰竭,而系统最多能创建的进程、线程的数量却少得多。
使用协程的优点是编程简单,结构清晰。但缺点是需要语言的支持,如果语言不支持,则需要用户在程序中自行实现调度。目前,原生支持协程的语言还很少。
二、Goroutine
1、Goroutine的定义
Go语言在语言级别支持轻量级线程,叫做Goroutine。Go语言标准库提供的所有系统调用操作(包括同步I/O操作),都会让出处理机给其他Goroutine。这使得轻量级线程的切换管理不依赖于系统的进程和线程,也不依赖于CPU的核心数量。
Goroutine是Go语言运行库的功能,不是操作系统提供的功能,Goroutine不是用线程实现的。Goroutine 就是一段代码,一个函数入口,以及在堆上为其分配的一个堆栈。因为节省了频繁创建和销毁线程的开销,所以它相对于进程、线程系统开销非常小,是轻量级的。可以很轻松地创建上百万个Goroutine,但它们并不是被操作系统所调度执行。
2、Goroutine的创建
Goroutine是Go语言中的轻量级线程实现,由Go运行时管理(Runtime)。在一个函数调用前加上关键字“go",这次调用就会在一个新的Goroutine中并发执行。当被调用的函数返回时,这个Goroutine也就自动结束了。需要注意的是,如果这个函数有返回值,那么这个返回值会被丢弃。
在Go语言中,可以使用关键字“go"创建并发执行的Goroutine。基本格式如下:
go func()
例如:
func test() {
fmt. Println("Go...")
}
func main() {
for i :=0; i< 10; i++{
go test()
}
}
在上面的例子中,在一个for循环中一共调用了10次test()函数,它们是并发执行的。可是在编译执行上述代码时,会发现显示器没有任何输出信息,要解释这种现象,首先要了解Go语言程序的执行机制。
Go程序从初始化main package并执行main()函数开始,当main()函数返回时程序退出,并且程序并不等待其他Goroutine结束。对于上述例子,main()函数启动了10个Goroutine,然后就直接返回退出了。而被启动的执行test()函数的10个Goroutine并没有来得及执行,所以程序没有任何输出结果。
在多进程或多进程编程时,操作系统解决上述问题的方法是,让父进程等待线程执行结束后再退出,比如使用WaitForSingleObject之类的调用,来等待所有线程执行完毕。而Go语言有自已特有的解决方式,那就是Channel(通道)。Channel可以在Goroutine之间进行通信,这样main()函数就可以知道Goroutine何时退出。通过Channel,main()函数就可以等待所有Goroutine都退出了自己再退出。
所以,在使用Go语言设计并发程序时,通常是Goroutine和Channel配合使用,二者不可缺其一。
三、Channel
1、程序间的并发通信
(1)共享内存
共享内存是指多个并发单位分别保存对同一个数据的引用,实现对该数据的共享。被共享的数据可以有多种形式,比如内存数据块、磁盘文件、网络数据等,在实际应用中最常见的就是内存数据块。
多个并发单位在同时访问共享内存时,必须使用互斥锁等相关机制,以保证对共享内存的互斥访问。这就造成了在多个并发单位间使用共享内存通信时,程序结构往往比较复杂,程序逻辑结构难于控制等问题。
(2)消息机制
Go语言是以并发编程作为语言的最核心优势,所以在处理程序并发模型时,它不再采用共享内存作为并发单位之间的通信手段,而是以消息机制作为主要通信方法。
消息机制规定每个并发单位是自包含的、独立的个体,并且都有自己的变量,这些变量不能在不同的并发单位之间共享。每个并发单位的输入、输出只有一种,那就是消息。这有点类似于进程的概念,每个进程都不会被其他进程打扰,它只做好自己的工作就行了。不同进程间靠消息进行通信,而不会共享内存数据。
2、Channel简介
Go语言提供的消息通信机制被称为Channel,它类似于单双向数据管道(Pipe),用户可以使用Channel在两个或多个Goroutine之间传递消息。Channel从设计上确保同一时刻只有一个Goroutine能从中接收数据,这就避免了使用互斥锁的问题。另外,Channel中数据的发送和接收都是原语操作,不会中断,只会失败。
Channel是进程内的通信方式,因此通过Channel传递对象的过程和调用函数时的参数传递行为比较一致,当然也可以传递指针等。如果需要跨进程进行通信,一般建议使用分布式系统的方法来解决,比如使用网络套接字(Socket)或者HTTP等通信协议。
3、Channel声明和初始化
在Go语言中,Channel是引用类型,也是类型相关的,也就是说一个Channel只能传递一种类型的值,这个类型需要在声明Channel时指定。Channel的一般声明形式如下:
var chanName chan ElementType
从上式可以看出,Channel的声明格式和一般变量声明基本相同,只是在类型前加了个关键字“chan”。ElementType指定Channel所能传递的元素类型。
例如:
var ch chan int
该例中,ch是一个可以传递int类型的Channel。
Channel除了可以传递基本类型的数据,还可以作为Array、Slice或Map的元素。
例如:
var chs [10]chan int
该例中,chs是一个包含10个可传递int类型数据的Channel。
还可以使用make()函数直接声明并初始化Channel,
例如:
ch : = make( char int)
该例中声明并创建了ch,并为其分配了内存。
4、数据接收和发送
Channel的主要用途是在不同的Goroutine之间传递数据,它使用通道运算符“<-”接收或者发送数据,将一个数据发送(写入)至Channel的语法如下:
ch <- value
向Channel写入数据通常会导致程序阻塞(Block),直到有其他Goroutine从这个Channel中读取数据。从Channel中接收(读取)数据的语法如下:
value :=<- ch
如果Channel之前没有写入数据,那么从Channel中读取数据也会导致阻塞,直到Channel中被写入数据为止。
5、Channel的关闭和迭代器
关闭Channel非常简单,直接使用Go语言提供的内置丽数close()即可。关闭Channel的操作语句如下:
close( chanName)
在关闭了一个Channel之后,往往用户还需判断Channel是否被关闭,这时可以在读取Channel的时候使用多重返回值的方式,例如:
value,ok :=<- ch
这个用法与Map中按键值获取value的过程比较类似,只需要看第二个bool返回值即可。如果返回值是false则表示Channel已被关闭,否则主函数还要继续阻塞接收或者发送。
对Channel的读取操作还可以使用range迭代器来完成,range操作直至Channel关闭(Close)方才终止循环。另外,在Go语言中,还经常把创建Goroutine和Channel的工作放在一个匿名函数中来完成。
只有发送端(另一端正在等待接收)才能关闭Channel,只有接收端才能获得关闭状态。Close调用不是必需的,但如果接收端使用range或者循环接收数据,就必须调用Close,否则就会导致“throw: all goroutines are asleep-deadlock !”错误。
6、单向Channel
前面例子中列举的通道既能发送数据,也能接收数据,被称为双向通道(Duplexchannel)。还可以将Channel指定为单向通道(Simplex- channel),即只能接收,或只能发送。在将一个Channel变量传递到一个函数时,可以通过将其指定为单向Channel变量,从而限制该函数对此Channel的操作,比如只能从此Channel读,或只能往该Channel写。
只能接收的Channel变量定义形式如下:
var chanName chan <- ElementType
只能发送的Channel变量定义形式如下:
var chanName <- chan ElementType
在定义了单向Channel后,还要对其初始化。在Go语言中,Channel是引用数据类型,也是一个原生数据类型,因此不仅支持被传递,还支持类型转换。所以,单向Channel可以由一个已定义的双向(正常)Channel转换而来。
例如:
ch := make(chan int)
chRead :=<- chan int(ch)
chWrite := chan <- int(ch)
该例中基于ch,通过类型转换初始化了两个单向Channel:chRead是一个单向读Channel,chWrite是一个单向写Channel。
7、异步Channel
前面的举例创建的都是不带Buffer的Channel,这种做法只适用于传递单个数据的应用场合,而对需要持续传输大量数据的应用场合就不适用了。对于在Goroutine间传输大量数据的应用,可以使用异步通道(Asynchronouschannel),从而达到消息队列的效果。
异步Channel,就是给Channel设定一个Buffer值。在Buffer未写满的情况下,不阻塞发送操作;在Buffer未读完之前,不阻塞接收操作。这里的Buffer是指被缓冲的数据对象的数量,而不是内存大小。
要创建一个带Buffer的Channel,只需要在调用make()函数时,将缓冲区的大小作为第二个参数传入即可。
例如:
ch : = make(chan int, 1024)
该例创建了一个大小为1024 的int类型Channel,即使没有读取方,写入方也可以一直往Channel中写入数据,在缓冲区被写满之前都不会阻塞。
从带Buffer的Channel中读取数据,可以使用与常规非缓冲Channel完全一致的方法,但一般是使用range来实现更为简洁的循环读取。
四、Select机制
在Go语言中,Select机制主要用于解决通道通信中的多路复用问题。因为通道的接收操作往往是阻塞式的,所以Select机制还经常和超时机制配合使用,将阻塞式的通信转换为非阻塞式,以提高系统通信效率。
如果有多个Channel需要监听,就可以使用Select机制,随机处理一个可用的Channel。Select的用法和Switch语句非常类似,由“select"开始一个新的选择块,每个选择条件由“case”语句来描述。与Switch语句可以使用任何形式条件表达式相比,Select机制有比较多的限制,其中最大的一条是每个“case”语句必须是一个I/O操作。
Select 机制的基本结构如下:
select {
case <- chan1:
//如果chan1成功读取数据,则进行该case处理语句.
case <- chan2:
//如果chan2成功读取数据,则进行该case处理语句.
.
.
.
default:
//如果上面都没有成功,则进入default处理流程.
}
可以看出,Select不像Switch语句,后面并不带判断条件,而是直接检测case语句。每个case语句都必须是一个面向Channel的操作。
Select机制的基本过程
(1)当所有被监听Channel中都无数据时,Select会一直等到其中一个有数据为止。
(2)当多个被监听Channel中都有数据时,Select会随机选择一个case执行。
(3)当所有被监听Channel中都无数据,且default子句存在时,则default子句会被执行。
(4)如果想持续监听多个Channel,需要使用for语句协助。
五、超时机制
在前面所有举例中,所有对Channel操作的错误问题都被忽略了,没有进行错误处理的程序显然是不安全的。即在向Channel写数据时发现已满,或从Channel读数据时发现为空,如果不正确处理这些问题,很可能会导致整个Goroutine死锁。在Go语言并发编程的通信过程中,所有错误处理都由超时机制来完成。
超时机制是一种解决通信死锁的机制,通常会设置一个超时参数,通信双方如果在设定的时间内仍然没处理完任务,则该处理过程会立即被终止,并返回对应的超时信息。
Go语言没有提供直接的超时处理机制,但可以利用Select机制解决超时问题。因为Select的特点是只要其中一个case已经完成,程序就会继续往下执行,而不会考虑其他case的执行情况。基于此特性,就可以使用Select为Channel实现超时处理功能。
六、Runtime Goroutine
1、出让时间片
在设计并发任务时,用户可以在每个Goroutine中控制何时主动让出时间片给其他Goroutine,这可以使用Gosched() 函数来实现。Gosched()类似于C#或Python中的Yield(函数迭代器),让出当前Goroutine的执行权限,调度器会安排其他等待的任务去运行,并在下轮某个时间片再从该位置恢复执行。
2、获取CPU核心数和任务数
有时为了将多个并发执行的Goroutine分配给不同的CPU核心去完成,用户就需要知道CPU核心的具体数目。为此,runtime包提供了NumCPU()函数可以完成这个任务。
为了观察系统任务调度情况,还可以使用NumGoroutine()函数返回正在执行和排队的任务总数。
3、终止当前Goroutine
如果要强行终止一个Goroutine的执行,可以调用Goexit()函数来完成。Goexit()将终止整个堆栈链,并在内层退出,但是defer语句仍然或被执行。