跳到主要内容

00-6-并发简略-对比并发模型

一 并发模型总结

  • 多进程:
    • 稳定性高:进程地址空间互相独立,一个进程出现问题,不会影响其他进程。Linux系统是个典型的多进程模型,稳定性极高,适合服务端开发
    • 开销很大
  • 多线程:
    • 开销较小
  • 协程:
    • 程序执行效率高
  • 非阻塞I/O:
    • 无需痛苦的同步编程

交换数据方式:

  • 多进程交换数据方式复杂(管道、消息队列、信号量、共享内存)
  • 多线程之间交换数据很简单,但会产生竞态条件,需要解决同步问题

综合而言,多线程方式具备大量优势,但是在处理信号、同时运行多套不同程序以及包含多个需要超大内存支持的任务等,多进程方式更适合,而协程和非阻塞IO则更能充分的提升程序的运行效率。

二 线程不一定比进程轻量

理论上,线程之间共享内存,创建新线程的时候不需要创建真正的虚拟内存空间,也不需要 MMU(内存管理单元)上下文切换。此外,线程间通信比进程之间通信更加简单,主要是因为线程之间有共享内存,而进程通信往往需要利用各种模式的 IPC(进程间通信),如信号量,消息队列,管道等。

但是在多处理器操作系统中,线程并不一定比进程更高效:例如 Linux 就是不区分线程和进程的,两者在 Linux 都被称作任务(task)。每个任务在 cloned 的时候都有一个介于最小到最大之间的共享级别。

  • 调用 fork() 创建任务时,创建的是一个没有共享文件描述符,PID 和内存空间的新任务。而调用 pthread_create() 创建任务时,创建的任务将包含上述所有共享资源。
  • 线程之间保持共享内存与多核的L1缓存中的数据同步,与在隔离内存中运行不同的进程相比,需要付出更加大的代价。

三 线程的改进方向

线程变慢的主要三个原因:

  • 线程自身有一个很大的堆(≥ 1MB)占用了大量内存,如果一下创建 1000 个线程意味着需要 1GB 的内存!!!!!
  • 线程需要重复存储许多寄存器,其中一些包括 AVX(高级向量扩展),SSE(流式 SIMD 外设),浮点寄存器,程序计数器(PC),堆栈指针(SP),这会降低应用程序性能。
  • 线程创建和消除需要调用操作系统以获取资源(例如内存),而这一操作相对是比较慢的。

四 goroutine

Goroutines 是在 Golang 中执行并发任务的方式,不过要切记:

Goroutines仅存在于 Go 运行时而不存在于 OS 中,因此需要 Go调度器(GoRuntimeScheduler) 来管理它们的生命周期。

Go运行时为此维护了三个C结构(https://golang.org/src/runtime/runtime2.go):

  • G 结构:表示单个 Goroutine,包含跟踪其堆栈和当前状态所需的对象。还包含自己负责的代码的引用。
  • M 结构:表示 OS 线程。包含一些对象指针,例如全局可执行的 Goroutines 队列,当前运行的 Goroutine,它自己的缓存以及对 Go 调度器的引用。
  • P 结构:也做Sched结构,它是一个单一的全局对象,用于跟踪 Goroutine 和 M 的不同队列以及调度程序运行时需要的其他一些信息,例如单一全局互斥锁(Global Sched Lock)。

G 结构主要存在于两种队列之中,一个是 M (线程)可以找到任务的可执行队列,另外一个是一个空闲的 Goroutine 列表。调度程序维护的 M(执行线程)只能每次关联其中一个队列。为了维护这两种队列并进行切换,就必须维持单一全局互斥锁(Global Sched Lock)。

因此,在启动时,go 运行空间会为 GC,调度程序和用户代码启动许多 Goroutine。并创建 OS 线程来处理这些 Goroutine。不过创建的线程数量最多可以等于 GOMAXPROCS(默认为 1,但为了获得最佳性能,通常设置为计算机上的处理器数量)。

五 协程对比线程的改进

为了使运行时的堆栈更小,go 在运行期间使用了大小可调整的有限堆栈,并且初始大小只有 2KB/goroutine。新的 Goroutine 通常会分配几 kb 的空间,这几乎总是足够的。如果不够的话,运行空间还能自动增长(或者缩小)内存来实现堆栈的管理,从而让大部分 Goroutine 存在于适量的内存中。每个函数调用的平均 CPU 开销大概是三个简单指令。因此在同一地址空间中创建数十万个 Goroutine 是切实可行的。但是如果 Goroutine 是线程的话,系统资源将很快被消耗完。

六 协程阻塞

当 Goroutine 进行阻塞调用时,例如通过调用阻塞的系统调用,这时调用的线程必须阻塞,go 的运行空间会操作自动将同一操作系统线程上的其他 Goroutine,将它们移动到从调度程序(Sched Struct)的线程队列中取出的另一个可运行的线程上,所以这些 Goroutine 不会被阻塞。因此,运行空间应至少创建一个线程,以继续执行不在阻塞调用中的其他 Goroutine。 而且关键的是程序员是看不到这一点的。结论是,我们称之为 Goroutines 的事物,可以是很低廉的:它们在堆栈的内存之外几乎没有开销,而内存中也只有几千字节。

Go 协程也可以很好地扩展。

但是,如果你使用只存在于 Go 的虚拟空间的 channels 进行通信(产生阻塞时),操作系统将不会阻塞该线程。 只是让该 Goroutine 进入等待状态,并安排另一个可运行的 Goroutine(来自 M 结构关联的可执行队列)它的位置。

七 Go Runtime Scheduler

Go Runtime Scheduler 跟踪记录每个 Goroutine,并安排它们依次地在进程的线程池中运行。

Go Runtime Scheduler 执行协作调度,这意味着只有在当前 Goroutine 阻塞或完成时才会调度另一个 Goroutine,这通过代码可以轻松完成。这里有些例子:

  • 调用系统调用如文件或网络操作阻塞时
  • 因为垃圾收集被停止后

这样比定时阻塞并调度新线程的抢占式调度要好得多,因为当线程数量增加,或者当高优先级任务将被调度运行时,有低优先级的任务已经在运行了(此时低优先级队列将被阻塞),定时抢占调度可能导致某些任务完成花费的时间大大超过实际所需时间。

另一个优点是,因为 Goroutine 在代码中隐式调用的,例如在睡眠或 channel 等待期间,编译只需要安全地恢复在这些时刻处存活的寄存器。在 Go 中,这意味着在上下文切换期间仅更新 3 个寄存器,即 PC,SP 和 DX(数据寄存器) 而不是所有寄存器(例如 AVX,浮点,MMX)。

八 goroutine 与 coroutine

C#、 Lua、 Python语言都支持协程 coroutine(Java也有一些第三方库支持)。

coroutine与 goroutine都可以将函数或者语句在独立的环境中运行,但是它们之间有两点不同:

  • goroutine可能发生并行执行,coroutine始终顺序执行
  • goroutine 使用 channel 通信,coroutine 使用 yield 和 resume

coroutine 程序需要主动交出控制权,宿主才能获得控制权并将控制权交给其他 coroutine

coroutine 的运行机制属于协作式任务处理。在早期的操作系统中,应用程序在不需要使用 CPU 时,会主动交出 CPU 使用权。如果开发者故意让应用程序长时间占用 CPU,操作系统也无能为力。coroutine 始终发生在单线程。

goroutine可能发生在多线程环境下, goroutine无法控制自己获取高优先度支持

goroutine 属于抢占式任务处理,和现有的多线程和多进程任务处理非常类似。应用程序对 CPU 的控制最终还需要由操作系统来管理,操作系统如果发现一个应用程序长时间大量地占用 CPU,那么用户有权终止这个任务。

九 Go协程总结

Go协程的特点:

  • 有独立的栈空间
  • 共享程序堆空间
  • 调度由用户控制

注意:

  • Go程序在启动时,就会为main函数创建一个默认的goroutine,也就是入口函数main本身也是一个协程
  • 如果主线程退出了,则协程即使还没有执行完毕也退出

单纯的将函数并发执行是没有意义的,函数与函数之间必须能够交换数据才能体现并发执行函数的意义。为了实现数据的通信,有两种常见并发模型:

  • 共享数据:一般使用共享内存方式来共享数据,Go中的实现方式为互斥锁(sync包)。
  • 消息:消息机制认为每个并发单元都是独立个体,拥有自己的变量。不同的并发单元之间不共享各自的变量,只通过消息来进行数据输入输出,Go中的实现方式为channle。

在Go中对上述两种方式都进行了实现,但是Go不推荐共享数据方式,推荐channel的方式进行协程通信。因为多个 goroutine 为了争抢数据,容易发生竞态问题,会造成执行的低效率,使用队列的方式是最高效的, channel 就是一种队列一样的结构。

如图所示: ![] (02-04.svg)

channel特性:

  • channel的本质是一个数据结构-队列,先进先出
  • channel是线程安全的,多goroutine访问时,不需要加锁,因为在任何时候,同时只能有一个goroutine访问通道。
  • channel拥有类型,一个string的channle只能存放string类型数据

golang奉行通过通信来共享内存,而不是通过共享内存来通信。