Go从入门到放弃16--并发设计

63 阅读4分钟

Go 的设计者敏锐地把握了 CPU 向多核方向发展的这一趋势,在决定去创建 Go 语言的时候,他们果断将面向多核、原生支持并发作为了 Go 语言的设计目标之一,并将面向并发作为 Go 的设计哲学。

进程与线程

在计算机科学中,线程是可以由调度程序(通常是操作系统的一部分)独立管理的最小程序指令集,而进程是程序运行的实例。

在大多数情况下,线程是进程的组成部分。一个进程中可以存在多个线程,这些线程共享进程的内存(例如全局变量)等资源。而进程之间相对独立,不同进程具有不同的内存地址空间、代表程序运行的机器码、进程状态、操作系统资源描述符等。

得到App_2022-08-15_23-49-25.png 在一个进程内部,可能有多个线程被同时处理。追求高并发处理、高性能的程序或者库一般都会设计为多线程。那为什么程序通常不采取多进程,而采取多线程的方式进行设计呢?这是因为开启一个新进程的开销要比开启一个新线程大得多,而且进程具有独立的内存空间,这使得多进程之间的共享通信更加困难。

操作系统调度到CPU中执行的最小单位是线程。在传统的单核(Core)CPU上运行的多线程应用程序必须交织线程,交替抢占CPU的时间片。但是,现代计算机系统普遍拥有多核处理器。在多核CPU上,线程可以分布在多个CPU核心上,从而实现真正的并行处理。

得到App_2022-08-15_23-55-45.png

并发与并行

21世纪以来,数据中心的硬件和网络环境发生了重大变化,多核处理器硬件成为数据中心的主流,要想充分利用多核的强大计算能力,一般有两种方案。

并行方案

并行方案就是在处理器核数充足的情况下启动多个单线程应用的实例,这样每个实例“运行”在一个核上(如图中的CPU核1~CPU核N),尽可能多地利用多核计算资源。 得到App_2022-08-15_23-32-49.png

并发方案

并发方案就是重新做应用结构设计,即将应用分解成多个在基本执行单元(图中这样的执行单元为操作系统线程)中执行的、可能有一定关联关系的代码片段(图中的模块1~模块N )。我们看到与并行方案中应用自身结构无须调整有所不同,并发方案中应用自身结构做出了较大调整,应用内部拆分为多个可独立运行的模块。这样虽然应用仍然以单实例的方式运行,但其中的每个内部模块都运行于一个单独的操作系统线程中,多核资源得以充分利用。

得到App_2022-08-15_23-54-35.png

Go的并发方案:goroutine

协程

协程又称用户态的线程,独立的栈空间,共享堆空间,调度由用户自己控制,这些用户级线程的调度也是自己实现的。

goroutine

goroutine是由 Go 运行时(runtime)负责调度的、轻量的用户级线程,为并发程序设计提供原生支持。

相比传统操作系统线程来说,goroutine 的优势主要是:

  • 资源占用小,每个 goroutine 的初始栈大小仅为 2k;
  • 由 Go 运行时而不是操作系统调度,goroutine 上下文切换在用户层完成,开销更小;
  • 在语言层面而不是通过标准库提供。goroutine 由go关键字创建,一退出就会被回收或销毁,开发体验更佳;
  • 语言内置 channel 作为 goroutine 间通信原语,为并发设计提供了强大支撑。

goroutine 的基本用法

当一个程序启动时,其主函数即在一个单独的goroutine中运行,我们叫它main goroutine。新的goroutine会用go语句来创建。在语法上,go语句是一个普通的函数或方法调用前加上关键字go

func hello() {
    fmt.Println("Hello Goroutine!")
}
func main() {
    go hello() // 启动另外一个goroutine去执行hello函数
    fmt.Println("main goroutine done!")
}

如果 main goroutine 退出了,那么也意味着整个应用程序的退出。此外,你还要注意的是,goroutine 执行的函数或方法即便有返回值,Go 也会忽略这些返回值。所以,如果你要获取 goroutine 执行后的返回值,你需要另行考虑其他方法,比如通过 goroutine 间的通信来实现。

参考资料

  • 《Go 语言第一课》
  • 《Go语言底层原理剖析》
  • 《Go语言精进之路》
  • Go语言圣经