传统I/O
传统I/O操作,即同步阻塞I/O,在Java中也叫BIO,一个进程同一时刻只能监听一个I/O流,且需要阻塞的等待数据的到来,通常只会用在客户端程序,服务端程序一般会使用I/O多路复用技术
Java实现
server
public class Server {
public static void main(String[] args) {
try (ServerSocket serverSocket = new ServerSocket()) {
serverSocket.bind(new InetSocketAddress("0.0.0.0", 9999));
while (true) {
Socket client = serverSocket.accept();
SocketAddress remoteSocketAddress = client.getRemoteSocketAddress();
System.out.println("Someone connected: " + remoteSocketAddress);
BufferedInputStream bis = new BufferedInputStream(client.getInputStream());
byte[] bytes = new byte[1024];
int read = bis.read(bytes);
if (read == -1) {
System.err.println("Read error from client: " + remoteSocketAddress);
continue;
}
if (read == 0) {
System.err.println("Client disconnected: " + remoteSocketAddress);
continue;
}
System.out.println("Client says: " + new String(bytes, 0, read, StandardCharsets.UTF_8));
BufferedOutputStream bos = new BufferedOutputStream(client.getOutputStream());
bos.write("received!".getBytes(StandardCharsets.UTF_8));
bos.flush();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
- serverSocket.accept()阻塞并等待数据到来,处理完成上一个I/O流后再继续下一个I/O流的处理
- 调用accept会执行进程上下文切换,从用户态切换到内核态,当前进程阻塞
- 当数据到来之后,数据被接收到socket的接收队列,然后唤醒正在阻塞的用户进程,这里又会执行一次进程上下文切换
- 每次处理需要进行两次上下文切换,非常耗时,且每次只能处理一个I/O流
client
static void bioClient() {
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress("127.0.0.1", 9999));
BufferedOutputStream bos = new BufferedOutputStream(socket.getOutputStream());
bos.write("hello, I'm client".getBytes(StandardCharsets.UTF_8));
bos.flush();
BufferedInputStream bis = new BufferedInputStream(socket.getInputStream());
byte[] bytes = new byte[1024];
int read = bis.read(bytes);
if (read == -1) {
bis.close();
bos.close();
System.exit(0);
}
System.out.println("Response from server: " + new String(bytes, 0, read, StandardCharsets.UTF_8));
} catch (IOException e) {
e.printStackTrace();
}
}
什么是I/O多路复用
I/O 多路复用即是利用操作系统提供的机制,同时监听多个输入/输出通道,以实现更高效的 I/O 操作处理,提高系统的性能和响应速度。
实现
I/O多路复用的实现方式有3种,分别是
- select(Windows/Linux/MacOS)
- poll(Linux/MacOS)
- epoll(Linux)/kqueue(MacOS)
Java中I/O多路复用的实现是NIO
select
select通过同时监听一组fd(不超过1024个)来实现多路复用
/* Number of descriptors that can fit in an `fd_set'. */
#define __FD_SETSIZE 1024
/* It's easier to assume 8-bit bytes than to get CHAR_BIT. */
#define __NFDBITS (8 * (int) sizeof (__fd_mask))
typedef struct
{
__fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
} fd_set;
- fd_set中存储的就是fd对应的标志位
- 通过调用select系统调用会将fd_set从用户态拷贝到内核态,然后阻塞当前进程,这里需要执行一次进程上下文切换
- 当内核接收到对应的fd的数据后,再次将fd_set从内核态拷贝到用户态,并唤醒进程,这里又需要执行一次上下文切换
- 然后线性遍历所有的fd,并执行对应的操作
- 接着继续下一轮select
poll
poll和select原理基本相同,但在poll中没有了fd数量的限制,poll使用结构体poll_fd来存储需要被监听的fd
struct pollfd
{
int fd; /* File descriptor to poll. */
short int events; /* Types of events poller cares about. */
short int revents; /* Types of events that actually occurred. */
};
- 每次可以监听多个fd,具体受操作系统本身fd数量限制
- 同样的整个过程中也会进行两次进程上下文切换
- 处理时也需要线性遍历所有监听的fd
epoll
epoll相对于select和poll的优势在于
- fd使用红黑树来存储,提高了查找、存储、更新的效率
- 使用事件驱动机制,内核为epoll维护了一个就绪队列,当有事件就绪时,就通过事件回调机制把对应的socket加入到就绪队列中,用户只需要调用epoll_wait就可以获取到所有就绪的fd,不需要线性遍历所有的fd
- 支持LT(Level Trigger)和ET(Edge Trigger)两种触发模式
- LT,水平触发模式:
- 对于读事件,只要流中还有数据,进程会不断的从epoll_wait调用中被唤醒,直到数据被处理完
- 对于写事件,只要写缓冲区可写,即缓冲区么被写满,那么进程也会不断的从epoll_wait调用中被唤醒
- ET,边缘触发模式:
- 对于读事件,当流中第一次出现数据时,进程会被epoll_wait调用中唤醒,且直到缓冲区中数据被处理完后下一次出现新数据之前,epoll_wait都不会第二次唤醒进程
- 对于写事件,当写缓冲区第一次可写,进程会被epoll_wait调用中唤醒,同时直到缓冲区中数据被写满后下一次可写之前,epoll_wait不会第二次唤醒进程
所以,LT会比ET有更多次的epoll_wait的调用,性能也就会比ET模式低,默认情况下epoll的触发模式是LT。在ET模式下处理读事件,需要一次把数据读完整,并且处理好异常情况,当read返回错误(错误类型为 EAGAIN 或 EWOULDBLOCK)时,才算把数据读完,同理,写事件也需要同样处理。
- LT,水平触发模式:
注意
在使用多路复用时,最好搭配非阻塞I/O使用,也就是将客户端socket设置为Non-Block,原因是多路复用返回的事件并不一定是可读写的,可能会由于数据错误等情况导致数据被丢弃,此时再调用阻塞的read/write时会阻塞进程,出现问题