网络基础
网络编程基础
Linux 的内核将所有外部设备都看做一个文件来操作,对一个文件的读写操作会调用内核
提供的系统命令,返回一个file descriptor (fd,文件描述符)
。而对一个 socket 的读写也会有相应的描述符,称为 socketfd (socket 描述符)
。描述符就是一个数字,它指向内核中的一个结构体。
无论是 Java 的 IO 库还是 Netty 等网络框架,最终都是在调用操作系统底层的函数。
Linux 网络编程提供了如下几个函数:
socket 函数
socket 函数用于创建一个套接字,执行成功返回一个 socketfd。
/** * 创建一个 socket * * @param domain 告诉系统使用哪个底层协议族,IPv4还是IPv6 * @param type 指定服务类型 * @param protocal 一般默认为 0 * @return 函数执行成功返回一个socket文件描述符,失败返回-1 */int socket(int domain, int type, int protocal)
bind 函数
socket 函数指定了协议类型以及服务类型,但是没有指定具体的 socket 地址。bind 函数用于给 scoket 函数创建的套接字 socket 绑定地址。
/** * 绑定 socket 地址 * * @param sockfd socket文件描述符,socket 函数返回的值 * @param my_addr socket地址 * @param addrlen socket地址的长度 * @return 函数执行成功返回0,失败返回-1 */int bind(int sockfd, const stuct sockaddr *my_addr, socklen_t addrlen);
listen 函数
listen 函数开始监听 socket,等待客户端来连接。sockfd 指定被监听的 socket;listen 函数会创建一个监听队列以存放待处理的客户端连接,backlog 指定队列的大小。
/** * 监听 socket * * @param sockfd socket文件描述符 * @param backlog 提示内核监听队列的最大长度 * @return 函数执行成功返回0,失败返回-1 */int listen(int sockfd, int backlog);
accept 函数
调用 accept 函数来建立与客户端的连接,accept 会返回一个新的已连接的 scocket,后续双方可以利用这个 socket 进行通信。
/** * 接受连接 * * @param sockfd 上述listen函数指定的监听socket * @param addr 请求连接方(即客户端)地址 * @param addrlen 客户端地址长度 * @return 函数执行成功返回一个新的连接 socket,失败返回-1 */int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
connect 函数
客户端调用 socket 函数创建套接字 socket,然后利用 connect 函数来连接服务端。
/** * 建立连接 * * @param sockfd socket函数返回一个socket * @param server_addr 服务端地址 * @param addrlen 服务端地址地址长度 * @return 函数执行成功返回0,失败返回-1 */int connect(int sockfd, const struct sockaddr *server_addr, socklen_t *addrlen);
close 函数
关闭已创建的套接字 socket。
/** * 关闭连接 * * @param sockfd socket函数返回一个socket * @return 函数执行成功返回0,失败返回-1 */int close(int sockfd);
套接字函数交互
下面看下网络编程中的函数是如何在客户端-服务端通信中工作的。
TCP 服务器通过 scoket() 创建一个套接字,调用 bind() 绑定地址。
调用 listen() 开始监听,listen 函数会创建两个队列:SYN 半连接队列,ACCEPT 全连接队列。listen 函数的参数 backlog
就是指定 ACCEPT 队列的大小。
TCP 客户端通过 scoket() 创建一个套接字,接着调用 connect() 连接服务端,通过TCP三次握手与服务端建立连接。第一次握手的连接为半连接,放入 SYN 队列,第三次握手已建立好连接,从 SYN 队列移到 ACCEPT 队列。
服务端调用 accept() 函数阻塞等待 ACCEPT 队列的连接,然后就可以通过这个已建立好连接的套接字 socket 开始与客户端交互数据。
TCP 三次握手
再熟悉下TCP三次握手的机制:
主动打开连接的客户端结束 CLOSED 阶段,被动打开的服务器端也结束 CLOSED 阶段,并进入 LISTEN 阶段,随后开始“三次握手”。
首先客户端向服务器端发送一段TCP报文:SYN=1, seq=x
,随后客户端进入 SYN-SENT 阶段。
服务器端接收到来自客户端的TCP报文之后,返回一段TCP报文:SYN=1,ACK=1,seq=y,ack=x+1
,随后服务端进入 SYN-RCVD 阶段。这个阶段的连接为半连接状态,会放入 SYN
队列中。
客户端接收到来自服务器端的确认报文之后,明确了从客户端到服务器的数据传输是正常的,结束 SYN-SENT 阶段,并返回最后一段TCP报文:ACK=1,seq=x+1,ack=y+1
,随后客户端进入 ESTABLISHED 阶段。
服务器接收到来自客户端的确认报文之后,明确了从服务器到客户端的数据传输是正常的,结束 SYN-RCVD 阶段,进入 ESTABLISHED 阶段。这个阶段的连接为全连接状态,会从 SYN
队列转移到 ACCEPT
队列中。
队列 SYN(半连接)
的大小通过系统参数 /proc/sys/net/ipv4/tcp_max_syn_backlog
来设置,默认值为 512。 有一种网络攻击 TCP SYN Flooding
就是通过建立大量半连接状态的请求,然后丢弃,导致 SYN 队列不能保存其它正常请求来达到攻击服务器的目的。
队列 ACCEPT(全连接)
的大小通过系统参数 /proc/sys/net/core/somaxconn
来设置,取 somaxconn
与 backlog
中的较小值。如果 ACCEPT 队列满了,服务端将发送一个 ECONNREFUSED - Connection refused
错误信息给客户端,表示拒绝连接。
Client/Server模型
网络编程的基本模型是 Client/Server 模型,也就是两个进程之间进行互相通信。其中服务端提供地址信息(IP和端口),客户端通过三次握手与服务端建立连接,连接建立成功,双方就可以通过网络套接字(Socket)进行通信。
在传统的阻塞IO模型中,ServerSocket
负责绑定IP地址,启动监听端口,Socket
发起连接操作。通过下图来看看 Client/Server 的通信流程。
首先在网络服务端一般会有一个专门的线程来轮询监听服务器的特定端口,负责与客户端建立TCP网络连接。
在BIO编程中,就是使用 ServerSocket
组件,它的底层其实就是在调用底层操作系统的 bind
函数来绑定地址和端口,然后调用 listen
函数开始监听TCP连接。
接着客户端创建套接字 Socket,发起连接请求,与服务端通过三次握手建立TCP连接。
客户端发送请求,ServerSocket 通过 accept
方法(底层调用操作系统 accept 函数)获取连接,得到套接字 Socket
,之后服务端和客户端通过 Socket 进行网络通信。
与客户端建立 Socket 通信后,一般会分派给另一个工作线程去处理请求,避免阻塞 ServerSocket 监听客户端。
工作线程开始从 Socket 读取数据,处理客户端请求,最后再通过 Socket 返回处理结果给客户端。
内核态与用户态
在 Linux 操作系统体系中,进程被分为2种类型,一种是操作系统自身运行的内核类进程,也被称为操作系统进程;另一种是运行在操作系统提供的能力之上的一种用户自定义的程序,称之为用户类进程。操作系统为了保护系统不被应用程序破坏,为操作系统设置了用户态
和内核态
两种状态。
操作系统的工作是管理CPU、内存、硬盘、网络设备、输入输出等设备。内核态中运行的程序可以调度CPU、分配内存回收内存、接受键鼠的中断信号等。
用户态中运行的程序很多涉及到硬件的操作都无法执行,一些容易发生安全问题的操作都被限制在只有内核态才可以执行,例如 I/O 操作,修改基址寄存器内容等。用户态想要获取系统资源(例如访问硬盘、网络IO),必须通过系统调用进入到内核态,由内核态获取到系统资源,再切换回用户态返回应用程序,这个过程就会涉及用户态和内核态的切换。
I/O 基础
网络I/O模型
根据 UNIX 网络编程对 I/O 模型的分类,UNIX 提供了5种I/O模型。
阻塞I/O模型
线程发起IO请求后,一直阻塞IO,直到缓冲区数据就绪后,再进入下一步操作。针对网络通信都是一请求一应答的方式。例如在用户态调用 recvfrom
函数,会一直阻塞直到数据包到达且被复制到用户进程的缓冲区才返回,在此期间会一直阻塞等待。
非阻塞I/O模型
应用进程发起系统调用,如果内核没有准备好数据,不阻塞立刻返回一个错误码,返回后应用进程可以做其他事情。一般应用进程需要不断发起系统调用,轮询检查这个状态看内核数据是否准备好了。
非阻塞IO仅针对数据未就绪时是非阻塞的,在数据拷贝过程还是阻塞的。
I/O复用模型
Linux 提供了 select/poll/epoll
, 进程通过将一个或多个fd
传递给 select/poll/epoll
系统调用,然后阻塞,其中任一个fd就绪就可以返回。
阻塞IO和非阻塞IO,如果要对多个fd进行监听,则需要同时开启多个线程。通过 select/poll/epoll 可以使单个线程具备监听多个连接的能力。
信号驱动I/O模型
应用程序通过为 SIGIO 信号注册一个信号关联函数监听fd,调用注册后应用程序可立即返回继续执行。当fd就绪时,通过产生 SIGIO 信号发起对应用程序信号关联函数的调用。
信号驱动IO产生信号后,应用程序仍然需要阻塞等待数据读取到用户空间。
异步I/O
异步IO模式下,应用程序触发系统调用后可立即返回,内核在数据拷贝完成后再对应用程序发出信号,触发应用程序逻辑。
异步IO数据拷贝的过程也是由CPU进行的,直到拷贝完成才通知应用程序,做到全程非阻塞。
I/O多路复用技术
当需要同时处理多个客户端的连接时,可以利用多线程或者I/O多路复用技术来处理。多线程在性能和扩展性上非常有限,IO多路复用技术则可以把多个IO阻塞复用到同一个阻塞上,从而使得系统在单线程的情况下可以同时处理多个客户端请求。
目前支持IO多路复用的系统调用有 select、pselect、poll、epoll
,epoll 已经成为了目前实现高性能网络服务器的必备技术,原因如下:
select 支持打开的fd数量有限,epoll 所支持的fd上限是操作系统的最大文件句柄数。
select/poll 是顺序扫描fd是否就绪,性能会随着fd数量增加而线性下降;epoll
使用基于事件驱动方式代替顺序扫描,当有fd就绪时,就立即回调函数,因此性能更高。
fd就绪后,需要把内核态数据传递到用户态,select/poll 都需要把数据从内核态拷贝到用户态;epoll 则通过内核和用户空间 mmap
同一块内存,使得这块物理内存对内核和用户均可见,减少用户态和内核态之间的数据交换。
Java IO 方式
Java IO 方式有很多种,基于不同的 IO 抽象模型和交互方式,可以简单区分为 BIO、NIO、AIO。
BIO
BIO 全称 Blocking IO
,是JDK1.4之前的传统IO模型,本身是同步阻塞
模式。
BIO 的抽象位于 java.io
包,它基于流模型实现,提供了我们最熟知的一些 IO 功能,比如 File 抽象、输入输出流等。很多时候,java.net
下面提供的部分网络 API,比如 Socket、ServerSocket、HttpURLConnection 等也归类到同步阻塞 IO 类库,因为网络通信同样是 IO 行为。
BIO 的优点是代码比较简单、直观,简化了上层的应用开发,缺点则是 IO 效率和扩展性存在局限性,容易成为应用性能的瓶颈。
下面使用 java.io
和 java.net
中的同步、阻塞式 API,实现一个简单的客户端服务端间网络通信。
Socket 服务端
package com.lyyzoo.netty.bio;import java.io.IOException;import java.io.InputStreamReader;import java.io.OutputStream;import java.net.ServerSocket;import java.net.Socket;import java.util.concurrent.ExecutorService;import java.util.concurrent.Executors;public class BioServer { private final ExecutorService executor = Executors.newFixedThreadPool(8); public void start(int port) throws IOException { // 监听端口 ServerSocket serverSocket = new ServerSocket(port); // 轮询等待客户端的请求 while (true) { // 一直阻塞,等待客户端来建立连接,这个 socket 就代表了某个客户端的一个TCP连接 Socket socket = serverSocket.accept(); // 利用线程池提升并发能力 executor.submit(new RequestHandler(socket)); } } /** * 请求处理器 */ static class RequestHandler implements Runnable { private final Socket socket; public RequestHandler(Socket socket) { this.socket = socket; } @Override public void run() { try { // 建立连接后,就可以开始交互数据了,先获取输入/输出流 InputStreamReader in = new InputStreamReader(socket.getInputStream()); OutputStream out = socket.getOutputStream(); // 读取客户端数据,处理客户端请求 char[] buf = new char[1024]; // 缓冲数组 int len; while ((len = in.read(buf, 0, 1024)) != -1) { System.out.println("客户端数据: " + new String(buf, 0, len)); } // 响应客户端请求,返回数据 out.write("Hello socket client!".getBytes()); // 停止发送数据 socket.shutdownOutput(); } catch (IOException e) { e.printStackTrace(); } } } public static void main(String[] args) throws IOException { BioServer bioServer = new BioServer(); bioServer.start(9000); }}
Socket 客户端
package com.lyyzoo.netty.bio;import java.io.InputStreamReader;import java.io.OutputStream;import java.net.Socket;public class BioClient implements Runnable { public static void main(String[] args) throws Exception { // 模拟10个客户端 for (int i = 0; i < 10; i++) { Thread client = new Thread(new BioClient()); client.start(); } Thread.sleep(5000); } @Override public void run() { try { String name = Thread.currentThread().getName(); // 建立与服务端的通信 Socket socket = new Socket("localhost", 9000); // 与服务端交互,先获取输入/输出流 InputStreamReader in = new InputStreamReader(socket.getInputStream()); OutputStream out = socket.getOutputStream(); // 向服务端发送数据 out.write("Hello socket!".getBytes()); out.write("Hello server!".getBytes()); socket.shutdownOutput(); // 停止发送数据 // 接收服务端响应数据 char[] buf = new char[1024]; int len; while ((len = in.read(buf)) != -1) { System.out.println(name + " # 服务端数据:" + new String(buf, 0, len)); } // 释放IO资源 out.close(); in.close(); socket.close(); } catch (Exception e) { e.printStackTrace(); } }}
这个程序的实现要点是:
服务器端启动 ServerSocket
,并监听本地的一个端口。
调用 accept
方法,阻塞等待客户端连接。
建立TCP连接得到 Socket
后,利用 Socket 来读取和响应数据给客户端。
程序中通过线程池处理客户端请求,提升并发能力,这实际上就是一种伪异步I/O 模型。使用线程池也可以避免频繁创建、销毁线程的开销,如果每个请求都同步阻塞新建一个线程来专门处理,那么在高并发的场景下,机器资源很快就会被耗尽,性能也非常低下。
这种 IO 工作方式就类似于下图,每一个客户端连接都需要建立一个Socket,并交由线程池中的一个线程去处理:
这种伪异步I/O无法从根本上解决同步I/O导致的通信线程阻塞问题,由于 I/O 操作是同步的,一旦有一方响应比较慢,那另一方也会被长时间阻塞,在此期间,其它连接就只能等待。并且,在高并发情况下,客户端数量可能是以万计,这时线程资源就变得非常紧张,线程上下文切换开销也会变得很明显,性能就会急剧下降。这就是同步阻塞方式的低扩展性劣势。
NIO
Java 1.4 中引入了 NIO
框架,全称 Non-block IO
,主要目标是让Java支持非阻塞I/O。
NIO 的抽象位于 java.nio
包,提供了 Channel、Selector、Buffer 等新的抽象,可以构建多路复用的、同步非阻塞
IO 程序,同时提供了更接近操作系统底层的高性能数据操作方式。
Java NIO 系统的核心在于三个部分:
Buffer(缓冲区)
高效的数据容器,除了布尔类型,所有原始数据类型都有相应的 Buffer 实现。
在面向流的 I/O 中,可以将数据写入或者将数据直接读到 Stream
对象中。在 NIO 中,所有的数据都是用 Buffer 来处理。
Channel(通道)
Channel 是一个通道,可以通过它读取和写入数据,是 NIO 中被用来支持批量式 IO 操作的一种抽象。
通道和流的不同之处在于通道是双向
的,流只是在一个方向移动(InputStream、OutputStream),而通道可以用于读,写或者同时用于读写。因为 Channel 是全双工的,所以它比流更好地映射底层操作系统的 API,特别是在UNIX网络编程中,底层操作系统的通道都是全双工的,同时支持读和写。
Selector(选择器)
Selector 是基于底层操作系统实现(epoll
),是 NIO 实现多路复用
的基础,它提供了一种高效的机制,可以检测到注册在 Selector 上的多个 Channel 中,是否有 Channel 处于就绪状态,进而实现了单线程对多 Channel 的高效管理,节省线程上下文切换的资源消耗。
与 Socket 和 ServerSocket 类对应,NIO提供了 SocketChannel
和 ServerSocketChannel
两种不同的套接字通道实现。下面将 BIO 中的程序用 NIO 改造下:
NIO 服务端
package com.lyyzoo.netty.nio;import java.io.IOException;import java.net.InetSocketAddress;import java.nio.ByteBuffer;import java.nio.CharBuffer;import java.nio.channels.SelectionKey;import java.nio.channels.Selector;import java.nio.channels.ServerSocketChannel;import java.nio.channels.SocketChannel;import java.nio.charset.CharsetDecoder;import java.nio.charset.CharsetEncoder;import java.nio.charset.StandardCharsets;import java.util.Iterator;public class NioServer { // NIO 编码器 private static final CharsetEncoder ENCODER = StandardCharsets.UTF_8.newEncoder(); // NIO 解码器 private static final CharsetDecoder DECODER = StandardCharsets.UTF_8.newDecoder(); public void start(int port) throws IOException { // 开启一个通道 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); // 设置为非阻塞,阻塞模式下,注册操作是不允许的 serverSocketChannel.configureBlocking(false); // 绑定端口;backlog: 等待连接的最大数量,限制客户端连接数量 serverSocketChannel.socket().bind(new InetSocketAddress(port), 100); // 打开一个多路复用器 Selector selector = Selector.open(); // 绑定多路复用器和通道,并监听TCP连接事件 serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); // 获取到达的事件 while (true) { // 一直阻塞,等待就绪的 Channel selector.select(); // 一个请求对应一个 SelectionKey,同时可能会有多个请求进来 Iterator<SelectionKey> keyIterator = selector.selectedKeys().iterator(); while (keyIterator.hasNext()) { SelectionKey selectionKey = keyIterator.next(); // 移除Key keyIterator.remove(); // 处理请求 new RequestHandler(selector, selectionKey).run(); } } } /** * 请求处理器 */ static class RequestHandler implements Runnable { private final Selector selector; private final SelectionKey selectionKey; public RequestHandler(Selector selector, SelectionKey selectionKey) { this.selector = selector; this.selectionKey = selectionKey; } @Override public void run() { SocketChannel socketChannel = null; try { // 连接请求 if (selectionKey.isAcceptable()) { ServerSocketChannel serverChannel = (ServerSocketChannel) selectionKey.channel(); // 调用 accept 与客户端建立TCP连接,连接建立后得到一个 SocketChannel socketChannel = serverChannel.accept(); // 设置非阻塞 socketChannel.configureBlocking(false); // 和多路复用器绑定,并监听读取数据事件 socketChannel.register(selector, SelectionKey.OP_READ); } // 发送数据请求,就需要接收客户端数据,然后对请求做处理,并响应客户端 else if (selectionKey.isReadable()) { // 拿到之前建立的 SocketChannel socketChannel = (SocketChannel) selectionKey.channel(); // 读取数据的缓冲区 ByteBuffer readBuffer = ByteBuffer.allocate(1024); // 从通道读取数据到缓冲区 int len = socketChannel.read(readBuffer); if (len > 0) { // 缓冲区复位 readBuffer.flip(); // 处理数据 System.out.println("客户端数据:" + DECODER.decode(readBuffer)); // 响应客户端数据 socketChannel.write(ENCODER.encode(CharBuffer.wrap("Hello socket channel!"))); // 关闭通道 socketChannel.close(); } } } catch (IOException e) { e.printStackTrace(); } } } public static void main(String[] args) throws IOException { NioServer server = new NioServer(); server.start(9000); }}
NIO 客户端
package com.lyyzoo.netty.nio;import java.net.InetSocketAddress;import java.nio.ByteBuffer;import java.nio.CharBuffer;import java.nio.channels.SelectionKey;import java.nio.channels.Selector;import java.nio.channels.SocketChannel;import java.nio.charset.CharsetDecoder;import java.nio.charset.CharsetEncoder;import java.nio.charset.StandardCharsets;import java.util.Iterator;public class NioClient implements Runnable { // NIO 编码器 private static final CharsetEncoder ENCODER = StandardCharsets.UTF_8.newEncoder(); // NIO 解码器 private static final CharsetDecoder DECODER = StandardCharsets.UTF_8.newDecoder(); private final Selector selector; public NioClient(Selector selector) { this.selector = selector; } @Override public void run() { try { // 打开一个通道,底层就是一个 Socket SocketChannel socketChannel = SocketChannel.open(); // 设置为非阻塞 socketChannel.configureBlocking(false); // 建立与服务端的通信 socketChannel.connect(new InetSocketAddress("localhost", 9000)); // 与多路复用器绑定,并监听连接事件 socketChannel.register(selector, SelectionKey.OP_CONNECT); // 一直阻塞,等待就绪 boolean isOver = false; while (!isOver) { selector.select(); Iterator<SelectionKey> keyIterator = selector.selectedKeys().iterator(); while (keyIterator.hasNext()) { SelectionKey selectionKey = keyIterator.next(); keyIterator.remove(); // 已建立连接,发送数据 if (selectionKey.isConnectable()) { // TCP连接已建立 if (socketChannel.finishConnect()) { // 向服务端发送数据 socketChannel.write(ENCODER.encode(CharBuffer.wrap("Hello NIO SocketChanel!"))); } // 更改为READ事件 selectionKey.interestOps(SelectionKey.OP_READ); } // 接收服务端数据 else if (selectionKey.isReadable()) { ByteBuffer byteBuffer = ByteBuffer.allocate(1024); int len = socketChannel.read(byteBuffer); if (len > 0) { byteBuffer.flip(); System.out.println("服务端数据:" + DECODER.decode(byteBuffer)); socketChannel.close(); isOver = true; } } } } } catch (Exception e) { e.printStackTrace(); } } public static void main(String[] args) throws Exception { // 打开一个多路复用器 Selector selector = Selector.open(); // 模拟10个客户端 for (int i = 0; i < 1; i++) { //Thread client = new Thread(new NioClient(selector)); //client.start(); new NioClient(selector).run(); } Thread.sleep(5000); }}
可以看出,NIO 大致分为这几步骤:
获取 ServerSocketChannel,绑定端口,并设置非阻塞(阻塞模式下,注册操作是不允许的)。
通过 Selector.open() 创建一个多路复用器 Selector,作为类似调度员的角色。
channel 注册到 Selector,通过指定 SelectionKey.OP_ACCEPT,告诉 selector 它关注的是新的连接请求。
Selector 阻塞在 select 操作,当有 Channel 发生接入请求,就会被唤醒。
根据 selector 返回的 channel 状态处理逻辑
可以看到,BIO 的代码都是同步阻塞模式,所以需要多线程以实现多任务处理。而 NIO 则是利用了单线程轮询事件的机制,通过高效地定位就绪的 Channel,来决定做什么,仅仅 select 阶段是阻塞的,可以有效避免大量客户端连接时,频繁线程切换带来的问题,应用的扩展能力有了非常大的提高。
这种 IO 工作方式就类似于下图:
AIO
在 Java 7 中,NIO 有了进一步的改进,也就是 NIO 2
,引入了异步非阻塞
IO 方式,也称为 AIO
(Asynchronous IO)。异步 IO 操作基于事件和回调机制
,可以简单理解为,应用操作直接返回,而不会阻塞在那里,当后台处理完成,操作系统会通知相应线程进行后续工作。
AIO 是真正意义上的异步非阻塞 IO 模型,BIO 和 NIO 需要用户线程定时轮询,去检查 IO 缓冲区数据是否就绪,占用应用程序线程资源,其实轮询相当于还是阻塞的,并非真正解放当前线程。而真正理想的异步非阻塞 IO 应该让内核系统完成,用户线程只需要告诉内核,当缓冲区就绪后,通知我或者执行我交给你的回调函数。
本文参考:
Linux网络编程入门
《Netty 权威指南》
原文:https://juejin.cn/post/7100846076034301983