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核心数

10 并发programming

10 并发programming

  • main、 longWait和 shortWait三函数作为独立的处理单元按顺序启动,然后开始并行运行,每一个函数都在运行的开始和结束阶段输出了消息。
  • 用time包中的Sleep
    • 按指定时间来暂停函数或协程的执行

  • 它们按照我们期望的顺序打印出了消息,几乎都一样,可是我们明白这是模拟出来的,并且是并行的方式。
  • 让 main暂停10秒从而它会在另外两个协程之后结束。
  • 如果让 main函数停止4秒,
    • main(会提前结束, longWait)无法完成
  • 如果不在main中等待,协程会随着程序的结東而消亡

  • 当main返回时,程序退出:
  • 它不会等待任何其他非main协程的结束。
  • 在服务器程序中,每一个请求都会启动一个协程来处理,
  • server函数必须保持运行状态。
    • 通常无限循环来达到这样的目的。

  • 协程是独立的处理单元,一旦陆续启动一些协程,你无法确定它们是什么时候真正开始执行的。
  • 代码逻辑必须独立于协程调用的顺序。

  • 对比用一个线程连续调用,移除Go语言关键字,重新运行

10 并发programming

  • 协程更有用的一个例子
    • 在一个非常长的数组中査找一个元素。
    • 将数组分割为若干切片,给每一个切片启动一个协程进行査找计算。
  • 这样许多并行的线程可以用于查找任务,整体的查找时间会缩短(除以协程的数量)。

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关键字实现,就是一个普通函数

10 并发programming

  • 上面的多个goroutine运行在同一个进程里面,共享内存数据,
  • 不过设计上要遵循:
    • 不要通过共享来通信,而通过通信来共享

  • runtime.Gosched
    • 让CPU把时间片让给别人,下次某个时候继续恢复执行该
      goroutine
  • 默认Go1.5后将标识并发系统线程个数的runtime.GOMAXPROCS的初始值由1改为了运行环境的CPU核数

  • 设置runtime.GOMAXPROCS(n)告诉调度器最大可以使用多少个线程
  • GOMAXPROCS设置了同时运行逻辑代码的系统线程的最大数量,并返回之前的设置。
  • 如果n<1,不改变当前设置。

10.2.2协程间通信

  • 两种最常见的并发通信模型:共享数据和消息。

  • 共享数据
    • 多个并发单元分别保持对同一个数据的引用,
    • 实现对该数据共享
  • 被共享的数据可能有多种形式,
    • 内存数据块、磁盘文件、网络数据等。
  • 最常见的是内存

10 并发programming

10 并发programming

  • 例子可以正常工作
  • 在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 并发programming

  • 定义一个含10个channel的数组(名为chs),并把数组中的每个
    channel分配给10个不同的 goroutine
  • 在每个goroutine的Add完成后,通过ch<-1向对应的channel中写数据
    • 在这个channel被读前,这个操作阻塞
  • 在所有的goroutine启动完成后,通过<-ch从10个channel中依次读数据
  • 在对应的channel写数据前,这个操作也阻塞
  • 就用channel实现了类似锁的功能
    • 进而保证所有goroutine完成后主函数才返回

  • 经常遇到需要实现条件等待的场景,这也是channel可发挥作用的地方
  • 对channel的熟练使用,才能真正理解和掌握Go语言并发编程

10.3.1基本语法