最后,我们谈到了开发并发代码的最困难的方面,它是所有其他问题的基础:人。 每行代码的编写者至少有一个人。
正如我们发现的,并发代码的难题由很多原因产生。 如果你是一名开发人员,并且在引入新功能时尝试解决所有这些问题,或修复程序中的错误,确定什么样的操作是正确的确实很困难。
如果你从零开始构建程序,需要建立一个合理的方式来模拟问题,但如果涉及到并发,就可能很难找到合适的抽象级别。你该如何向调用者暴露并发接口?应该使用什么样的技术使之简单而有效?应该支持什么样的并发规模?有不同的结构化方式来思考这些问题,但这些问题的解决方案有时候更接近艺术而不是技术。
作为一个对已有代码改造的开发人员,哪些代码利用并发并不总是很明显,如何安全地使用前人的代码有时更与智力无关。考虑下面的函数声明:
// 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处理并发的方式实际上可以帮助你更清楚地表达问题。 让我们来看看为什么这么说。