开篇前言
最近的工作中我用到了Netty这个开源库,对于其内部的实现原理有着浓厚的兴趣,于是就想写这个专题来记录对netty的学习过程。我大概的计划就是先写一篇必须要知道的基础知识和netty的简单使用(也就是本篇文章),后面就通过源码分析一下Netty的运行流程,以及Netty各司其职的每个组件(EventLoop、Channel、Pipeline、ChannelHandler和ByteBuf等)的运行机理,最后会剥离出Netty中用到的一些设计模式和学习过程中的一些总结。
一 基础必知之Socket
说起socket大家应该很熟悉,但是内部的机理应该很模糊。Socket可以作插口或者插槽讲,在我们写软件程序时,可以想象为弄一根网线,一头插在客户端,一头插在服务端,然后进行通信,所以在通信之前,双方都要建立一个Socket。大家都知道TCP/IP网络的四层体系和**OSI七层模型中不都没有Socket的影子,下面我用一幅图来表示socket在网络模型中的地位。

Socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口。在设计模式中,Socket其实就是一个门面模式,它把复杂的TCP/IP协议族隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部,让Socket去组织数据,以符合指定的协议。一般由操作系统或者JVM自己实现。 java和.net中的socket其实就是对底层的抽象调用。有一点需要注意,运行在同一主机上的其他应用程序可能也会通过底层套接字抽象来使用网络,因此会与java socket实例竞争资源,如端口。对于套接字结构,是指底层实现(包括JVM和TCP/IP,但通常是后者)的数据结构集,包含了特定Socket所关联的信息。 在网络层,Socket函数需要指定到底是IPv4还是IPv6,分别对应设置为AF_INET和AF_INET6。另外,还要指定到底是TCP还是UDP。还记得咱们前面讲过的,TCP协议是基于数据流的,所以设置为SOCK_STREAM,而UDP是基于数据报的,因而设置为SOCK_DGRAM。下面我们详细介绍一下Socket的函数调用过程。
1.1 Socket基于TCP协议的Socket程序函数调用过程
TCP的服务端要先监听一个端口,一般是先调用bind函数,给这个Socket赋予一个IP地址和端口。 至于为什么需要端口呢?要知道,你写的是一个应用程序,当一个网络包来的时候,内核要通过TCP头里面的这个端口,来找到你这个应用程序,把包给你。为什么要IP地址呢?有时候,一台机器会有多个网卡,也就会有多个IP地址,你可以选择监听所有的网卡,也可以选择监听一个网卡,这样,只有发给这个网卡的包,才会给你。这里我先给一张基于TCP协议的Socket程序函数调用过程的图。

当服务端有了IP和端口号,就可以调用listen函数进行监听。在TCP的状态图里面,有一个listen状态,当调用这个函数之后,服务端就进入了这个状态,这个时候客户端就可以发起连接了。
- 在内核中,为每个Socket维护两个队列。一个是已经建立了连接的队列,这时候连接三次握手已经完毕,处于established状态;一个是还没有完全建立连接的队列,这个时候三次握手还没完成,处于syn_rcvd的状态。
- 接下来,服务端调用accept函数,拿出一个已经完成的连接进行处理。如果还没有完成,就要等着。
- 在服务端等待的时候,客户端可以通过connect函数发起连接。先在参数中指明要连接的IP地址和端口号,然后开始发起三次握手。内核会给客户端分配一个临时的端口。一旦握手成功,服务端的accept就会返回另一个Socket。 在这里注意的是,就是监听的Socket和真正用来传数据的Socket是两个,一个叫作监听Socket,一个叫作已连接Socket。
- 连接建立成功之后,双方开始通过read和write函数来读写数据,就像往一个文件流里面写东西一样。
在这里说TCP的Socket就是一个文件流,是非常准确的。因为,Socket在Linux中就是以文件的形式存在的。除此之外,还存在文件描述符。写入和读出,也是通过文件描述符。 在内核中,Socket是一个文件,那对应就有文件描述符。每一个进程都有一个数据结构task_struct,里面指向一个文件描述符数组,来列出这个进程打开的所有文件的文件描述符。文件描述符是一个整数,是这个数组的下标。这个数组中的内容是一个指针,指向内核中所有打开的文件的列表。既然是一个文件,就会有一个inode,只不过Socket对应的inode不像真正的文件系统一样,保存在硬盘上的,而是在内存中的。在这个inode中,指向了Socket在内核中的Socket结构。在这个结构里面,主要的是两个队列,一个是发送队列,一个是接收队列。在这两个队列里面保存的是一个缓存sk_buff。这个缓存里面能够看到完整的包的结构。整个数据结构可由下面一幅图表示:

1.2 基于UDP协议的Socket程序函数调用过程
对于UDP来讲,过程有些不一样。UDP是没有连接的,所以不需要三次握手,也就不需要调用listen和connect,但是,UDP的的交互仍然需要IP和端口号,因而也需要bind。UDP是没有维护连接状态的,因而不需要每对连接建立一组Socket,而是只要有一个Socket,就能够和多个客户端通信。也正是因为没有连接状态,每次通信的时候,都调用sendto和recvfrom,都可以传入IP地址和端口。下面就用一幅图来表示基于UDP协议的Socket程序函数调用过程。

上面介绍的TCP和UDP的调用方式可以实现一个简单的网络交互的程序了。就像上面的过程一样,在建立连接后,进行一个while循环。客户端发了收,服务端收了发。但是这样的方式基本上只能一对一沟通。如果你是一个服务器,同时只能服务一个客户,肯定是不行的。下面一个小节来看看如何使服务器能建立更多的连接,这里我主要以Linux的Socket为例。
1.3 如何建立更多的Socket连接
在这节开始前,我们先来算一下理论值,也就是最大连接数,系统会用一个四元组({本机IP, 本机端口, 对端IP, 对端端口})来标识一个TCP连接。服务器通常固定在某个本地端口上监听,等待客户端的连接请求。因此,服务端端TCP连接四元组中只有对端IP, 也就是客户端的IP和对端的端口,也即客户端的端口是可变的,因此,最大TCP连接数=客户端IP数×客户端端口数。对IPv4,客户端的IP数最多为2的32次方,客户端的端口数最多为2的16次方,也就是服务端单机最大TCP连接数,约为2的48次方。 上面计算的值时理论上的值,服务端最大并发TCP连接数远不能达到理论上限。首先主要是文件描述符限制,按照上面的原理,Socket都是文件,所以首先要通过ulimit配置文件描述符的数目;另一个限制是内存,按上面的数据结构,每个TCP连接都要占用一定内存,操作系统是有限的。那么怎样才能够去建立更多的连接呢?下面介绍一下系统的做法。
1.3.1 创建子进程
这里的父进程就相当于是一个代理,在那里监听来的请求。一旦建立了一个连接,就会有一个已连接Socket,这时候你可以创建一个子进程,然后将基于已连接Socket的交互交给这个新的子进程来做。在Linux下,创建子进程使用fork函数。通过名字可以看出,这是在父进程的基础上完全拷贝一个子进程。在Linux内核中,会复制文件描述符的列表,也会复制内存空间,还会复制一条记录当前执行到了哪一行程序的进程。显然,复制的时候在调用fork,复制完毕之后,父进程和子进程都会记录当前刚刚执行完fork。这两个进程刚复制完的时候,几乎一模一样,只是根据fork的返回值来区分到底是父进程,还是子进程。如果返回值是0,则是子进程;如果返回值是其他的整数,就是父进程。下面用一幅图来表示进程的复制。

1.3.2 创建线程
上面一种创建子进程的方式,在创建或撤消进程时,由于系统都要为之分配和回收资源,导致系统的开销明显大于创建或撤消线程时的开销。所以使用上面一种方式是非常重量的,相对于创建线程就相对于来说轻量的很。在Linux下,通过pthread_create创建一个线程,也是调用do_fork。不同的是,虽然新的线程在task列表会新创建一项,但是很多资源,例如文件描述符列表、进程空间,还是共享的,只不过多了一个引用而已。由此可看的确节约了很多的开销,下面还是由一幅图来说明吧。

上面两种方式虽然可以避免一对一的通信方式,,其实还是有问题的。新到来一个TCP连接,就需要分配一个进程或者线程。一台机器无法创建很多进程或者线程。有个C10K,它的意思是一台机器要维护1万个连接,就要创建1万个进程或者线程,那么操作系统是无法承受的。那就得购买更多的服务器,这样还是得增加成本。那要还有什么可以优化的呢?
1.3.3 IO多路复用,一个线程维护多个Socket
这里我们将一个进程比作一个项目组,每个线程就是一个项目。一个项目组可以看多个项目了,这样就对应着多线程。这个时候,每个项目组都应该有个项目进度墙,将自己组看的项目列在那里,然后每天通过项目墙看每个项目的进度,一旦某个项目有了进展,就派人去盯一下。由于Socket是文件描述符,因而某个线程盯的所有的Socket,都放在一个文件描述符集合fd_set中,这就是项目进度墙,然后调用select函数来监听文件描述符集合是否有变化。一旦有变化,就会依次查看每个文件描述符。那些发生变化的文件描述符在fd_set对应的位都设为1,表示Socket 可读或者可写,从而可以进行读写操作,然后再调用select,接着盯着下一轮的变化(想到这里是不是想到了上篇我在写HTTP的时候的些WebSocket中的ajax轮询方式)。 上面select函数还是有问题的,因为每次Socket所在的文件描述符集合中有Socket发生变化的时候,都需要通过轮询的方式,也就是需要将全部项目都过一遍的方式来查看进度,这大大影响了一个项目组能够支撑的最大的项目数量。因而使用select,能够同时盯的项目数量由FD_SETSIZE限制。如果改成事件通知的方式,情况就会好很多,项目组不需要通过轮询挨个盯着这些项目,而是当项目进度发生变化的时候,主动通知项目组(其实就是一个观察者模式),然后项目组再根据项目进展情况做相应的操作。在Linux中能完成这件事情的函数叫epoll,它在内核中的实现不是通过轮询的方式,而是通过注册callback函数的方式,当某个文件描述符发送变化的时候,就会主动通知。如下图:

如图,假设进程打开了Socket m, n, x等多个文件描述符,现在需要通过epoll来监听是否这些Socket都有事件发生。其中epoll_create创建一个epoll对象,也是一个文件,也对应一个文件描述符,同样也对应着打开文件列表中的一项。在这项里面有一个红黑树,在红黑树里,要保存这个epoll要监听的所有Socket。当epoll_ctl添加一个Socket的时候,其实是加入这个红黑树,同时红黑树里面的节点指向一个结构,将这个结构挂在被监听的Socket的事件列表中。当一个Socket来了一个事件的时候,可以从这个列表中得到epoll对象,并调用call back通知它。这种通知方式使得监听的Socket数据增加的时候,效率不会大幅度降低,能够同时监听的Socket的数目也非常的多了。上限就为系统定义的、进程打开的最大文件描述符个数。因而,这是一种虽优的解决方式。 这里补充一点,在Windows中与epoll相对应的是一个叫做IOCP的模型,有兴趣可以了解一下。关于socket的知识还有很多,这里就先介绍到这里。
二 Netty内置的传输
上一节我们熟悉了socket,也重点介绍了怎样可以建立更多的Socket连。举得例子是Linux中的epoll,其实在Netty中也充分运用到了Epoll,当Netty的运行环境是Linux的时候,可以建议使用EpollServerSocketChannel.class替代NioServerSocketChannel.class。Netty为Linux提供了一组NIO API,其以一种和它本身的设计更加一致的方式使用epoll,并且以一种更加轻量的方式使用中断。Netty其实大量使用在服务端,而服务端基本上部署在Linux上,这样就可以更好的运用到这一优势。下面我们看看Netty提供的另外几种传输方式。
2.1 OIO-阻塞IO
OIO(OioServerSocketChannel),每个线程只能处理一个channel(同步的,该线程和该channel绑定)。那么OIO如何处理海量连接请求呢?是对每个请求封装成一个request,然后从线程池中挑一个worker线程专门为此请求服务,如果线程池中的线程用完了,就对请求进行排队。请求中如果有读写数据,是会阻塞线程。
Netty的OIO传输实现代表了一种折中:它可以通过常规的传输API使用,但是由于它是建立在java.net包的阻塞实现之上的,所以它不是异步的。但是,它仍然非常适合于某些用途。 例如,你可能需要移植使用了一些进行阻塞调用的库(如JDBC)的遗留代码,而将逻辑转换为非阻塞的可能也是不切实际的。相反,你可以在短期内使用Netty的OIO传输,然后再将你的代码移植到纯粹的异步传输上。让我们来看一看怎么做。 在java.net API中,你通常会有一个用来接受到达正在监听的ServerSocket的新连接的线程。会创建一个新的和远程节点进行交互的套接字,并且会分配一个新的用于处理相应通信流量的线程。这是必需的,因为某个指定套接字上的任何I/O操作在任意的时间点上都可能会阻塞。使用单个线程来处理多个套接字,很容易导致一个套接字上的阻塞操作也捆绑了所有其他的套接字。 有了这个背景,你可能会想,Netty是如何能够使用和用于异步传输相同的AP工来支持OIO的呢。答案就是,Netty利用了SO_ TIMEOUT这个Socket标志,它指定了等待一个工//O操作完成的最大毫秒数。如果操作在指定的时间间隔内没有完成,则将会抛出一个SocketTimeoutException 。Netty将捕获这个异常并继续处理循环。在EventLoop下一次运行时,它将再次尝试。这实际上也是类似于Netty这样的异步框架能够支持OIO的唯一方式。下面用一张图来说明其过程。

2.2 NIO-非阻塞IO
java.nio全称java non-blocking IO(实际上是new io),是指JDK 1.4及以上版本里提供的新api(New IO),为所有的原始类型(boolean类型除外)提供缓存支持的数据容器,使用它可以提供非阻塞式的高伸缩性网络。java原生的NIO有很多的缺点,如下:
- NIO的类库和API还是有点复杂,比如Buffer的使用。
- Selector编写复杂,如果对某个事件注册后,业务代码过于耦合。
- 需要了解很多多线程的知识,熟悉网络编程。
- 面对断连重连、保丢失、粘包等,处理复杂。
- 而且NIO存在epoll的空轮询bug,selector空轮训导致CPU飙升至100%,直到jdk1.7都没解决这个bug,只是降低了出现的概率。
有了上面的缺点,其实用原生的NIO编程是很不理想的,所以Netty对NIO进行了封装,并且还修复了空轮询的bug。在Netty中NIO提供了一个所有I/O操作的全异步的实现。它利用了自NIO子系统被引入JDK1.4时便可用的基于选择器的API。 选择器背后的基本概念是充当一个注册表,在那里你将可以请求在Channel的状态发生变化时得到通知。可能的状态变化有:
- 新的Channel已被接受并且就绪;
- Channel连接已经完成;
- Channel有已经就绪的可供读取的数据;
- Channel可用于写数据。
选择器运行在一个检查状态变化并对其做出相应响应的线程上,在应用程序对状态的改变做出响应之后,选择器将会被重置,并将重复这个过程。Netty应用程序正在请求通知的状态变化集由位模式组合而成。下图是所有的常量值代表定义的位模式。

对于所有Netty的传输实现都共有的用户级别API完全地隐藏了这些NIO的内部细节。下面用 《netty实战》 中的一幅图来该处理流程。

其实Netty中的NIO和上面的epoll实现的模式差不多,只是一个是Linux的内置网络编程框架,另一个是java级别的实现。他们两个的共同特征都是实现了零拷贝,什么是零拷贝呢?具体的内部实现原理这里就不做过多介绍,下面只是给个概念吧。
零拷贝(zero-copy)是一种目前只有在使用 NIO 和 Epoll 传输时才可使用的特性。它使你可以快速高效地将数据从文件系统移动到网络接口,而不需要将其从内核空间复制到用户空间,其在像 FTP 或者HTTP 这样的协议中可以显著地提升性能。但是,并不是所有的操作系统都支持这一特性。特别地,它对于实现了数据加密或者压缩的文件系统是不可用的——只能传输文件的原始内容。反过来说,传输已被加密的文件则不是问题。
Netty的内置传输还有用于JVM内部通信的Local传输和Embedded传输。Local传输用于在同一个JVM中运行的客户端和服务器程序之间的异步通信。Embedded传输的关键是一个被称为EmbeddedChannel的具体的Channel实现。在这里就不过多介绍了。
三Netty的简单使用
在这章中我打算用传统的socket编程和netty之间的对比来说明netty的简单使用。首先我先来看看传统的Socket的编程。
3.1传统Socket编程
public class Server {
private ServerSocket serverSocket;
public Server(int port) {
try {
this.serverSocket = new ServerSocket(port);
System.out.println("服务端启动成功,端口:" + port);
} catch (IOException exception) {
System.out.println("服务端启动失败");
}
}
public void start() {
new Thread(new Runnable() {
@Override
public void run() {
doStart();
}
}).start();
}
private void doStart() {
while (true) {
try {
Socket client = serverSocket.accept();
new ServerHandler(client).start();
} catch (IOException e) {
System.out.println("服务端异常");
}
}
}
}
上面是一个服务端监听程序,下面再建立一个处理客服端传来的数据的Handler,如下:
public class ServerHandler {
public static final int MAX_DATA_LEN = 1024;
private final Socket socket;
public ServerHandler(Socket socket) {
this.socket = socket;
}
public void start() {
System.out.println("新客户端接入");
new Thread(new Runnable() {
@Override
public void run() {
doStart();
}
}).start();
}
private void doStart() {
try {
InputStream inputStream = socket.getInputStream();
while (true) {
byte[] data = new byte[MAX_DATA_LEN];
int len;
while ((len = inputStream.read(data)) != -1) {
String message = new String(data, 0, len);
System.out.println("客户端传来消息: " + message);
socket.getOutputStream().write(data);
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
下面我们需要一个启动的入口,ServerBoot如下:
public class ServerBoot {
private static final int PORT = 8000;
public static void main(String[] args) {
Server server = new Server(PORT);
server.start();
}
}
最后我们再添加一个客户端的代码。
public class Client {
private static final String HOST = "127.0.0.1";
private static final int PORT = 8000;
private static final int SLEEP_TIME = 5000;
public static void main(String[] args) throws IOException {
final Socket socket = new Socket(HOST, PORT);
new Thread(new Runnable() {
@Override
public void run() {
System.out.println("客户端启动成功!");
while (true) {
try {
String message = "hello world";
System.out.println("客户端发送数据: " + message);
socket.getOutputStream().write(message.getBytes());
} catch (Exception e) {
System.out.println("写数据出错!");
}
sleep();
}
}
}).start();
}
private static void sleep() {
try {
Thread.sleep(SLEEP_TIME);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
上面写的是一个传统的Socket网络程序。
3.2Netty简单实现
下面我们看看Netty是咋个实现的这样的流程的,首先需要一个Handler。
public class ServerHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelActive(ChannelHandlerContext ctx) {
System.out.println("channelActive");
}
@Override
public void channelRegistered(ChannelHandlerContext ctx) {
System.out.println("channelRegistered");
}
@Override
public void handlerAdded(ChannelHandlerContext ctx) {
System.out.println("handlerAdded");
}
@Override
public void channelRead(final ChannelHandlerContext ctx, Object msg) throws Exception {
super.channelRead(ctx, msg);
new Thread(new Runnable() {
@Override
public void run() {
// 耗时的操作
String result = loadFromDB();
ctx.channel().writeAndFlush(result);
ctx.executor().schedule(new Runnable() {
@Override
public void run() {
// ...
}
}, 1, TimeUnit.SECONDS);
}
}).start();
}
private String loadFromDB() {
return "hello world!";
}
}
接着看看具体的实现吧。
public final class NettyServer {
public static void main(String[] args) throws Exception {
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childOption(ChannelOption.TCP_NODELAY, true)
.childAttr(AttributeKey.newInstance("childAttr"), "childAttrValue")
.handler(new ServerHandler())
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) {
ch.pipeline().addLast(new AuthHandler());
//..
}
});
ChannelFuture f = b.bind(8888).sync();
f.channel().closeFuture().sync();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}
下面我们开始分析Netty中这段代码,
- 这里的bossGroup与上面传统Socket中对应的是Service中的start()方法中的Thread。workerGroup与上面传统Socket中对应的是Client中main方法中的Thread。
- 接着创建了一个ServerBootstrap实例。将两个EventLoop配置进去,这里使用的是NIO传输,所以将服务端的Channel的类型指定为NioServerSocketChannel 。
- 接下来是调用childOption这里的主要用途是给每个客户端的连接指定一些TCP的配置。
- 接下来是调用childAttr,主要是在创建连接的时候绑定一些基本属性。
- 然后下面是设置一个Handler,我在这里设置的是一个ServerHandler,他与第一节Socket编程中Server的 doStart() 方法相对应,后面的文章中我会详细介绍。
- 紧接着是设置一个childHandler,里面主要是在initChannel()方法中添加一些Handler来处理接收到的数据。这里与第一节Socket编程Client中Thread的run方法中的逻辑代码相对应,主要是进行一些数据流的处理,后面的文章我也会详细介绍。
- 最后的两步主要是,绑定了服务器 ,并等待绑定完成。(对sync()方法的调用将导致当前Thread阻s塞,一直到绑定操作完成为止)。 该应用程序将会阻塞等待直到服务器的Channel关闭(因为在Channel的CloseFuture上调用了sync()方法)。然后,你将可以关闭EventLoopGroup,并释放所有的资源,包括所有被创建的线程。
上面我只是简单的把Netty的运行流程和传统的Socket之间做了对应关系,其实他们的底层实现是完全不同的。后面的文章我会对Netty的每个组件进行源码分析,到时候就可以理解Netty的内部实现机理。这是本系列的第一篇文章,后面一个月我会持续更新Netty的系列文章。
参考资料
《Netty实战》 《Linux网络编程》
原文地址(我的个人博客):Netty学习系列(一)-基础必知