并行和并发其实是不同的概念,官方有专门的介绍视频,大家有兴趣可以去看看。我个人是这么理解:
我们以 Node.js 为例来理解并发和并行的概念。
在开发中,同步和异步是两种常见的方法调用方式,它们在处理任务时有着不同的执行方式和特点。
在实际业务开发中,同步和异步调用是代码逻辑中最常见的方法调用形式。
总的来说,同步方法调用会阻塞调用者的执行,直到方法执行完成,而异步方法调用则允许调用者在等待任务执行的同时继续执行其他任务,提高了系统的并发性和响应性能。在实际开发中,根据任务的特点和系统的需求选择合适的调用方式非常重要。
上下文切换是指在多任务系统中,由于 CPU 需要从一个任务(例如线程或进程)切换到另一个任务时,需要保存当前任务的状态(即上下文),并恢复另一个任务的状态。这个过程确实是昂贵的,因为涉及到保存和恢复大量的状态信息。
在单个 CPU 核心上进行线程切换,会消耗大量时间。上下文切换的延迟大约在 50 到 100 纳秒之间,这意味着即使在纳秒级的时间尺度上,也会有显著的性能损失。考虑到硬件平均每个核心每纳秒执行 12 条指令,一次上下文切换可能会消耗 600 到 1200 条指令的执行时间。这个消耗是非常显著的,尤其对于高性能要求的应用程序而言。
如果是跨 CPU 进行线程切换,还会导致 CPU 缓存失效,进一步增加了成本。CPU 从缓存访问数据的成本相比从主存访问数据要低很多,但是当线程切换跨越 CPU 时,可能会导致缓存失效,需要重新从主存中加载数据,这会增加切换的成本。
因此,有效地管理上下文切换成本是系统设计和优化中的一个重要方面,特别是在高并发和高性能的应用场景下。使用轻量级线程或协程等技术,可以减少上下文切换的开销,提高系统的并发性能。
大部分操作系统的任务调度都是使用时间片轮转的抢占式调度算法,即某一线程运行完一个时间分片之后,系统内核进行调度,中断处理器,将这个线程的寄存器放入内存中,然后执行下一个线程,并从内存中恢复它的寄存器数据,开始执行新的线程。 为什么需要线程
在早期的操作系统中并没有线程的概念,进程是能拥有资源和独立运行的最小单位,也是程序执行的最小单位。后来,随着计算机的发展,对CPU的要求越来越高,进程之间的切换开销较大,已经无法满足越来越复杂的程序的要求了。于是就诞生了线程,线程是程序执行中一个单一的顺序控制流程,是程序执行流的最小单元,是处理器调度和分派的基本单位。
进程与线程的关系和区别
这里推荐一篇阮老师的文章:进程与线程的一个简单解释
进程是计算机进行资源分配和调度的基本单位,是一个具有一定独立功能的程序在一个数据集上的一次动态执行的过程,是操作系统进行资源分配和调度的一个独立单位,是应用程序运行的载体。每个进程都拥有独立的内存空间和自己的地址空间。一个进程的主要组成部分包括程序指令集、数据集、程序控制块(PCB)。
线程是计算机进行CPU分配的基本单位。它依附于进程存在,共享相同的内存空间和资源。一个进程可以拥有多个线程。打开 macOS 中的活动监视器,我们可以看到每条任务都有线程这一列,可以看到某个进程开启了多少个线程。
线程的组成部分有线程ID、当前指令计数器(PC)、寄存器和堆栈等。线程拥有自己独立的栈和共享堆。
协程是比线程更加轻量化的任务,具有内核不可见性的特征,是由开发者编写的应用程序来进行管理的,又被叫做用户空间线程。它有如下特性
协程Goroutine也是Go语言并发模型最重要的特性之一。它由 Go语言的运行时环境(runtime)管理,相比于传统的线程,Goroutine 的创建和销毁的成本更低,可以轻松创建数以千计甚至更多的并发执行单元。通过 Goroutine 和 Channel 在多个协程间进行通信和数据同步,避免了传统并发编程中锁的复杂性和可能带来的问题。这种并发模型让开发者能够更容易地编写出高效、可靠的并发代码,从而提高了开发效率和程序的性能。
协程与线程的映射关系 协程和线程之间的关系可能有多种情况。 一、N : 1 最简单的做法是用多个协程绑定一个线程,协程的切换不会导致内核态的陷入,故而协程切换非常轻量。
缺点
二、1 : 1 协程与线程之间使用 1 : 1 的映射关系,避免了上述阻塞问题,然而这样与线程类似,协程的调度也需要CPU来完成,代价较大,失去协程的轻便型。
三、M : N M个协程绑定了N个线程,解决了上面两种方法的缺点,代价是实现的复杂性。
线程级并行的确是随着多核微处理器的普及而变得越来越重要。在过去,提高单个核心的时钟频率是提高处理器性能的主要手段之一,但是随着时钟频率的逐渐达到物理极限,进一步提升变得越来越困难。因此,利用多核处理器的并行计算能力成为了提高整体系统性能的必然选择。
通过线程级并行,应用程序能够更好地利用多核处理器的潜在计算能力,实现更高的并发性和吞吐量。当一个线程在等待I/O操作或内存访问时,其他线程可以继续执行,从而最大程度地减少了系统的闲置时间,提高了系统的整体利用率和响应速度。
随着计算机体系结构的不断发展,线程级并行的重要性将会持续增加。因此,开发者需要逐渐转向设计和优化能够充分利用多核处理器的并发应用程序,以实现ultimate的性能和效率。
另一方面,并发编程中的各种问题确实是挑战性的,而且需要认真考虑和解决。让我们逐一来看:
总的来说,并发编程是一项复杂的任务,需要仔细考虑各种可能的问题,并采取适当的措施来确保程序的正确性和性能。
在 Go 语言中,Goroutine 是程序的最基本执行单元,类似于线程,但是它们的创建和管理由 Go 运行时环境(runtime)进行调度,而不是由操作系统。这使得 Goroutine 的创建和销毁成本非常低,从而使得可以轻松地创建成千上万个 Goroutine,而不会产生太大的性能开销。与 Java 中的线程池不同,Go 中的 Goroutine 由运行时环境进行隐式管理和调度。这意味着开发者不需要显式地创建线程池或手动管理 Goroutine 的生命周期。Go 运行时环境会自动处理 Goroutine 的调度和复用,以确保最佳的性能和资源利用率。
Goroutine的使用非常简单,就是函数前面加上go关键词。看下面一段示例代码:
gofunc Hello(){
fmt.Println(“hello”)
}
func main(){
go Hello()
fmt.Println("start")
}
代码的执行结果是什么呢,有可能为空只打印了end,因为在 main函数运行结束退出的时候,该goroutine尚未执行就被销毁了。
start
如果想要让程序在Hello函数执行之后,main函数再退出,我们可以使用sync.waitGroup或者 channel 来实现简单的并发控制,这里不再举例。Go 推荐 CSP (Communicating Sequential Processes) 并发模型来实现。 Golang中的CSP模型的实现基础就是 goroutine 和 channel。其中,channel 是go的并发单位之间的通信机制,类似于管道。
Go语言的并发哲学:不要以共享内存的方式来通信;相反,要通过通信的方式来共享内存。
Go 语言的调度器(Go Scheduler)负责管理和调度所有的 Goroutine。它是一个高效的调度器,能够有效地利用系统资源,并在多个 Goroutine 之间进行合理的调度,从而实现并发执行。
Go的并发实现原理是采用一种类似 M : N 协程映射关系的模型-GMP 模型。
上图中,G、M、P节点的含义如下
调度流程 任何用户线程最终都要交由 OS 线程来执行的,goroutine(下面简称 G)也不例外,但是 G 并不直接绑定 OS 线程运行,而是由 Goroutine Scheduler 中的 P(逻辑处理器)来作为两者的中介,P 可以看作是一个抽象的资源或者一个上下文,一个 P 绑定一个 OS 线程。
G 实际上是由 M 通过 P 来进行调度运行的,但是在 G 的层面来看,P 提供了 G 运行所需的一切资源和环境,因此在 G 看来 P 就是运行它的 “CPU”。
P 持有一个由可运行的 Goroutine 组成的环形的运行队列 runq,还反向持有一个线程。调度器在调度时会从 P 的队列中选择队列头的 Goroutine 放到线程 M 上执行。
由 G、P、M 这三种由 Go 抽象出来的实现,最终形成了 Go 调度器的基本结构。
我们将go的调度看作一个生产消费模型,那生产的goroutine放在哪里呢?来详细看一下可运行队列的结构:
这实际上是一个三级可运行队列,runnext 实际上只能指向一个 goroutine,是一个特殊的队列。 那新的 goroutine 将被放到哪个可运行队列呢?
goinit() //...
for i := 0; i < max_sched; i++ {
create_os_thread(sched); // 创建系统线程来运行调度器
}
func sched() {
for {
g := find_g(); // 根据算法寻找goroutine
run(g); // 运行一个goroutine
save(g); // 保存现场
}
}
在寻找goroutine时,根据可执行队列来找;runnext -> local -> global 。 如果都没有找到,那就从别的P来偷取。
gotype g struct {
stack stack // g自己的栈
m *m // 隶属于哪个M
sched gobuf // 保存了g的现场,goroutine切换时通过它来恢复
atomicstatus uint32 // G的运行状态
goid int64
schedlink guintptr // 下一个g, g链表
preempt bool //抢占标记
lockedm muintptr // 锁定的M,g中断恢复指定M执行
gopc uintptr // 创建该goroutine的指令地址
startpc uintptr // goroutine 函数的指令地址
}
type m struct {
g0 *g // g0, 每个M都有自己独有的g0
curg *g // 当前正在运行的g
p puintptr // 隶属于哪个P
nextp puintptr // 当m被唤醒时,首先拥有这个p
id int64
spinning bool // 是否处于自旋
park note
alllink *m // on allm
schedlink muintptr // 下一个m, m链表
mcache *mcache // 内存分配
lockedg guintptr // 和 G 的lockedm对应
freelink *m // on sched.freem
}
type p struct {
id int32
status uint32 // P的状态
link puintptr // 下一个P, P链表
m muintptr // 拥有这个P的M
mcache *mcache
// P本地runnable状态的G队列,无锁访问
runqhead uint32
runqtail uint32
runq [256]guintptr
runnext guintptr // 一个比runq优先级更高的runnable G
// 状态为dead的G链表,在获取G时会从这里面获取
gFree struct {
gList
n int32
}
gcBgMarkWorker guintptr // (atomic)
gcw gcWork
}
可以发现,Goroutine 有以下 9 种状态:
状态 | 描述 |
---|---|
_Gidle | 刚刚被分配并且还没有被初始化 |
_Grunnable | 没有执行代码,没有栈的所有权,存储在运行队列中 |
_Grunning | 可以执行代码,拥有栈的所有权,被赋予了内核线程 M 和处理器 P |
_Gsyscall | 正在执行系统调用,拥有栈的所有权,没有执行用户代码,被赋予了内核线程 M 但是不在运行队列上 |
_Gwaiting | 由于运行时而被阻塞,没有执行用户代码并且不在运行队列上,但是可能存在于 Channel 的等待队列上 |
_Gdead | 没有被使用,没有执行代码,可能有分配的栈 |
_Gcopystack | 栈正在被拷贝,没有执行代码,不在运行队列上 |
_Gpreempted | 由于抢占而被阻塞,没有执行用户代码并且不在运行队列上,等待唤醒 |
_Gscan | GC 正在扫描栈空间,没有执行代码,可以与其他状态同时存在 |
这些不同的状态被聚合成三种:等待中、可运行、运行中,运行期间会在这三种状态来回切换:
实际上,虽然 Go 早期对并发的实现是协作式的,但是在更新的版本之中,Goroutine 与其说是协程,不如说是用户态线程(两级线程模型)更为贴近了。这两者之间有一些相似,例如都对内核不可见;但也存在差别,例如 Goroutine采用抢占式的调度,而协程一般是非抢占的。在语义上,我们通常将 Goroutine 更加视作线程来使用,不关注它们之间如何调度。 首先,区别于用户级线程模型,两级线程模型中的一个进程可以与多个内核线程 (KSE) 关联,也就是说
所以,两级线程模型既不是用户级线程模型那种完全靠自己调度的也不是内核级线程模型完全靠操作系统调度的,而是中间态(自身调度与系统调度协同工作)。Go的 runtime 调度算法就是采取上述两级线程模型。
在这里用用网上看到的的一个实验来对比并发性能。实现方式采用java内置实现的线程池与go自带的goroutine调度做直观的对比。 将Go与Java的结果制成对比图表:
语言\任务数量 | 1,000,000 | 10,000,000 | 100,000,000 |
---|---|---|---|
Go耗时 | 316 ms | 3.03 s | 31.93 s |
Java耗时 | 436 ms | 3.33 s | 95.51 s |
可以看到,当任务数量较大时,两者之间性能存在巨大的差别。
创建线程和销毁线程都是比较耗时的操作,频繁的创建和销毁线程会浪费很多CPU的资源。通常使用线程池可以缓解这个问题。
当使用线程池时,我们可以设置线程池的最大线程数。在高并发的情况下,如何选择最优的线程数量呢?
我们希望至少可以创建处理器核心数那么多个线程。这就保证了有尽可能多地处理器核心可以投入到工作中去。所以,应用程序的最小线程数应该等于可用的处理器核数。
如果所有的任务都是计算密集型的,则创建处理器可用核心数那么多个线程就可以了。在这种情况下,创建更多的线程对程序性能而言反而是不利的。因为当有多个任务处于就绪状态时,处理器核心需要在线程间频繁进行上下文切换,而这种切换对程序性能损耗较大。
但如果任务都是IO密集型的,那么我们就需要开更多的线程来提高性能。 当一个任务执行IO操作时,其线程将被阻塞,于是处理器可以立即进行上下文切换以便处理其他就绪线程。如果我们只有处理器可用核心数那么多个线程的话,则即使有待执行的任务也无法处理,因为我们已经拿不出更多的线程供处理器调度了。
总结: 对于计算密集型任务,由于任务执行时间较长,线程可能不会频繁地被阻塞,因此线程数可以与处理器核心数相匹配或稍少一些,以避免不必要的上下文切换。而对于 IO 密集型任务,由于任务执行时间大部分都花费在等待 IO 操作上,因此需要更多的线程来充分利用处理器核心,以确保在一个线程被阻塞时,其他线程仍然可以执行 我们提供了一个理想公式。
线程数 = CPU可用核心数/(1 - 阻塞系数),其中阻塞系数的取值在0和1之间。计算密集型任务的阻塞系数为0,而IO密集型任务的阻塞系数则接近1。
所以,在通常情况下,根据程序是IO密集型还是计算密集型,线程数有不同的最佳值(其中N代表CPU核数):
那同样地,在go中,线程的数量不受 CPU 核心数限制,能在系统调用中被阻塞的线程可以被大量创建。(我们可以理解为 GMP 模型中, M 的数量)
Go 官方: There is no limit to the number of threads that can be blocked in system calls on behalf of Go code; those do not count against the GOMAXPROCS limit
在 go 原生 runtime 中,我们可以找到一个调节 GMP 模型中 machine 这个内核线程限制的方法即 runtime.debug.SetMaxThreads
go// SetMaxThreads sets the maximum number of operating system
// threads that the Go program can use. If it attempts to use more than
// this many, the program crashes.
// SetMaxThreads returns the previous setting.
// The initial setting is 10,000 threads.
//
// The limit controls the number of operating system threads, not the number
// of goroutines. A Go program creates a new thread only when a goroutine
// is ready to run but all the existing threads are blocked in system calls, cgo calls,
// or are locked to other goroutines due to use of runtime.LockOSThread.
//
// SetMaxThreads is useful mainly for limiting the damage done by
// programs that create an unbounded number of threads. The idea is
// to take down the program before it takes down the operating system.
func SetMaxThreads(threads int) int {
return setMaxThreads(threads)
}
SetMaxThreads
的主要作用是防止线程过多导致的操作系统崩溃,当限制被打破时,程序会直接退出,从而避免OS崩溃。接下来我们来测试下。我们使用net.LookupHost来创建一个使用线程超过50的测试程序。
govar threadProfile = pprof.Lookup("threadcreate")
func main() {
fmt.Printf("初始线程数=%d\n", threadProfile.Count())
var wg sync.WaitGroup
taskCnt := 50
wg.Add(taskCnt)
for i := 0; i < taskCnt; i++ {
go func() {
defer wg.Done()
for j := 0; j < taskCnt; j++ {
_, _ = net.LookupHost("www.baidu.com")
}
}()
}
wg.Wait()
fmt.Printf("结束时线程数=%d\n", threadProfile.Count())
}
go中默认限制每个程序最多创建10000个线程,50个线程肯定没有任何问题,假如在测试代码中加入限制:
gofunc init() {
debug.SetMaxThreads(5)
}
再次运行,情况如下
所以,官方的说明确实得到了验证。我们不能像 java 线程池那样,调整 runtime 的最大线程数来获得最大并发性能,这个方法只能用来保护系统的稳定性。不过,我们可以通过控制 GMP 中 P 的数量。 在开发程序时,我们都希望多核CPU的算力都被利用到;在默认情况下,Go的最大 P 数量受限制于 GOMAXPROCS(支持修改),而它的默认值数为CPU的逻辑核数。因此,Go程序可以充分使用机器的每一个核心,最大程度地提高程序的并发性能。
GMP模型中,每个 M 都要绑定一个 P 才能够运行。当然,通过控制 P,我们就可以控制线程调度的最大数量。理论上,GOMAXPROCS 高于真正可使用的CPU核心数后会导致Go调度器不停地进行OS线程切换。 在任何情况下,Go运行时并行执行(注意不是并发)的 goroutines 数量是小于等于 P 的数量的。
调度器的设计是为了在充分利用系统资源的同时,尽可能地减少因系统调用而导致的性能损失。调度器将 goroutines 分配给一组逻辑处理器(P),这些 P 可以映射到系统的物理处理器上。为了最大限度地利用系统资源,通常会将 P 的数量设置为与物理处理器核心数相匹配。这样可以确保在多核系统上 goroutine 能够并行执行,从而提高系统的性能。
然而,在涉及系统调用的情况下,调度器可能会面临一些挑战。当一个 goroutine 执行系统调用时,它可能会阻塞当前的逻辑处理器(P),这意味着该 P 上的其他 goroutines 将无法执行,从而导致性能下降。sysmon 是 Go 运行时的监控组件,负责监视系统状态并做出相应的调度决策。然而,由于 sysmon 的调度周期通常较长(最快约为 20 微秒,最慢可达 10-20 毫秒,操作系统在 1ms 内可以完成几十次线程调度),这可能导致在发现 goroutine 阻塞并重新分配 P 前会有一定的延迟。这种情况下可能会出现的问题是,即使一个 goroutine 阻塞了当前的 P,但调度器可能会在较长的时间内没有意识到这一点,导致该 P 上的其他 goroutines 无法执行,从而浪费了系统资源。为了解决这个问题,需要调整调度器的策略,以缩短监控周期或实现更及时的系统调用检测。这样可以更快地发现并处理因系统调用而导致的 P 阻塞,从而最大程度地减少系统资源的浪费,提高系统的性能。
补充说明:调度器迟钝不是 M 迟钝,M 也就是操作系统线程,是非常的敏感的,只要阻塞就会被操作系统调度(除了极少数自旋的情况)。但是 GO 的调度器会等待一个时间间隔才会行动,这也是为了减少调度器干预的次数。也就是说,如果一个 M 调用了什么 API 导致了操作系统线程阻塞了,操作系统立刻会把这个线程 M 调度走,挂起等阻塞解除。这时候,Go 调度器不会马上把这个 M 持有的 P 抢走。这就会导致一定的 P 被浪费了。
我们应用场景中,Go 项目很多的并发其实是网络 IO;这种场景,由于 Go 封装的 net/http 库底层是基于 epoll 机制的,在建立连接以后并不会阻塞线程,所以增加GOMAXPROCS是没有效果的。 但是对于其他会阻塞线程的 I/O 密集型或者系统调用比较多的场景,通过把GOMAXPROCS的值适当地增大到 CPU核心数以上,实际上可以提高系统的吞吐性能。而这个值应该是多少呢? 这里我认为可以沿用上面线程池的结论,或者利用公式来进行计算。当然,最终落实到业务上来,还要结合压测等方式,来确定一个最佳的值。
前面提到,goroutine有非常轻量的特性。在 Go 1.4 版本之后的中,最小栈从8kb降低到了2kb,并延续到现在。通过查看go最新版本的源码 https://golang.org/src/runtime/stack.go 可以验证这一点:
go// The minimum size of stack used by Go code
_StackMin = 2048
所以,每个goroutine也至少占用2kb的内存。虽然每个goroutine很小,但是如果数量太大,也会造成非常危险的后果。我们可以用下面这段代码来做一个暴力尝试:
func main() { for i := 0; i < math.MaxInt64; i++ { go func(i int) { println("go func: ", i) time.Sleep(time.Second) }(i) } }
实测在本机开始运行一小会后,IDE发生卡死,CPU占用增加,系统卡顿,程序占用的内存超过了物理内存的上限,操作系统开始进行内存交换。同时标准输出卡住。
所以在实际开发中,要对goroutine调用进行合理的控制。通过控制goroutine 数量,可以做到
如何控制goroutine数量呢,可以使用 channel + sync 的方式来完成,可以看下面这个栗子:
gopackage main
import (
"runtime"
"time"
)
func run(index int, ch chan bool) {
println(time.Now().Local().String(), "index is", index, ", goroutine cnt is", runtime.NumGoroutine())
time.Sleep(time.Second * 3)
<-ch // 释放
}
func main() {
maxTask := 100
ch := make(chan bool, 3) // 控制goroutine数量
for i := 0; i < maxTask; i++ {
ch <- true
go goRunTask(i, ch)
}
}
运行结果:
通过这个方式就实现了goroutine数量的控制。 而当任务情况更加复杂时,这样简单的实现在性能上会无法满足要求,也非常不够灵活:
要解决这些问题,正是使用 goroutine pool 的绝赞理由。通过控制 pool 的 size ,也就是程序最多同时运行多少个 worker,我们可以巧妙地限制 goroutine 的数量,篇幅有限,我们后续会对goroutine pool 进行介绍。
内存泄漏是指程序在运行过程中分配的内存空间,在不再需要时没有被正确释放,导致这部分内存无法被重新利用,最终可能耗尽系统的可用内存资源。内存泄漏通常发生在动态内存分配和释放的过程中,如果程序没有正确释放不再使用的内存,就会导致内存泄漏问题。内存泄漏可能由多种原因引起,例如:
在Go开发中,goroutine 泄漏是常见的一种类型,它通常是指 goroutine 从创建后一直运行到进程结束而没有符合预期地退出,一直占有资源。当大量的 goroutine 一直保持运行,将造成未能释放已经不再使用的内存,程序可用内存减少,程序最终发生崩溃。 在Go中,goroutine的结束不能由外部来决定,只能自行结束。
要知道什么时候会发生协程泄漏,我们首先得知道协程怎样终止:
基于上面几种泄漏的触发时机,总结原因可能有:
因此,在进行开发工作时,要注意不要写出无法自行结束的 Goroutine;当发生内存泄漏的时候,要检查代码中goroutine的生命周期。
在翻看我们的业务代码时,我们发现了这样一个案例。 使用 sync.WaitGroup 来控制并发,使用 channel 来处理并发任务中产生的错误。把这段逻辑抽象以后的代码:
package main import ( "fmt" "time" ) func main() { ch := make(chan int) // 启动一个 goroutine go func() { for { select { case val := <-ch: fmt.Println("Received:", val) // 这里没有处理退出信号 } } }() // 模拟发送一条消息 ch <- 1 // 程序在这里睡眠,模拟某种延迟或阻塞 time.Sleep(2 * time.Second) // 程序退出,goroutine 没有正常关闭 }
改进
gopackage main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int)
done := make(chan struct{})
// 启动一个 goroutine
go func() {
for {
select {
case val := <-ch:
fmt.Println("Received:", val)
case <-done:
fmt.Println("Goroutine exiting...")
return
}
}
}()
// 模拟发送一条消息
ch <- 1
// 程序在这里睡眠,模拟某种延迟或阻塞
time.Sleep(2 * time.Second)
// 发送退出信号
close(done)
// 等待一段时间以确保 goroutine 退出
time.Sleep(1 * time.Second)
}
分析
通过合理利用多核CPU的计算能力和 Go 语言的并发特性,可以更好地解决复杂业务环境中的高并发问题,并提升程序的性能和稳定性。
本文作者:sora
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!