阻塞 I/O 模型是对开发人员最友好的,也是心智负担最低的模型,而 I/O 多路复用的这种通过回调割裂执行流的模型,对开发人员来说还是过于复杂了,于是 Go 选择了为开发人员提供阻塞 I/O 模型,Gopher 只需在 Goroutine 中以最简单、最易用的“阻塞 I/O 模型”的方式,进行 Socket 操作就可以了。
网络 I/O 操作都是系统调用,Goroutine 执行 I/O 操作的话,一旦阻塞在系统调用上,就会导致 M 也被阻塞,为了解决这个问题,Go 设计者将这个“复杂性”隐藏在 Go 运行时中,他们在运行时中实现了网络轮询器(netpoller),netpoller 的作用,就是只阻塞执行网络 I/O 操作的 Goroutine,但不阻塞执行 Goroutine 的线程(也就是 M)。
比如:当用户层针对某个 Socket 描述符发起read操作时,如果这个 Socket 对应的连接上还没有数据,运行时就会将这个 Socket 描述符加入到 netpoller 中监听,同时发起此次读操作的 Goroutine 会被挂起。直到 Go 运行时收到这个 Socket 数据可读的通知,Go 运行时才会重新唤醒等待在这个 Socket 上准备读数据的那个 Goroutine。而这个过程,从 Goroutine 的视角来看,就像是 read 操作一直阻塞在那个 Socket 描述符上一样。
而且,Go 语言在网络轮询器(netpoller)中采用了 I/O 多路复用的模型。考虑到最常见的多路复用系统调用 select 有比较多的限制,比如:监听 Socket 的数量有上限(1024)、时间复杂度高,等等,Go 运行时选择了在不同操作系统上,使用操作系统各自实现的高性能多路复用函数,比如:Linux 上的 epoll、Windows 上的 iocp、FreeBSD/MacOS 上的 kqueue、Solaris 上的 event port 等,这样可以最大程度提高 netpoller 的调度和执行性能。

Listen 底层实现

Go中的Listen函数会做如下事情:

  1. 创建socket并设置非阻塞
  2. bind绑定并监听本地的一个端口
  3. 调用listen开始监听
  4. epoll_create创建一个epoll对象
  5. epoll_etl将listen的socket添加到epoll中等待连接到来

    Accept 底层实现

    Go中的Accept函数主要做如下事情:

  6. 调用accept系统调用接收一个连接

  7. 如果连接未到达,阻塞当前协程
  8. 新连接到达后,会加入到epoll中管理并返回

    Read 内部实现

    Write 内部实现

    并发 Socket 读写

    对于 Read 操作而言,由于 TCP 是面向字节流,conn.Read无法正确区分数据的业务边界,因此,多个 Goroutine 对同一个 conn 进行 read 的意义不大,Goroutine 读到不完整的业务包,反倒增加了业务处理的难度。
    每次 Write 操作都是受 lock 保护,直到这次数据全部写完才会解锁。因此,在应用层面,要想保证多个 Goroutine 在一个conn上 write 操作是安全的,需要一次 write 操作完整地写入一个“业务包”。
    同时,我们也可以看出即便是 Read 操作,也是有 lock 保护的。多个 Goroutine 对同一conn的并发读,不会出现读出内容重叠的情况,但就像前面讲并发读的必要性时说的那样,一旦采用了不恰当长度的切片作为 buf,很可能读出不完整的业务包,这反倒会带来业务上的处理难度。