🌙 1.串行、并发和并行
串行:指令按照顺序执行
并发:(concurrency)指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。
并行:(parallel)指在同一时刻,有多条指令在多个处理器上同时执行,所以无论从微观还是从宏观来看,二者都是一起执行的。
并发与并行概念的区别是是否同时执行,比如吃饭时,电话来了,需要停止吃饭去接电话,接完电话继续吃饭,这是并发执行,但是吃饭时电话来了,边吃边接是并行。
🌙 2.协程
协程:也称为纤程(Coroutine), 是一个特殊的函数,这个函数可以在某个地方挂起,并且可以重新在挂起处外继续运行。
协程与进程、线程相比并不是一个维度的概念,协程不是被操作系统内核所管理的,而是完全由程序所控制,也就是在用户态执行。这样带来的好处是性能大幅度的提升,因为不会像线程切换那样消耗资源。
正如一个进程可以拥有多个线程一样,一个线程可以拥有多个协程(目前的协程框架一般都是设计成 1:N 模式)。
注意:
- 多个进程或一个进程内的多个线程是可以并行运行的
- 一个线程内的多个协程却是串行的,无论CPU有多少个核,因为协程本质上还是一个函数,当一个协程运行时,其它协程必须挂起
- 但是协程的切换过程只有用户态,即没有陷入内核态,因此切换效率比进程和线程高很多
协程自己会主动适时的让出 CPU,也就是说每个协程池里面有一个调度器,这个调度器是被动调度的。意思就是他不会主动调度。而且当一个协程发现自己执行不下去了(比如异步等待网络的数据回来,但是当前还没有数据到),这个时候就可以由这个协程通知调度器,这个时候执行到调度器的代码,调度器根据事先设计好的调度算法找到当前最需要 CPU 的协程。切换这个协程的 CPU 上下文把 CPU 的运行权交个这个协程,直到这个协程出现执行不下去需要等等的情况,或者它调用主动让出 CPU 的 API 之类,触发下一次调度。
🌙 3.协程的优缺点
优点:
- 占用小:协程更加轻量,创建成本更小,降低了内存消耗,协程一般只占据极小的内存(2~5KB),而线程是1MB左右。虽然线程和协程都是独有栈,但是线程栈是固定的,比如在Java中,基本是2M,假如一个栈只有一个打印方法,还要为此开辟一个2M的栈,就太浪费了。而Go的的协程具备动态收缩功能,初始化为2KB,最大可达1GB
- 运行效率高:线程切换需要从用户态->内核态->用户态,而协程切换是在用户态上,即用户态->用户态->用户态,其切换过程由语言层面的调度器(coroutine)或者语言引擎(goroutine)实现。
- 减少了同步锁:协程最终还是运行在线程上,本质上还是单线程运行,没有临界区域的话自然不需要锁的机制。多协程自然没有竞争关系。但是,如果存在临界区域,依然需要使用锁,协程可以减少以往必须使用锁的场景
- 同步代码思维写出异步代码
缺点:
- 无法利用多核资源:协程运行在线程上,单线程应用无法很好的利用多核,只能以多进程方式启动。
- 协程不能有阻塞操作:线程是抢占式,线程在遇见IO操作时候,线程从运行态→阻塞态,释放cpu使用权。这是由操作系统调度。协程是非抢占式,如果遇见IO操作时候,协程是主动释放执行权限的,如果无法主动释放,程序将阻塞,无法往下执行,随之而来是整个线程被阻塞。
- CPU密集型不是长处:假设这个线程中有一个协程是 CPU 密集型的他没有 IO 操作,也就是自己不会主动触发调度器调度的过程,那么就会出现其他协程得不到执行的情况,所以这种情况下需要程序员自己避免。
应用场景:
- 高性能计算,牺牲公平性换取吞吐。协程最早来自高性能计算领域的成功案例,协作式调度相比抢占式调度而言,可以在牺牲公平性时换取吞吐
- IO Bound 的任务:虽然异步IO在数据到达的时候触发回调,减少了线程切换带来性能损失,但是该思想不符合人类的思维模式。异步回调在破坏点思维连贯性的同时也破坏掉了程序的连贯性,让你在阅读程序的时候花费更多的精力。但是协程可以很好解决这个问题。比如把一个 IO 操作 写成一个协程。当触发 IO 操作的时候就自动让出 CPU 给其他协程。要知道协程的切换很轻的。协程通过这种对异步 IO 的封装既保留了性能也保证了代码的容易编写和可读性。
🌙 2.go并发
- 协程:go程序在语言层面支持了并发,
goroutine
是go语言提供的一种用户态线程,有时我们称之为协程。
所谓协程,某种程度上也可以叫做轻量线程,他不由系统而由应用程序创建和管理,因此使用的开销较低(一般为4k)。
我们可以创建多个协程,并且他们跑在同一个内核线程智商的时候,就需要一个调度器来维护这些协程,确保所有的协程都能使用CPU,并且尽可能公平的使用CPU资源。
- 调度器:主要有四个重要部分,分别是 M、G、P和Sched
- M:work thread 代表了系统线程的内核线程,由操作系统管理
- P:processor 衔接M和G的调度上下文,负责等待执行的G和M对接。P的数量可以通过
GOMAXPROCS()
来设置,他其实代表了真正的并发度,即有多少个goroutine
可以同时运行。 - G:goroutine 协程的实体, 包括了调用栈,重要的调度信息,例如 channel 等。
- Sched:
runtime.Gosched()
用于让出CPU时间片,让出当前协程的执行权限,调度器安排其他等待的任务运行,并在下次某个时候从该位置恢复执行。
func testDemo() {
for i := 1; i <= 10; i++ {
go func(i int) {
if i == 5 {
// 协程让出,但并不代表不执行,而是 5永远不会第一输出
runtime.Gosched()
}
if i == 8 {
// 终止当前协程
runtime.Goexit()
}
// 打印一组无规律数字
fmt.Println(i)
}(i)
}
time.Sleep(time.Second)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
在操作系统的内核线程和编程语言的用户线程之间,实际上存在3种线程对应模型,也就是:1:1,1:N,M:N。
- N:1, 多个(N)用户线程始终在一个内核线程上跑,context上下文切换很快,但是无法真正的利用多核。
- 1:1, 一个用户线程就只在一个内核线程上跑,这时可以利用多核,但是上下文切换很慢,切换效率很低。
- M:N,多个协程在多个内核线程上跑,这个可以集齐上面两者的优势,但是无疑增加了调度的难度。
M:N 综合两种方式(N:1,1:1)的优势。多个协程可以在多个内核线程上处理。既能快速切换上下文,也能利用多核的优势,而Go正是选择这种实现方式。
runtime.NumCPU() // 返回当前CPU内核数
runtime.GOMAXPROCS(2) // 设置运行时最大可执行CPU数
runtime.NumGoroutine() // 当前正在运行的协程数
2
3
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
var quit = make(chan int)
func loop() {
for i := 0; i < 1000; i++ {
Factorial(uint64(1000))
}
quit <- 1
}
func Factorial(n uint64) (result uint64) {
if n > 0 {
result = Factorial(n-1) * n
return result
}
return 1
}
var wg1, wg2 sync.WaitGroup
func main() {
test1(&wg1) // 耗时约3s
test2() // 耗时约9s
}
func test2() {
// 设置执行时使用的核数
runtime.GOMAXPROCS(2)
test1(&wg2)
}
func test1(wg *sync.WaitGroup) {
var now = time.Now()
fmt.Println("1:", now)
fmt.Println("系统CPU内核数:", runtime.NumCPU())
fmt.Println("当前正在运行的协程数:",runtime.NumGoroutine())
a := 5000
for i := 1; i <= a; i++ {
wg.Add(1)
// 开启协程
go loop()
}
for i := 0; i < a; i++ {
select {
case <-quit:
wg.Done()
}
}
gap := time.Since(now).Seconds()
fmt.Println("2:", time.Now())
fmt.Println("time gap:", gap)
wg.Wait()
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
🌙 3.goroutine
Go语言从语言层面原生提供了协程支持,即 goroutine,执行goroutine只需极少的栈内存(大概是4~5KB),所以Go可以轻松的运行多个并发任务。
在go语言中,协程的使用很简单,直接在函数前加上关键字go
即可。go
关键字就是用来创建一个协程的,后面的函数就是这个协程需要执行的代码逻辑。
package main
import (
"fmt"
"time"
)
func main() {
for i:=1;i<10;i++ {
go func(i int) {
fmt.Println(i)
}(i)
}
// 暂停等待打印执行完成
time.Sleep(1e9)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
下面的程序同时处理两件事,命令行会不断地输出 tick,同时可以使用 fmt.Scanln()
接受用户输入。两个环节可以同时进行,直到按 Enter键时将输入的内容写入 input变量中并返回, 整个程序终止。
func running() {
var times int
for {
times++
fmt.Println("tick:", times)
time.Sleep(time.Second)
}
}
func main() {
go running()
var input string
fmt.Scanln(&input)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
🌙 4.测试
func test1() {
for i := 1; i <= 10; i++ {
go func(){
// 全部打印11:因为开启协程也会耗时,协程没有准备好,循环已经走完
fmt.Println(i)
}()
}
time.Sleep(time.Second)
}
func test2() {
for i := 1; i <= 10; i++ {
go func(i int){
// 打印无规律数字
fmt.Println(i)
}(i)
}
time.Sleep(time.Second)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19