认识golang的并发编程入门

原创 ryan007  2017-07-14 13:32  阅读 220 次

并发简述

随着多核CPU的普及, 为了更快的处理任务, 出现了各种并发编程的模型, 主要有以下几种:

高并发模型:多进程、多线程、协程优缺点

多进程:

         优点:简单, 隔离性好, 进程间几乎无影响;

          缺点:开销最大,每次运行都要操作很多资源;

多线程:

        优点:目前使用最多的方式, 开销比多进程小;

        缺点:高并发模式下, 效率会有影响;

协程:

        优点:用户态线程,不直接由操作系统调度,开销小,切换方便;

        缺点:需要语言层级的支持;

 

协程介绍

协程是个抽象的概念, 可以映射到到操作系统层面的进程, 线程等概念.
由于协程是用户态的线程, 不用操作系统来调度, 所以不受操作系统的限制, 可以轻松的创建百万个甚至上千万,, 因此也被称为 "轻量级线程".

golang的协程不是由库实现的, 而是受语言级别支持的, 这个是和python的很大区别,因此, 在 golang 中, 使用协程非常方便.

下面通过例子演示在 golang 中, 如何使用协程来完成并发操作.

golang 并发

实现方式

golang 中, 通过 go 关键字可以非常简单的启动一个协程, 几乎没有什么学习成本.
当然并发编程中固有的业务上的困难依然存在(比如并发时的同步, 超时等), 但是 golang 在语言级别给我们提供了优雅简洁的解决这些问题的途径.

理解了 golang 中协程的使用, 会给我们写并发程序时带来极大的便利.
首先以一个简单的例子开始 golang 的并发编程.

package main

import (
 "fmt"
 "time"
)

func main() {
 for i := 0; i < 10; i++ {
 go say(i)
 }

 time.Sleep(time.Second * 2)
}

func say(i int) int {
 fmt.Printf("Number is %d, square is %d \n", i, i * i)
 return i * i
}

执行结果如下: (同时启动10个协程做运算, 执行顺序可能会不一样)

$ go run main.go
Number is 0, square is 0
Number is 1, square is 1
Number is 2, square is 4
Number is 4, square is 16
Number is 3, square is 9
Number is 5, square is 25
Number is 6, square is 36
Number is 7, square is 49
Number is 8, square is 64
Number is 9, square is 81

通过 go 关键字启动协程之后, 主进程并不会等待协程的执行, 而是继续执行直至结束.
本例中, 如果没有 time.Sleep(time.Second * 2) 等待2秒的话, 那么主进程不会等待那10个协程的运行结果, 直接就结束了.
主进程结束也会导致那10个协程的执行中断, 所以, 如果去掉 time.Sleep 这行代码, 可能屏幕上什么显示也没有. 

简单示例

实际使用协程时, 我们一般会等待所有协程执行完成(或者超时)后, 才会结束主进程, 但是不会用 time.Sleep 这种方式,因为主进程并不知道协程什么时候会结束, 没法设置等待时间.

这时, 就看出 golang 中的 channel 机制所带来的好处了. 下面用 channel 来改造上面的 time.Sleep,

package main

import (
 "fmt"
)

func main() {
 ch := make(chan string)
 for i := 0; i < 10; i++ {
 go say(i, ch)
 }
 for i := 0; i < 10; i++ {
 fmt.Print(<-ch)
 }
}

func say(i int, ch chan string) {
 ch <- fmt.Sprintf("Number is %d, square is %d \n", i, i * i)
}

程序执行结果和上面一样, 因为是并发的缘故, 可能输出 顺序可能会不一样.

$ go run main.go
Number is 9, square is 81
Number is 0, square is 0
Number is 1, square is 1
Number is 2, square is 4
Number is 3, square is 9
Number is 4, square is 16
Number is 5, square is 25
Number is 6, square is 36
Number is 7, square is 49
Number is 8, square is 64

golang 的 chan 可以是任意类型的, 上面的例子中定义的是 string 型.
从上面的程序可以看出, 往 chan 中写入数据之后, 协程会阻塞在那里, 直到在某个地方将 chan 中的值读取出来, 协程才会继续运行下去.

上面的例子中, 我们启动了10个协程, 每个协程都往 chan 中写入了一个字符串, 然后在 main 函数中, 依次读取 chan 中的字符串, 并在屏幕上打印出来.
通过 golang 中的 chan, 不仅实现了主进程 和 协程之间的通信, 而且不用像 time.Sleep 那样不可控(因为你不知道要 Sleep 多长时间).

并发时的缓冲

以上例子中, 所有协程使用的是同一个 chan, channel的容量默认大小为1, 当某个协程向 chan 中写入数据时, 其他协程再次向 chan 中写入数据时, 其实是阻塞的.
等到 channel 中的数据被读出之后, 才会再次让某个其他协程写入, 因为每个协程都执行的非常快, 所以看不出来.

改造下上面的例子, 加入些 Sleep 代码, 延长每个协程的执行时间, 我们就可以看出问题, 代码如下:

package main

import (
 "fmt"
 "time"
)

func main() {
 ch := make(chan string)
 for i := 0; i < 10; i++ {
 go say(i, ch)
 }
 for i := 0; i < 10; i++ {
 time.Sleep(1 * time.Second)
 fmt.Print(<-ch)
 }
}

func say(i int, ch chan string) {
 ch <- fmt.Sprintf("Start Time now %s Number is %d\n", time.Now().String(), i)
 time.Sleep(5 * time.Second)
 ch <- fmt.Sprintf("Time now %s Number is %d, square is %d \n", time.Now().String(), i, i * i)
}

执行结果如下:

$ go run main.go
Start Time now 2017-07-14 11:50:49.391683232 +0800 CST Number is 0
Start Time now 2017-07-14 11:50:49.391797993 +0800 CST Number is 1
Start Time now 2017-07-14 11:50:49.391801258 +0800 CST Number is 2
Start Time now 2017-07-14 11:50:49.391803488 +0800 CST Number is 3
Start Time now 2017-07-14 11:50:49.3918056 +0800 CST Number is 4
Time now 2017-07-14 11:50:55.391613523 +0800 CST Number is 0, square is 0
Time now 2017-07-14 11:50:56.391682544 +0800 CST Number is 1, square is 1
Time now 2017-07-14 11:50:57.39187329 +0800 CST Number is 2, square is 4
Time now 2017-07-14 11:50:58.391995831 +0800 CST Number is 3, square is 9
Time now 2017-07-14 11:50:59.392123518 +0800 CST Number is 4, square is 16

为了演示 chan 的阻塞情况, 上面的代码中特意加了一些 time.Sleep 函数.

  • 每个执行 Sum 函数的协程都会运行 5 秒
  • main函数中每隔 1 秒读一次 chan 中的数据

从打印结果我们可以看出, 所有协程几乎是同一时间开始的, 这说明了什么?协程确实是并发的。其中, 最快的协程(Number is 0, square is 0)执行了 6 秒左右, 为什么呢?
说明它阻塞在了 say 函数中的第一行上, 等了 1 秒之后, main 函数开始读出 chan 中数据后才继续运行.
它自身运行需要 5 秒, 正好 6 秒左右.

最慢的协程执行了 10 秒左右, 这个也很好理解, 总共启动了 5 个协程, main 函数每隔 1 秒 读出一次 chan, 最慢的协程等待了 5 秒,
再加上自身执行了 5 秒, 所以一共 10 秒左右.

到这里, 我们很自然会想到能否增加 chan 的容量, 从而使得每个协程尽快执行, 完成自己的操作, 而不用等待, 消除由于 main 函数的处理所带来的瓶颈呢?
答案是当然可以, 而且在 golang 中实现还很简单, 只要在创建 chan 时, 指定 chan 的容量就行,只需要把上面的例子make(chan string)改为make(chan string, 5)输出结果如下:

package main

import (
     "fmt"
     "time"
)

func main() {
     var ch = make(chan string, 10)

     for i := 0; i < 5; i++ {
             go sum(i, i+10, ch)
     }

     for i := 0; i < 10; i++ {
             time.Sleep(time.Second * 1)
             fmt.Print(<-ch)
     }
}

func sum(start, end int, ch chan string) int {
     ch <- fmt.Sprintf("Sum from %d to %d is starting at %s\n", start, end, time.Now().String())
     var sum int = 0
     for i := start; i < end; i++ {
             sum += i
     }
     time.Sleep(time.Second * 10)
     ch <- fmt.Sprintf("Sum from %d to %d is %d at %s\n", start, end, sum, time.Now().String())
     return sum
}

//执行结果
Start Time now 2017-07-14 11:56:39.499785815 +0800 CST Number is 0
Start Time now 2017-07-14 11:56:39.499892912 +0800 CST Number is 1
Start Time now 2017-07-14 11:56:39.499896673 +0800 CST Number is 2
Start Time now 2017-07-14 11:56:39.499899098 +0800 CST Number is 3
Start Time now 2017-07-14 11:56:39.499901533 +0800 CST Number is 4
Time now 2017-07-14 11:56:44.500004188 +0800 CST Number is 4, square is 16
Time now 2017-07-14 11:56:44.500026924 +0800 CST Number is 0, square is 0
Time now 2017-07-14 11:56:44.500031769 +0800 CST Number is 1, square is 1
Time now 2017-07-14 11:56:44.500034889 +0800 CST Number is 2, square is 4
Time now 2017-07-14 11:56:44.500037769 +0800 CST Number is 3, square is 9

 

从执行结果可以看出, 所有协程几乎都是 5秒完成的. 所以在使用协程时, 记住可以通过使用缓存来进一步提高并发性。

注意:在main函数定义的time.Sleep一样是阻塞的,也就是说,总完成的时间是包含这个时间,而在goroutine里面的时间不包含这个时间,

这是不是合理的呢?

请看下面的代码和输出结果:

package main

import (
     "fmt"
     "time"
)

func main() {
     ch := make(chan string, 5)
     for i := 0; i < 5; i++ {
             go say(i, ch)
     }
     for i := 0; i < 10; i++ {
	     time.Sleep(10 * time.Second)
             fmt.Print(<-ch)
     }
}

func say(i int, ch chan string)  {
     ch <- fmt.Sprintf("Start Time now %s Number is %d\n", time.Now().String(), i)
     time.Sleep(5 * time.Second)
     ch <- fmt.Sprintf("Time now %s Number is %d, square is %d \n", time.Now().String(), i, i * i)
}


$time go run main.go
Start Time now 2017-07-14 12:18:54.770657206 +0800 CST Number is 0
Start Time now 2017-07-14 12:18:54.770788397 +0800 CST Number is 1
Start Time now 2017-07-14 12:18:54.770793931 +0800 CST Number is 2
Start Time now 2017-07-14 12:18:54.770796551 +0800 CST Number is 3
Start Time now 2017-07-14 12:18:54.770799043 +0800 CST Number is 4
Time now 2017-07-14 12:18:59.77087702 +0800 CST Number is 4, square is 16
Time now 2017-07-14 12:18:59.770903354 +0800 CST Number is 0, square is 0
Time now 2017-07-14 12:18:59.770908063 +0800 CST Number is 1, square is 1
Time now 2017-07-14 12:18:59.770911471 +0800 CST Number is 2, square is 4
Time now 2017-07-14 12:18:59.770915108 +0800 CST Number is 3, square is 9

real	1m40.411s
user	0m0.146s
sys	0m0.042s

我的天呀,1m40s,这是不是执行时间太长啦!goroutine显示的可是5s,这是不是太不科学啦。

其实是这样的,我们很明确看到goroutine已经处理完毕,是输出的时候sleep的时间阻挡了打印,这种情况以后要注意,

不能因为一个疏忽导致性能大幅下降。

好了,本篇介绍到这里,下一篇我将介绍goroutine的调度,看看goroutine如何工作的。

本文地址:http://it.zhongduwang.com/articles/golang-goroutine-introduction
版权声明:本文为原创文章,版权归 ryan007 所有,欢迎分享本文,转载请保留出处!

发表评论


表情