简介程序员
多核处理器愈来愈普及,那有没有一种简单的办法,可以让咱们写的软件释放多核的威力?答案是:Yes。随着Golang, Erlang, Scale等为并发设计的程序语言的兴起,新的并发模式逐渐清晰。正如过程式编程和面向对象同样,一个好的编程模式须要有一个极其简洁的内核,还有在此之上丰富的外延,能够解决现实世界中各类各样的问题。本文以GO语言为例,解释其中内核、外延。sql
并发模式以内核数据库
这种并发模式的内核只须要协程和通道就够了。其中协程负责执行代码,通道负责在协程之间传递事件。编程
并发编程一直以来都是个很是困难的工做。要想编写一个良好的并发程序,咱们不得不了解线程, 锁,semaphore,barrier甚至CPU更新高速缓存的方式,并且他们个个都有怪脾气,到处是陷阱。笔者除非万不得以,决不会本身操做这些底层 并发元素。一个简洁的并发模式不须要这些复杂的底层元素,只需协程和通道就够了。数组
协程是轻量级的线程。在过程式编程中,当调用一个过程的时候,须要等待其执行完才返回。而调用一个协程的时候,不须要等待其执行完,会当即返回。协程十分轻量,Go语言能够在一个进程中执行有数以十万计的协程,依旧保持高性能。而对于普通的平台,一个进程有数千个线程,其CPU会忙于上下文切换,性能急剧降低。随意建立线程可不是一个好主意,可是咱们能够大量使用的协程。缓存
通道是协程之间的数据传输通道。通道能够在众多的协程之间传递数据,具体能够值也能够是个引用。通道有两种使用方式。安全
· 协程能够试图向通道放入数据,若是通道满了,会挂起协程,直到通道能够为他放入数据为止。网络
· 协程能够试图向通道索取数据,若是通道没有数据,会挂起协程,直到通道返回数据为止。数据结构
如此,通道就能够在传递数据的同时,控制协程的运行。有点像事件驱动,也有点像阻塞队列。这两个概念很是的简单,各个语言平台都会有相应的实现。在Java和C上也各有库能够实现二者。多线程
只要有协程和通道,就能够优雅的解决并发的问题。没必要使用其余和并发有关的概念。那如何用这两把利刃解决各式各样的实际问题呢?
并发模式以外延
协程相较于线程,能够大量建立。打开这扇门,咱们拓展出新的用法,能够作生成器,可让函数返回“服务”,可让循环并发执行,还能共享变量。可是出现新 的用法的同时,也带来了新的棘手问题,协程也会泄漏,不恰当的使用会影响性能。下面会逐一介绍各类用法和问题。演示的代码用GO语言写成,由于其简洁明 了,并且支持所有功能。
1.生成器
有的时候,咱们须要有一个函数能不断生成数据。比方说这个函数能够读文件,读网络,生成自增加序列,生成随机数。这些行为的特色就是,函数的已知一些变量,如文件路径。而后不断调用,返回新的数据。
下面生成随机数为例,以让咱们作一个会并发执行的随机数生成器。
// 函数rand_generator_1 ,返回 int funcrand_generator_1() int { return rand.Int() } // 上面是一个函数,返回一个int。假如rand.Int()这个函数调用须要很长时间等待,那该函数的调用者也会所以而挂起。因此咱们能够建立一个协程,专门执行rand.Int()。 // 函数rand_generator_2,返回通道(Channel) funcrand_generator_2() chan int { // 建立通道 out := make(chan int) // 建立协程 go func() { for { //向通道内写入数据,若是无人读取会等待 out <- rand.Int() } }() return out } funcmain() { // 生成随机数做为一个服务 rand_service_handler :=rand_generator_2() // 从服务中读取随机数并打印 fmt.Printf("%d\n",<-rand_service_handler) }
上面的这段函数就能够并发执行了rand.Int()。有一点值得注意到函数的返回能够理解为一个“服务”。但咱们须要获取随机数据时候,能够随时向这个 服务取用,他已经为咱们准备好了相应的数据,无需等待,随要随到。若是咱们调用这个服务不是很频繁,一个协程足够知足咱们的需求了。但若是咱们须要大量访问,怎么办?咱们能够用下面介绍的多路复用技术,启动若干生成器,再将其整合成一个大的服务。
调用生成器,能够返回一个“服务”。能够用在持续获取数据的场合。用途很普遍,读取数据,生成ID,甚至定时器。这是一种很是简洁的思路,将程 序并发化。
2.多路复用
多路复用是让一次处理多个队列的技术。Apache使用处理每一个链接都须要一个进程,因此其并发性能不是很好。而Nginx使用多路复用的技术,让一 个进程处理多个链接,因此并发性能比较好。一样,在协程的场合,多路复用也是须要的,但又有所不一样。多路复用能够将若干个类似的小服务整合成一个大服务。
那么让咱们用多路复用技术作一个更高并发的随机数生成器吧。
// 函数rand_generator_3 ,返回通道(Channel) funcrand_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 }
上面是使用了多路复用技术的高并发版的随机数生成器。经过整合两个随机数生成器,这个版本的能力是刚才的两倍。虽然协程能够大量建立,可是众多协程仍是会争抢输出的通道。Go语言提供了Select关键字来解决,各家也有各家窍门。加大输出通道的缓冲大小是个通用的解决方法。
多路复用技术能够用来整合多个通道。提高性能和操做的便捷。配合其余的模式使用有很大的威力。
3.Future技术
Future是一个颇有用的技术,咱们经常使用Future来操做线程。咱们能够在使用线程的时候,能够建立一个线程,返回Future,以后能够经过它等待结果。 可是在协程环境下的Future能够更加完全,输入参数一样能够是Future的。
调用一个函数的时候,每每是参数已经准备好了。调用协程的时候也一样如此。可是若是咱们将传入的参 数设为通道,这样咱们就能够在不许备好参数的状况下调用函数。这样的设计能够提供很大的自由度和并发度。函数调用和函数参数准备这两个过程能够彻底解耦。 下面举一个用该技术访问数据库的例子。
//一个查询结构体 typequery struct { //参数Channel sql chan string //结果Channel result chan string } //执行Query funcexecQuery(q query) { //启动协程 go func() { //获取输入 sql := <-q.sql //访问数据库,输出结果通道 q.result <- "get" + sql }() } funcmain() { //初始化Query q := query{make(chan string, 1),make(chan string, 1)} //执行Query,注意执行的时候无需准备参数 execQuery(q) //准备参数 q.sql <- "select * fromtable" //获取结果 fmt.Println(<-q.result) }
上面利用Future技术,不单让结果在Future得到,参数也是在Future获取。准备好参数后,自动执行。Future和生成器的区别在 于,Future返回一个结果,而生成器能够重复调用。还有一个值得注意的地方,就是将参数Channel和结果Channel定义在一个结构体里面做为 参数,而不是返回结果Channel。这样作能够增长聚合度,好处就是能够和多路复用技术结合起来使用。
Future技术能够和各个其余技术组合起来用。能够经过多路复用技术,监听多个结果Channel,当有结果后,自动返回。也能够和生成器组合使用,生 成器不断生产数据,Future技术逐个处理数据。Future技术自身还能够首尾相连,造成一个并发的pipe filter。这个pipe filter能够用于读写数据流,操做数据流。
Future是一个很是强大的技术手段。能够在调用的时候不关心数据是否准备好,返回值是否计算好的问题。让程序中的组件在准备好数据的时候自动跑起来。
4.并发循环
循环每每是性能上的热点。若是性能瓶颈出如今CPU上的话,那么九成可能性热点是在一个循环体内部。因此若是能让循环体并发执行,那么性能就会提升不少。
要并发循环很简单,只有在每一个循环体内部启动协程。协程做为循环体能够并发执行。调用启动前设置一个计数器,每个循环体执行完毕就在计数器上加一个元素,调用完成后经过监听计数器等待循环协程所有完成。
//创建计数器 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 }
上面是一个并发循环例子。经过计数器来等待循环所有完成。若是结合上面提到的Future技术的话,则没必要等待。能够等到真正须要的结果的地方,再去检查数据是否完成。
经过并发循环能够提供性能,利用多核,解决CPU热点。正由于协程能够大量建立,才能在循环体中如此使用,若是是使用线程的话,就须要引入线程池之类的东西,防止建立过多线程,而协程则简单的多。
5.ChainFilter技术
前面提到了Future技术首尾相连,能够造成一个并发的pipe filter。这种方式能够作不少事情,若是每一个Filter都由同一个函数组成,还能够有一种简单的办法把他们连起来。
因为每一个Filter协程均可以并发运行,这样的结构很是有利于多核环境。下面是一个例子,用这种模式来产生素数。
// Aconcurrent prime sieve packagemain // Sendthe sequence 2, 3, 4, ... to channel 'ch'. funcGenerate(ch chan<- int) { for i := 2; ; i++ { ch<- i // Send 'i' to channel 'ch'. } } // Copythe values from channel 'in' to channel 'out', //removing those divisible by 'prime'. funcFilter(in <-chan int, out chan<- int, prime int) { for { i := <-in // Receive valuefrom 'in'. if i%prime != 0 { out <- i // Send'i' to 'out'. } } } // Theprime sieve: Daisy-chain Filter processes. funcmain() { ch := make(chan int) // Create a newchannel. 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 } }
上面的程序建立了10个Filter,每一个分别过滤一个素数,因此能够输出前10个素数。
Chain-Filter经过简单的代码建立并发的过滤器链。这种办法还有一个好处,就是每一个通道只有两个协程会访问,就不会有激烈的竞争,性能会比较好
6.共享变量
协程之间的通讯只可以经过通道。可是咱们习惯于共享变量,并且不少时候使用共享变量能让代码更简洁。好比一个Server有两个状态开和关。其余仅仅但愿获取或改变其状态,那又该如何作呢。能够将这个变量至于0通道中,并使用一个协程来维护。
下面的例子描述如何用这个方式,实现一个共享变量。
//共享变量有一个读通道和一个写通道组成 typesharded_var struct { reader chan int writer chan int } //共享变量维护协程 funcsharded_var_whachdog(v sharded_var) { go func() { //初始值 var value int = 0 for { //监听读写通道,完成服务 select { case value =<-v.writer: case v.reader <-value: } } }() } funcmain() { //初始化,并开始维护协程 v := sharded_var{make(chan int),make(chan int)} sharded_var_whachdog(v) //读取初始值 fmt.Println(<-v.reader) //写入一个值 v.writer <- 1 //读取新写入的值 fmt.Println(<-v.reader) }
这样,就能够在协程和通道的基础上实现一个协程安全的共享变量了。定义一个写通道,须要更新变量的时候,往里写新的值。再定义一个读通道,须要读的时候,从里面读。经过一个单独的协程来维护这两个通道。保证数据的一致性。
通常来讲,协程之间不推荐使用共享变量来交互,可是按照这个办法,在一些场合,使用共享变量也是可取的。不少平台上有较为原生的共享变量支持,到底用那种 实现比较好,就见仁见智了。另外利用协程和通道,能够还实现各类常见的并发数据结构,如锁等等,就不一一赘述。
7.协程泄漏
协程和内存同样,是系统的资源。对于内存,有自动垃圾回收。可是对于协程,没有相应的回收机制。会不会若干年后,协程普及了,协程泄漏和内存泄漏同样成为 程序员永远的痛呢?通常而言,协程执行结束后就会销毁。协程也会占用内存,若是发生协程泄漏,影响和内存泄漏同样严重。轻则拖慢程序,重则压垮机器。
C和C++都是没有自动内存回收的程序设计语言,但只要有良好的编程习惯,就能解决规避问题。对于协程是同样的,只要有好习惯就能够了。
只有两种状况会致使协程没法结束。一种状况是协程想从一个通道读数据,但无人往这个通道写入数据,或许这个通道已经被遗忘了。还有一种状况是程想往一个通道写数据,但是因为无人监听这个通道,该协程将永远没法向下执行。下面分别讨论如何避免这两种状况。
对于协程想从一个通道读数据,但无人往这个通道写入数据这种状况。解决的办法很简单,加入超时机制。对于有不肯定会不会返回的状况,必须加入超时,避免出 现永久等待。另外不必定要使用定时器才能终止协程。也能够对外暴露一个退出提醒通道。任何其余协程均可以经过该通道来提醒这个协程终止。
对于协程想往一个通道写数据,但通道阻塞没法写入这种状况。解决的办法也很简单,就是给通道加缓冲。但前提是这个通道只会接收到固定数目的写入。比方说, 已知一个通道最多只会接收N次数据,那么就将这个通道的缓冲设置为N。那么该通道将永远不会堵塞,协程天然也不会泄漏。也能够将其缓冲设置为无限,不过这 样就要承担内存泄漏的风险了。等协程执行完毕后,这部分通道内存将会失去引用,会被自动垃圾回收掉。
funcnever_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 hasoccurred case <-timeout: // the read from ch has timedout } }
上面是个避免泄漏例子。使用超时避免读堵塞,使用缓冲避免写堵塞。
和内存里面的对象同样,对于长期存在的协程,咱们不用担忧泄漏问题。一是长期存在,二是数量较少。要警戒的只有那些被临时建立的协程,这些协程数量大且生 命周期短,每每是在循环中建立的,要应用前面提到的办法,避免泄漏发生。协程也是把双刃剑,若是出问题,不但没能提升程序性能,反而会让程序崩溃。但就像 内存同样,一样有泄漏的风险,但越用越溜了。
并发模式之实现
在并发编程大行其道的今天,对协程和通道的支持成为各个平台比不可少的一部分。虽然各家有各家的叫法,但都能知足协程的基本要求—并发执行和可大量建立。笔者对他们的实现方式总结了一下。
下面列举一些已经支持协程的常见的语言和平台。
GoLang 和Scala做为最新的语言,一出生就有完善的基于协程并发功能。Erlang最为老资格的并发编程语言,返老还童。其余二线语言则几乎所有在新的版本中加入了协程。
使人惊奇的是C/C++和Java这三个世界上最主流的平台没有在对协程提供语言级别的原生支持。他们都背负着厚重的历史,没法改变,也无需改变。但他们还有其余的办法使用协程。
Java平台有不少方法实现协程:
· 修改虚拟机:对JVM打补丁来实现协程,这样的实现效果好,可是失去了跨平台的好处
· 修改字节码:在编译完成后加强字节码,或者使用新的JVM语言。稍稍增长了编译的难度。
· 使用JNI:在Jar包中使用JNI,这样易于使用,可是不能跨平台。
· 使用线程模拟协程:使协程重量级,彻底依赖JVM的线程实现。
其中修改字节码的方式比较常见。由于这样的实现办法,能够平衡性能和移植性。最具表明性的JVM语言Scale就能很好的支持协程并发。流行的Java Actor模型类库akka也是用修改字节码的方式实现的协程。
对于C语言,协程和线程同样。可使用各类各样的系统调用来实现。协程做为一个比较高级的概念,实现方式实在太多,就不讨论了。比较主流的实现有libpcl, coro,lthread等等。
对于C++,有Boost实现,还有一些其余开源库。还有一门名为μC++语言,在C++基础上提供了并发扩展。
可见这种编程模型在众多的语言平台中已经获得了普遍的支持,再也不小众。若是想使用的话,随时能够加到本身的工具箱中。
结语
本文探讨了一个极其简洁的并发模型。在只有协程和通道这两个基本元件的状况下。能够提供丰富的功能,解决形形色色实际问题。并且这个模型已经被普遍的实 现,成为潮流。相信这种并发模型的功能远远不及此,必定也会有更多更简洁的用法出现。或许将来CPU核心数目将和人脑神经元数目同样多,到那个时候,咱们 又要从新思考并发模型了。