最近在看一些 Java 的内容,途中会对比着 Go 学一下。发现 Go 的协程机制真的很牛逼,协程的机制搭配 epoll 使得 Go 非常适合网络 IO。本篇文章会讲下八股文,然后对比测试下网络 IO 和磁盘 IO
PS:需要对协程和 GMP 的基础知识有深入理解,对 epoll 有了解
Go 适合网络 IO
计算机程序场景分为:CPU 密集型和 IO 密集型。IO 又分为网络 IO 和磁盘 IO
为什么 Go 适合网络 IO 呢?,这得先讲讲网络 socket 句柄和文件句柄的不同:
-
socket 句柄
- 实现了
.poll
,可以用 epoll 池来管理;
- socket 句柄可读(socket buffer 对端网络发数据过来)可写(socket buffer 有空间可以写入数据)事件有意义
- 并且 go 中把 epoll 的 socket 句柄设置为 noblocking。这样当 epoll 中没有 socket fd 可读可写时,也会直接返回,然后调度其他协程,这样就实现了网络 IO 的并发
-
文件句柄
- 文件句柄一般没有实现
.poll
- 文件 IO 的 read/write 都是阻塞的
- 文件句柄可读可写事件则没有意义,因为文件句柄理论上是永远都是可读可写的,不会阻塞等待
再讲磁盘 IO 导致线程阻塞:
磁盘 IO 是系统调用,且读写是阻塞的,只有完成整个系统调用才会返回,所以会导致卡线程。Go 能做的就是在执行阻塞系统调用前,解除当前执行线程 M 和当前 P 的绑定,然后执行当前 G 的阻塞系统调用。并且调度线程可能会创建新线程(不一定会创建)来绑定这个 P,然后执行 runq 队列中的 G。当进行很多磁盘 IO 且这些 IO 都很慢时,会导致创建出很多线程并处于阻塞状态。所以,对于磁盘 IO,Go 协程的机制和线程机制没有太大的区别,都是耗费一个线程阻塞等待
那这种协程的机制对比线程的机制会有什么不同?
看一个场景:Web 服务器,当一个 HTTP 请求过来,线程模型一个线程处理一个请求,协程模型一个协程处理一个请求,当该请求依赖于另一个服务时,需要通过网络 IO 再请求另一个服务器。
线程模型:
此时,线程模型的线程只能阻塞等待,即使线程模型内部封装了 epoll,其实也是只有那个 epoll 线程阻塞了,但是处理请求的线程依赖于该请求的结果,所以要么阻塞同步等待 epoll 线程唤醒该线程,要么注册一个回调函数,该回调函数的函数体是处理该请求的剩余代码,然后该线程继续处理下一个请求。待 epoll 收到请求响应后执行该回调函数
协程模型:
协程模型其实就是由 Go 的语言内部直接封装了 epoll 和“回调函数”,只是底层的协程机制让我们在 Go 业务代码书写层面使用同步的方法达到异步回调的效果,因为 Go 协程封装了帮我们“回调”的逻辑,我们可以理解 Go 在应用层把一个线程(其实就是指令流),切分成一串一串指令流,并发交替执行
实验网络 IO 和磁盘 IO
实验环境:Ubuntu22,CPU 32 核,内存 32 G
磁盘 IO:
1
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
|
func main() {
// 奇怪,设置 P 为 1 时,阻塞线程会变得比 32 少,可能有限制阻塞线程和 P 的最大比例关系???
runtime.GOMAXPROCS(4)
printThreadInfo("初始化 main 时:", "ps", "-T", "-p", strconv.Itoa(os.Getpid()))
wg := sync.WaitGroup{}
wg.Add(32)
for i := 1; i <= 32; i++ {
i := i
go func() {
// 32 个文件,每个文件名字 1 2 3,依次类推,每个文件 300 多 MB
file, err := os.Open(fmt.Sprintf("/home/ayang/Downloads/%d", i))
if err != nil {
panic(err.Error())
}
_, err = io.ReadAll(file)
if err != nil {
panic(err.Error())
}
wg.Done()
}()
}
time.Sleep(time.Second * 1)
printThreadInfo("文件读取中:", "ps", "-T", "-p", strconv.Itoa(os.Getpid()))
wg.Wait()
printThreadInfo("文件读取后:", "ps", "-T", "-p", strconv.Itoa(os.Getpid()))
}
func printThreadInfo(preStr string, name string, arg ...string) {
cmd := exec.Command(name, arg...)
output, err := cmd.Output()
if err != nil {
panic(err.Error())
return
}
fmt.Println(preStr)
fmt.Println(string(output))
}
|
实验结果:
1
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
66
67
68
69
70
71
72
|
初始化 main 时:
PID SPID TTY TIME CMD
194880 194880 pts/1 00:00:00 main
194880 194881 pts/1 00:00:00 main
194880 194882 pts/1 00:00:00 main
194880 194883 pts/1 00:00:00 main
194880 194884 pts/1 00:00:00 main
文件读取中:
PID SPID TTY TIME CMD
194880 194880 pts/1 00:00:00 main
194880 194881 pts/1 00:00:00 main
194880 194882 pts/1 00:00:00 main
194880 194883 pts/1 00:00:00 main
194880 194884 pts/1 00:00:00 main
194880 194889 pts/1 00:00:00 main
194880 194890 pts/1 00:00:00 main
194880 194891 pts/1 00:00:00 main
194880 194892 pts/1 00:00:00 main
194880 194893 pts/1 00:00:00 main
194880 194894 pts/1 00:00:00 main
194880 194895 pts/1 00:00:00 main
194880 194896 pts/1 00:00:00 main
194880 194897 pts/1 00:00:00 main
194880 194898 pts/1 00:00:00 main
194880 194899 pts/1 00:00:00 main
194880 194900 pts/1 00:00:00 main
194880 194901 pts/1 00:00:00 main
194880 194902 pts/1 00:00:00 main
194880 194903 pts/1 00:00:00 main
194880 194904 pts/1 00:00:00 main
194880 194905 pts/1 00:00:00 main
194880 194906 pts/1 00:00:00 main
194880 194907 pts/1 00:00:00 main
文件读取后:
PID SPID TTY TIME CMD
194880 194880 pts/1 00:00:00 main
194880 194881 pts/1 00:00:00 main
194880 194882 pts/1 00:00:01 main
194880 194883 pts/1 00:00:04 main
194880 194884 pts/1 00:00:03 main
194880 194889 pts/1 00:00:06 main
194880 194890 pts/1 00:00:00 main
194880 194891 pts/1 00:00:00 main
194880 194892 pts/1 00:00:01 main
194880 194893 pts/1 00:00:00 main
194880 194894 pts/1 00:00:01 main
194880 194895 pts/1 00:00:04 main
194880 194896 pts/1 00:00:01 main
194880 194897 pts/1 00:00:00 main
194880 194898 pts/1 00:00:02 main
194880 194899 pts/1 00:00:00 main
194880 194900 pts/1 00:00:00 main
194880 194901 pts/1 00:00:01 main
194880 194902 pts/1 00:00:00 main
194880 194903 pts/1 00:00:01 main
194880 194904 pts/1 00:00:01 main
194880 194905 pts/1 00:00:01 main
194880 194906 pts/1 00:00:02 main
194880 194907 pts/1 00:00:05 main
194880 194923 pts/1 00:00:00 main
194880 194924 pts/1 00:00:02 main
194880 194925 pts/1 00:00:00 main
194880 194926 pts/1 00:00:00 main
194880 194927 pts/1 00:00:02 main
194880 194928 pts/1 00:00:00 main
194880 194929 pts/1 00:00:02 main
194880 194930 pts/1 00:00:01 main
194880 194931 pts/1 00:00:02 main
194880 194932 pts/1 00:00:00 main
194880 194933 pts/1 00:00:00 main
|
网络 IO:
1
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
|
func main() {
runtime.GOMAXPROCS(4)
printThreadInfo("初始化 main 时:", "ps", "-T", "-p", strconv.Itoa(os.Getpid()))
for i := 1; i <= 32; i++ {
i := i
go func() {
// 开启 32 个服务器
err := http.ListenAndServe(fmt.Sprintf(":%d", i+65500), nil)
if err != nil {
panic(err.Error())
}
}()
}
time.Sleep(time.Second * 3)
printThreadInfo("开启 32 个服务器后:", "ps", "-T", "-p", strconv.Itoa(os.Getpid()))
var c chan struct{}
<-c
}
func printThreadInfo(preStr string, name string, arg ...string) {
cmd := exec.Command(name, arg...)
output, err := cmd.Output()
if err != nil {
panic(err.Error())
return
}
fmt.Println(preStr)
fmt.Println(string(output))
}
|
实验结果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
初始化 main 时:
PID SPID TTY TIME CMD
223184 223184 pts/1 00:00:00 main
223184 223185 pts/1 00:00:00 main
223184 223186 pts/1 00:00:00 main
223184 223187 pts/1 00:00:00 main
223184 223188 pts/1 00:00:00 main
223184 223189 pts/1 00:00:00 main
开启 32 个服务器后:
PID SPID TTY TIME CMD
223184 223184 pts/1 00:00:00 main
223184 223185 pts/1 00:00:00 main
223184 223186 pts/1 00:00:00 main
223184 223187 pts/1 00:00:00 main
223184 223188 pts/1 00:00:00 main
223184 223189 pts/1 00:00:00 main
223184 223191 pts/1 00:00:00 main
|
结论:磁盘 IO 导致线程阻塞,网络 IO 可以切换协程执行,体现为多网络 IO(请求)并发处理
End