C10K 问题
C10K 问题: http://www.kegel.com/c10k.html
我们使用BIO的时候,来一个连接就抛出一个线程。被抛出的独立的线程进行阻塞,等待接收已连接的client发来的数据,这样不会影响其他client继续连接。每个线程自己忙自己的。
但是随着连接数的变大,抛出的线程越多,由于线程之间的切换,系统的性能会越来越低。
举一个例子
一个客户端可以通过2个不同的ip,与服务端创建2*65000个连接。
C10Kclient.java
package com.bjmashibing.system.io;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.channels.SocketChannel;
import java.util.LinkedList;
public class C10Kclient {
public static void main(String[] args) {
LinkedList<SocketChannel> clients = new LinkedList<>();
InetSocketAddress serverAddr = new InetSocketAddress("192.168.150.11", 9090);
for (int i = 10000; i < 65000; i++) { // 一个客户端可以通过2个不同的ip,与服务端创建2*65000个连接
try {
SocketChannel client1 = SocketChannel.open();
SocketChannel client2 = SocketChannel.open();
/*
linux中你看到的连接就是:
client...port: 10508
client...port: 10508
*/
client1.bind(new InetSocketAddress("192.168.150.1", i)); // 这台机器上的第一个ip
// 192.168.150.1:10000 192.168.150.11:9090
client1.connect(serverAddr);
boolean c1 = client1.isOpen();
clients.add(client1);
client2.bind(new InetSocketAddress("192.168.110.100", i)); // 这台机器上的第二个ip
// 192.168.110.100:10000 192.168.150.11:9090
client2.connect(serverAddr);
boolean c2 = client2.isOpen();
clients.add(client2);
} catch (IOException e) {
e.printStackTrace();
}
}
System.out.println("clients " + clients.size());
try {
System.in.read();
} catch (IOException e) {
e.printStackTrace();
}
}
}
关于为什么需要在Linux上单独配一个路由条目
在Linux上单独配一个路由条目
因为物理机的192.168.110.100和虚拟机的192.168.150.0不是直连关系
192.168.150.2是虚拟机所在网络的网关,用于完成网络地址转换。
来自192.168.110.100的网络包在返回给客户端的时候,经过NAT地址转换,目标ip被改成了192.168.150.2,Windows收到之后不知道这个ip应该发给谁了,导致三次握手的第二次回的包被windows丢弃了,没有连接上。
运行C10Kclient.java 时,下面这个图可以说明,无论再多的连接,服务端始终是使用的同一个ip:端口
关于为什么连接的速度并不快?(一秒钟仅能够建立4个连接左右)
为什么BIO慢?
创建一个连接的过程如下图所示。
- accept系统调用,是一个阻塞的循环过程,这个过程耗费时间。
- 抛出一个线程的速度比较慢。
这就是整个BIO的弊端。想要调优,就要解决阻塞的问题,但阻塞是由内核提供给我们的API决accept receive定的。
阻塞问题:
- accept 阻塞
- 每接受一个客户端,则clone 一个线程来读取数据,没有数据时,则接收阻塞
NIO 的引入
NIO的N是啥意思呢?有两个角度可以理解
- Non-Blocking IO (操作系统中)
- New IO (JDK中)
Linux中的文件描述符是输入输出双向的。
Java中ServerSocketChannel也是淡化了输入、输出的概念,把输入、输出合在一起了。
一段NIO的代码
SocketNIO.java
package com.bjmashibing.system.io;
import java.net.InetSocketAddress;
import java.net.StandardSocketOptions;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.LinkedList;
public class SocketNIO {
public static void main(String[] args) throws Exception {
LinkedList<SocketChannel> clients = new LinkedList<>();
ServerSocketChannel ss = ServerSocketChannel.open();
ss.bind(new InetSocketAddress(9090));
ss.configureBlocking(false); //设置false时,是非阻塞。OS层面的NONBLOCKING,不会阻塞地去等待连接。
// ss.setOption(StandardSocketOptions.TCP_NODELAY, false);
// StandardSocketOptions.TCP_NODELAY
// StandardSocketOptions.SO_KEEPALIVE
// StandardSocketOptions.SO_LINGER
// StandardSocketOptions.SO_RCVBUF
// StandardSocketOptions.SO_SNDBUF
// StandardSocketOptions.SO_REUSEADDR
while (true) {
//接受客户端的连接,不会阻塞
Thread.sleep(1000);
SocketChannel client = ss.accept(); //开始接收客户端。不会阻塞等待连接,如果得不到连接,则返回 -1,NULL
// ss.accept();方法调用内核了,有下面这些情况:
// 1,没有客户端连接进来,返回值是啥呢?在 BIO 的时候一直卡着,但是在NIO的时候不会卡着,返回的是-1,NULL
// 2、有客户端的连接,ss.accept();返回的是这个客户端的fd 5(OS层面),Client对象(java层面)
if (client == null) {
System.out.println("null.....");// 可以不处理它,不要把这段当做性能消耗去思考。
} else {
client.configureBlocking(false); //重点
// socket有两个角度:
// 1、服务端的listen socket<连接请求三次握手后,往我这里扔,我去通过accept,得到后面的连接的socket>
// 2、服务端接受客户端连接进来之后,形成的连接socket<连接后的数据读写使用的>
int port = client.socket().getPort();
System.out.println("client...port: " + port);
clients.add(client);
}
//执行读取行为:遍历已连接的客户端去读写数据,这个过程不会阻塞
ByteBuffer buffer = ByteBuffer.allocateDirect(4096); //直接内存分配,可以在堆里分配,也可以在堆外分配
for (SocketChannel c : clients) { //串行化!!!! 多线程!!
int num = c.read(buffer); // 返回值 >0 -1 0 不会阻塞
if (num > 0) {
buffer.flip();
byte[] aaa = new byte[buffer.limit()];
buffer.get(aaa);
String b = new String(aaa);
System.out.println(c.socket().getPort() + " : " + b);
buffer.clear();
}
}
}
}
}
运行起来,通过strace追踪上面的代码,观察与BIO的不同
ss.configureBlocking(false);非阻塞状态下
可以发现,非阻塞下的socket服务,面对accept,会快速返回结果,-1的话代表当前还没有连接,且不会阻塞。
非阻塞了,这有什么意义?
假设这时候有两个客户端连接进来了,其中一个的示例是下面这个样子的:
曾经我们是需要抛出一个线程,把这个线程扔出去,让它自己去读取数据。
现在不需要抛线程了,完全在一个线程中执行,只不过是无数个循环。而在没有连接的时候,循环也不会被阻塞,依然继续循环,从而让循环后半部分的读取数据的逻辑就有机会在当前循环当中被执行到。
NIO通信模型
关于创建socket文件描述符,绑定端口,监听端口的操作和BIO是一致的,但在非阻塞模式下,对于accept这种系统调用是非阻塞的。 而当accept到了一个连接socket时,针对这个socket的读操作,也可以通过设置非阻塞模式而不阻塞。
从而,NIO实现了,在一个单线程的情况下,既可以建立多个连接,也可以去处理每一个有响应的socket的连接。
单线程是与BIO本质上的区别。
再用C10K压测一下
用C10K压测一下NIO的性能(单客户端10W连接):大约每秒钟能建立50个左右的连接
现在使用NIO的瓶颈是:当连接进来很多客户端时,for (SocketChannel c : clients)遍历每一个客户端去读取数据的过程耗费了性能。
另外,报错超出文件描述符的数量,这个是可以设置的:ulimit -SHn 500000(软硬openfile,改成50万)
通过ulimit -a一个进程可以打开的最大文件描述符:
注:为啥ulimit -n 1024,但是连接数超过了1024呢?
这个理论是对的,只不过要看用户。权限对root来说等于虚设,很多资源的约束在root用户也是放开的。而且在公司里,生产环境肯定是非root用户启动程序。
更改单进程打开文件描述符限制个数:
除了ulimit的每个进程的开辟的文件描述符限制,还有全局总共可以开辟的文件描述符个数。
内核级别文件描述符:
内核根据物理内存大小进行评估总共可以开辟的文件描述符个数
NIO的优势
相较于BIO而言,实现了通过1个或者几个线程来解决N个IO连接的处理
无效的无用的调用read函数,读取数据
NIO依旧存在的问题
虽然是非阻塞的,但是瓶颈在对接受的socket读取上面,还是要无差别的对全部的socket遍历进行recv,而recv是一个系统调用操作,这意味着要进行复杂度为O(n)的系统调用,而这种无差别的调用其实很多是没有意义的,因为有的socket确实就是没有进入可读状态。
延伸
那其实也就意味着,如果能够确定哪些socket可读,然后只对那些可读的socket进行recv,就可以进一步提高效率,解决当前瓶颈了。 也就是后续的,多路复用时代!