1. 进程、线程和协程的区别
- 进程: 进程是具有一定独立功能的程序,进程是系统资源分配和调度的最小单位。每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。
进程就是应用程序的启动实例。比如我们运行一个游戏,打开一个软件,就是开启了一个进程。
- 线程: 线程是进程的一个实体,线程是内核态,而且是 CPU 调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。
线程从属于进程,是程序的实际执行者。一个进程至少包含一个主线程,也可以有更多的子线程。
- 协程: 协程是一种用户态的轻量级线程,协程的调度完全是由用户来控制的。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。
协程,是一种比线程更加轻量级的存在。正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。最重要的是,协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态中执行)。
-
线程和协程的区别
- 线程切换需要陷入内核,然后进行上下文切换,而协程在用户态由协程调度器完成,不需要陷入内核,这样代价就小了。
- 协程的切换时间点是由调度器决定,而不是由系统内核决定的,尽管它们的切换点都是时间片超过一定阈值,或者是进入 I/O 或睡眠等状态时。
- 基于垃圾回收的考虑,Go 实现了垃圾回收,但垃圾回收的必要条件是内存位于一致状态,因此就需要暂停所有的线程。如果交给系统去做,那么会暂停所有的线程使其一致。对于 Go 语言来说,调度器知道什么时候内存位于一致状态,所以也就没有必要暂停所有运行的线程。
2. 介绍一下 Goroutine
Goroutine 是一个与其他 goroutines 并行运行在同一地址空间的 Go 函数或方法。
协程(goroutine) 轻量级线程, goroutine 是由 Go 的运行时(runtime)调度和管理的。Go 程序会智能地将 goroutine 中的任务合理地分配给每个 CPU。它在语言层面已经内置了调度和上下文切换的机制。
goroutine 是 Go 并发设计的核心,也叫协程,它比线程更加轻量,因此可以同时运行成千上万个并发任务。在 Go 语言中,每一个并发的执行单元叫作一个 goroutine。我们只需要在调用的函数前面添加 go 关键字,就能使这个函数以协程的方式运行。
3. context 包结构原理和用途
Context(上下文)是 Golang 应用开发常用的并发控制技术 ,它可以控制一组呈树状结构的 goroutine,每个 goroutine 拥有相同的上下文。Context 是并发安全的,主要是用于控制多个协程之间的协作、取消操作。
Context 只定义了接口,凡是实现该接口的类都可称为是一种 context。contex理分析
- 「Deadline」 方法:可以获取设置的截止时间,返回值 deadline 是截止时间,到了这个时间,Context 会自动发起取消请求,返回值 ok 表示是否设置了截止时间。
- 「Done」 方法:返回一个只读的 channel ,类型为 struct {}。如果这个 chan 可以读取,说明已经发出了取消信号,可以做清理操作,然后退出协程,释放资源。
- 「Err」 方法:返回 Context 被取消的原因。
- 「Value」 方法:获取 Context 上绑定的值,是一个键值对,通过 key 来获取对应的值。
4. goroutine 调度
GMP 是 Go 语言运行时(runtime)层面的实现,是 go 语言自己实现的一套调度系统。区别于操作系统调度 OS 线程。
-
G 很好理解,就是个 goroutine 的,里面除了存放本 goroutine 信息外 还有与所在 P 的绑定等信息。
-
P 管理着一组 goroutine 队列,P 里面会存储当前 goroutine 运行的上下文环境(函数指针,堆栈地址及地址边界),P 会对自己管理的 goroutine 队列做一些调度(比如把占用 CPU 时间较长的 goroutine 暂停、运行后续的 goroutine 等等)当自己的队列消费完了就去全局队列里取,如果全局队列里也消费完了会去其他 P 的队列里抢任务。
-
M(machine)是 Go 运行时(runtime)对操作系统内核线程的虚拟, M 与内核线程一般是一一映射的关系, 一个 groutine 最终是要放到 M 上执行的;
P 与 M 一般也是一一对应的。他们关系是: P 管理着一组 G 挂载在 M 上运行。当一个 G 长久阻塞在一个 M 上时,runtime 会新建一个 M,阻塞 G 所在的 P 会把其他的 G 挂载在新建的 M 上。当旧的 G 阻塞完成或者认为其已经死掉时 回收旧的 M。
P 的个数是通过 runtime.GOMAXPROCS 设定(最大 256),Go1.5 版本之后默认为物理线程数。 在并发量大的时候会增加一些 P 和 M,但不会太多,切换太频繁的话得不偿失。
单从线程调度讲,Go 语言相比起其他语言的优势在于 OS 线程是由 OS 内核来调度的,goroutine 则是由 Go 运行时(runtime)自己的调度器调度的,这个调度器使用一个称为 m:n 调度的技术(复用 / 调度 m 个 goroutine 到 n 个 OS 线程)。 其一大特点是 goroutine 的调度是在用户态下完成的, 不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池, 不直接调用系统的 malloc 函数(除非内存池需要改变),成本比调度 OS 线程低很多。 另一方面充分利用了多核的硬件资源,近似的把若干 goroutine 均分在物理线程上, 再加上本身 goroutine 的超轻量,以上种种保证了 go 调度方面的性能。
5. 如何避免 Goroutine 泄露和泄露场景
gorouinte 里有关于 channel 的操作,如果没有正确处理 channel 的读取,会导致 channel 一直阻塞住,goroutine 不能正常结束
-
channel 操作不当:
package main
import (
"fmt"
"time"
)func main() {
// 问题场景: 在 Goroutine 中创建 channel,但没有相应的 channel 读取操作
go func() {
ch := make(chan int)
ch <- 1 // 这里会导致 Goroutine 永久阻塞
}()time.Sleep(5 * time.Second) fmt.Println("Program ended")
}
在上面的代码中,我们创建了一个 Goroutine,它向一个无人接收的 channel 写入数据。这会导致该 Goroutine 永久阻塞,从而造成 Goroutine 泄露。
解决方法如下:
package main
import (
"fmt"
"time"
)
func main() {
// 解决方法: 确保 channel 的读取操作与写入操作相匹配
ch := make(chan int)
go func() {
x := <-ch // 从 channel 中读取数据
fmt.Println("Received value:", x)
}()
ch <- 1 // 向 channel 中写入数据
time.Sleep(5 * time.Second)
fmt.Println("Program ended")
}
在这个解决方案中,我们在创建 Goroutine 之前先创建了 channel,并在 Goroutine 中读取 channel 中的数据。这样可以确保 Goroutine 能够正常退出,避免泄露。
-
context 使用不当:
package main
import (
"context"
"fmt"
"time"
)func main() {
// 错误示例: 在 Goroutine 中使用 context,但没有正确地取消 context
ctx, _ := context.WithTimeout(context.Background(), 2*time.Second)go func() { doSomething(ctx) }() time.Sleep(5 * time.Second) fmt.Println("Program ended")
}
func doSomething(ctx context.Context) {
for {
// 不检查 ctx.Done() 就一直执行
time.Sleep(time.Second)
fmt.Println("Doing something...")
}
}
在上面的代码中,我们创建了一个 Goroutine,并向其传递了一个 context。但是,我们没有在 doSomething
函数中正确地处理 context 的取消。这意味着,即使 context 已经超时,Goroutine 仍然会一直执行下去,而不会退出。,从而造成 Goroutine 泄露。
解决方法如下:
package main
import (
"context"
"fmt"
"time"
)
func main() {
// 解决方法: 在 Goroutine 中正确地处理 context 的取消
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
go func() {
doSomething(ctx)
}()
time.Sleep(5 * time.Second)
fmt.Println("Program ended")
}
func doSomething(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("Context canceled, exiting Goroutine")
return
default:
// 执行一些操作
time.Sleep(time.Second)
fmt.Println("Doing something...")
}
}
}
在解决方案中,我们在 doSomething
函数中使用 select
语句来检查 context 是否已被取消。如果 context 已被取消,Goroutine 就会安全地退出,避免 Goroutine 泄露。
6. waitgroup 用法和原理
waitgroup 内部维护了一个计数器,当调用 wg.Add(1)
方法时,就会增加对应的数量;当调用 wg.Done()
时,计数器就会减一。直到计数器的数量减到 0 时,就会调用
runtime_Semrelease 唤起之前因为 wg.Wait()
而阻塞住的 goroutine。
使用方法:
-
main 协程通过调用 wg.Add (delta int) 设置 worker 协程的个数,然后创建 worker 协程;
-
worker 协程执行结束以后,都要调用 wg.Done ();
-
main 协程调用 wg.Wait () 且被 block,直到所有 worker 协程全部执行结束后返回。
实现原理:
-
WaitGroup 主要维护了 2 个计数器,一个是请求计数器 v,一个是等待计数器 w,二者组成一个 64bit 的值,请求计数器占高 32bit,等待计数器占低 32bit。
-
每次 Add 执行,请求计数器 v 加 1,Done 方法执行,请求计数器减 1,v 为 0 时通过信号量唤醒 Wait ()。
-