BIO

BIO:blocking IO,也就是阻塞IO。本文会从linux内核开始分析为什么会阻塞,阻塞在哪呢?

单线程BIO服务器端

一个线程实现的服务器端代码如下:

public class SingleThreadServer {

    public static final int PORT = 8899;

    public static void main(String[] args) throws IOException {

        ServerSocket serverSocket = new ServerSocket(); // socket(AF_INET6, SOCK_STREAM, IPPROTO_IP) = 5

        serverSocket.bind(new InetSocketAddress(PORT)); // bind(5, {sa_family=AF_INET6, sin6_port=htons(8899), inet_pton(AF_INET6, "::", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, 28) = 0
        // bind()里面会调用listen方法 listen(5, 50)

        System.out.println("server is start at " + PORT); //

        while (true) {
            try (
                    Socket socket = serverSocket.accept(); // poll([{fd=5, events=POLLIN|POLLERR}], 1, -1 阻塞
                    InputStream inputStream = socket.getInputStream();
            ) {
                System.out.println("connect success " + socket.getPort());

                BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));

                System.out.println("receive from client: " + reader.readLine()); // recvfrom(6,
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

    }
}

在linux下使用strace命令来根据程序产生了哪些系统调用。

  1. 启动服务器端程序strace -ff -o out java SingleThreadServer

程序的打印结果为

server is start at 8899

观察发现当前目录下多出了许多以out.开头的文件,这些就是程序产生的系统调用日志文件,每个线程对应一个日志文件。

-rw-r--r--. 1 root root   9527 Jul  6 10:45 out.22775
-rw-r--r--. 1 root root 183107 Jul  6 10:45 out.22776
-rw-r--r--. 1 root root    878 Jul  6 10:45 out.22777
-rw-r--r--. 1 root root    834 Jul  6 10:45 out.22778
-rw-r--r--. 1 root root    932 Jul  6 10:45 out.22779
-rw-r--r--. 1 root root    897 Jul  6 10:45 out.22780
-rw-r--r--. 1 root root   6473 Jul  6 10:45 out.22781
-rw-r--r--. 1 root root   1096 Jul  6 10:45 out.22782
-rw-r--r--. 1 root root   1159 Jul  6 10:45 out.22783
-rw-r--r--. 1 root root   1187 Jul  6 10:45 out.22784
-rw-r--r--. 1 root root   5152 Jul  6 10:45 out.22785
-rw-r--r--. 1 root root   5104 Jul  6 10:45 out.22786
-rw-r--r--. 1 root root   5831 Jul  6 10:45 out.22787
-rw-r--r--. 1 root root   1055 Jul  6 10:45 out.22788
-rw-r--r--. 1 root root 107160 Jul  6 10:45 out.22789

main线程所在的日志为第二个,也就是out.22776。

下面查看程序所在进程创建的文件描述符fd,

$ ps -ef|grep java
root     22775 22773  0 10:45 pts/2    00:00:00 java SingleThreadServer
$ cd /proc/22775/fd
$ ll
lrwx------. 1 root root 64 Jul  6 10:47 0 -> /dev/pts/2
lrwx------. 1 root root 64 Jul  6 10:47 1 -> /dev/pts/2
lrwx------. 1 root root 64 Jul  6 10:47 2 -> /dev/pts/2
lr-x------. 1 root root 64 Jul  6 10:47 3 -> /usr/java/jdk1.8.0_144/jre/lib/rt.jar
lrwx------. 1 root root 64 Jul  6 10:47 4 -> socket:[93794673]
lrwx------. 1 root root 64 Jul  6 10:47 5 -> socket:[93794675]

发现目前有5个文件描述符。

再来看一下out.22776的内容,只提取了其中关键的内容:

socket(AF_INET6, SOCK_STREAM, IPPROTO_IP) = 5 
bind(5, {sa_family=AF_INET6, sin6_port=htons(8899), inet_pton(AF_INET6, "::", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, 28) = 0
listen(5, 50)                           = 0
poll([{fd=5, events=POLLIN|POLLERR}], 1, -1

说明:

  • socket:创建一个socket,文件描述符为5。
  • bind:绑定文件描述符5到端口8899。
  • listen:监听文件描述符5,也就是8899端口。
  • poll:阻塞,等待文件描述符5有连接到来才会继续往下执行,函数等于后面的为返回值。

poll函数的说明,使用man 2 poll查看函数的帮助文档,如提示No manual entry for poll in section 2,先安装yum install man-pages帮助文档:

poll函数的定义如下:

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

struct pollfd {
    int fd;     /* 文件描述符 */
    short events;     /* 等待的事件 POLLIN|POLLERR 有数据可读或者发生错误 */
    short revents;    /* 实际发生了的事件 */
};

每一个pollfd结构体指定了一个被监视的文件描述符,可以传递多个结构体,表示poll()监视多个文件描述符。每个结构体的events域是监视该文件描述符的事件掩码,由用户来设置这个域。revents域是文件描述符的操作结果事件掩码,内核在调用返回时设置这个域。events域中请求的任何事件都可能在revents域中返回。

使用netstat也会发现有一个进程22775在8899端口监听:

$ netstat -anotp | grep 8899
tcp6       0      0 :::8899                 :::*                    LISTEN      22775/java           off (0.00/0/0)
  1. 使用telnet充当客户端,先建立连接(TCP三次握手),telnet localhost 8899

控制台打印结果如下,程序往下走了一步,客户端的端口号为57518:

server is start at 8899
connect success 57518

再来看一下进程的文件描述符的个数:

lrwx------. 1 root root 64 Jul  6 10:47 0 -> /dev/pts/2
lrwx------. 1 root root 64 Jul  6 10:47 1 -> /dev/pts/2
lrwx------. 1 root root 64 Jul  6 10:47 2 -> /dev/pts/2
lr-x------. 1 root root 64 Jul  6 10:47 3 -> /usr/java/jdk1.8.0_144/jre/lib/rt.jar
lrwx------. 1 root root 64 Jul  6 10:47 4 -> socket:[93794673]
lrwx------. 1 root root 64 Jul  6 10:47 5 -> socket:[93794675]
lrwx------. 1 root root 64 Jul  6 10:51 6 -> socket:[93793686]

发现多了一个文件描述符6。

再来看一下out.22776的内容

poll([{fd=5, events=POLLIN|POLLERR}], 1, -1) = 1 ([{fd=5, revents=POLLIN}])
accept(5, {sa_family=AF_INET6, sin6_port=htons(57518), inet_pton(AF_INET6, "::1", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, [28]) = 6                   = 1
recvfrom(6,

accept创建了一个描述符6,也就是建立了一个连接,recvfrom阻塞,等待读取文件描述符6接收的数据。

使用netstat会发现此时多了两个连接:

tcp6       0      0 :::8899                 :::*                    LISTEN      22775/java           off (0.00/0/0)
tcp6       0      0 ::1:8899                ::1:57518               ESTABLISHED 22775/java           off (0.00/0/0)
tcp6       0      0 ::1:57518               ::1:8899                ESTABLISHED 31646/telnet         off (0.00/0/0)
  1. 在telnet客户端中输入hello

控制打印结果如下,程序接收到客户端的hello,进入下一轮循环,阻塞在accept()方法

server is start at 8899
connect success 57518
receive from client: hello

再来看一下进程的文件描述符的个数:

lrwx------. 1 root root 64 Jul  6 10:47 0 -> /dev/pts/2
lrwx------. 1 root root 64 Jul  6 10:47 1 -> /dev/pts/2
lrwx------. 1 root root 64 Jul  6 10:47 2 -> /dev/pts/2
lr-x------. 1 root root 64 Jul  6 10:47 3 -> /usr/java/jdk1.8.0_144/jre/lib/rt.jar
lrwx------. 1 root root 64 Jul  6 10:47 4 -> socket:[93794673]
lrwx------. 1 root root 64 Jul  6 10:47 5 -> socket:[93794675]

文件描述符6消失,也就是之前的连接已经被关闭。

再来看一下out.22776的内容

recvfrom(6, "hello\r\n", 8192, 0, NULL, NULL) = 7               = 0
write(1, "receive from client: hello", 26) = 26
write(1, "\n", 1)                       = 1
close(6)                                = 0
poll([{fd=5, events=POLLIN|POLLERR}], 1, -1

收到客户端发送过来的hello,关闭连接,使用poll继续监听客户端的连接。

一个线程实现的服务器端存在的缺点:BIO会产生两次阻塞,第一次在等待连接时阻塞(因为不知何时客户端会建立连接所以需要阻塞),第二次在等待读取数据时阻塞(因为不知何时客户端会发送数据所以会阻塞等待数据)。如果一个客户端只建立连接,迟迟不发送数据,这个客户端将会一直阻塞,那么第二个客户端将无法建立连接,所以无法处理多个客户端请求。

多线程BIO服务器端

多线程实现的服务器端代码如下:

public class MuitiThreadServer {

    public static final int PORT = 8899;

    public static void main(String[] args) throws IOException {

        ServerSocket serverSocket = new ServerSocket();

        serverSocket.bind(new InetSocketAddress(PORT));

        System.out.println("server is start at " + PORT);

        while (true) {
            Socket socket = serverSocket.accept();
            new Thread(new ServerHandler(socket)).start(); // 每客户端每连接
        }
    }
}

public class ServerHandler implements Runnable {

    private Socket socket;

    public ServerHandler(Socket socket) {
        this.socket = socket;
    }

    @Override
    public void run() {

        try (
                InputStream inputStream = socket.getInputStream();
        ) {
            System.out.println("connect success " + socket.getPort());

            BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));

            System.out.println("receive from client: " + reader.readLine());
        } catch (Exception e) {
            e.printStackTrace();
        }

    }
}

多线程BIO服务器虽然解决了单线程BIO无法处理并发的弱点,但是也带来一个问题:每个连接一个线程,如果有大量的连接会导致消耗大量的内存,也会导致大量的线程上下文切换。

下面继续使用strace跟踪程序的系统调用过程:

  1. 启动程序strace -ff -o out java MuitiThreadServer

out.31471的关键内容如下:

socket(AF_INET6, SOCK_STREAM, IPPROTO_IP) = 5
bind(5, {sa_family=AF_INET6, sin6_port=htons(8899), inet_pton(AF_INET6, "::", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, 28) = 0
listen(5, 50)                           = 0
write(1, "server is start at 8899", 23) = 23
write(1, "\n", 1)                       = 1
poll([{fd=5, events=POLLIN|POLLERR}], 1, -1

与上面的单线程实现的服务端程序一样,poll方法被阻塞,对应阻塞的java方法为accept()。

  1. 使用telnet客户端建立连接telnet localhost 8899

out.31471的关键内容如下:

poll([{fd=5, events=POLLIN|POLLERR}], 1, -1) = 1 ([{fd=5, revents=POLLIN}])
accept(5, {sa_family=AF_INET6, sin6_port=htons(46994), inet_pton(AF_INET6, "::1", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, [28]) = 6
clone(child_stack=0x7fe8943acfb0, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, parent_tidptr=0x7fe8943ad9d0, tls=0x7fe8943ad700, child_tidptr=0x7fe8943ad9d0) = 31647
poll([{fd=5, events=POLLIN|POLLERR}], 1, -1

当有客户端建立连接时,poll方法往下执行,使用accept方法创建了文件描述符6,然后使用clone方法克隆了一个线程31647来读取数据,下面来看一下out.31647的内容。

out.31647

write(1, "connect success 46994", 21)   = 21
write(1, "\n", 1)                       = 1
recvfrom(6,

线程31647阻塞在recvfrom方法,对应的java方法为read(),等待读取客户端的数据。

  1. 在telnet客户端中输入hello

out.31647

recvfrom(6, "hello\r\n", 8192, 0, NULL, NULL) = 7
write(1, "receive from client: hello", 26) = 26
write(1, "\n", 1)                       = 1               = 6
close(6)                                = 0
exit(0)

收到客户端发送过来的hello,关闭连接,同时线程也死亡了。

多线程BIO服务器端的缺点:每来一个连接都需要创建一个线程,断开连接时销毁线程,非常消耗资源。

使用线程池实现BIO服务器端

public class ThreadPoolServer {

    private static final int PORT = 8899;

    private static final ExecutorService EXECUTOR_SERVICE = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());

    public static void main(String[] args) throws IOException {

        ServerSocket serverSocket = new ServerSocket();

        serverSocket.bind(new InetSocketAddress(PORT));

        System.out.println("server is start at " + PORT);

        while (true) {
            Socket socket = serverSocket.accept();
            EXECUTOR_SERVICE.submit(new ServerHandler(socket));
        }
    }
}

这种线程池实现BIO的异步也称为伪异步IO,虽然解决了每次连接都要创建和销毁线程的问题,实现线程的复用,但是根本问题没有解决,根本问题是由于两次阻塞(blocking)才需要多个线程。

要想使用少量的线程来实现服务器端就需要使用NIO。