Don't communicate by sharing memory; share memory by communicating.
不要通过共享数据来通讯,以通讯的方式来共享数据。
channel
类型的值,被用来以通讯的方式共享数据。一般被用来在不同的goroutine
(代表并发编程模型中的用户级线程)之间传递数据。
进程:【资源分配单位】描述的就是程序的执行过程,是运行着的程序的代表,一个进程其实就是某个程序运行的一个产物。(静静躺在那里的代码是程序、奔跑着、正在发挥功能的代码就是进程)。
线程:【CPU调度单位】总是在进程之内,被视为进程中运行着的控制流(代码执行流程)。
每个进程中的内容 | 每个线程中的内容 |
---|---|
地址空间 | 程序计数器 |
全局变量 | 寄存器 |
文件句柄 | 栈 |
子进程 | 状态 |
即将发送的定时器 | |
信号与信号处理程序 | |
账户信息 |
特殊状态:挂起,表示进程没有占有物理内存空间。
由于虚拟内存管理原因,进程的所使用的空间可能并没有映射到物理内存,而是在硬盘上,这时进程就会出现挂起状态,另外调用 sleep 也会被挂起。
挂起状态可以分为两种:
在操作系统中,是用进程控制块(process control block,PCB)数据结构来描述进程的。
PCB 是进程存在的唯一标识,这意味着一个进程的存在,必然会有一个 PCB,如果进程消失了,那么 PCB 也会随之消失。
PCB包含的内容:
PCB通常是通过链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列。比如:
各个进程之间是共享 CPU 资源的,在不同的时候进程之间需要切换,让不同的进程可以在 CPU 执行,那么这个一个进程切换到另一个进程运行,称为进程的上下文切换。
CPU 寄存器和程序计数是 CPU 在运行任何任务前,所必须依赖的环境,这些环境就叫做 CPU 上下文
CPU 上下文切换就是先把前一个任务的 CPU 上下文(CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。
系统内核会存储保持下来的上下文信息,当此任务再次被分配给 CPU 运行时,CPU 会重新加载这些上下文,这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。
上面说到所谓的「任务」,主要包含进程、线程和中断。所以,可以根据任务的不同,把 CPU 上下文切换分成:进程上下文切换、线程上下文切换和中断上下文切换。
进程是由内核管理和调度的,所以进程的切换只能发生在内核态。
所以,进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。
由于多进程地址空间不同,数据不能共享,一个进程内创建的变量在另一个进程是无法访问。于是操作系统提供了各种系统调用,搭建起各个进程间通信的桥梁,这些方法统称为进程间通信 IPC (IPC InterProcess Communication)
常见进程间通信方式:
用户线程和内核线程的对应关系:
用户线程是基于用户态的线程管理库来实现的,那么线程控制块(Thread Control Block, TCB) 也是在库里面来实现的,对于操作系统而言是看不到这个 TCB 的,它只能看到整个进程的 PCB。
所以,用户线程的整个线程管理和调度,操作系统是不直接参与的,而是由用户级线程库函数来完成线程的管理,包括线程的创建、终止、同步和调度等。
用户线程的优点:
用户线程的缺点:
内核线程是由操作系统管理的,线程对应的 TCB 自然是放在操作系统里的,这样线程的创建、终止和管理都是由操作系统负责。
内核线程的优点:
内核线程的缺点:
轻量级进程(Light-weight process,LWP)是内核支持的用户线程,一个进程可有一个或多个 LWP,每个 LWP 是跟内核线程一对一映射的,也就是 LWP 都是由一个内核线程支持。
对于,线程相比进程能减少开销,体现在:
所以,线程比进程不管是时间效率,还是空间效率都要高。
所谓操作系统的任务调度,实际上的调度对象是线程,而进程只是给线程提供了虚拟内存、全局变量等资源。
对于线程和进程,我们可以这么理解:
另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。
CPU、内存和I/O设备之间速度差异:
访问时间 | 设备类型 | 容量 |
---|---|---|
1ns | 寄存器 | 1KB |
2ns | 高速缓存 | 4MB |
10ns | 主存 | 16GB |
10ms | 磁盘 | 4TB |
解决方式:
合理使用线程是一门艺术,合理编写一道准确无误的多线程程序更是一门艺术。
在没有采用同步机制的情况下,多个线程中的执行操作往往是不可预测的。
如果对一个变量的操作分为多个步骤,多线程执行的过程中,无论是并发还是并行,由于可见性问题,线程单独操作变量,并将结果写回内存,将会导致变量结果与预期不一致。
因此在多线程中,需要保证对变量的操作是原子性的,即这个操作是一个原子操作(要么全部执行,要么全部不执行)。
原子性操作是完全独立于任何其他进程运行的操作,原子操作多用于现代操作系统和并行处理系统中。原子操作通常在内核中使用,因为内核是操作系统的主要组件。但是,大多数计算机硬件,编译器和库也提供原子性操作。
编译器有时候确实是 「好心办坏事」,它为了优化系统性能,往往更换指令的执行顺序。
死锁:每个线程都在等待其他线程释放资源,而其他资源也在等待每个线程释放资源,这样没有线程抢先释放自己的资源,产生死锁无线等待下去。
四个必要条件,破坏一个就不会死锁:
活锁:两个并行线程尝试获取另一个锁失败后,释放自己持有的锁,过程一直重复,虽然没有线程阻塞,但任务没有向下执行。
当多个线程共享资源,即同时对一共享数据进行修改,从而影响程序运行的正确性时,就称为竞态条件。
线程安全的核心是对状态访问操作进行管理,只要共享变量或变量状态可变就会出现问题。
在并发场景下,线程切换(上下文切换)这个操作开销很大,把大量的时间消耗在线程切换上而不是线程运行上。
线程上下文切换时,要看它们是不是属于同一个进程:
context:上下文切换的资源,寄存器的状态,程序计数器,栈等。
切换过程包括:
引起切换的原因:
sleep()
方法,让出CPU并发模型其实和分布式系统模型非常相似,在并发模型中是线程彼此进行通信,而在分布式系统模型中是 进程 彼此进行通信。然而本质上,进程和线程也非常相似。
这些共享状态可能会使用一些工作队列来保存业务数据、数据缓存、数据库的连接池等。
在线程通信中,线程需要确保共享状态是否能够让其他线程共享,而不是仅仅停留在 CPU 缓存中让自己可用,当然这些都是程序员在设计时就需要考虑的问题。
多线程在访问共享数据时,会丢失并发性,因为操作系统要保证只有一个线程能够访问数据,这会导致共享数据的争用和抢占。未抢占到资源的线程会阻塞。
在 Actor 模型中,每一个 Actor 其实就是一个 Worker, 每一个 Actor 都能够处理任务。
Actor 模型是一个并发模型,它定义了一系列系统组件应该如何动作和交互的通用规则。一个参与者Actor对接收到的消息做出响应,然后可以创建出更多的 Actor 或发送更多的消息,同时准备接收下一条消息。
Actor 模型重在参与交流的实体(即进程),而 CSP 重在交流的通道,如 Go 中的 channel。
也叫CSP(Communicating sequential processes)。
在 Channel 模型中,worker 通常不会直接通信,与此相对的,他们通常将事件发送到不同的 通道(Channel)上,然后其他 worker 可以在这些通道上获取消息。
有的时候 worker 不需要明确知道接下来的 worker 是谁,他们只需要将作者写入通道中,监听 Channel 的 worker 可以订阅或者取消订阅,这种方式降低了 worker 和 worker 之间的耦合性。
与 Actor 相比,CSP 最大的优点是灵活性。Actor 模型,负责通信的媒介和执行单元是耦合的。而 CSP 中,channel 是第一类对象,可以被独立创造、写入、读出数据,也可以在不同执行单元中传递。
CSP 模型也易受死锁影响,且没有提供直接的并行支持。并行需要建立在并发基础上,引入了不确定性。
CSP 模型不关注发送消息的进程,而是关注发送消息时使用的 channel,而 channel 不像 Actor 模型那样进程与队列紧耦合。而是可以单独创建和读写,并在进程 (goroutine) 之间传递。
Go语言拥有:
这个调度器是Go语言运行时系统的重要组成部分,主要负责统筹调配Go并发编程模型中的三个主要元素:
宏观上讲,由于P的存在,用户级线程和系统级线程可以呈现多对多的关系。
例如:
因为调度器帮我们做了很多事,所以Go程序才能高效地利用操作系统和计算机资源。程序中所有的用户级线程都会被充分地调度,其中的代码也都会并发地运行,即使用户级线程有数十万计。
$GOMAXPROCS
或者是由runtime
的方法GOMAXPROCS()
决定。这意味着在程序执行的任意时刻都只有$GOMAXPROCS
个goroutine在同时运行。Go 是采用 CSP 的思想的,channel 是 go 在并发编程通信的推荐手段,Go 语言推荐使用通信来进行进程间同步消息。这样做有三点好处:
首先,使用发送消息来同步信息相比于直接使用共享内存和互斥锁是一种更高级的抽象,使用更高级的抽象能够为我们在程序设计上提供更好的封装,让程序的逻辑更加清晰;
其次,消息发送在解耦方面与共享内存相比也有一定优势,我们可以将线程的职责分成生产者和消费者,并通过消息传递的方式将它们解耦,不需要再依赖共享内存;
最后,Go 语言选择消息发送的方式,通过保证同一时间只有一个活跃的线程能够访问数据,能够从设计上天然地避免线程竞争和数据冲突的问题;
sync.WaitGroup
:某任务需要多 goroutine 协同工作,每个 goroutine 只能做该任务的一部分,只有全部的 goroutine 都完成,任务才算是完成channel+select
:比较优雅的通知一个 goroutine 结束;多groutine中数据传递context
:多层级groutine之间的信号传播(包括元数据传播,取消信号传播、超时控制等),优雅的解决了 goroutine 启动后不可控的问题package main
import "fmt"
func main() {
for i := 0; i < 10; i++ {
go func() {
fmt.Println(i)
}()
}
}
代码执行后,不会有任何内容输出。
与一个进程总会有一个主线程类似,每一个独立的Go程序运行起来总会有一个主用户线程(goroutine)。这个主goroutine会在Go程序的运行准备工作完成后被自动地启用,并不需要任何手动操作。
每条go语句一般都会携带一个函数调用,这个被调用的函数被称为go函数,主用户线程(goroutine)的go函数,就是那个程序入口的main函数。
go函数被真正执行的时间,总会与其所属的go语句被执行的时间不同。当程序执行到一条go语句,Go语言运行时系统,会先试图从某个存放空闲的用户级线程的队列中获取某个用户级线程,它只有找不到空闲的用户级线程的情况们才会去创建一个新的用户级线程。
创建一个新的用户级线程并不会像创建一个进程或者一个系统级线程那样,必须通过操作系统的系统调用来完成,在Go语言的运行时系统内部就可以完成了,一个用户级线程就相当于需要并发执行代码片段的上下文环境。
在拿到空闲的用户级线程之后,Go语言运行时系统会用这个用户级线程去包装那个go函数(函数中的代码),然后再把这个用户级线程追加到某个可运行的用户级线程队列(先进先出)中。虽然在队列中被安排运行的时间很快,上述的准备工作也不可避免,因此存在一定时间消耗。所以go函数的执行时间,总是会明显滞后(相对于CPU和Go程序)于go语句的执行时间。
只要go语句本身执行完毕,Go程序完全不用等待go函数的执行,它会立刻去执行后面的语句,这就是异步并发执行。
注意:一旦主用户级线程(main函数中的那些代码)执行完毕,当前的Go程序就会结束运行。如果在Go程序结束的那一刻,还有用户级线程没有运行,那就没有机会运行了。
严格的说,Go语言并不会保证用户级线程会以怎样的顺序运行,因为主用户级线程会与手动启动的其他用户级线程一起接受调度,又因为调度器很可能会在用户级线程中的代码只执行了一部分的时候暂停,以期所有的用户级线程有更公平的运行机会。所以哪个用户级线程先执行完,是不可预知的,除非使用了某种Go语言提供的方式进行人为干预。
Sleep()
一会:但是时间难以把握sync.WaitGroup
类型sign := make(chan struct{}, num) // 结构体类型的通道
sign <- struct{}{}
<- sign
struct{}
类似于空接口interface{}
,代表既不包含任何字段也不拥有任何方法的空结构体类型。
struct{}
类型的值的表示方法只有一个:struct{}{}
,它占用的内存空间是0字节。这个值在整个Go程序中永远都只会存在一份。无数次的试用这个值的字面量,但是用到的却是同一个值。
package main
import (
"fmt"
"sync/atomic"
"time"
)
func main() {
// 用户级线程随机执行
for i := 0; i < 10; i++ {
go func() {
fmt.Println(i)
}()
}
// 用户级线程按顺序执行
for i := 0; i < 10; i++ {
go func(i int) { // 让go函数接收一个int型参数,在调用它的时候,把变量传进去
fmt.Println(i) // 这样Go语言包装每个用户级线程都可以拿到一个唯一的整数
}(i)
}
// 在go语句被执行时,传给go函数的参数`i`会被先求值,如此就得到了当次迭代的序号,
// 之后,无论go函数会在什么时候执行,这个参数值都不会变,
// 也就是go函数中调用`fmt.Prinrln`函数打印的一定是那个当次迭代的序号。
var count uint32
for i := uint32(0); i < 10; i++ {
go func(i uint32) {
fn := func() {
fmt.Println(i)
}
trigger(i, fn)
}(i)
}
trigger := func(i uint32, fn func()) {
for {
if n := atomic.LoadUint32(&count); n == i { // 原子操作
fn()
atomic.AddUint32(&count, 1) // 原子操作
break
}
time.Sleep(time.Nanosecond)
}
}
trigger(10, func(){})
}
Go 会优化系统调用(无论阻塞与否),通过运行时封装它们。封装的那一层会把 P
和线程 M
分离,并且可以让另一个用户线程在它上面运行。下面以文件读取举例:
func main() {
buf := make([]byte, 0, 2)
fd, _ := os.Open("number.txt")
fd.Read(buf)
fd.Close()
println(string(buf)) // 42
}
文件读取的流程如下:
P0
现在在空闲 list 中,有可能被唤醒。当系统调用 exit 时,Go 会遵守下面的规则,直到有一个命中了。
然而,在像 http 请求等 non-blocking I/O 情形下,Go 在资源没有准备好时也会处理请求。在这种情形下,第一个系统调用 — 遵循上述流程图 — 由于资源还没有准备好所以不会成功,(这样就)迫使 Go 使用 network poller 并使协程停驻,如下示例。
func main() {
http.Get(`https://httpstat.us/200`)
}
当第一个系统调用完成且显式地声明了资源还没有准备好,G 会在 network poller 通知它资源准备就绪之前一直处于停驻状态。在这种情形下,线程 M 不会阻塞:
在 Go 调度器在等待信息时 G 会再次运行。调度器在获取到等待的信息后会询问 network poller 是否有 G 在等待被运行。
如果多个协程都准备好了,只有一个会被运行,其他的会被加到全局的可运行队列中,以备后续的调度。
在系统调用中,Go 不会限制可阻塞的 OS 线程数。
GOMAXPROCS
变量表示可同时运行用户级线程的操作系统线程的最大数量。系统调用中可被阻塞的最大线程数并没有限制;可被阻塞的线程数对GOMAXPROCS
没有影响。这个包的GOMAXPROCS
函数查询和修改这个最大数限制。
如下示例:
func main() {
var wg sync.WaitGroup
for i := 0;i < 100 ;i++ {
wg.Add(1)
go func() {
http.Get(`https://httpstat.us/200?sleep=10000`)
wg.Done()
}()
}
wg.Wait()
}
利用追踪工具得到的线程数如下:
由于 Go 优化了系统线程使用,所以当 G 阻塞时,它仍可复用,这就解释了为什么图中的数跟示例代码循环中的数不一致。