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命令来根据程序产生了哪些系统调用。
- 启动服务器端程序
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)
- 使用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)
- 在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跟踪程序的系统调用过程:
- 启动程序
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()。
- 使用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(),等待读取客户端的数据。
- 在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。