非阻塞 IO (goroutine是怎样工作的)

July 1, 2020

有两种流行的编程模型来设计并发的程序,线程或者事件驱动(Event driven)。两种方式都能处理大量的并发请求,不过各有各的优缺点,使用线程就需要考虑线程同步、资源占用等问题,使用事件循环就不好利用多核的CPU。

并发编程要处理的最频繁的操作就是I/O,对一个需要处理大量并发的程序来说,它肯定是一个I/O密集型的程序,而不是计算密集型的。而I/O的操作又分阻塞和非阻塞两种模式。

Go作为一个为并发而设计的语言,通过goroutine在语言层面提供了并发编程的支持,所以Go里面的I/O到底是阻塞还是非阻塞的呢?

要理解这个问题,首先需要弄清楚的是阻塞和非阻塞的含义。对一个I/O操作来说,因为它依赖程序外部的状态(比如网络的情况),调用一个阻塞的I/O意味者函数可能会一直阻塞住,直到操作完成。而非阻塞会在无法完成操作时,则会立即返回一个错误码。非阻塞的一大优势是它可以和多路复用函数(epoll,kqueue)一起使用,从而达到高并发的效果。

Go提供的接口里,所有的I/O操作都是阻塞的,当然这个阻塞不是真的阻塞系统线程,而只是阻塞住了正在执行I/O操作的goroutine,在背后Go实际上是通过操作系统提供的非阻塞I/O来实现这一切,把非阻塞转换成阻塞的目的就是语言设计的一个特性,从而使得处理并发的代码写起来和读起来都非常的直观。

Go里面把阻塞调用转换成非阻塞调用的系统叫做netpoller, 当一个goroutine尝试读或者写一个文件描述符,并且获取到错误码提示操作暂时还不能完成的时候,就会调用netpoller,注册I/O事件,调度goroutine,让出线程。netpoller系统最开始是为了网络I/O设计的,后来也添加了文件I/O的支持

所以这样是不是说Go里面所有的I/O操作本质上都是非阻塞的呢?答案是:不是,因为文件I/O里面最常见的磁盘I/O,是没有非阻塞的概念

这是一个操作系统本身的限制, 在Unix操作系统里面,内核提供系统调用(System call)来提供服务,这其中就包括包括磁盘和网络I/O。系统调用可以根据功能能划分,有负责I/O操作的,有负责内存管理的,也有负责进程调度的。但是其实还有另外一种划分方式,那就是:“慢”的系统调用,和其他系统调用。慢的系统调用是指,调用它们可能会导致程序永远阻塞住。 在《Unix环境高级编程》里,作者有提到,这些“慢”的系统调用包括:

  • 读(Read)会永远阻塞调用者,如果某些文件类型(管道,终端设备,网络设备)的数据还没有好
  • 写(Write)会永远阻塞调用者,如果数据没法被上面同样类型的文件立即接受(管道没有空间了,网络流量控制等等)
  • 打开(Open)会阻塞住直到一些条件满足在某些文件类型上
  • 读和写带有强制锁(Mandatory lock)的文件
  • 一些ioctl操作
  • 一些进程间通信函数

而书里也有提到,磁盘I/O是不被当成“慢”的系统调用的,尽管读写磁盘可能会暂时的阻塞一会,但是磁盘文件不像网络或者管道的文件描述符一样,它永远是可读和可写的。当你把一个普通文件的描述符加到select里面时,会发现它永远是立即可读和可写的,而epoll会直接报错,不允许添加一个普通文件的描述符。

所以在Go里,当进行磁盘I/O操作时,不能只是挂起goroutine,而是需要像普通系统调用一样,挂起一个线程,来等待调用的完成,当然这一切对Go的使用者是不可见的,所有的I/O操作看起来都是阻塞的,所以它才是一个为并发设计的语言,你不需要关心线程的创建和销毁,也不需要关心事件驱动里的回调函数,通过goroutine,可以写出更加容易理解的代码,同时达到一样的高并发的效果。

磁盘I/O虽然不支持非阻塞的模式,但是可以通过AIO或者Linux内核最新的io_uring来实现异步的I/O操作。Go社区也开始了怎样支持新的的io_uring的讨论。得益于新的内核技术,应该在不久的将来能看到更好的关于磁盘读写的性能提升。

comments powered by Disqus