Go——并发编程
并发编程一、并发基础2、协程3、goroutine4、并发通信5、channel5.1、基本语法5.2、select5.3、缓冲机制5.4、超时机制5.5、channel的消息传递5.6、单向channel5.7、关闭channel6、多核并行化7、出让时间片8、同步8.1、同步锁8.2、全局唯一性操作一、并发基础回到在Windows和Linux出现之前的古老年代程序员在开发程序时并没有并发的概念因为命令式程序设计语言是以串行为基础的程序会顺序执行每一条指令整个程序只有一个执行上下文即一个调用栈一个堆。并发则意味着程序在运行时有多个执行上下文对应着多个调用栈。我们知道每一个进程在运行时都有自己的调用栈和堆有一个完整的上下文而操作系统在调度进程的时候会保存被调度进程的上下文环境等该进程获得时间片后再恢复该进程的上下文到系统中。从整个操作系统层面来说多个进程是可以并发的那么并发的价值何在下面我们先看以下几种场景。一方面我们需要灵敏响应的图形用户界面一方面程序还需要执行大量的运算或者IO密集操作而我们需要让界面响应与运算同时执行。当我们的Web服务器面对大量用户请求时需要有更多的“Web服务器工作单元”来分别响应用户。我们的事务处于分布式环境上相同的工作单元在不同的计算机上处理着被分片的数据。计算机的CPU从单内核core向多内核发展而我们的程序都是串行的计算机硬件的能力没有得到发挥。我们的程序因为IO操作被阻塞整个程序处于停滞状态其他IO无关的任务无法执行。从以上几个例子可以看到串行程序在很多场景下无法满足我们的要求。下面我们归纳了并发程序的几条优点让大家认识到并发势在必行并发能更客观地表现问题模型并发可以充分利用CPU核心的优势提高程序的执行效率并发能充分利用CPU与其他硬件设备固有的异步性。现在我们已经意识到并发的好处了那么到底有哪些方式可以实现并发执行呢就目前而言并发包含以下几种主流的实现模型。多进程。多进程是在操作系统层面进行并发的基本模式。同时也是开销最大的模式。在Linux平台上很多工具链正是采用这种模式在工作。比如某个Web服务器它会有专门的进程负责网络端口的监听和链接管理还会有专门的进程负责事务和运算。这种方法的好处在于简单、进程间互不影响坏处在于系统开销大因为所有的进程都是由内核管理的。多线程。多线程在大部分操作系统上都属于系统层面的并发模式也是我们使用最多的最有效的一种模式。目前我们所见的几乎所有工具链都会使用这种模式。它比多进程的开销小很多但是其开销依旧比较大且在高并发模式下效率会有影响。基于回调的非阻塞/异步IO。这种架构的诞生实际上来源于多线程模式的危机。在很多高并发服务器开发实践中使用多线程模式会很快耗尽服务器的内存和CPU资源。而这种模式通过事件驱动的方式使用异步IO使服务器持续运转且尽可能地少用线程降低开销它目前在Node.js中得到了很好的实践。但是使用这种模式编程比多线程要复杂因为它把流程做了分割对于问题本身的反应不够自然。协程。协程Coroutine本质上是一种用户态线程不需要操作系统来进行抢占式调度且在真正的实现中寄存于线程中因此系统开销极小可以有效提高线程的任务并发性而避免多线程的缺点。使用协程的优点是编程简单结构清晰缺点是需要语言的支持如果不支持则需要用户在程序中自行实现调度器。目前原生支持协程的语言还很少。接下来我们先诠释一下传统并发模型的缺陷之后再讲解goroutine并发模型是如何逐一解决这些缺陷的。人的思维模式可以认为是串行的而且串行的事务具有确定性。线程类并发模式在原先的确定性中引入了不确定性这种不确定性给程序的行为带来了意外和危害也让程序变得不可控。线程之间通信只能采用共享内存的方式。为了保证共享内存的有效性我们采取了很多措施比如加锁等来避免死锁或资源竞争。实践证明我们很难面面俱到往往会在工程中遇到各种奇怪的故障和问题。我们可以将之前的线程加共享内存的方式归纳为“共享内存系统”虽然共享内存系统是一种有效的并发模式但它也暴露了众多使用上的问题。计算机科学家们在近40年的研究中又产生了一种新的系统模型称为“消息传递系统”。对线程间共享状态的各种操作都被封装在线程之间传递的消息中这通常要求发送消息时对状态进行复制并且在消息传递的边界上交出这个状态的所有权。从逻辑上来看这个操作与共享内存系统中执行的原子更新操作相同但从物理上来看则非常不同。由于需要执行复制操作所以大多数消息传递的实现在性能上并不优越但线程中的状态管理工作通常会变得更为简单。最早被广泛应用的消息传递系统是由C. A. R. Hoare在他的Communicating Sequential Processes中提出的。在CSP系统中所有的并发操作都是通过独立线程以异步运行的方式来实现的。这些线程必须通过在彼此之间发送消息从而向另一个线程请求信息或者将信息提供给另一个线程。使用类似CSP的系统将提高编程的抽象级别。随着时间的推移一些语言开始完善消息传递系统并以此为核心支持并发比如Erlang。2、协程执行体是个抽象的概念在操作系统层面有多个概念与之对应比如操作系统自己掌管的进程process、进程内的线程thread以及进程内的协程coroutine也叫轻量级线程。与传统的系统级线程和进程相比协程的最大优势在于其“轻量级”可以轻松创建上百万个而不会导致系统资源衰竭而线程和进程通常最多也不能超过1万个。这也是协程也叫轻量级线程的原因。多数语言在语法层面并不直接支持协程而是通过库的方式支持但用库的方式支持的功能也并不完整比如仅仅提供轻量级线程的创建、销毁与切换等能力。如果在这样的轻量级线程中调用一个同步IO操作比如网络通信、本地文件读写都会阻塞其他的并发执行轻量级线程从而无法真正达到轻量级线程本身期望达到的目标。Go语言在语言级别支持轻量级线程叫goroutine。Go语言标准库提供的所有系统调用操作当然也包括所有同步IO操作都会出让CPU给其他goroutine。这让事情变得非常简单让轻量级线程的切换管理不依赖于系统的线程和进程也不依赖于CPU的核心数量。3、goroutinegoroutine是Go语言中的轻量级线程实现由Go运行时runtime管理。你将会发现它的使用出人意料得简单。假设我们需要实现一个函数Add()它把两个参数相加并将结果打印到屏幕上具体代码如下funcAdd(x,yint){z:xy fmt.Println(z)}那么如何让这个函数并发执行呢具体代码如下goAdd(1,1)是不是很简单你应该已经猜到“go”这个单词是关键。与普通的函数调用相比这也是唯一的区别。的确go是Go语言中最重要的关键字这一点从Go语言本身的命名即可看出。在一个函数调用前加上go关键字这次调用就会在一个新的goroutine中并发执行。当被调用的函数返回时这个goroutine也自动结束了。需要注意的是如果这个函数有返回值那么这个返回值会被丢弃。具体的代码如下所示。importfmtfuncAdd(x,yint){z:xy fmt.Println(z)}funcmain(){fori:0;i10;i{goAdd(i,i)}}在上面的代码里我们在一个for循环中调用了10次Add()函数它们是并发执行的。可是当你编译执行了上面的代码就会发现一些奇怪的现象屏幕上什么都没有。是什么原因呢明明调用了10次Add()应该有10次屏幕输出才对。要解释这个现象就涉及Go语言的程序执行机制了。Go程序从初始化main package并执行main()函数开始当main()函数返回时程序退出且程序并不等待其他goroutine非主goroutine结束。对于上面的例子主函数启动了10个goroutine然后返回这时程序就退出了而被启动的执行Add(i, i)的goroutine没有来得及执行所以程序没有任何输出。OK问题找到了怎么解决呢提到这一点估计写过多线程程序的读者就已经恍然大悟并且摩拳擦掌地准备使用类似WaitForSingleObject之类的调用或者写个自己很拿手的忙等待或者稍微先进一些的sleep循环等待来等待所有线程执行完毕。在Go语言中有自己推荐的方式它要比这些方法都优雅得多。要让主函数等待所有goroutine退出后再返回如何知道goroutine都退出了呢这就引出了多个goroutine之间通信的问题。下一节我们将主要解决这个问题。4、并发通信在工程上有两种最常见的并发通信模型共享数据和消息。共享数据是指多个并发单元分别保持对同一个数据的引用实现对该数据的共享。被共享的数据可能有多种形式比如内存数据块、磁盘文件、网络数据等。在实际工程应用中最常见的无疑是内存了也就是常说的共享内存。先看看我们在C语言中通常是怎么处理线程间数据共享的如代码清单所示。#includestdio.h#includestdlib.h#includepthread.hvoid*count();pthread_mutex_tmutex1PTHREAD_MUTEX_INITIALIZER;intcounter0;main(){intrc1,rc2;pthread_tthread1,thread2;/* 创建线程每个线程独立执行函数function count */if((rc1pthread_create(thread1,NULL,count,NULL))){printf(Thread creation failed: %d\n,rc1);}if((rc2pthread_create(thread2,NULL,count,NULL))){printf(Thread creation failed: %d\n,rc2);}/* 等待所有线程执行完毕 */pthread_join(thread1,NULL);pthread_join(thread2,NULL);exit(0);}void*count(){pthread_mutex_lock(mutex1);counter;printf(Counter value: %d\n,counter);pthread_mutex_unlock(mutex1);}现在我们尝试将这段C语言代码直接翻译为Go语言代码如代码清单所示。import(fmtruntimesync)varcounterint0funcCount(lock*sync.Mutex){lock.Lock()counterfmt.Println(counter)lock.Unlock()}funcmain(){lock:sync.Mutex{}fori:0;i10;i{goCount(lock)}for{lock.Lock()c:counter lock.Unlock()runtime.Gosched()ifc10{break}}}在上面的例子中我们在10个goroutine中共享了变量counter。每个goroutine执行完成后将counter的值加1。因为10个goroutine是并发执行的所以我们还引入了锁也就是代码中的lock变量。每次对n的操作都要先将锁锁住操作完成后再将锁打开。在主函数中使用for循环来不断检查counter的值同样需要加锁。当其值达到10时说明所有goroutine都执行完毕了这时主函数返回程序退出。事情好像开始变得糟糕了。实现一个如此简单的功能却写出如此臃肿而且难以理解的代码。想象一下在一个大的系统中具有无数的锁、无数的共享变量、无数的业务逻辑与错误处理分支那将是一场噩梦。这噩梦就是众多C/C开发者正在经历的其实Java和C#开发者也好不到哪里去。Go语言既然以并发编程作为语言的最核心优势当然不至于将这样的问题用这么无奈的方式来解决。Go语言提供的是另一种通信模型即以消息机制而非共享内存作为通信方式。消息机制认为每个并发单元是自包含的、独立的个体并且都有自己的变量但在不同并发单元间这些变量不共享。每个并发单元的输入和输出只有一种那就是消息。这有点类似于进程的概念每个进程不会被其他进程打扰它只做好自己的工作就可以了。不同进程间靠消息来通信它们不会共享内存。Go语言提供的消息通信机制被称为channel接下来我们将详细介绍channel。现在让我们用Go语言社区的那句著名的口号来结束这一小节“不要通过共享内存来通信而应该通过通信来共享内存。”5、channelchannel是Go语言在语言级别提供的goroutine间的通信方式。我们可以使用channel在两个或多个goroutine之间传递消息。channel是进程内的通信方式因此通过channel传递对象的过程和调用函数时的参数传递行为比较一致比如也可以传递指针等。如果需要跨进程通信我们建议用分布式系统的方法来解决比如使用Socket或者HTTP等通信协议。Go语言对于网络方面也有非常完善的支持。channel是类型相关的。也就是说一个channel只能传递一种类型的值这个类型需要在声明channel时指定。如果对Unix管道有所了解的话就不难理解channel可以将其认为是一种类型安全的管道。在了解channel的语法前我们先看下用channel的方式重写上面的例子是什么样子的以此对channel先有一个直感的认识如代码清单所示。import(fmt)varcounterint0funcCount(chchanint){fmt.Println(Counting)ch-1}funcmain(){chs:make([]chanint,10)fori:0;i10;i{chs[i]make(chanint)goCount(chs[i])}for_,ch:rangechs{-ch}}在这个例子中我们定义了一个包含10个channel的数组名为chs并把数组中的每个channel分配给10个不同的goroutine。在每个goroutine的fmt.Println()函数完成后我们通过ch - 1语句向对应的channel中写入一个数据。在这个channel被读取前这个操作是阻塞的。在所有的goroutine启动完成后我们通过-ch语句从10个channel中依次读取数据。在对应的channel写入数据前这个操作也是阻塞的。这样我们就用channel实现了类似锁的功能进而保证了所有goroutine完成后主函数才返回。是不是比共享内存的方式更简单、优雅呢我们在使用Go语言开发时经常会遇到需要实现条件等待的场景这也是channel可以发挥作用的地方。对channel的熟练使用才能真正理解和掌握Go语言并发编程。下面我们学习下channel的基本语法。5.1、基本语法一般channel的声明形式为varchanNamechanElementType与一般的变量声明不同的地方仅仅是在类型之前加了chan关键字。ElementType指定这个channel所能传递的元素类型。举个例子我们声明一个传递类型为int的channelvarchchanint或者我们声明一个map元素是bool型的channel:varmmap[string]chanbool上面的语句都是合法的。定义一个channel也很简单直接使用内置的函数make()即可ch:make(chanint)这就声明并初始化了一个int型的名为ch的channel。在channel的用法中最常见的包括写入和读出。将一个数据写入发送至channel的语法很直观如下ch-value向channel写入数据通常会导致程序阻塞直到有其他goroutine从这个channel中读取数据。从channel中读取数据的语法是value:-ch如果channel之前没有写入数据那么从channel中读取数据也会导致程序阻塞直到channel中被写入数据为止。我们之后还会提到如何控制channel只接受写或者只允许读取即单向channel。5.2、select早在Unix时代select机制就已经被引入。通过调用select()函数来监控一系列的文件句柄一旦其中一个文件句柄发生了IO动作该select()调用就会被返回。后来该机制也被用于实现高并发的Socket服务器程序。Go语言直接在语言级别支持select关键字用于处理异步IO问题。select的用法与switch语言非常类似由select开始一个新的选择块每个选择条件由case语句来描述。与switch语句可以选择任何可使用相等比较的条件相比select有比较多的限制其中最大的一条限制就是每个case语句里必须是一个channel操作大致的结构如下select{case-chan1:// 如果chan1成功读到数据则进行该case处理语句casechan2-1:// 如果成功向chan2写入数据则进行该case处理语句default:// 如果上面都没有成功则进入default处理流程}可以看出select不像switch后面并不带判断条件而是直接去查看case语句。每个case语句都必须是一个面向channel的操作。比如上面的例子中第一个case试图从chan1读取一个数据并直接忽略读到的数据而第二个case则是试图向chan2中写入一个整型数1如果这两者都没有成功则到达default语句。基于此功能我们可以实现一个有趣的程序funcmain(){ch:make(chanint,1)for{select{casech-0:casech-1:}i:-ch fmt.Println(Value received:,i)}}其实很简单这个程序实现了一个随机向ch中写入一个0或者1的过程。当然这是个死循环。5.3、缓冲机制之前我们示范创建的都是不带缓冲的channel这种做法对于传递单个数据的场景可以接受但对于需要持续传输大量数据的场景就有些不合适了。接下来我们介绍如何给channel带上缓冲从而达到消息队列的效果。要创建一个带缓冲的channel其实也非常容易c:make(chanint,1024)在调用make()时将缓冲区大小作为第二个参数传入即可比如上面这个例子就创建了一个大小为1024的int类型channel即使没有读取方写入方也可以一直往channel里写入在缓冲区被填完之前都不会阻塞。从带缓冲的channel中读取数据可以使用与常规非缓冲channel完全一致的方法但我们也可以使用range关键来实现更为简便的循环读取fori:rangec{fmt.Println(Received:,i)}5.4、超时机制在之前对channel的介绍中我们完全没有提到错误处理的问题而这个问题显然是不能被忽略的。在并发编程的通信过程中最需要处理的就是超时问题即向channel写数据时发现channel已满或者从channel试图读取数据时发现channel为空。如果不正确处理这些情况很可能会导致整个goroutine锁死。虽然goroutine是Go语言引入的新概念但通信锁死问题已经存在很长时间在之前的C/C开发中也存在。操作系统在提供此类系统级通信函数时也会考虑入超时场景因此这些方法通常都会带一个独立的超时参数。超过设定的时间时仍然没有处理完任务则该方法会立即终止并返回对应的超时信息。超时机制本身虽然也会带来一些问题比如在运行比较快的机器或者高速的网络上运行正常的程序到了慢速的机器或者网络上运行就会出问题从而出现结果不一致的现象但从根本上来说解决死锁问题的价值要远大于所带来的问题。使用channel时需要小心比如对于以下这个用法i:-ch不出问题的话一切都正常运行。但如果出现了一个错误情况即永远都没有人往ch里写数据那么上述这个读取动作也将永远无法从ch中读取到数据导致的结果就是整个goroutine永远阻塞并没有挽回的机会。如果channel只是被同一个开发者使用那样出问题的可能性还低一些。但如果一旦对外公开就必须考虑到最差的情况并对程序进行保护。Go语言没有提供直接的超时处理机制但我们可以利用select机制。虽然select机制不是专为超时而设计的却能很方便地解决超时问题。因为select的特点是只要其中一个case已经完成程序就会继续往下执行而不会考虑其他case的情况。基于此特性我们来为channel实现超时机制//匿名的超时等待函数timeout:make(chanbool,1)gofunc(){time.Sleep(1e9)timeout-true}()select{case-ch://从ch中读取到数据case-timeout://一直没有从ch中读取到数据但从timeout中读取到了数据}这样使用select机制可以避免永久等待的问题因为程序会在timeout中获取到一个数据后继续执行无论对ch的读取是否还处于等待状态从而达成1秒超时的效果。这种写法看起来是一个小技巧但却是在Go语言开发中避免channel通信超时的最有效方法。在实际的开发过程中这种写法也需要被合理利用起来从而有效地提高代码质量。5.5、channel的消息传递需要注意的是在Go语言中channel本身也是一个原生类型与map之类的类型地位一样因此channel本身在定义后也可以通过channel来传递。我们可以使用这个特性来实现Unix上非常常见的管道pipe特性。管道也是使用非常广泛的一种设计模式比如在处理数据时我们可以采用管道设计这样可以比较容易以插件的方式增加数据的处理流程。下面我们利用channel可被传递的特性来实现我们的管道。为了简化表达我们假设在管道中传递的数据只是一个整型数在实际的应用场景中这通常会是一个数据块。首先限定基本的数据结构typePipeDatastruct{valueinthandlerfunc(int)intnextchanint}然后我们写一个常规的处理函数。我们只要定义一系列PipeData的数据结构并一起传递给这个函数就可以达到流式处理数据的目的funchandle(queuechan*PipeData){fordata:rangequeue{data.next-data.handler(data.value)}}这里我们只给出了大概的样子限于篇幅不再展开。同理利用channel的这个可传递特性我们可以实现非常强大、灵活的系统架构。相比之下在C、Java、C#中要达成这样的效果通常就意味着要设计一系列接口。与Go语言接口的非侵入式类似channel的这些特性也可以大大降低开发者的心智成本用一些比较简单却实用的方式来达成在其他语言中需要使用众多技巧才能达成的效果。5.6、单向channel顾名思义单向channel只能用于发送或者接收数据。channel本身必然是同时支持读写的否则根本没法用。假如一个channel真的只能读那么肯定只会是空的因为你没机会往里面写数据。同理如果一个channel只允许写即使写进去了也没有丝毫意义因为没有机会读取里面的数据。所谓的单向channel概念其实只是对channel的一种使用限制。我们在将一个channel变量传递到一个函数时可以通过将其指定为单向channel变量从而限制该函数中可以对此channel的操作比如只能往这个channel写或者只能从这个channel读。单向channel变量的声明非常简单如下varch1chanint// ch1是一个正常的channel不是单向的varch2chan-float64// ch2是单向channel只用于写float64数据varch3-chanint// ch3是单向channel只用于读取int数据那么单向channel如何初始化呢之前我们已经提到过channel是一个原生类型因此不仅支持被传递还支持类型转换。只有在介绍了单向channel的概念后读者才会明白类型转换对于channel的意义就是在单向channel和双向channel之间进行转换。示例如下ch4:make(chanint)ch5:-chanint(ch4)// ch5就是一个单向的读取channelch6:chan-int(ch4)// ch6 是一个单向的写入channel基于ch4我们通过类型转换初始化了两个单向channel单向读的ch5和单向写的ch6。为什么要做这样的限制呢从设计的角度考虑所有的代码应该都遵循“最小权限原则”从而避免没必要地使用泛滥问题进而导致程序失控。写过C程序的读者肯定就会联想起const指针的用法。非const指针具备const指针的所有功能将一个指针设定为const就是明确告诉函数实现者不要试图对该指针进行修改。单向channel也是起到这样的一种契约作用。下面我们来看一下单向channel的用法funcParse(ch-chanint){forvalue:rangech{fmt.Println(Parsing value,value)}}除非这个函数的实现者无耻地使用了类型转换否则这个函数就不会因为各种原因而对ch进行写避免在ch中出现非期望的数据从而很好地实践最小权限原则。5.7、关闭channel关闭channel非常简单直接使用Go语言内置的close()函数即可close(ch)在介绍了如何关闭channel之后我们就多了一个问题如何判断一个channel是否已经被关闭我们可以在读取的时候使用多重返回值的方式x,ok:-ch这个用法与map中的按键获取value的过程比较类似只需要看第二个bool返回值即可如果返回值是false则表示ch已经被关闭。6、多核并行化在执行一些昂贵的计算任务时我们希望能够尽量利用现代服务器普遍具备的多核特性来尽量将任务并行化从而达到降低总计算时间的目的。此时我们需要了解CPU核心的数量并针对性地分解计算任务到多个goroutine中去并行运行。下面我们来模拟一个完全可以并行的计算任务计算N个整型数的总和。我们可以将所有整型数分成M份M即CPU的个数。让每个CPU开始计算分给它的那份计算任务最后将每个CPU的计算结果再做一次累加这样就可以得到所有N个整型数的总和typeVector[]float64// 分配给每个CPU的计算任务func(v Vector)DoSome(i,nint,u Vector,cchanint){for;in;i{v[i]u.Op(v[i])}c-1//发信号告诉任务管理者我已经计算完成了}constNCPU16//假设共有16核func(v Vector)DoAll(u Vector){c:make(chanint,NCPU)//用于接收每个CPU的任务完成信号fori:0;iNCPU;i{gov.DoSome(i*len(v)/NCPU,(i1)*len(v)/NCPU,u,c)}//等待所有CPU的任务完成fori:0;iNCPU;i{-c//获取到一个数据表示一个CPU计算完成了}//到这里表示所有计算已经结束}这两个函数看起来设计非常合理。DoAll()会根据CPU核心的数目对任务进行分割然后开辟多个goroutine来并行执行这些计算任务。是否可以将总的计算时间降到接近原来的1/N呢答案是不一定。如果掐秒表正常点的话应该用7.8节中介绍的Benchmark方法会发现总的执行时间没有明显缩短。再去观察CPU运行状态你会发现尽管我们有16个CPU核心但在计算过程中其实只有一个CPU核心处于繁忙状态这是会让很多Go语言初学者迷惑的问题。官方的答案是这是当前版本的Go编译器还不能很智能地去发现和利用多核的优势。虽然我们确实创建了多个goroutine并且从运行状态看这些goroutine也都在并行运行但实际上所有这些goroutine都运行在同一个CPU核心上在一个goroutine得到时间片执行的时候其他goroutine都会处于等待状态。从这一点可以看出虽然goroutine简化了我们写并行代码的过程但实际上整体运行效率并不真正高于单线程程序。在Go语言升级到默认支持多CPU的某个版本之前我们可以先通过设置环境变量GOMAXPROCS的值来控制使用多少个CPU核心。具体操作方法是通过直接设置环境变量GOMAXPROCS的值或者在代码中启动goroutine之前先调用以下这个语句以设置使用16个CPU核心runtime.GOMAXPROCS(16)到底应该设置多少个CPU核心呢其实runtime包中还提供了另外一个函数NumCPU()来获取核心数。可以看到Go语言其实已经感知到所有的环境信息下一版本中完全可以利用这些信息将goroutine调度到所有CPU核心上从而最大化地利用服务器的多核计算能力。抛弃GOMAXPROCS只是个时间问题。7、出让时间片我们可以在每个goroutine中控制何时主动出让时间片给其他goroutine这可以使用runtime包中的Gosched()函数实现。实际上如果要比较精细地控制goroutine的行为就必须比较深入地了解Go语言开发包中runtime包所提供的具体功能。8、同步8.1、同步锁Go语言包中的sync包提供了两种锁类型sync.Mutex和sync.RWMutex。Mutex是最简单的一种锁类型同时也比较暴力当一个goroutine获得了Mutex后其他goroutine就只能乖乖等到这个goroutine释放该Mutex。RWMutex相对友好些是经典的单写多读模型。在读锁占用的情况下会阻止写但不阻止读也就是多个goroutine可同时获取读锁调用RLock()方法而写锁调用Lock()方法会阻止任何其他goroutine无论读和写进来整个锁相当于由该goroutine独占。从RWMutex的实现看RWMutex类型其实组合了MutextypeRWMutexstruct{w Mutex writerSemuint32readerSemuint32readerCountint32readerWaitint32}对于这两种锁类型任何一个Lock()或RLock()均需要保证对应有Unlock()或RUnlock()调用与之对应否则可能导致等待该锁的所有goroutine处于饥饿状态甚至可能导致死锁。锁的典型使用模式如下varl sync.Mutexfuncfoo(){l.Lock()deferl.Unlock()//...}这里我们再一次见证了Go语言defer关键字带来的优雅。8.2、全局唯一性操作对于从全局的角度只需要运行一次的代码比如全局初始化操作Go语言提供了一个Once类型来保证全局的唯一性操作具体代码如下varastringvaronce sync.Oncefuncsetup(){ahello, world}funcdoprint(){once.Do(setup)print(a)}functwoprint(){godoprint()godoprint()}如果这段代码没有引入Once, setup()将会被每一个goroutine先调用一次这至少对于这个例子是多余的。在现实中我们也经常会遇到这样的情况。Go语言标准库为我们引入了Once类型以解决这个问题。once的Do()方法可以保证在全局范围内只调用指定的函数一次这里指setup()函数而且所有其他goroutine在调用到此语句时将会先被阻塞直至全局唯一的once.Do()调用结束后才继续。这个机制比较轻巧地解决了使用其他语言时开发者不得不自行设计和实现这种Once效果的难题也是Go语言为并发性编程做了尽量多考虑的一种体现。如果没有once.Do()我们很可能只能添加一个全局的bool变量在函数setup()的最后一行将该bool变量设置为true。在对setup()的所有调用之前需要先判断该bool变量是否已经被设置为true如果该值仍然是false则调用一次setup()否则应跳过该语句。实现代码如下所示vardoneboolfalsefuncsetup(){ahello, worlddonetrue}funcdoprint(){if!done{setup()}print(a)}这段代码初看起来比较合理但是细看还是会有问题因为setup()并不是一个原子性操作这种写法可能导致setup()函数被多次调用从而无法达到全局只执行一次的目标。这个问题的复杂性也更加体现了Once类型的价值。为了更好地控制并行中的原子性操作sync包中还包含一个atomic子包它提供了对于一些基础数据类型的原子操作函数比如下面这个函数funcCompareAndSwapUint64(val*uint64,old,newuint64)(swappedbool)就提供了比较和交换两个uint64类型数据的操作。这让开发者无需再为这样的操作专门添加Lock操作。