你想过这个问题吗?Go 程序到底是哪种场景最适合。可能你已经听说过答案了:IO 密集型的场景。
计算机程序场景会分成 cpu 密集型 和 IO 密集型。CPU 密集型说的是一直在运算 CPU 指令,这种常见于算法运行喽。IO 密集型说的是经常下发 IO ,比如网卡,磁盘,或者其他外设。
你肯定记住了答案:Go 适合 IO 密集型的场景。但其实这里并不准确。更准确的是 Go 适合的是网络 IO 密集型的场景,而非磁盘 IO 密集型。甚至可以说,Go 对于磁盘 IO 密集型并不友好。
(资料图片)
根本原因:在于网络 socket 句柄和文件句柄的不同。网络 IO 能够用异步化的事件驱动的方式来管理,磁盘 IO 则不行。这个在我之前 Linux 句柄系列也详细提过这个。
socket 句柄可读可写事件都有意义,socket buffer 里有数据,说明对端网络发数据过来了,即满足可读事件。有 buffer 可以写,那么说明还能发送数据,满足可写事件。
所以 socket 的句柄实现了 .poll方法,可以用 epoll 池来管理。文件句柄可读可写事件则没有意义,因为文件句柄理论上是永远都是可读可写的,不会阻塞调用。
所以文件的 .poll一般是不实现的,所以自然也用不了 epoll 池来管理。而能否用 epoll 池来管理 fd 则决定了能否在 Go 里用 epoll 池 IO 复用的形式来实现 IO 并发。
socket 句柄可以设置为 noblocking (非阻塞的方式),这样当网络 IO 还未就绪的时候就可以在 Go 代码里把调度权切走,去执行其他协程,这样就实现了网络 IO 的并发。
但是磁盘 IO 则不行,文件 IO 的 read/write 都是同步的 IO ,没有实现 .poll所以也用不了 epoll 池来监控读写事件。所以磁盘 IO 的完成只能同步等待。
然而磁盘 IO 的等待则会带来 Go 最不能容忍的事情:卡线程。接下来就来看看磁盘 IO 的 read/write 等系统调用的原理。
Go 的代码执行者是系统线程,也就是 G-M-P 模型的 M ,M 不断的从队列 P 中取 G(协程任务)出来执行。当 G 出现等待事件的时候(比如网络 IO),那么立马切走,取下一个执行。这样让 M 一直不停的满载,就能保证 Go 协程任务的高吞吐。
那么问题来了,如果某个 G 卡线程了,就相当于这个 M 被废了,吞吐能力就下降。如果 M 全卡住了那相当于整个程序卡死了。这个是 Go 绝对无法容忍的。
然而对于类似系统调用这种卡线程却是无法人为控制的。Go runtime 为了解决这个问题,就只能创建更多的线程来保证一直有可运行的 M 。
所以,你经常会发现,当系统调用很慢的时候,M 的数量会变多,甚至会暴涨。曾经,奇伢就遇到过,磁盘大量随机读,并且压力过载的情况,Go 程序线程数持续上涨,最终超过 1 万个被 panic 了。下面来看一下 Go 怎么处理这种系统调用的?
当文件句柄 read/write 的时候,走系统调用回上下包装两个函数:entersyscall,exitsyscall :
entersyscall//系统调用read/writeexitssyscall其中,这两个调用都是为了和 Go runtime 的调度逻辑做交互,协商解决。
entersyscall 的作用:
把当前 M 的 P 设置为 _Psyscall状态,打上标识解绑 P -> M 的绑定,但 M 还保留 P 的指针。
existsyscall 的作用:
由于 M 到 P 的指向还在,那么优先还是用原来的 P如果原来的 P 被处理掉了,那么就去用一个新的 P ,如果还没有,那就把只能挂到全局队列了。
所以,你会发现,这里最重要的就是一个状态的标记。Go 的 sysmon(内部监控线程)发现有这种卡了超过 10 ms 的 M ,那么就会把 P 剥离出来,给到其他的 M 去处理执行,M 数量不够就会新创建。
系统调用的逻辑是属于 Go 程序外部代码,Go 用 entersyscall 和 exitsyscall 来包装一下,主要是和调度交互。
思考一下,cgo 好像也是外部代码,它又是怎么解决阻塞可能导致的问题呢?
也是用的 entersyscall 和 exitssyscall 来配合哦。