Go语言并发之美


简介           多核处理器越来越普及,那有没有一种简单的办法,能够让我们写的软件释放多核的威力?答案是:Yes。随着Golang, Erlang, Scale等为并发设计的程序语言的兴起,新的并发模式逐渐清晰。正如过程式编程和面向对象一样,一个好的编程模式需要有一个极其简洁的内核,还有在此之上丰富的外延,可以解决现实世界中各种各样的问题。本文以GO语言为例,解释其中内核、外延。   并发模式之内核           这种并发模式的内核只需要协程和通道就够了。其中协程负责执行代码,通道负责在协程之间传递事件。 Go语言并发之美 查看大图         并发编程一直以来都是个非常困难的工作。要想编写一个良好的并发程序,我们不得不了解线程,锁,semaphore,barrier甚至CPU更新高速缓存的方式,而且他们个个都有怪脾气,处处是陷阱。笔者除非万不得以,决不会自己操作这些底层并发元素。一个简洁的并发模式不需要这些复杂的底层元素,只需协程和通道就够了。           协程是轻量级的线程。在过程式编程中,当调用一个过程的时候,需要等待其执行完才返回。而调用一个协程的时候,不需要等待其执行完,会立即返回。协程十分轻量,Go语言可以在一个进程中执行有数以十万计的协程,依旧保持高性能。而对于普通的平台,一个进程有数千个线程,其CPU会忙于上下文切换,性能急剧下降。随意创建线程可不是一个好主意,但是我们可以大量使用的协程。

        通道是协程之间的数据传输通道。通道可以在众多的协程之间传递数据,具体可以值也可以是个引用。通道有两种使用方式。

        ·  协程可以试图向通道放入数据,如果通道满了,会挂起协程,直到通道可以为他放入数据为止。

        ·  协程可以试图向通道索取数据,如果通道没有数据,会挂起协程,直到通道返回数据为止。

        如此,通道就可以在传递数据的同时,控制协程的运行。有点像事件驱动,也有点像阻塞队列。这两个概念非常的简单,各个语言平台都会有相应的实现。在Java和C上也各有库可以实现两者。

Go语言并发之美 查看大图           只要有协程和通道,就可以优雅的解决并发的问题。不必使用其他和并发有关的概念。那如何用这两把利刃解决各式各样的实际问题呢?  

Ubuntu 安装Go语言包

《Go语言编程》高清完整版电子书

Go语言并行之美 -- 超越 “Hello World”

我为什么喜欢Go语言

Go语言内存分配器的实现

Go语言的国际化支持(基于gettext-go)

  并发模式之外延           协程相较于线程,可以大量创建。打开这扇门,我们拓展出新的用法,可以做生成器,可以让函数返回“服务”,可以让循环并发执行,还能共享变量。但是出现新的用法的同时,也带来了新的棘手问题,协程也会泄漏,不恰当的使用会影响性能。下面会逐一介绍各种用法和问题。演示的代码用GO语言写成,因为其简洁明了,而且支持全部功能。   生成器          有的时候,我们需要有一个函数能不断生成数据。比方说这个函数可以读文件,读网络,生成自增长序列,生成随机数。这些行为的特点就是,函数的已知一些变量,如文件路径。然后不断调用,返回新的数据。 Go语言并发之美 查看大图

下面生成随机数为例,以让我们做一个会并发执行的随机数生成器。

非并发的做法是这样的:

// 函数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,甚至定时器。这是一种非常简洁的思路,将程序并发化。

多路复用

        多路复用是让一次处理多个队列的技术。Apache使用处理每个连接都需要一个进程,所以其并发性能不是很好。而Nginx使用多路复用的技术,让一个进程处理多个连接,所以并发性能比较好。同样,在协程的场合,多路复用也是需要的,但又有所不同。多路复用可以将若干个相似的小服务整合成一个大服务。

 

Go语言并发之美 查看大图

 

        那么让我们用多路复用技术做一个更高并发的随机数生成器吧。

// 函数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关键字来解决,各家也有各家窍门。加大输出通道的缓冲大小是个通用的解决方法。

        多路复用技术可以���来整合多个通道。提升性能和操作的便捷。配合其他的模式使用有很大的威力。

Future技术

        Future是一个很有用的技术,我们常常使用Future来操作线程。我们可以在使用线程的时候,可以创建一个线程,返回Future,之后可以通过它等待结果。  但是在协程环境下的Future可以更加彻底,输入参数同样可以是Future的。

 

Go语言并发之美 查看大图

 

        调用一个函数的时候,往往是参数已经准备好了。调用协程的时候也同样如此。但是如果我们将传入的参数设为通道,这样我们就可以在不准备好参数的情况下调用函数。这样的设计可以提供很大的自由度和并发度。函数调用和函数参数准备这两个过程可以完全解耦。下面举一个用该技术访问数据库的例子。

//一个查询结构体

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是一个非常强大的技术手段。可以在调用的时候不关心数据是否准备好,返回值是否计算好的问题。让程序中的组件在准备好数据的时候自动跑起来。

并发循环

       循环往往是性能上的热点。如果性能瓶颈出现在CPU上的话,那么九成可能性热点是在一个循环体内部。所以如果能让循环体并发执行,那么性能就会提高很多。

 

Go语言并发之美 查看大图

 

        要并发循环很简单,只有在每个循环体内部启动协程。协程作为循环体可以并发执行。调用启动前设置一个计数器,每一个循环体执行完毕就在计数器上加一个元素,调用完成后通过监听计数器等待循环协程全部完成。

//建立计数器

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热点。正因为协程可以大量创建,才能在循环体中如此使用,如果是使用线程的话,就需要引入线程池之类的东西,防止创建过多线程,而协程则简单的多。

ChainFilter技术

      前面提到了Future技术首尾相连,可以形成一个并发的pipe filter。这种方式可以做很多事情,如果每个Filter都由同一个函数组成,还可以有一种简单的办法把他们连起来。

 

Go语言并发之美 查看大图

 

        由于每个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通过简单的代码创建并发的过滤器链。这种办法还有一个好处,就是每个通道只有两个协程会访问,就不会有激烈的竞争,性能会比较好。

共享变量

        协程之间的通信只能够通过通道。但是我们习惯于共享变量,而且很多时候使用共享变量能让代码更简洁。比如一个Server有两个状态开和关。其他仅仅希望获取或改变其状态,那又该如何做呢。可以将这个变量至于0通道中,并使用一个协程来维护。

 

Go语言并发之美 查看大图

 

         下面的例子描述如何用这个方式,实现一个共享变量。

//共享变量有一个读通道和一个写通道组成

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)

}

        这样,就可以在协程和通道的基础上实现一个协程安全的共享变量了。定义一个写通道,需要更新变量的时候,往里写新的值。再定义一个读通道,需要读的时候,从里面读。通过一个单独的协程来维护这两个通道。保证数据的一致性。

        一般来说,协程之间不推荐使用共享变量来交互,但是按照这个办法,在一些场合,使用共享变量也是可取的。很多平台上有较为原生的共享变量支持,到底用那种实现比较好,就见仁见智了。另外利用协程和通道,可以还实现各种常见的并发数据结构,如锁等等,就不一一赘述。

更多详情见请继续阅读下一页的精彩内容:

  • 1
  • 2
  • 下一页

相关内容