并发
当有多个进程需要运行时,但是系统只有一个 CPU,则它根本不可能真正同时运行一个以上的进程,它只能把 CPU 运行时间划分成若干个时间段,再将时间段分配给各个进程执行,在一个时间段的进程代码运行时,其它进程处于挂起状态。
一旦该时间段结束,或者由于其它 I/O 操作而被中止,就会发生上下文切换,并运行另一个进程。这样多个进程共享 CPU 并以交错的方式执行的方式我们称之为并发(Concurrent)
并行
在一个处理器芯片中可以有多个 CPU ;在上面的示例中,我们只使用一个 CPU 内核,其他内核处于空闲状态。实际上浪费了资源,我们可以将每个进程运行在不同的 CPU 内核上。进程之间互不干扰,可以同时进行,这种方式我们称之为并行(Parallel)。
为什么在 Go 中构建并发原语
让我们回顾一下操作系统的基本原理。并发的概念始于操作系统中的线程和进程。操作系统的工作是为每个进程提供公平的机会,让每个进程都有机会运行。
什么是进程
进程是正在运行的程序的实例。它表示程序的执行。它分配了自己的内存空间。它有几个区域。代码区域存储已编译的代码。数据区域存储全局变量和静态变量。
堆区域用于动态内存分配。堆栈区域用于当前执行范围内的局部变量。当进程从内存中换出并稍后恢复时,还必须存储和恢复其他信息。
其中的关键是程序计数器和所有程序寄存器的值。
什么是线程
线程是 CPU 执行的最小单位。它们由一个程序计数器、一个堆栈和一组寄存器组成。一个进程可以有多个线程。单线程进程对一个进程仅使用一个线程。这就是以前的工作方式。
目前,多线程应用程序具有多个线程来执行单个进程。所有线程共享打开的文件、数据和代码区域。
例如,在没有多线程前,Web 服务器必须一次处理一个请求。但是对于线程,每当你收到一个请求时,你就会启动一个新线程来处理请求,然后回去监听更多请求。
这意味着我们可以同时处理多个请求!请求网页的用户加载速度会更快。
两种类型的线程
有两种类型的线程由操作系统管理。
-
• 用户线程:: 用户线程是由应用程序开发人员在用户空间中创建和管理的线程。它们在应用程序的地址空间中运行,并由应用程序自己的线程库调度和分配资源。
-
• 内核线程是由操作系统内核直接创建和管理的线程。它们在操作系统的内核空间中运行,并由操作系统调度和分配资源。
线程的局限性
在上图中,每个线程都有自己的堆栈。问题是每个线程都带有固定的堆栈大小;在我的机器中,它是 8KB。假设我有 8GB 的内存,我可以创建 1000 个线程。这种固定的堆栈大小限制了我们可以创建的线程数。
此外,如果我们将线程数量扩展得太多,就会遇到 C10K 问题,即随着线程数量的增加,我们的应用程序将变得反应迟钝,因为处理器将花费大量时间用于线程调度上。
死锁问题
如上所述,所有线程共享同一个地址空间。它们共享进程的数据和堆区域。因此,线程之间通过共享内存进行通信。这种内存共享带来了复杂性。
为了解决这个问题,操作系统提供了加锁机制,一旦线程想要访问共享内存就会对其加锁。当线程完成读取或更新内存后就会解锁。当多个线程同时竞争同一个资源,并且彼此持有对方需要的资源时,就可能发生死锁。
Goroutines
Goroutines 是由 Go 运行时而不是操作系统内核管理的轻量级线程。Goroutines 是用户级线程。Go 运行时可以在单个进程中处理成千上万的 goroutine。
Goroutines 的堆栈大小仅为 2KB,而内核级线程的堆栈大小为 8KB。Goroutines 的创建成本比传统线程低得多,因此可以轻松创建许多线程来执行并发任务。
为了处理共享内存的局限性,goroutine之间通过通道传输数据。Go 是一种在设计时考虑了并发性的编程语言,它包括 CSP 的内置实现。在 Go 中,通道是一等公民,用于在并发执行的 goroutine 之间进行通信。
通信顺序进程 (CSP) 是一种并发模型,它允许多个进程间通过通道相互通信。这个模型是由Tony Hoare在他1978年的论文“Communicating Sequential Processes”中介绍的。
Go Scheduler
因为 goroutines 是由用户创建的。Go 调度器管理 goroutine 的执行。调度器确定哪个 goroutine 应该被被执行,它通过将它们调度到操作系统线程上来实现。调度器使用循环算法来确保公平性,因此每个 goroutine 都会获得相等的 CPU 时间片。
它也被称为 M:N 调度程序。Go 运行时创建多个操作系统线程,相当于 GOMAXPROCS。默认情况下,它设置为 CPU 中可用的内核/处理器数。Go 调度器在多个工作线程上分发可运行的 goroutine。在任何时候,都可以在 M 个 OS 线程上调度 N 个 goroutines。
考虑到可能会有占用大量 CPU 时间的程序。它会阻塞其它程序运行。为了解决这个问题,go 调度程序实现了异步抢占。异步抢占根据时间条件触发。如果一个 goroutine 耗时超过 10 毫秒,Go 就会触发抢占。
Go 调度程序的组件
-
1. 全局运行队列:它包含所有准备执行的 goroutine。当使用“go”关键字创建 go 例程时,它们将被放入此队列中。
-
2. 操作系统线程(M):它们是在其上执行 goroutines 的底层资源。线程数由 GOMAXPROCS 环境变量控制。
-
3. 本地运行队列 (N):OS 线程有自己的本地运行队列。它包含所有准备在此线程上执行的 goroutine。当一个线程没有更多的 goroutine 要执行时,它将从其他线程的本地运行队列中窃取 goroutines。
-
4. 工作窃取:当一个线程没有更多的 goroutine 要执行时,它会从其他线程的本地运行队列中窃取 goroutines。这有助于在所有线程之间平衡工作负载。
-
5. 阻塞操作:调度程序支持阻塞操作,例如 I/O 和系统调用。它将阻塞的 goroutine 移动到一个单独的线程,并在本地运行队列中执行其他 goroutine。
-
6. 垃圾回收:Go 调度程序与垃圾回收器密切合作,以管理内存分配和解除分配。当 goroutine 执行完毕时,调度程序会通知垃圾回收器,这将释放 goroutine 正在使用的任何内存。
这确保了每个 goroutine 都有公平的 CPU 时间机会,goroutines 的工作负载在操作系统线程之间是平衡的。这就是释放开发者编写高性能、高效并发程序的能力的原因。