GoLang并发机制探究goroutine原理详细讲解

目录
  • 1. 进程与线程
  • 2. goroutine原理
  • 3. 并发与并行
    • 3.1 在1个逻辑处理器上运行Go程序
    • 3.2 goroutine的停止与重新调度
    • 3.3 在多个逻辑处理器上运行Go程序

通常程序会被编写为一个顺序执行并完成一个独立任务的代码。如果没有特别的需求,最好总是这样写代码,因为这种类型的程序通常很容易写,也很容易维护。不过也有一些情况下,并行执行多个任务会有更大的好处。一个例子是,Web 服务需要在各自独立的套接字(socket)上同时接收多个数据请求。每个套接字请求都是独立的,可以完全独立于其他套接字进行处理。具有并行执行多个请求的能力可以显著提高这类系统的性能。考虑到这一点,Go 语言的语法和运行时直接内置了对并发的支持。

1. 进程与线程

当运行一个应用程序的时候,操作系统会为这个应用程序启动一个进程。可以将这个进程看作一个包含了应用程序在运行中需要用到和维护的各种资源的容器。这些资源包括但不限于内存地址空间、文件和设备的句柄以及线程。

一个线程是一个执行空间,这个空间会被 操作系统调度来运行函数中所写的代码。每个进程至少包含一个线程,每个进程的初始线程被称作主线程。因为执行这个线程的空间是应用程序的本身的空间,所以当主线程终止时,应用程序也会终止。操作系统将线程调度到某个处理器上运行,这个处理器并不一定是进程所在的处理器。不同操作系统使用的线程调度算法一般都不一样,但是这种不同会被 操作系统屏蔽,并不会展示给程序员。

2. goroutine原理

Go 语言里的并发指的是能让某个函数独立于其他函数运行的能力。操作系统会在物理处理器上调度线程来运行,而Go语言中当一个函数创建为goroutine时,Go 会将其视为一个独立的工作单元,这个单元会被调度到可用的逻辑处理器上执行。每个逻辑处理器都分别绑定到单个操作系统线程。Go语言运行时默认会为每个可用的物理处理器分配一个逻辑处理器。

Go 语言运行时的调度器是一个复杂的软件,能管理被创建的所有goroutine 并为其分配执行时间。这个调度器在操作系统之上,将操作系统的线程与运行时的逻辑处理器绑定,并在逻辑处理器上运行goroutine。调度器在任何给定的时间,都会全面控制哪个goroutine 要在哪个逻辑处理器上运行。

下图中可以看到操作系统线程、逻辑处理器和本地运行队列之间的关系。如果创建一个goroutine 并准备运行,这个goroutine 就会被放到调度器的全局运行队列中。之后,调度器就将这些队列中的goroutine 分配给一个逻辑处理器,并放到这个逻辑处理器对应的本地运行队列中。本地运行队列中的goroutine 会一直等待直到自己被分配的逻辑处理器执行。

有时,正在运行的goroutine 需要执行一个阻塞的系统调用,如打开一个文件。当这类调用发生时,线程和goroutine 会从逻辑处理器上分离,该线程会继续阻塞,等待系统调用的返回。与此同时,这个逻辑处理器就失去了用来运行的线程。所以,调度器会创建一个新线程,并将其绑定到该逻辑处理器上。之后,逻辑处理器会从本地运行队列里选择另一个goroutine 来运行。一旦被阻塞的系统调用执行完成并返回,对应的goroutine 会放回到本地运行队列,而之前的线程会保存好,以便之后可以继续使用。

如果一个 goroutine 需要做一个网络I/O 调用,流程上会有些不一样。在这种情况下,goroutine会和逻辑处理器分离,并移到集成了网络轮询器的运行时。一旦该轮询器指示某个网络读或者写操作已经就绪,对应的goroutine 就会重新分配到逻辑处理器上来完成操作。调度器对可以创建的逻辑处理器的数量没有限制,但语言运行时默认限制每个程序最多创建10 000 个线程。这个限制值可以通过调用runtime/debug 包的SetMaxThreads 方法来更改。如果程序试图使用更多的线程,就会崩溃。

3. 并发与并行

并发(concurrency)不是并行(parallelism)。并行是让不同的代码片段同时在不同的物理处理器上执行。并行的关键是同时做很多事情,而并发是指同时管理很多事情,这些事情可能只做了一半就被暂停去做别的事情了。在很多情况下,并发的效果比并行好,因为操作系统和硬件的总资源一般很少,但能支持系统同时做很多事情。这种“使用较少的资源做更多的事情”的哲学,也是指导Go 语言设计的哲学。

如果希望让goroutine 并行,必须使用多于一个逻辑处理器。当有多个逻辑处理器时,调度器会将goroutine 平等分配到每个逻辑处理器上。这会让goroutine 在不同的线程上运行。不过要想真的实现并行的效果,用户需要让自己的程序运行在有多个物理处理器的机器上。否则,哪怕Go 语言运行时使用多个线程,goroutine 依然会在同一个物理处理器上并发运行,达不到并行的效果。

下图展示了在一个逻辑处理器上并发运行goroutine 和在两个逻辑处理器上并行运行两个并发的goroutine 之间的区别。调度器包含一些聪明的算法,这些算法会随着Go 语言的发布被更新和改进,所以不推荐盲目修改语言运行时对逻辑处理器的默认设置。如果真的认为修改逻辑处理器的数量可以改进性能,也可以对语言运行时的参数进行细微调整。

3.1 在1个逻辑处理器上运行Go程序

下面的代码通过调用runtime 包的GOMAXPROCS 函数,更改调度器只可以使用1个逻辑处理器。创建两个goroutine,以并发的形式分别显示大写和小写的英文字母:

package main
import (
	"fmt"
	"runtime"
	"sync"
)
func main() {
	runtime.GOMAXPROCS(1) // 分配一个逻辑处理器给调度器使用
	var wg sync.WaitGroup
	wg.Add(2)
	fmt.Println("Start Goroutines")
	go func() {
		defer wg.Done()
		for count := 0; count < 3; count++ {
			for char := 'a'; char < 'a'+26; char++ {
				fmt.Printf("%c ", char)
			}
		}
	}()
	go func() {
		defer wg.Done()
		for count := 0; count < 3; count++ {
			for char := 'A'; char < 'A'+26; char++ {
				fmt.Printf("%c ", char)
			}
		}
	}()
	fmt.Println("Waiting To Finish")
	wg.Wait()
	fmt.Println("\nTerminating Program")
}

程序的输出为:

Start Goroutines
Waiting To Finish
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z A B C D E F G H I J K L M N O P Q R S T U V W X Y Z A B C
D E F G H I J K L M N O P Q R S T U V W X Y Z a b c d e f g h i j k l m n o p q r s t u v w x y z a b c d e f g h i j k l m n
o p q r s t u v w x y z a b c d e f g h i j k l m n o p q r s t u v w x y z 
Terminating Program

使用1个逻辑处理器,在同一个时刻实际只有一个线程在运行,而且每个goroutine花费的时间太短,并没有发生goroutine的停止与重新调度,所以通过程序输出可以看出每个goroutine在一个逻辑处理器上并发运行的效果,他们看起来是顺序执行的。

3.2 goroutine的停止与重新调度

基于调度器的内部算法,一个正运行的goroutine 在工作结束前,可以被停止并重新调度。调度器这样做的目的是防止某个goroutine 长时间占用逻辑处理器。当goroutine 占用时间过长时,调度器会停止当前正运行的goroutine,并给其他可运行的goroutine 运行的机会。

下图从逻辑处理器的角度展示了这一场景。在第1 步,调度器开始运行goroutine A,而goroutine B 在运行队列里等待调度。之后,在第2 步,调度器交换了goroutine A 和goroutine B。由于goroutine A 并没有完成工作,因此被放回到运行队列。之后,在第3 步,goroutine B 完成了它的工作并被系统销毁。这也让goroutine A 继续之前的工作。

下面的代码中,同样设置只使用1个逻辑处理器,程序创建了两个goroutine,分别打印1~5000 内的素数。查找并显示素数会消耗不少时间,这会让调度器有机会在第一个goroutine 找到所有素数之前,切换该goroutine的时间片:

package main
import (
	"fmt"
	"runtime"
	"sync"
)
var wg sync.WaitGroup
func main() {
	runtime.GOMAXPROCS(1) // 分配一个逻辑处理器给调度器使用
	wg.Add(2)
	// 创建两个goroutine
	fmt.Println("Create Goroutines")
	go printPrime("A")
	go printPrime("B")
	fmt.Println("Waiting To Finish")
	wg.Wait()
	fmt.Println("Terminating Program")
}
// 显示 5000 以内的素数值
func printPrime(prefix string) {
	defer wg.Done()
next:
	for outer := 2; outer < 5000; outer++ {
		for inner := 2; inner < outer; inner++ {
			if outer%inner == 0 {
				continue next
			}
		}
		fmt.Printf("%s:%d\n", prefix, outer)
	}
	fmt.Println("Completed", prefix)
}

程序的输出为:

Create Goroutines
Waiting To Finish
B:2
B:3
...
B:4583
B:4591
A:3 ** 切换 goroutine
A:5
...
A:4561
A:4567
B:4603 ** 切换 goroutine
B:4621
...
Completed B
A:4457 ** 切换 goroutine
A:4463
...
A:4993
A:4999
Completed A
Terminating Program

goroutine B 先显示素数。goroutine B 打印到素数4591后,调度器就将正运行的goroutine切换为goroutine A。之后goroutine A 在线程上执行了一段时间,再次切换为goroutine B。这次goroutine B 完成了所有的工作。一旦goroutine B 返回,就会看到线程再次切换到goroutine A 并完成所有的工作。每次运行这个程序,调度器切换的时间点都会稍微有些不同。

3.3 在多个逻辑处理器上运行Go程序

如果给调度器分配多个逻辑处理器,我们会看到之前的示例程序的输出行为会有些不同。下面的代码中把逻辑处理器的数量改为2,让我们看看打印英文字母的效果:

package main
import (
	"fmt"
	"runtime"
	"sync"
)
func main() {
	runtime.GOMAXPROCS(2) // 分配2个逻辑处理器给调度器使用
	var wg sync.WaitGroup
	wg.Add(2)
	fmt.Println("Start Goroutines")
	go func() {
		defer wg.Done()
		// 显示小写字母表3 次
		for count := 0; count < 3; count++ {
			for char := 'a'; char < 'a'+26; char++ {
				fmt.Printf("%c ", char)
			}
		}
	}()
	go func() {
		defer wg.Done()
		// 显示大写字母表3 次
		for count := 0; count < 3; count++ {
			for char := 'A'; char < 'A'+26; char++ {
				fmt.Printf("%c ", char)
			}
		}
	}()
	fmt.Println("Waiting To Finish")
	wg.Wait()
	fmt.Println("\nTerminating Program")
}

程序输出为:

Start Goroutines
Waiting To Finish
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z A B C D E F G H I J K L M N O P Q R S a b c d e f g h i j k l m 
n o p q r s t u v w x y z a b c d e f g h i j k l m n o p q r s t u v w x y z a b c d e f g h i j k l m n o p q r s t u v w x y z T U 
V W X Y Z A B C D E F G H I J K L M N O P Q R S T U V W X Y Z 
Terminating Program

两个goroutine 几乎是同时开始运行的,大小写字母是混合在一起显示的。所以每个goroutine 独自运行在自己的线程上。

到此这篇关于GoLang并发机制探究goroutine原理详细讲解的文章就介绍到这了,更多相关GoLang goroutine内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • 解决Golang中goroutine执行速度的问题

    突然想到了之前一直没留意的for循环中开goroutine的执行顺序问题,就找了段代码试了试,试了几次后发现几个有意思的地方,我暂时没有精力往更深处挖掘,希望有golang大神能简单说一说这几个地方是怎么回事. 代码: package main import "fmt" func Count(ch chan int) { fmt.Println("Count doing") ch <- 1 fmt.Println("Counting") }

  • Golang CSP并发机制及使用模型

    目录 CSP并发模型 Golang CSP Channel Goroutine Goroutine 调度器 总结 今天介绍一下 go语言的并发机制以及它所使用的CSP并发模型 CSP并发模型 CSP模型是上个世纪七十年代提出的,用于描述两个独立的并发实体通过共享的通讯 channel(管道)进行通信的并发模型. CSP中channel是第一类对象,它不关注发送消息的实体,而关注与发送消息时使用的channel. Golang CSP Golang 就是借用CSP模型的一些概念为之实现并发进行理论

  • Golang 语言控制并发 Goroutine的方法

    goroutine 是 Go语言中的轻量级线程实现,由 Go 运行时(runtime)管理.Go 程序会智能地将 goroutine 中的任务合理地分配给每个 CPU. 01介绍 Golang 语言的优势之一是天生支持并发,我们在 Golang 语言开发中,通常使用的并发控制方式主要有 Channel,WaitGroup 和 Context,本文我们主要介绍一下 Golang 语言中并发控制的这三种方式怎么使用?关于它们各自的详细介绍在之前的文章已经介绍过,感兴趣的读者朋友们可以按需翻阅. 02

  • Golang Goroutine的使用

    什么是 Goroutine goroutine 是 Go 并行设计的核心.goroutine 说到底其实就是协程,它比线程更小,十几个 goroutine 可能体现在底层就是五六个线程,Go 语言内部帮你实现了这些 goroutine 之间的内存共享. 执行 goroutine 只需极少的栈内存(大概是4~5KB),当然会根据相应的数据伸缩.也正因为如此,可同时运行成千上万个并发任务.goroutine 比 thread 更易用.更高效.更轻便. 一般情况下,一个普通计算机跑几十个线程就有点负载

  • GoLang使goroutine停止的五种方法实例

    目录 1.goroutine停止介绍 2.goroutine停止的5种方法 2.1使用for-range 2.2使用for-select(向退出通道发出退出信号) 2.3使用for-select(关闭退出通道) 2.4使用for-select(关闭多个channel) 2.5使用context包 总结 GoLang之使goroutine停止的5种方法 1.goroutine停止介绍 goroutine是Go语言实现并发编程的利器,简单的一个指令go function就能启动一个goroutine

  • 文字解说Golang Goroutine和线程的区别

    目录 一.Golang Goroutine? 二.线程是什么? 三.调度的区别 1.线程调度 2.goroutine 调度 四.栈空间的区别 1.线程占用 2.goroutine 占用 五.标识的区别 1.线程标识 2.goroutine 标识 总结 Golang Goroutine和线程的区别 Golang,轻松学习 一.Golang Goroutine? 当使用者分配足够多的任务,系统能自动帮助使用者把任务分配到 CPU 上,让这些任务尽量并发运作.这种机制在 Go语言中被称为 gorout

  • GoLang并发机制探究goroutine原理详细讲解

    目录 1. 进程与线程 2. goroutine原理 3. 并发与并行 3.1 在1个逻辑处理器上运行Go程序 3.2 goroutine的停止与重新调度 3.3 在多个逻辑处理器上运行Go程序 通常程序会被编写为一个顺序执行并完成一个独立任务的代码.如果没有特别的需求,最好总是这样写代码,因为这种类型的程序通常很容易写,也很容易维护.不过也有一些情况下,并行执行多个任务会有更大的好处.一个例子是,Web 服务需要在各自独立的套接字(socket)上同时接收多个数据请求.每个套接字请求都是独立的

  • Springboot启动原理详细讲解

    主启动类方法: @SpringBootApplication public class MyJavaTestApplication { public static void main(String[] args) { SpringApplication.run(MyJavaTestApplication.class, args); } } 点击进入方法 public static ConfigurableApplicationContext run(Class<?>[] primarySour

  • Golang设计模式中的桥接模式详细讲解

    目录 桥接模式 概念示例 桥接模式 桥接是一种结构型设计模式, 可将业务逻辑或一个大类拆分为不同的层次结构, 从而能独立地进行开发. 层次结构中的第一层 (通常称为抽象部分) 将包含对第二层 (实现部分) 对象的引用. 抽象部分将能将一些 (有时是绝大部分) 对自己的调用委派给实现部分的对象. 所有的实现部分都有一个通用接口, 因此它们能在抽象部分内部相互替换. 概念示例 假设你有两台电脑: 一台 Mac 和一台 Windows. 还有两台打印机: 爱普生和惠普. 这两台电脑和打印机可能会任意组

  • Golang设计模式中抽象工厂模式详细讲解

    目录 抽象工厂模式 概念示例 抽象工厂模式 抽象工厂模式是一种创建型设计模式, 它能创建一系列相关的对象, 而无需指定其具体类. 抽象工厂定义了用于创建不同产品的接口, 但将实际的创建工作留给了具体工厂类. 每个工厂类型都对应一个特定的产品变体. 在创建产品时, 客户端代码调用的是工厂对象的构建方法, 而不是直接调用构造函数 (new操作符). 由于一个工厂对应一种产品变体, 因此它创建的所有产品都可相互兼容. 客户端代码仅通过其抽象接口与工厂和产品进行交互. 该接口允许同一客户端代码与不同产品

  • java并发学习-CountDownLatch实现原理全面讲解

    CountDownLatch在多线程并发编程中充当一个计时器的功能,并且维护一个count的变量,并且其操作都是原子操作. 如下图,内部有下static final的Sync类继承自AQS. 该类主要通过countDown()和await()两个方法实现功能的,首先通过建立CountDownLatch对象,并且传入参数即为count初始值. 如果一个线程调用了await()方法,那么这个线程便进入阻塞状态,并进入阻塞队列. 如果一个线程调用了countDown()方法,则会使count-1:当c

  • SpringBoot详细讲解断言机制原理

    目录 1.简单断言 2.数组断言 3.组合断言 4.异常断言 5.超时断言 6.快速失败 JUnit 5 内置的断言可以分成如下几个类别: 1.简单断言 用来对单个值进行简单的验证.如: 方法 说明 assertEquals 判断两个对象或两个原始类型是否相等 assertNotEquals 判断两个对象或两个原始类型是否不相等 assertSame 判断两个对象引用是否指向同一个对象 assertNotSame 判断两个对象引用是否指向不同的对象 assertTrue 判断给定的布尔值是否为

  • Python多进程并发与同步机制超详细讲解

    目录 多进程 僵尸进程 Process类 函数方式 继承方式 同步机制 状态管理Managers 在<多线程与同步>中介绍了多线程及存在的问题,而通过使用多进程而非线程可有效地绕过全局解释器锁. 因此,通过multiprocessing模块可充分地利用多核CPU的资源. 多进程 多进程是通过multiprocessing包来实现的,multiprocessing.Process对象(和多线程的threading.Thread类似)用来创建一个进程对象: 在类UNIX平台上,需要对每个Proce

  • Golang Mutex 原理详细解析

    目录 前言 Lock 单协程加锁 加锁被阻塞 Unlock 无协程阻塞下的解锁 解锁并唤醒协程 自旋 什么是自旋 自旋条件 自旋的优势 自旋的问题 Mutex 的模式 Normal 模式 Starving 模式 Woken 状态 前言 互斥锁是在并发程序中对共享资源进行访问控制的主要手段.对此 Go 语言提供了简单易用的 Mutex.Mutex 和 Goroutine 合作紧密,概念容易混淆,一定注意要区分各自的概念. Mutex 是一个结构体,对外提供 Lock()和Unlock()两个方法,

  • Spring超详细讲解事务和事务传播机制

    目录 为什么需要事务 Spring 声明事务 Transactional参数说明 propagation isolation timeout 事务回滚失效解决方案 @Transactional工作原理 Spring 事务的传播机制 为什么需要事务传播机制? 传播机制的类型 为什么需要事务 事务是将一组操作封装成一个执行单元,要么全部成功,要么全部失败.如果没有事务,转账操作就会出现异常,因此需要保证原子性. Spring 声明事务 只需要在方法上添加@Transactional注解就可以实现,无

随机推荐