传统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)时,才算把数据读完,同理,写事件也需要同样处理。

注意

在使用多路复用时,最好搭配非阻塞I/O使用,也就是将客户端socket设置为Non-Block,原因是多路复用返回的事件并不一定是可读写的,可能会由于数据错误等情况导致数据被丢弃,此时再调用阻塞的read/write时会阻塞进程,出现问题