加载中...

这是一个使用HTML5制作的幻灯片

使用 键开始播放。

控制键
  • 使用 进行前后翻页。
  • 使用 Ctrl/Command+- 进行页面缩放。
  • 3 切换3D效果。
  • 0 显示帮助。

并发(Concurrency)
不是
并行(Parallelism)

Rob Pike
[email protected]
Waza 外刊IT评论网翻译

当今世界是一个并行的世界

多核.

网络.

CPU云.

海量用户.

我们要用技术解决这些问题。
这就是为什么会有并发概念的出现。

Go语言支持并发

Go语言提供:

  • 并发执行(goroutines)
  • 同步和消息传输(channels)
  • 多路并发控制(select)

并发真酷!耶,并行了!!

不!错了。

当Go语言发布时,很多人区分不了这两者之间的差别。

"我用4个处理器来执行素数筛选程序,但程序执行的更慢了!"

并发 Concurrency

将相互独立的执行过程综合到一起的编程技术。

(这里是指通常意义上的执行过程,而不是Linux进程。很难定义。)

并行 Parallelism

同时执行(通常是相关的)计算任务的编程技术。

并发 vs. 并行

并发是指同时处理很多事情。

而并行是指同时能完成很多事情。

两者不同,但相关。

一个重点是组合,一个重点是执行。

并发提供了一种方式让我们能够设计一种方案将问题(非必须的)并行的解决。

一个比方

并发:鼠标,键盘,显示器,硬盘——同时工作。

并行:向量数量积

并发+通信

并发是一种将一个程序分解成小片段独立执行的程序设计方法。

通信是指各个独立的执行任务间的合作。

这是Go语言采用的模式,包括Erlang等其它语言都是基于这种SCP模式:

C. A. R. Hoare: Communicating Sequential Processes (CACM 1978)

繁忙的地鼠

概念太抽象。我们来点具体的。

我们的问题

运一堆没用的手册到焚烧炉里。


如果只有一只地鼠,这需要很长时间。

更多的地鼠!


更多的地鼠还不行;他们需要更多的小推车。

更多的地鼠和更多的小推车


这样快多了,但在装运处和焚烧炉处出现了瓶颈。
还有,这些地鼠需要能同时工作。
它们需要相互通知。(这就是地鼠之间的通信)

所有东西都增加一倍

消除瓶颈;让他们能真正的相互独立不干扰。

这样吞吐速度会快一倍。

并发组合

并发组合两个地鼠的工作过程。

并发组合

现在的这种工作流程不能自动的实现并行!

如果只有一只地鼠
这仍然是并发(就是目前的这种工作方式),但它不是并行。

然而,它是可以并行的!

需要设计出另外的工作流程来实现并发组合。

新的工作流程


三只地鼠在工作,但看起来工作有些滞后。
每只地鼠都在做一种独立的工序,
并且相互合作(通信)。

更细分工的并发

增加一只地鼠,专门运回空的小推车。

四只地鼠组成了一个优化的工作流程,每只只做自己一种简单的工序。

如果任务布置的合理,这将会比最初一个地鼠的工作快4倍。

结果

我们通过在现有的工作流程里加入并发过程从而改进了执行效率。

地鼠越多能做的越多;工作效率越高。

这是一种比仅仅并行更深刻的认识。

并发过程

四个地鼠有不同的工作环节:

  • 往小推车里装书
  • 移动小推车到焚烧炉
  • 卸载书到焚烧炉里
  • 送回空的小推车

不同的并发设计能导致不同的并行方式。

更多的并行!

现在我们可以让并行再多一倍;按照现在的并行模式很容易实现这些。八个地鼠,全部繁忙。


或者它们可以完全不并行

请记住,只有一个地鼠在工作(零并行),这仍然是一个正确的并发的工作方案。


换一种设计

现在我们换一种设计来组织我们的地鼠的并发工作流程。

两个地鼠,一个中转站。


让常规的流程并行化

更多的并发流程能获得更多的吞吐量。


或者另外一种方法

在每个中转站之间都引入多个地鼠并发的模式:


全程优化

使用这种技术策略,16个地鼠都很繁忙!


习得

有很多分解流程的方式。

这都是并发设计。

一旦完成了分解,并行可能会丧失,但很容易纠正。

回到计算机世界

将我们的运书工作替换成如下:

  • 书堆 => Web内容
  • 地鼠 => CPU
  • 小推车 => 调度,渲染或网络传输
  • 焚烧炉 => 代理,浏览器或其他消费源

我们现在的这种设计就是一种可扩展的Web服务的并发设计。
地鼠提供Web内容服务。

关于Go语言的一点背景知识

这里不是一个详细的教材,只是快速做一些重点介绍。

Go例程(Goroutines)

一个Go例程就是一个和其它Go例程在同一地址空间里但却独立运行的函数。

f("hello", "world") // f runs; we wait
go f("hello", "world") // f starts running
g() // does not wait for f to return


就像是在shell里使用 & 标记启动一个命令。

Go例程不是线程

(很像线程,但比线程更轻量。)

多个例程可以在系统线程上做多路通信。

当一个Go例程阻塞时,所在的线程会阻塞,但其它Go例程不受影响。

通道 Channels

通道是类型化的值,能够被Go例程用来做同步或交互信息。

timerChan := make(chan time.Time)
go func() {
    time.Sleep(deltaT)
    timerChan <- time.Now() // send time on timerChan
}()
// Do something else; when ready, receive.
// Receive will block until timerChan delivers.
// Value sent is other goroutine's completion time.
completedAt := <-timerChan


Select

这select语句很像switch,但它的判断条件是基于通信,而不是基于值的等量匹配。

select {
case v := <-ch1:
    fmt.Println("channel 1 sends", v)
case v := <-ch2:
    fmt.Println("channel 2 sends", v)
default: // optional
    fmt.Println("neither channel was ready")
}


Go语言非常的支持并发

非常。

一个程序里产生成千上万个Go例程很正常。
(有一次调试一个程序发现有130万个例程。)

堆栈初始很小,但随着需求会增长或收缩。

Go例程不是不耗资源,但它们很轻量级的。

闭包在这里也是重要角色

它让一些并发运算更容易表达。

它们是局部函数。
下面是一个非并发例子。

func Compose(f, g func(x float) float)
                  func(x float) float {
     return func(x float) float {
        return f(g(x))
    }
}

print(Compose(sin, cos)(0.5))


一些例子

通过实例学习Go语言并发

启动后台程序

使用闭包封装一个后台操作。

下面是从输入通道拷贝数据到输出通道。

go func() { // copy input to output
    for val := range input {
        output <- val
    }
}()

这个for range操作会一直执行到处理掉通道内最后一个值。

一个简单的负载均衡的例子(1)

数据类型:

type Work struct {
    x, y, z int
}


一个简单的负载均衡的例子(2)

一个worker的任务:

func worker(in <-chan *Work, out chan<- *Work) {
   for w := range in {
      w.z = w.x * w.y
      Sleep(w.z)
      out <- w
   }
}

必须保证当一个worker阻塞时其他worker仍能运行。

一个简单的负载均衡的例子(3)

runner:

func Run() {
   in, out := make(chan *Work), make(chan *Work)
   for i := 0; i < NumWorkers; i++ {
       go worker(in, out)
   }
   go sendLotsOfWork(in)
   receiveLotsOfResults(out)
}


很简单的任务,但如果没有并发机制,你仍然很难这么简单的解决。

并发是并行成为可能

这个负载均衡的例子具有很明显的并行和可扩展性。

Worker数可以非常巨大。

Go语言的这种并发特征能的开发一个安全的、好用的、可扩展的、并行的软件变得很容易。

并发简化了同步

没有明显的需要同步的操作。

程序的这种设计隐含的实现了同步。

真是太简单了

让我们实现一个更有意义的负载均衡的例子。

负载均衡


定义请求

请求者向均衡服务发送请求。

type Request struct {
    fn func() int  // The operation to perform.
    c  chan int    // The channel to return the result.
}

注意这返回的通道是放在请求内部的。
通道是first-class值

请求函数

没有实际用处,但能很好的模拟一个请求者,一个负载产生者。

func requester(work chan<- Request) {
    c := make(chan int)
    for {
        // Kill some time (fake load).
        Sleep(rand.Int63n(nWorker * 2 * Second))
        work <- Request{workFn, c} // send request
        result := <-c              // wait for answer
        furtherProcess(result)  
    }    
}


Worker定义

一些请求通道,加上一些负载记录数据。

type Worker struct {
    requests chan Request // work to do (buffered channel)
    pending  int          // count of pending tasks
    index     int         // index in the heap
}


Worker

均衡服务将请求发送给压力最小的worker。

func (w *Worker) work(done chan *Worker) {
    for {
        req := <-w.requests // get Request from balancer
        req.c <- req.fn()   // call fn and send result
        done <- w           // we've finished this request
    }
}

请求通道(w.requests)将请求提交给各个worker。均衡服务跟踪请求待处理的数量来判断负载情况。
每个响应直接反馈给它的请求者。

你可以将循环体内的代码当成Go例程从而实现并行。

定义负载均衡器

负载均衡器需要一个装很多worker的池子和一个通道来让请求者报告任务完成情况。

type Pool []*Worker

type Balancer struct {
    pool Pool
    done chan *Worker
}


负载均衡函数

简单!

func (b *Balancer) balance(work chan Request) {
    for {
        select {
        case req := <-work: // received a Request...
            b.dispatch(req) // ...so send it to a Worker
        case w := <-b.done: // a worker has finished ...
            b.completed(w)  // ...so update its info
        }
    }
}


你只需要实现dispatch和completed方法。

储存通道的堆(heap)

将负载均衡的池子用一个Heap接口实现,外加一些方法:

func (p Pool) Less(i, j int) bool {
    return p[i].pending < p[j].pending
}


现在我们的负载均衡使用堆来跟踪负载情况。

Dispatch

需要的东西都有了。

// Send Request to worker
func (b *Balancer) dispatch(req Request) {
    // Grab the least loaded worker...
    w := heap.Pop(&b.pool).(*Worker)
    // ...send it the task.
    w.requests <- req
    // One more in its work queue.
    w.pending++
    // Put it into its place on the heap.
    heap.Push(&b.pool, w)
}


Completed

// Job is complete; update heap
func (b *Balancer) completed(w *Worker) {
    // One fewer in the queue.
    w.pending--
    // Remove it from heap.                  
    heap.Remove(&b.pool, w.index)
    // Put it into its place on the heap.
    heap.Push(&b.pool, w)
}


习得

一个复杂的问题可以被拆分成容易理解的组件。

它们可以被并发的处理。

结果就是容易理解,高效,可扩展,好用。

或许更加并行。

最后一个例子

我们有几个相同的数据库,我们想最小化延迟,分别询问他们,挑选第一个响应的。

查询数据库

func Query(conns []Conn, query string) Result {
    ch := make(chan Result, len(conns))  // buffered
    for _, conn := range conns {
        go func(c Conn) {
            ch <- c.DoQuery(query):
        }(conn)
    }
    return <-ch
}

并发和垃圾回收机制让这成为一个很小很容易解决的问题。

(作业练习:处理晚来的响应。)


结论

并发很强大。

并发不是并行。

并发帮助实现并行。

并发使并行(扩展等)变得容易。

更多信息

Go: golang.org

一些历史: swtch.com/~rsc/thread/

另一个视频: tinyurl.com/newsqueak

并行不是并发(Harper): tinyurl.com/pincharper

一个并发window系统(Pike): tinyurl.com/pikecws

并发系列(McIlroy): tinyurl.com/powser

最后,并行但不是并发:
research.google.com/archive/sawzall.html