Go语言程序设计(十七)并发编程

一、操作系统提供的并发基础

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语句仍然或被执行。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值