10 并发programming
文章目录
- Go从语言层面支持并行
- 通常程序会写为一个顺序执行并完成一个独立任务的代码,
- 这种类型的程序很容易写,也很容易维护。
- 有些情况下,并行执行多个任务会有更大好处
- Web服务需要在各自独立的socket上同时接收多个数据请求。
- 每个socket请求都是独立的,可完全独立于其他socket处理,具有并行执行多个请求的能力可以显著提高这类系统的性能。
- Go的语法和运行时直接内置了对并发的支持。
- Go语言里的并发指的是能让某个函数独立于其他函数运行的能力。
- 当一个函数创建为协程( goroutine)时,Go将其视为一个独立的工作单元,这个单元会被调度到可用的逻辑处理器上执行
- 对没有用过通道编写并发程序来说,
- 通道会让他们感觉兴奋
- 用通道使编写并发程序更易,也能够让并发程序出错更少。
10.1并发编程基础
10.1.1并发与并行
- Go运行时调度器如何利用操作系统来并发运行goroutine。
- 当运行一个应用程序时,OS会为这个应用程序启动一个进程,
- 进程:应用程序运行中用到和维护的各种资源的容器
- 包括内存地址空间、文件和设备的句柄以及线程
- 一个线程是一个执行空间,
- 这个空间会被OS调度来运行函数中所写的代码。
- 每个进程的初始线程被称作主线程。
- 执行这个线程的空间是应用程序本身的空间,所以当主线程终止时,应用程序也终止。
- OS将线程调度到某个处理器上运行,
- 这个处理器并不一定是进程所在的处理器。
- 不同OS的线程调度算法不一样
- 会被操作系统屏蔽,不展示给程序员
- OS在物理处理器上调度线程运行,
- 而Go的运行时会在逻辑处理器上调度goroutine来运行
- 每个逻辑处理器分別绑定到单个操作系统线程
- Go的运行时默认会为每个可用的物理处理器分配一个逻辑处理器,
- 这些逻辑处理器会用于执行所有被创建的goroutine
- 即便只有一个逻辑处理器,Go也可以以神奇的效率和性能,并发调度无数个 goroutine。
- 创一个goroutine并准备运行,这个goroutine就会被放到调度器的全局运行队列
- 调度器将队列中的goroutine分配给一个逻辑处理器,
- 并放到这个逻辑处理器对应的本地运行队列
- 本地运行队列的goroutine一直等
- 直到自己被分配的逻辑处理器执行
- goroutine要执行一个阻塞的系统调用,如打开一文件
- 线程和goroutine会从逻辑处理器上分离,该线程继续阻塞,等系统调用返回
- 逻辑处理器就失去了用来运行的线程,
- 调度器会创建一个新线程,并将其绑定到该逻辑处理器
- 调度器从本地运行队列里选另一个goroutine来运行。
- 一旦被阻塞的系统调用执行完并返回,对应的goroutine会放回到本地运行队列,而之前的线程会保存好,以便之后可继续用
- 如果一个goroutine要做一个网络I/O调用,流程有些不一样
- goroutine和逻辑处理器分离,并移到集成了网络轮询器的运行时
- 一旦该轮询器指示某个网络读或写已就绪,对应的goroutine就会重新分配到逻辑处理器上
- 调度器对可创建的逻辑处理器的数量没限制,运行时默认限制每个程序最多10000个线程,runtime/debug包的SetMaxThreads来更改
- 如果程序试图使用更多的线程,就崩溃
- concurrency不是并行( parallelism)。
- 并行: 让不同代码片段同时在不同的物理处理器上执行。
- 并行的关键是同时做很多事情,
- 并发: 同时管理很多事情,这些事情可能只做了一半就被暂停去做别的事情了
- 很多情况下,并发效果比并行好,
- 因为OS和硬件的总资源一般很少,
- 但能支持系统同时做很多事情。
- “用较少的资源做更多的事情”,是指导Go设计的哲学
- 如果希望让goroutine并行,必须使用一个以上逻辑处理器。
- 有多个逻辑处理器时,调度器会将 goroutine平等分配到每个逻辑处理器上,这会让 goroutine在不同的线程上运行。
- 不过要想真的实现并行的效果,需要让自己的程序运行在有多个物理处理器的机器上。
- 否则,哪怕Go运行时使用多个线程, goroutine依然会在同一个物理处理器上并发运行,达不到并行效果。
- 调度器包含一些聪明的算法,这些算法会随着Go的发布被更新和改进,不推荐盲目修改语言运行时对逻辑处理器的默认设置。
- 如果真的认为修改逻辑处理器的数量可以改进性能,也可以对语言运行时的参数细微调整
- 后面会介绍如何做这种修改。
10.1.2指定使用核心数
- 默认会调用CPU核心数
- flags包调整程序运行时调用的CPU核心数
- main、 longWait和 shortWait三函数作为独立的处理单元按顺序启动,然后开始并行运行,每一个函数都在运行的开始和结束阶段输出了消息。
- 用time包中的Sleep
- 按指定时间来暂停函数或协程的执行
- 它们按照我们期望的顺序打印出了消息,几乎都一样,可是我们明白这是模拟出来的,并且是并行的方式。
- 让 main暂停10秒从而它会在另外两个协程之后结束。
- 如果让 main函数停止4秒,
- main(会提前结束, longWait)无法完成
- 如果不在main中等待,协程会随着程序的结東而消亡
- 当main返回时,程序退出:
- 它不会等待任何其他非main协程的结束。
- 在服务器程序中,每一个请求都会启动一个协程来处理,
- server函数必须保持运行状态。
- 通常无限循环来达到这样的目的。
- 协程是独立的处理单元,一旦陆续启动一些协程,你无法确定它们是什么时候真正开始执行的。
- 代码逻辑必须独立于协程调用的顺序。
- 对比用一个线程连续调用,移除Go语言关键字,重新运行
- 协程更有用的一个例子
- 在一个非常长的数组中査找一个元素。
- 将数组分割为若干切片,给每一个切片启动一个协程进行査找计算。
- 这样许多并行的线程可以用于查找任务,整体的查找时间会缩短(除以协程的数量)。
10.2协程( goroutine)
- 执行体是抽象概念,在OS层面有多个概念与之对应,比如操作系统自己掌管的进程( process,)、进程内的线程( thread)及进程内的协程( coroutine,也叫轻量级线程)。
- 与传统的系统级线程和进程,协程的最大优势
- 其“轻量级”,可轻松创建上百万个协程而不导致系统资源衰竭
- 线程和进程通常最多也不能超过1万个。
- 协程也叫轻量级线程的原因
- 多数语言语法层面不直接支持协程,而是通过库方式支持,
- 用库的方式支持的功能也不完整,
- 仅提供轻量级线程的创建、销毁与切换
- 如果在这样的轻量级线程中调用一个同步I/O
- 如网络通信、本地文件读写
- 都会阻塞其他的并发执行的轻量级线程,
- 无法真正达到轻量级线程本身期望达到的目标
- Go语言级别支持轻量级线程,goroutine。
- Go标准库提供的所有系统调用(包括所有同步I/O),
- 都会出让CPU给其他goroutine
- (创建协程会自动分配一个合适的CPU优先级,不管优先级如何都与同级协程竞争CPU,从外部看就是出让了部分CPU)
- 这让轻量级线程的切換管理不依赖于系统的线程和进程,也不依赖CPU核心数量,而是交给Go运行时负责统一调度(也允许手动控制)。
10.2.1协程基础
- goroutine就是协程,比线程更小,
- 十几个goroutine可能体现在底层也就五六个线程
- Go内部帮你实现了这些goroutine之间的内存共享。
- 执行goroutine只需极少内存(4KB~5KB),根据相应的数据伸缩。
- 正因如此,程序可同时运行成千上万个并发任务。
- goroutine比 thread易用、高效、轻便。
- goroutine是通过Go程序的runtime管理的一个线程管理器。
- goroutine通过go关键字实现,就是一个普通函数
- 上面的多个goroutine运行在同一个进程里面,共享内存数据,
- 不过设计上要遵循:
- 不要通过共享来通信,而通过通信来共享
- runtime.Gosched
- 让CPU把时间片让给别人,下次某个时候继续恢复执行该
goroutine
- 让CPU把时间片让给别人,下次某个时候继续恢复执行该
- 默认Go1.5后将标识并发系统线程个数的runtime.GOMAXPROCS的初始值由1改为了运行环境的CPU核数
- 设置runtime.GOMAXPROCS(n)告诉调度器最大可以使用多少个线程
- GOMAXPROCS设置了同时运行逻辑代码的系统线程的最大数量,并返回之前的设置。
- 如果n<1,不改变当前设置。
10.2.2协程间通信
- 两种最常见的并发通信模型:共享数据和消息。
- 共享数据
- 多个并发单元分别保持对同一个数据的引用,
- 实现对该数据共享
- 被共享的数据可能有多种形式,
- 内存数据块、磁盘文件、网络数据等。
- 最常见的是内存
- 例子可以正常工作
- 在10个 goroutine中共享变量 counter
- 每个goroutine执行完后,counter加1
- 10个 goroutine是并发执行的,所以还引入锁,也就是lock
- 每次对n的操作,都要先将锁锁住,操作完成后,再将锁打开
- for循环不断检查 counter(同样需要加锁)。
- 达到10时,说明所有goroutine都执行完,
- 这时主函数返回,程序退出。
- Go提供另一种通信模型,即以消息机制而非共享内存
- 消息机制认为每个并发单元是自包含的、独立的个体,并且都有自己的变量,但在不同并发单元间这些变量不共享。
- 每个并发单元的输入和输出只有一种,那就是消息
- Go提供的消息通信机制被称为通道,接下来将介绍channel
- 不要通过共享内存来通信,而应该通过通信来共享内存
10.3通道( channel)
- Go在语言级别提供的goroutine间的通信方式
- 可用channel在两或多个goroutine间传递消息
- channel是进程内的通信方式
- 通过channel传递对象的过程
- 和调函数时的参数传递比较一致,也可以传递指针
- 跨进程通信,建议用分布式系统的方法,
- 如用Socket或HTTP等通信协议
- Go对于网络方面有完善支持
- channel是类型相关的,一个 channel只能传递一种类型的值,
- 需在声明channel时指定。
- 如果对UNIX管道有所了解的话,就不难理解channel,
- 可以将其认为是一种类型安全的管道。
- 看一下用channel重写上面的例子是什么样
- 定义一个含10个channel的数组(名为chs),并把数组中的每个
channel分配给10个不同的 goroutine - 在每个goroutine的Add完成后,通过ch<-1向对应的channel中写数据
- 在这个channel被读前,这个操作阻塞
- 在所有的goroutine启动完成后,通过<-ch从10个channel中依次读数据
- 在对应的channel写数据前,这个操作也阻塞
- 就用channel实现了类似锁的功能
- 进而保证所有goroutine完成后主函数才返回
- 经常遇到需要实现条件等待的场景,这也是channel可发挥作用的地方
- 对channel的熟练使用,才能真正理解和掌握Go语言并发编程