(转)Concurrency|(转)Concurrency in Go 1--并发

数据竞争 当两个或更多的操作必须以正确的顺序执行时,就会出现竞争状态,但如果程序没有写入,无法使操作顺序得到保持。
大多数时候,这出现在所谓的数据竞争中,其中一个并发操作尝试在某些未确定的时间读取变量,而另一个并发操作尝试写入同一个变量。
这里有一个简单的例子:

1 var data int 2 go func() { // 1 3data++ 4 }() 5 if data =https://www.it610.com/article/= 0 { 6fmt.Printf("the value is %v.\n", data) 7 }

  1. 在Go中,可以使用go关键字同时运行一个函数。 这样做创建了所谓的goroutine。
    在第3行和第5行都试图访问名为data的变量,但是并没有施行任何措施保证执行的顺序。运行此代码有三种可能的结果:
  • 没有输出。在这种情况下,第3行是在第5行之前执行的。
  • 输出 the value is 0。在这种情况下,第5行和第6行在第3行之前执行。
  • 输出 the value is 1。在这种情况下,第5行在第3行之前执行,但第3行在第6行之前执行。
内存访问同步 加锁
var memoryAccess sync.Mutex //1 var value int go func() { memoryAccess.Lock() //2 value++ memoryAccess.Unlock() //3 }()memoryAccess.Lock() //4 if value =https://www.it610.com/article/= 0 { fmt.Printf("the value is %v.\n", value) } else { fmt.Printf("the value is %v.\n", value) } memoryAccess.Unlock() //5

虽然我们已经解决了数据竞争,但我们并没有真正解决竞争条件!这个程序的操作顺序仍然不确定。 我们刚刚只是缩小了非确定性的范围。
(TODO)后面提到解决
死锁
type value struct { musync.Mutex value int }var wg sync.WaitGroup printSum := func(v1, v2 *value) { defer wg.Done() v1.mu.Lock()//1 defer v1.mu.Unlock() //2time.Sleep(2 * time.Second) //3 v2.mu.Lock() defer v2.mu.Unlock()fmt.Printf("sum=%v\n", v1.value+v2.value) }var a, b value wg.Add(2) go printSum(&a, &b) go printSum(&b, &a) wg.Wait()

科夫曼条件(全部满足则为会导致死锁): 相互排斥 并发进程在任何时候都拥有资源的独占权。
等待条件 【(转)Concurrency|(转)Concurrency in Go 1--并发】并发进程必须同时持有资源并等待额外的资源。
没有抢占 并发进程持有的资源只能由该进程释放,因此它满足了这种情况。
循环等待 并发进程(P1)等待并发进程(P2),同时P2也在等待P1,因此也符合"循环等待"这一条件
(转)Concurrency|(转)Concurrency in Go 1--并发
文章图片
concurrey_1.png 科夫曼条件同样有助于我们规避死锁。如果我们确保至少有一个条件不成立,就可以防止发生死锁。
活锁
活锁是正在主动执行并发操作的程序,但这些操作无法向前移动程序的状态。
cadence := sync.NewCond(&sync.Mutex{}) go func() { for range time.Tick(1 * time.Millisecond) {cadence.Broadcast() } }()takeStep := func() { cadence.L.Lock() cadence.Wait() cadence.L.Unlock() }tryDir := func(dirName string, dir *int32, out *bytes.Buffer) bool { //1 fmt.Fprintf(out, " %v", dirName) atomic.AddInt32(dir, 1) //2 takeStep()//3 if atomic.LoadInt32(dir) == 1 { fmt.Fprint(out, ". Success!") return true } takeStep() atomic.AddInt32(dir, -1) //4 return false }var left, right int32 tryLeft := func(out *bytes.Buffer) bool { return tryDir("left", &left, out) } tryRight := func(out *bytes.Buffer) bool { return tryDir("right", &right, out) }walk := func(walking *sync.WaitGroup, name string) { var out bytes.Buffer defer func() { fmt.Println(out.String()) }() defer walking.Done() fmt.Fprintf(&out, "%v is trying to scoot:", name) for i := 0; i < 5; i++ { //1 if tryLeft(&out) || tryRight(&out) { //2 return } } fmt.Fprintf(&out, "\n%v tosses her hands up in exasperation!", name) }var peopleInHallway sync.WaitGroup //3 peopleInHallway.Add(2) go walk(&peopleInHallway, "Alice") go walk(&peopleInHallway, "Barbara") peopleInHallway.Wait()

程序会产生如下输出:
Alice is trying to scoot: left right left right left right left right left right Alice tosses her hands up in exasperation! Barbara is trying to scoot: left right left right left right left right left right Barbara tosses her hands up in exasperation!

饥饿
饥饿是指并发进程无法获得执行工作所需的任何资源的情况。
当我们讨论活锁时,每个goroutine所缺乏的资源就是一个共享锁。 活锁需要与饥饿分开讨论,因为在活锁过程中,所有并发进程都是平等的,并且没有任何任务可以被完成。 更广泛地说,饥饿通常意味着有一个或多个贪婪的并发进程不公平地阻止一个或多个并发进程尽可能有效地完成工作,或者根本不可能完成工作。
下面这个例子展示了一个贪婪的goroutine和一个知足的goroutine:
var wg sync.WaitGroup var sharedLock sync.Mutex const runtime = 1*time.SecondgreedyWorker := func() { defer wg.Done()var count int for begin := time.Now(); time.Since(begin) <= runtime; { sharedLock.Lock() time.Sleep(3*time.Nanosecond) sharedLock.Unlock() count++ }fmt.Printf("Greedy worker was able to execute %v work loops\n", count) }politeWorker := func() { defer wg.Done()var count int for begin := time.Now(); time.Since(begin) <= runtime; { sharedLock.Lock() time.Sleep(1*time.Nanosecond) sharedLock.Unlock()sharedLock.Lock() time.Sleep(1*time.Nanosecond) sharedLock.Unlock()sharedLock.Lock() time.Sleep(1*time.Nanosecond) sharedLock.Unlock()count++ } fmt.Printf("Polite worker was able to execute %v work loops.\n", count) }wg.Add(2) go greedyWorker() go politeWorker()wg.Wait()

这个代码段会输出:
Polite worker was able to execute 289777 work loops. Greedy worker was able to execute 471287 work loops

greedy 贪婪地持有整个工作循环的共享锁,而polite 试图只在需要时才锁定。 二者都进行了相同数量的模拟工作(休眠时间为三纳秒),但正如你在相同的时间内看到的那样,greedy 几乎完成了两倍的工作量!
但在这里我们要清楚的了解到,greedy不必要的扩大了对共享锁的控制,并且(通过饥饿)阻碍了polite有效的执行。
我们在例子中使用计数的方式识别饥饿,在记录和抽样度量指标时这是一个很不错的方法。检测和解决饥饿的方法之一就是就是记录程序完成的时间,然后确定你的程序执行速度是否与预期的一样高。
值得一提的是,前面的代码示例也可以作为进行同步内存访问的性能分支示例。 因为同步访问内存的代价很高,所以扩大我们的锁定范围可能会产生额外的代价。 另一方面,正如我们所看到的那样,我们冒着令其他并发进程挨饿的风险。
如果你利用内存访问同步,你必须在性能粗粒度同步和公平性细粒度同步之间找到平衡点。 当开始调试应用程序时,我强烈建议你将内存访问同步仅限于程序的关键部分; 如果同步成为性能问题,则可以扩大范围。 除此之外,其他的解决方式可能会更难以操作。
因此,饥饿可能会导致程序无效或不正确。 前面的例子表明了执行效率是如何被降低的,如果你有一个非常贪婪的并发进程,以至于完全阻止另一个并发进程完成工作,那么你的问题就大了。
我们还需要考虑来自程序之外导致的饥饿问题。请记住,饥饿还可以产生于于CPU,内存,文件句柄和数据库连接:任何必须共享的资源都是饥饿的候选对象。
并发安全性 最后,我们谈到了开发并发代码的最困难的方面,它是所有其他问题的基础:人。 每行代码的编写者至少有一个人。
正如我们发现的,并发代码的难题由很多原因产生。 如果你是一名开发人员,并且在引入新功能时尝试解决所有这些问题,或修复程序中的错误,确定什么样的操作是正确的确实很困难。
如果你从零开始构建程序,需要建立一个合理的方式来模拟问题,但如果涉及到并发,就可能很难找到合适的抽象级别。你该如何向调用者暴露并发接口?应该使用什么样的技术使之简单而有效?应该支持什么样的并发规模?有不同的结构化方式来思考这些问题,但这些问题的解决方案有时候更接近艺术而不是技术。
作为一个对已有代码改造的开发人员,哪些代码利用并发并不总是很明显,如何安全地使用前人的代码有时更与智力无关。考虑下面的函数声明:
// CalculatePi 会在开始和结束位置之间计算Pi的数字 func CalculatePi(begin, end int64, pi *Pi)

以较高精度计算pi是最好的方法,但这个例子引发了很多问题:
我该如何调用这个函数?
我是否负责实例化此函数的多个并发调用?
看起来函数的所有实例都将直接在我传入地址的Pi实例上运行; 是由我负责同步对内存的访问,还是函数为我处理?
仅此一个函数就引发了这些问题。 想象一下任何规模适中的程序,你就可以开始理解并发可能带来的复杂性。
注释可以在这里创造奇迹。如果函数是这样写的呢?
// CalculatePi 会在开始和结束位置之间计算Pi的数字 // // 在内部,CalculatePi会创建FLOOR((end-begin)/ 2)递归调用 // CalculatePi的并发进程。 写入pi的同步锁由Pi结构内部处理。 func CalculatePi(begin, end int64, pi *Pi)

我们现在明白,调用者可以简单地调用该函数,而不必担心访问控制或同步问题。 重要的是,注释涵盖了这些方面:
谁负责并发?
问题空间如何映射到并发基元?
谁负责同步?
当需要暴露涉及并发问题的函数、方法和变量时,请尽可能让你的同事和未来的自己受益:不一定非要写出冗长的注释,但请尽量覆盖上面的三个要素。
还要考虑到函数命名在含义上的模糊。也许我们应该让函数看起来没有副作用:
func CalculatePi(begin, end int64) []uint

这个函数的签名本身就消除了任何同步问题的疑问,但仍然留下了是否使用并发的问题。 我们可以再次修改签名,以明确的告诉调用者我们要返回什么:
func CalculatePi(begin, end int64) <-chan uint

现在我们首次看到了被称为channel(通道)的用法。随后在第三章会有更详细的介绍。修改后的函数签名表明CalculatePi将至少有一个goroutine,我们不应该为创建自己的goroutine而烦恼。
然后,这些修改会产生性能影响,必须予以考虑,我们又回到了平衡清晰度与性能之间的问题。 清晰性非常重要,因为我们希望将来尽可能使用此代码的人能够做正确的事情,并且由于显而易见的原因,性能很重要。 两者不是相互排斥的,但它们很难同时被处理的很好。
体会下我们在上面遇到的各种困难,并尝试将它们扩展到团队规模。
喔,真是个相当可怕的情景。
好消息是,Go已经逐步的给出了简单实用的解决方案。语言本身就具备了较强的可读性而又不失简约。Go鼓励并发建模的正确性,可组合性和可伸缩性。事实上,Go处理并发的方式实际上可以帮助你更清楚地表达问题。 让我们来看看为什么这么说。

    推荐阅读