Go-简洁的并发
2015-10-02 02:22
549 查看
Go-简洁的并发
http://www.yankay.com/go-clear-concurreny/多核处理器越来越普及。有没有一种简单的办法,能够让我们写的软件释放多核的威力?是有的。随着Golang, Erlang, Scala等为并发设计的程序语言的兴起,新的并发模式逐渐清晰。正如过程式编程和面向对象一样,一个好的编程模式有一个极其简洁的内核,还有在此之上丰富的外延。可以解决现实世界中各种各样的问题。本文以GO语言为例,解释其中内核、外延。
并发模式之内核
这种并发模式的内核只需要协程和通道就够了。协程负责执行代码,通道负责在协程之间传递事件。不久前,并发编程是个非常困难的事。要想编写一个良好的并发程序,我们不得不了解线程,锁,semaphore,barrier甚至CPU更新高速缓存的方式,而且他们个个都有怪脾气,处处是陷阱。笔者除非万不得以,决不会自己操作这些底层并发元素。一个简洁的并发模式不需要这些复杂的底层元素,协程和通道就够了。
协程是轻量级的线程。在过程式编程中,当调用一个过程的时候,需要等待其执行完才返回。而调用一个协程的时候,不需要等待其执行完,会立即返回。协程十分轻量,Go语言可以在一个进程中执行有数以十万计的协程,依旧保持高性能。而对于普通的平台,一个进程有数千个线程,其CPU会忙于上下文切换,性能急剧下降。随意创建线程可不是一个好主意,但是我们可以大量使用的协程。
通道是协程之间的数据传输通道。通道可以在众多的协程之间传递数据,具体可以值也可以是个引用。通道有两种使用方式。
协程可以试图向通道放入数据,如果通道满了,会挂起协程,直到通道可以为他放入数据为止。
协程可以试图向通道索取数据,如果通道没有数据,会挂起协程,直到通道返回数据为止。
如此,通道就可以在传递数据的同时,控制协程的运行。有点像事件驱动,也有点像阻塞队列。
这两个概念非常的简单,各个语言平台都会有相应的实现。在Java和C上也各有库可以实现两者。
Golang | Erlang | Scala(Actor) | |
协程 | goroutines | process | actor |
消息队列 | channel | mailbox | channel |
并发模式之外延
协程相较于线程,可以大量创建。打开这扇门,我们拓展出新的用法,可以做生成器,可以让函数返回“服务”,可以让循环并发执行,还能共享变量。但是出现新的用法的同时,也带来了新的棘手问题,协程也会泄漏,不恰当的使用会影响性能。下面会逐一介绍各种用法和问题。演示的代码用GO语言写成,因为其简洁明了,而且支持全部功能。
生成器
有的时候,我们需要有一个函数能不断生成数据。比方说这个函数可以读文件,读网络,生成自增长序列,生成随机数。这些行为的特点就是,函数的已知一些变量,如文件路径。然后不断调用,返回新的数据。下面生成随机数为例, 以让我们做一个会并发执行的随机数生成器。
非并发的做法是这样的:
1234 | // 函数 rand_generator_1 ,返回 intfunc rand_generator_1() int { return rand.Int()} |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | // 函数 rand_generator_2,返回 通道(Channel) func rand_generator_2() chan int { // 创建通道 out := make(chan int) // 创建协程 go func() { for { //向通道内写入数据,如果无人读取会等待 out <- rand.Int() } }() return out } func main() { // 生成随机数作为一个服务 rand_service_handler := rand_generator_2() // 从服务中读取随机数并打印 fmt.Printf("%dn", <-rand_service_handler) } |
调用生成器,可以返回一个“服务”。可以用在持续获取数据的场合。用途很广泛,读取数据,生成ID,甚至定时器。这是一种非常简洁的思路,将程序并发化。
多路复用
多路复用是让一次处理多个队列的技术。Apache使用处理每个连接都需要一个进程,所以其并发性能不是很好。而Nighx使用多路复用的技术,让一个进程处理多个连接,所以并发性能比较好。同样,在协程的场合,多路复用也是需要的,但又有所不同。多路复用可以将若干个相似的小服务整合成一个大服务。那么让我们用多路复用技术做一个更高并发的随机数生成器吧。
123456789101112131415161718192021222324 | // 函数 rand_generator_3 ,返回通道(Channel)func rand_generator_3() chan int { // 创建两个随机数生成器服务 rand_generator_1 := rand_generator_2() rand_generator_2 := rand_generator_2() //创建通道 out := make(chan int) //创建协程 go func() { for { //读取生成器1中的数据,整合 out <- <-rand_generator_1 } }() go func() { for { //读取生成器2中的数据,整合 out <- <-rand_generator_2 } }() return out} |
Furture技术
Furture是一个很有用的技术,我们常常使用Furture来操作线程。我们可以在使用线程的时候,可以创建一个线程,返回Furture,之后可以通过它等待结果。 但是在协程环境下的Furtue可以更加彻底,输入参数同样可以是Furture的。调用一个函数的时候,往往是参数已经准备好了。调用协程的时候也同样如此。但是如果我们将传入的参数设为通道,这样我们就可以在不准备好参数的情况下调用函数。这样的设计可以提供很大的自由度和并发度。函数调用和函数参数准备这两个过程可以完全解耦。下面举一个用该技术访问数据库的例子。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | //一个查询结构体 type query struct { //参数Channel sql chan string //结果Channel result chan string } //执行Query func execQuery(q query) { //启动协程 go func() { //获取输入 sql := <-q.sql //访问数据库,输出结果通道 q.result <- "get " + sql }() } func main() { //初始化Query q := query{make(chan string, 1), make(chan string, 1)} //执行Query,注意执行的时候无需准备参数 execQuery(q) //准备参数 q.sql <- "select * from table" //获取结果 fmt.Println(<-q.result) } |
Furture技术可以和各个其他技术组合起来用。可以通过多路复用技术,监听多个结果Channel,当有结果后,自动返回。也可以和生成器组合使用,生成器不断生产数据,Furture技术逐个处理数据。Furture技术自身还可以首尾相连,形成一个并发的pipe filter。这个pipe filter可以用于读写数据流,操作数据流。
Future是一个非常强大的技术手段。可以在调用的时候不关心数据是否准备好,返回值是否计算好的问题。让程序中的组件在准备好数据的时候自动跑起来。
并发循环
循环往往是性能上的热点。如果性能瓶颈出现在CPU上的话,那么九成可能性热点是在一个循环体内部。所以如果能让循环体并发执行,那么性能就会提高很多。要并发循环很简单,只有在每个循环体内部启动协程。协程作为循环体可以并发执行。调用启动前设置一个计数器,每一个循环体执行完毕就在计数器上加一个元素,调用完成后通过监听计数器等待循环协程全部完成。
12345678910111213 | //建立计数器sem := make(chan int, N); //FOR循环体for i,xi := range data { //建立协程 go func (i int, xi float) { doSomething(i,xi); //计数 sem <- 0; } (i, xi);}// 等待循环结束for i := 0; i < N; ++i { <-sem } |
Chain Filter技术
前面提到了Future技术首尾相连,可以形成一个并发的pipe filter。这种方式可以做很多事情,如果每个Filter都由同一个函数组成,还可以有一种简单的办法把他们连起来。由于每个Filter协程都可以并发运行,这样的结构非常有利于多核环境。下面是一个例子,用这种模式来产生素数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | // A concurrent prime sieve package main // Send the sequence 2, 3, 4, ... to channel 'ch'. func Generate(ch chan<- int) { for i := 2; ; i++ { ch <- i // Send 'i' to channel 'ch'. } } // Copy the values from channel 'in' to channel 'out', // removing those divisible by 'prime'. func Filter(in <-chan int, out chan<- int, prime int) { for { i := <-in // Receive value from 'in'. if i%prime != 0 { out <- i // Send 'i' to 'out'. } } } // The prime sieve: Daisy-chain Filter processes. func main() { ch := make(chan int) // Create a new channel. go Generate(ch) // Launch Generate goroutine. for i := 0; i < 10; i++ { prime := <-ch print(prime, "n") ch1 := make(chan int) go Filter(ch, ch1, prime) ch = ch1 } } |
Chain-Filter通过简单的代码创建并发的过滤器链。这种办法还有一个好处,就是每个通道只有两个协程会访问,就不会有激烈的竞争,性能会比较好。
共享变量
下面的例子描述如何用这个方式,实现一个共享变量。
123456789101112131415161718192021222324252627282930313233 | //共享变量有一个读通道和一个写通道组成type sharded_var struct { reader chan int writer chan int} //共享变量维护协程func sharded_var_whachdog(v sharded_var) { go func() { //初始值 var value int = 0 for { //监听读写通道,完成服务 select { case value = <-v.writer: case v.reader <- value: } } }()} func main() { //初始化,并开始维护协程 v := sharded_var{make(chan int), make(chan int)} sharded_var_whachdog(v) //读取初始值 fmt.Println(<-v.reader) //写入一个值 v.writer <- 1 //读取新写入的值 fmt.Println(<-v.reader)} |
协程泄漏
协程和内存一样,是系统的资源。对于内存,有自动垃圾回收。但是对于协程,没有相应的回收机制。会不会若干年后,协程普及了,协程泄漏和内存泄漏一样成为程序员永远的痛呢?一般而言,协程执行结束后就会销毁。协程也会占用内存,如果发生协程泄漏,影响和内存泄漏一样严重。轻则拖慢程序,重则压垮机器。C和C++都是没有自动内存回收的程序设计语言,但只要有良好的编程习惯,就能解决规避问题。对于协程是一样的,只要有好习惯就可以了。只有两种情况会导致协程无法结束。一种情况是协程想从一个通道读数据,但无人往这个通道写入数据,或许这个通道已经被遗忘了。还有一种情况是程想往一个通道写数据,可是由于无人监听这个通道,该协程将永远无法向下执行。下面分别讨论如何避免这两种情况。对于协程想从一个通道读数据,但无人往这个通道写入数据这种情况。解决的办法很简单,加入超时机制。对于有不确定会不会返回的情况,必须加入超时,避免出现永久等待。另外不一定要使用定时器才能终止协程。也可以对外暴露一个退出提醒通道。任何其他协程都可以通过该通道来提醒这个协程终止。对于协程想往一个通道写数据,但通道阻塞无法写入这种情况。解决的办法也很简单,就是给通道加缓冲。但前提是这个通道只会接收到固定数目的写入。比方说,已知一个通道最多只会接收N次数据,那么就将这个通道的缓冲设置为N。那么该通道将永远不会堵塞,协程自然也不会泄漏。也可以将其缓冲设置为无限,不过这样就要承担内存泄漏的风险了。等协程执行完毕后,这部分通道内存将会失去引用,会被自动垃圾回收掉。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | func never_leak(ch chan int) { //初始化timeout,缓冲为1 timeout := make(chan bool, 1) //启动timeout协程,由于缓存为1,不可能泄露 go func() { time.Sleep(1 * time.Second) timeout <- true }() //监听通道,由于设有超时,不可能泄露 select { case <-ch: // a read from ch has occurred case <-timeout: // the read from ch has timed out } } |
和内存里面的对象一样,对于长期存在的协程,我们不用担心泄漏问题。一是长期存在,二是数量较少。要警惕的只有那些被临时创建的协程,这些协程数量大且生命周期短,往往是在循环中创建的,要应用前面提到的办法,避免泄漏发生。协程也是把双刃剑,如果出问题,不但没能提高程序性能,反而会让程序崩溃。但就像内存一样,同样有泄漏的风险,但越用越溜了。
并发模式之实现
在并发编程大行其道的今天,对协程和通道的支持成为各个平台比不可少的一部分。虽然各家有各家的叫法,但都能满足协程的基本要求—并发执行和可大量创建。笔者对他们的实现方式总结了一下。下面列举一些已经支持协程的常见的语言和平台。
语言/平台 | 实现时间 | 协程名称 | 备注 |
GoLang | 原生支持 | goroutines | |
Erlang | 原生支持 | process | 函数式语言 |
Scala | 原生支持 | actor | 函数式编程 |
Python | 2.5版本后 | coroutine | 官方Python不完全实现 Stackless Python支持 |
Perl | 6.0版本后 | coroutine | |
Ruby | 1.9 版本后 | fiber | |
Lua | 原生支持 | coroutine | |
C# | .net 2.0版本后 | fiber |
令人惊奇的是C/C++和Java这三个世界上最主流的平台没有在对协程提供语言级别的原生支持。他们都背负着厚重的历史,无法改变,也无需改变。但他们还有其他的办法使用协程。
Java平台有很多方法实现协程:
修改虚拟机:对JVM打补丁来实现协程,这样的实现效果好,但是失去了跨平台的好处
修改字节码:在编译完成后增强字节码,或者使用新的JVM语言。稍稍增加了编译的难度。
使用JNI:在Jar包中使用JNI,这样易于使用,但是不能跨平台。
使用线程模拟协程:使协程重量级,完全依赖JVM的线程实现。
其中修改字节码的方式比较常见。因为这样的实现办法,可以平衡性能和移植性。最具代表性的JVM语言Scala就能很好的支持协程并发。流行的Java Actor模型类库akka也是用修改字节码的方式实现的协程。
对于C语言,协程和线程一样。可以使用各种各样的系统调用来实现。协程作为一个比较高级的概念,实现方式实在太多,就不讨论了。比较主流的实现有libpcl, coro,lthread等等。
对于C++,有Boost实现,还有一些其他开源库。还有一门名为μC++语言,在C++基础上提供了并发扩展。
可见这种编程模型在众多的语言平台中已经得到了广泛的支持,不再小众。如果想使用的话,随时可以加到自己的工具箱中。
结语
本文探讨了一个极其简洁的并发模型。在只有协程和通道这两个基本元件的情况下。可以提供丰富的功能,解决形形色色实际问题。而且这个模型已经被广泛的实现,成为潮流。相信这种并发模型的功能远远不及此,一定也会有更多更简洁的用法出现。或许未来CPU核心数目将和人脑神经元数目一样多,到那个时候,我们又要重新思考并发模型了。
相关文章推荐
- hihocoder#1044状态压缩dp+滚动数组
- HDOJ 1902 The Dragon of Loowater【贪心】
- 搜索的正确姿势
- 精读《In The Plex》,这本迄今为止最全面解析Google却迟迟没有
- GOF 23设计模式之 装饰模式(Decorator)
- UVA 11292 Dragon of Loowater
- Golang 1.3 发布时间。最终找到地方下载。
- 【点到线段距离】URAL - 1348 Goat in the Garden 2
- Hugo探究
- LightOJ 1259 Goldbach`s Conjecture
- Go 语言并发笔记
- codeforces #322 div 2 D. Three Logos (枚举)
- Django CSRF原理分析
- Codeforces Round #322 (Div. 2) D. Three Logos 模拟
- The following SDK component was not installed: extra-google-m2repository
- Django基础(一)
- God of War - HDU 2809(状态压缩+模拟)
- go语言之并发
- Django模板系统(非常详细)
- 使用Google Volley框架访问服务端,为Listview加载图片。