0%

NIO——及其在Golang网络库中的应用

NIO(Non-blocking I/O),是一种同步非阻塞的I/O模型,也是I/O多路复用的基础,是现今主流的大流量、高并发IO有效解决方案。

五种IO模型

在UNIX下,IO模式分为五类,分别是:阻塞式IO(bloking IO)、非阻塞式IO(non-blocking IO)、多路复用IO模型(multiplexing IO)、信号驱动IO模型(signal-driven IO)以及异步IO模型(asynchronous IO)。其中又以前三种模式最为常见。

5种IO模型对比图

传统的BIO模式

在阐述选择NIO的原因之前,首先说明一下阻塞和非阻塞的概念。阻塞和非阻塞的核心区别就在于,在IO就绪态(读就绪、写就绪、有新连接)到来之前是否会阻塞等待。

在最初的网络编程中,我们使用BIO模式构建编程模型,如下面的伪代码所示,这是经典的per thread per connection模型。这段代码的核心部分在于accept()、socket.read()、socket.write()三个函数,这三个函数在等待IO就绪态到来的过程中都将阻塞各自的线程。当连接数量达到一定程度之后,这样的阻塞、对线程资源的无效占用就变得不可容忍。后续的优化包括建立线程池,进行线程扩容,但这并没有根本解决问题。而NIO+多路复用的网络模型很好的解决了这个问题。

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
//BIO JAVA伪代码示意
class Server {
public static void main() {
while(true){
socket = server.accept();
executor.submit(new ConnectIOHandler(socket));
}
}
}

class ConnectIOnHandler implements Runnable{
private Socket socket;
public ConnectIOnHandler(Socket socket){
this.socket = socket;
}

@Override
public void run() {
while (!Thread.currentThread.isInturruted() && socket.isClosed()) {
//读取数据
String data = socket.read()....
if (data != null) {
//处理数据
dosomething();
//写数据
socket.write()...
}
}
}

NIO + multiplexing IO

现今的高性能网络库基本采用了NIO+多路复用的模式构建,例如著名的netty,那么这是为什么呢?我们都知道,在网络IO最耗时的部分就在于等待IO就绪的过程,而真正的IO操作是一个高性能的过程,而NIO有一个重要的特点:socket的主要读、写、注册和接收函数,在等待就绪态前都是非阻塞的,只有在进行真正的IO操作时是同步阻塞的。结合多路复用带来的事件通知特性,就可以构建一套更高性能的网络模型。

读到这里你可能会有疑问,为什么是NIO + multiplexing IO,而不是BIO + multiplexing IO呢?以Linux下的Epoll举例,我们向Epoll的selector中注册一个socket并标示可读事件,当epoll_wait返回可读事件EPOLLIN到来,我们只知道socket可读,但不会知道有多少的数据可读,如果我们多次调用read函数将可能导致阻塞事件发生,所以如果是BIO+ multiplexing IO,我们必须每次read过后就马上返回epoll_wait,这种要求是苛刻的,在某些业务场景下也是不允许的,所以在实际的应用中,BIO + multiplexing IO的组合几乎不会出现。

golang的NIO

这是一个典型的Golang TCP Server示例

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
package main

import (
"fmt"
"net"
)

func main() {
listen, err := net.Listen("tcp", ":8888")
if err != nil {
fmt.Println("listen error: ", err)
return
}

for {
conn, err := listen.Accept()
if err != nil {
fmt.Println("accept error: ", err)
break
}

// start a new goroutine to handle the new connection
go HandleConn(conn)
}
}
func HandleConn(conn net.Conn) {
defer conn.Close()
packet := make([]byte, 1024)
for {
// 如果没有可读数据,也就是读 buffer 为空,则阻塞
_, _ = conn.Read(packet)
// 同理,不可写则阻塞
_, _ = conn.Write(packet)
}
}

这段代码看起来和上文中的JAVA BIO代码很类似。那么这段Go代码里就绪态等待也会阻塞线程么?答案是并不会。
相比与java,golang应用直接调用的是更为轻量级的协程goroutine,当socket在进行就绪态等待的时候,会阻塞协程,但是并不会阻塞线程。
同时,golang的原生网络库底层同样实现了一套NIO + multiplexing IO的网络模型(netpoll),我们以Linux环境举例,在Linux下,netpoll的底层实现是Epoll, 我们的连接套接字被创建后会被设置为NO-BLOCK模式,而后加入到Epoll中进行监听,当读写就绪态到来之后,套接字阻塞的goroutine会被加入到可运行队列中,等待golang调度器的调度运行。

欢迎关注我的其它发布渠道