你是否想知道如何应对高并发?Go 语言为你提供了答案!
并发编程是当前软件领域中不可忽视的一个关键概念。随着 CPU 等硬件的不断发展,我们都渴望让我们的程序运行速度更快、更快。而 Go 语言在语言层面天生支持并发,充分利用现代 CPU 的多核优势,这也是 Go 语言能够广泛流行的一个重要原因。
在 Java 中,要支持高并发有几种方案可供选择。首先,我们可以通过开启多部署节点集群来增加高并发处理能力,通过增加机器硬件来实现。其次,我们可以在单节点上开启多线程来处理请求。然而,即使在单节点内创建线程也是非常耗费资源的。因此,通常情况下我们会使用线程池来管理线程的创建和销毁。然而,有一个公式你可能会很熟悉,即核心线程数等于 CPU 核数的一半加一。这意味着我们并不是线程创建得越多,对于我们的 Java 程序就越好。
在我们明确了问题的痛点之后,我们可以进一步探究一下 Go 语言是如何解决这些问题,并且将高并发作为 Go 语言的一项特色功能。
goroutine
我们在 Java 中开启线程的方式是直接创建一个 Thread 对象。然而,在 Go 语言中,如果我们想要实现异步处理,我们可以使用"go"关键字来开启一个 goroutine 协程。协程的最大优势在于其轻量级,可以轻松创建上百万个协程而不会导致系统资源的耗尽,而线程和进程通常最多也不能超过 1 万个。举个例子:
在 Go 语言中,我们可以非常简单地使用关键字"go"来开启一个协程,从而实现异步处理函数 f。只需在函数 f 的调用前面加上"go"关键字,就能使得该函数在一个独立的协程中异步执行。
不仅可以使用"go"关键字来开启一个协程异步执行具名函数,还可以使用"go"关键字来开启一个协程异步执行匿名函数。
今天我们的重点不在这里,而是要讨论为什么 Go 语言适合处理高并发的情况。我们都知道,操作系统的 CPU 最小调度单位是线程,然而 Go 语言却使用了协程的概念。那么问题来了,Go 语言是如何将这些协程交给 CPU 来处理的呢?如果无法将它们交给 CPU 处理,那么就算再创建多少协程也无法运行代码。在这里,我们就需要了解一下 Go 语言的调度器,也就是 GPM 调度模型。
GPM 调度模型
可以借鉴一下以下图例,总的来说,我们可以像线程池一样,无论创建了多少协程,都需要将它们放入队列中。然后,剩下的任务就交给调度器来处理。
其中:
G:使用关键字"go"加上一个函数调用可以创建一个 goroutine(简称 G)。每次调用"go f()"都会创建一个新的 G,其中包含要执行的函数 f 以及相关的上下文信息。
全局队列(Global Queue)是用来存放等待运行的 G(Goroutine)的地方。
P 是指 goroutine 执行所需的物理资源,每个 P 最多可以承载 GOMAXPROCS 个 goroutine 的执行。
P 的本地队列是类似于全局队列的,它存放了等待运行的 G,并且数量限制在 256 个以内。每当新建一个 G 时,优先将其加入到 P 的本地队列中,如果本地队列已满,则会批量移动部分 G 到全局队列中。
为了使线程能够执行任务,需要通过获取调度器(P)来获取任务(G)。线程首先尝试从调度器的本地队列获取任务,如果本地队列为空,则线程会尝试从全局队列或其他调度器的本地队列获取任务。一旦线程获取到任务,就会执行任务,并在任务执行完毕后再次从调度器获取下一个任务,持续重复这个过程。
Goroutine 调度器和操作系统调度器通过 M 结合起来,形成了调度的基本单位。在这个结合中,每个 M 代表一个内核线程,而操作系统调度器则负责将这些内核线程分配到 CPU 的核心上进行执行。
channel
单纯地将函数并发执行是没有意义的,因为函数与函数之间需要进行数据交换,才能真正体现并发执行函数的意义。
虽然可以利用共享内存进行数据交换,但是在不同的 goroutine 中使用共享内存容易导致竞态问题的出现。为了确保数据交换的正确性,许多并发模型都需要通过使用互斥量对内存进行加锁来解决这个问题。然而,这种做法往往会带来性能问题,因为加锁操作会引入额外的开销。
Go 语言采用的并发模型是 CSP(Communicating Sequential Processes),这个模型强调了通过通信共享内存的方式来实现并发,而不是通过共享内存来实现通信。这种设计理念使得 Go 语言在处理并发任务时更加高效和安全。
如果说 goroutine 是 Go 程序中实现并发执行的主体,那么 channel 就是连接这些 goroutine 之间的纽带。channel 是一种能够使得一个 goroutine 向另一个 goroutine 发送特定值的通信机制。
Mutex(互斥锁)在实现上也是使用了重量级锁。与 Java 的互斥锁相比,Go 语言的 Mutex 有以下几点区别:
内存开销:Go 语言的 Mutex 相对较轻量,使用较少的内存。这是因为 Go 语言的 Mutex 只包含一个字段,用于表示锁的状态,而 Java 的互斥锁通常包含更多的字段和数据结构。
锁的语法:在 Go 语言中,可以使用 mutex.Lock()和 mutex.Unlock()方法来手动控制锁的获取和释放,这样可以更灵活地控制锁的粒度。而在 Java 中,使用 synchronized 关键字来实现互斥锁,锁的粒度相对固定,只能对整个方法或代码块进行加锁。
锁的性能:由于 Go 语言的 Mutex 较为轻量,并且采用了更高效的实现方式,比如以下几个方面:
自旋锁:在低并发的情况下,Go 语言的 Mutex 会采用自旋锁的方式。自旋锁是一种忙等待的锁,当一个 Goroutine 尝试获取锁时,如果锁已经被其他 Goroutine 持有,则该 Goroutine 会一直循环检查锁的状态,直到成功获取锁。这种方式避免了线程切换的开销,提高了性能。
优化的调度策略:Go 语言的调度器在处理 Goroutine 的调度时会进行优化,尽量将锁的持有者与等待者调度到同一个处理器(P)上执行,减少线程之间的上下文切换和锁竞争的开销。
等待队列:当一个 Goroutine 无法获取到 Mutex 锁时,它会进入等待队列,等待锁的释放。Go 语言的 Mutex 的等待队列是基于链表实现的,相比 Java 的互斥锁使用的等待队列,具有更低的内存开销和更高的效率。
总结
并发编程是当前软件领域中一个重要的概念。Go 语言通过 goroutine 和 channel 的特性,天生支持高并发处理,充分利用现代 CPU 的多核优势。与 Java 相比,Go 语言的协程更加轻量级,可以轻松创建上百万个协程。Go 语言的调度器采用 GPM 调度模型,通过将协程放入队列中,由调度器分配给 CPU 处理。此外,Go 语言采用 CSP 模型,通过 channel 实现协程之间的通信,避免了共享内存带来的竞态问题。相比之下,Go 语言的 Mutex 锁更轻量、灵活,并且具有更高的性能。总的来说,Go 语言适合处理高并发的情况,成为了当前软件开发领域的热门语言之一。
文章转载自:努力的小雨
评论