go语言打印日期_Go语言中常见的并发模式

677ebf79ac7ed40ffbae8b1e731abd02.png

Go语言最吸引人的地方是它内建的并发支持。Go语言并发体系的理论是C.A.R Hoare在1978年提出的通信顺序进程(Communicating Sequential Process,CSP)。CSP有着精确的数学模型,并实际应用在了Hoare参与设计的T9000通用计算机上。从Newsqueak、Alef、Limbo到现在的Go语言,对于对CSP有着20多年实战经验的Rob Pike来说,他更关注的是将CSP应用在通用编程语言上产生的潜力。作为Go并发编程核心的CSP理论的核心概念只有一个:同步通信。关于同步通信的话题我们在前文已经讲过,本节我们将简单介绍Go语言中常见的并发模式。

首先要明确一个概念:并发不是并行。并发更关注的是程序的设计层面,并发的程序完全是可以顺序执行的,只有在真正的多核CPU上才可能真正地同时运行。并行更关注的是程序的运行层面,并行一般是简单的大量重复,例如,GPU中对图像处理都会有大量的并行运算。为了更好地编写并发程序,从设计之初Go语言就注重如何在编程语言层级上设计一个简洁安全高效的抽象模型,让程序员专注于分解问题和组合方案,而且不用被线程管理和信号互斥这些烦琐的操作分散精力。

在并发编程中,对共享资源的正确访问需要精确地控制,在目前的绝大多数语言中,都是通过加锁等线程同步方案来解决这一困难问题,而Go语言却另辟蹊径,它将共享的值通过通道传递(实际上多个独立执行的线程很少主动共享资源)。在任意给定的时刻,最好只有一个Goroutine能够拥有该资源。数据竞争从设计层面上就被杜绝了。为了提倡这种思考方式,Go语言将其并发编程哲学化为一句口号:“不要通过共享内存来通信,而应通过通信来共享内存。”(Do not communicate by sharing memory; instead, share memory by communicating.)

这是更高层次的并发编程哲学(通过通道来传值是Go语言推荐的做法)。虽然像引用计数这类简单的并发问题通过原子操作或互斥锁就能很好地实现,但是通过通道来控制访问能够让你写出更简洁正确的程序。

1.6.1 并发版本的“Hello, World”

先以在一个新的Goroutine中输出“你好, 世界”,main等待后台线程输出工作完成之后退出的简单的并发程序作为热身。

并发编程的核心概念是同步通信,但是同步的方式却有多种。先以大家熟悉的互斥量sync.Mutex来实现同步通信。根据文档,我们不能直接对一个未加锁状态的sync.Mutex进行解锁,这会导致运行时异常。下面这种方式并不能保证正常工作:

func main() { var mu sync.Mutex go func(){ fmt.Println("你好, 世界") mu.Lock() }() mu.Unlock()}

因为mu.Lock()和mu.Unlock()并不在同一个Goroutine中,所以也就不满足顺序一致性内存模型。同时它们也没有其他的同步事件可以参考,这两个事件不可排序也就是可以并发的。因为可能是并发的事件,所以main()函数中的mu.Unlock()很有可能先发生,而这个时刻mu互斥对象还处于未加锁的状态,因而会导致运行时异常。

下面是修复后的代码:

func main() { var mu sync.Mutex mu.Lock() go func(){ fmt.Println("你好, 世界") mu.Unlock() }() mu.Lock()}

修复的方式是在main()函数所在线程中执行两次mu.Lock(),当第二次加锁时会因为锁已经被占用(不是递归锁)而阻塞,main()函数的阻塞状态驱动后台线程继续向前执行。当后台线程执行到mu.Unlock()时解锁,此时打印工作已经完成了,解锁会导致main()函数中的第二个mu.Lock()阻塞状态取消,此时后台线程和主线程再没有其他的同步事件参考,它们退出的事件将是并发的:在main()函数退出导致程序退出时,后台线程可能已经退出了,也可能没有退出。虽然无法确定两个线程退出的时间,但是打印工作是可以正确完成的。

使用sync.Mutex互斥锁同步是比较低级的做法。我们现在改用无缓存通道来实现同步:

func main() { done := make(chan int) go func(){ fmt.Println("你好, 世界") 

根据Go语言内存模型规范,对于从无缓存通道进行的接收,发生在对该通道进行的发送完成之前。因此,后台线程

上面的代码虽然可以正确同步,但是对通道的缓存大小太敏感:如果通道有缓存,就无法保证main()函数退出之前后台线程能正常打印了。更好的做法是将通道的发送和接收方向调换一下,这样可以避免同步事件受通道缓存大小的影响:

func main() { done := make(chan int, 1) // 带缓存通道 go func(){ fmt.Println("你好, 世界") done 

对于带缓存的通道,对通道的第K

个接收完成操作发生在第K+C

个发送操作完成之前,其中C

是通道的缓存大小。虽然通道是带缓存的,但是main线程接收完成是在后台线程发送开始但还未完成的时刻,此时打印工作也是已经完成的。

基于带缓存通道,我们可以很容易将打印线程扩展到N

个。下面的例子是开启10个后台线程分别打印:

func main() { done := make(chan int, 10) // 带10个缓存 // 开N个后台打印线程 for i := 0; i < cap(done); i++ { go func(){ fmt.Println("你好, 世界") done 

对于这种要等待N

个线程完成后再进行下一步的同步操作有一个简单的做法,就是使用sync.WaitGroup来等待一组事件:

func main() { var wg sync.WaitGroup // 开N个后台打印线程 for i := 0; i < 10; i++ { wg.Add(1) go func() { fmt.Println("你好, 世界") wg.Done() }() } // 等待N个后台线程完成 wg.Wait()}

其中wg.Add(1)用于增加等待事件的个数,必须确保在后台线程启动之前执行(如果放到后台线程之中执行则不能保证被正常执行到)。当后台线程完成打印工作之后,调用wg.Done()表示完成一个事件。main()函数的wg.Wait()是等待全部的事件完成。

1.6.2 生产者/消费者模型

并发编程中最常见的例子就是生产者/消费者模型,该模型主要通过平衡生产线程和消费线程的工作能力来提高程序的整体处理数据的速度。简单地说,就是生产者生产一些数据,然后放到成果队列中,同时消费者从成果队列中来取这些数据。这样就让生产和消费变成了异步的两个过程。当成果队列中没有数据时,消费者就进入饥饿的等待中;而当成果队列中数据已满时,生产者则面临因产品积压导致CPU被剥夺的问题。

Go语言实现生产者和消费者并发很简单:

// 生产者:生成factor整数倍的序列func Producer(factor int, out chan

我们开启了两个Producer生产流水线,分别用于生成3和5的倍数的序列。然后开启一个Consumer消费者线程,打印获取的结果。我们通过在main()函数休眠一定的时间来让生产者和消费者工作一定时间。正如1.6.1节中说的,这种靠休眠方式是无法保证稳定的输出结果的。

我们可以让main()函数保存阻塞状态不退出,只有当用户输入Ctrl+C时才真正退出程序:

func main() { ch := make(chan int, 64) // 成果队列 go Producer(3, ch) // 生成3的倍数的序列 go Producer(5, ch) // 生成5的倍数的序列 go Consumer(ch) // 消费生成的队列 // Ctrl+C 退出 sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) fmt.Printf("quit (%v)
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值