笔记整理
前置知识
1、系统调用
我们的硬件都是交给操作系统的kernel(内核,c/c++写的)管理,当我们的程序想要获取硬件的数据时(如网卡缓冲区的数据),由于保护模式
,程序是不能直接读取硬件数据,需要调用kernel的代码去读取,这个过程就是系统调用。
系统调用是比较耗cpu性能的,因为这个过程需要保护现场,恢复现场,系统态内核态的切换等等。
2、创建一个soket连接必经的三步:
(linux socket编程-socket接口详细连接地址)
- 调用
int socket(int domain, int type, int protocol)
;这个函数建立一个协议族为domain、协议类型为type、协议编号为protocol的套接字文件描述符。 - 调用
int bind( int sockfd, struct sockaddr* addr, socklen_t addrlen)
;在进行网络通信的时候,必须把一个套接字与一个地址相关联。 - 调用
int listen(int sockfd, int backlog);
监听。
传统BIO
传统的bio就是我们写烂了的那个代码,如下:
public class Server {
public static void main(String[] args) {
try {
// 1.注册端口
ServerSocket ss = new ServerSocket(9999);
// 2.定义一个循环接收客户端的Socket连接请求
// 初始化一个线程池对象
SocketServerPoolHandler poolHandler = new SocketServerPoolHandler(3, 10);
while (true) {
Socket socket = ss.accept();
// 3.把Socket对象交给一个线程池进行处理
// 把Socket封装成一个任务对象交给线程池处理,ServerRunnableTarget的具体代码就不给出了哈。
Runnable target = new ServerRunnableTarget(socket);
poolHandler.execute(target);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
因为accept()会阻塞,read()也会阻塞,所以不要把两个放到一个线程里。
这样其实没什么问题的,如果连接数比较小这样的性能也不错,但由于是一个线程一个请求,请求数一多就会造成C10K问题。
tips: 为什么accept()会阻塞,read()也会阻塞?
因为这两个函数实际上会调用操作系统kernel的accept()、recv(),也就是会发生系统调用。
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
/*参数说明
sockfd是由socket函数返回的套接字描述符,
addr和addrlen用来返回已连接的对端进程(客户端)的协议地址。
如果我们对客户端的协议地址不感兴趣,可以把arrd和addrlen均置为空指针
*/
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
/*sockfd :套接字
buf : 待发送或者接收的缓存
len : 如果是recv指期望接收的长度,如果是send指要发送的长度。
flags : 标志位*/
在操作系统中这两个函数就会阻塞,所以accept()会阻塞,read()也会阻塞。
NIO(无多路复用器版)
于是就有了对bio的改进—nio(在java里指new io,操作系统指nonBlocking io)。
nio就是用一个线程管理多个连接(注意这里可以一个线程管理多个连接是因为操作系统的accept()、recv()修改了,没有连接、数据就返回-1,不会阻塞了)。
如果没有操作系统的提升就不能一个线程管理多个连接,不然会堵车,一个阻塞了后面所有都阻塞,因为只有一个线程嘛。代码如下:
public class SocketNIO {
public static void main(String[] args) throws Exception {
LinkedList<SocketChannel> clients = new LinkedList<>();//保存soket连接的channel
ServerSocketChannel ss = ServerSocketChannel.open(); //服务端开启监听:接受客户端
ss.bind(new InetSocketAddress(9090));
ss.configureBlocking(false); //不阻塞
while (true) {
//接受客户端的连接
SocketChannel client = ss.accept(); //不会阻塞 -1 NULL
//accept 调用内核了:1,没有客户端连接进来, 在BIO 的时候一直卡着,但是在NIO ,不卡着,返回-1(内核层面),null(java层面)
if (client == null) {
// System.out.println("null.....");//没有客户端连接
} else {
client.configureBlocking(false); //得到连接同样设置为非阻塞
int port = client.socket().getPort();
System.out.println("client..port: " + port);
clients.add(client);//将得到的连接添加到LinkedList<SocketChannel> clients
}
ByteBuffer buffer = ByteBuffer.allocateDirect(4096); //得到一个缓冲区buffer可以在堆里、外
//**遍历已经链接进来的客户端能不能读写数据**
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();
}
}
}
}
}
tips:因为操作系统做了升级,accept()、recv()不会阻塞了,那么就不用向以前那样一个线程一个连接了,可以用一个线程管理多个连接,但是这样还是有弊端:
- 在“遍历已经链接进来的客户端能不能读写数据”这块代码里,如果已经链接进来的客户端很多,那么这块代码就会执行很久,这个过程中将不能接受新的连接。其实这个很好解决,我们可以专门开一个线程处理这块代码。
这种写法最大的性能损耗不是循环次数太多,而是太多系统调用,太多用户态和内核态的切换!
我们的read(buffer)函数虽然不会阻塞,但是还是需要调用内核的recv()函数,这就会产生大量的系统调用,而且可能还是大量无意义的系统调用(假设1万个链接,有可能只有一个链接有数据,那9999次都是无用的系统调用)。
NIO(多路复用器版,select、poll、epoll)
前面说了假设1万个链接,有可能只有一个链接有数据,那9999次都是无用的系统调用,那可不可以某一个函数主动告诉我哪些soket有数据,然后我只遍历这些有数据的就好啦?当然可以,这就是epoll()
函数,也是最终的纠结方案,但其实在此之前还经历了几次演变select->poll->epoll。
(1)select、poll 都是调用 client.register()
方法在jvm里开辟一个数组将所有soket连接的文件描述符(fd)放进去,可以通过selector.select()
方法通过一次系统调用将所有文件描述符传给内核,让内核帮我们循环,返回有事件的文件描述符集合! 可以看到这里还是要循环,只是内核帮我们做了,这样减少了系统调用的次数,仅仅有一次系统调用,这就是IO多路复用
!
(2)epoll在java层面的代码和前面两个一样,只是不会在jvm里开辟一个数组将所有soket连接的文件描述符(fd)放进去,而是会在内核开辟空间存。client.register()
会调用内核的epoll_ctl(fd3,ADD,fd7,EPOLLIN,,将文件描述法添加到内核空间,当有事件时内核会帮我们把所有有事件的文件描述符放到一起返回给我们,这样只有在创建连接的时候会发生一次系统调用,内核也不会每次循环了,效率再次提升。
代码如下:
public class SocketMultiplexingSingleThreadv1 {
//注意!!!!!!!!!!!
//java 使用多路复用器 ,多路复用器其实是kernel提供的,种类很多,java的selector包装了这些东东
//具体用哪一个就看你的操作系统有哪个了,一般有epoll就用epoll
private ServerSocketChannel server = null;
private Selector selector = null; //linux 多路复用器(select poll epoll kqueue) nginx event{}
int port = 9090;
public void initServer() {
try {
server = ServerSocketChannel.open();
server.configureBlocking(false);
server.bind(new InetSocketAddress(port));
//如果在epoll模型下,open--》 epoll_create -> fd3
selector = Selector.open(); // select poll | *epoll 优先选择:epoll 但是可以 -D修正
//内核 epoll 开辟空间 select poll 不开辟空间
//c语言实现jvm J空间
//java代码 空间
// selector ? select ? poll ? epoll ?
//server 约等于 listen状态的 fd4
/*
register
如果:
select,poll:jvm里开辟一个数组 fd4 放进去
epoll: epoll_ctl(fd3,ADD,fd4,EPOLLIN
*/
server.register(selector, SelectionKey.OP_ACCEPT);
} catch (IOException e) {
e.printStackTrace();
}
}
public void start() {
initServer();
System.out.println("服务器启动了。。。。。");
try {
while (true) { //死循环
Set<SelectionKey> keys = selector.keys();
System.out.println(keys.size()+" size");
//1,调用多路复用器(select,poll or epoll (epoll_wait))
/*
select()是啥意思:
1,select,poll 其实 内核的select(fd4) poll(fd4)
2,epoll: 其实 内核的 epoll_wait()
*, 参数可以带时间:没有时间,0 : 阻塞,有时间设置一个超时
selector.wakeup() 结果返回0
懒加载:
其实再触碰到selector.select()调用的时候触发了epoll_ctl的调用
*/
//完成了 内核到 jvm 空间的一个事件的拷贝
while (selector.select() > 0) {
//是从jvm空间把 有时间的集合拷贝到java程序猿手里~!!!!!!!!
Set<SelectionKey> selectionKeys = selector.selectedKeys(); //返回的有状态的fd集合
Iterator<SelectionKey> iter = selectionKeys.iterator();
//so,管你啥多路复用器,你呀只能给我状态,我还得一个一个的去处理他们的R/W。同步好辛苦!!!!!!!!
// NIO 自己对着每一个fd调用系统调用,浪费资源,那么你看,这里是不是调用了一次select方法,知道具体的那些可以R/W了?
//幕兰,是不是很省力?
//我前边可以强调过,socket: listen 通信 R/W
while (iter.hasNext()) {
SelectionKey key = iter.next();
iter.remove(); //set 不移除会重复循环处理
if (key.isAcceptable()) {
//看代码的时候,这里是重点,如果要去接受一个新的连接
//语义上,accept接受连接且返回新连接的FD对吧?
//那新的FD怎么办?
//select,poll,因为他们内核没有空间,那么在jvm中保存和前边的fd4那个listen的一起
//epoll: 我们希望通过epoll_ctl把新的客户端fd注册到内核空间
acceptHandler(key);
} else if (key.isReadable()) {
readHandler(key); //连read 还有 write都处理了
//在当前线程,这个方法可能会阻塞 ,如果阻塞了十年,其他的IO早就没电了。。。
//所以,为什么提出了 IO THREADS
//redis 是不是用了epoll,redis是不是有个io threads的概念 ,redis是不是单线程的
//tomcat 8,9 异步的处理方式 IO 和 处理上 解耦
}
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
public void acceptHandler(SelectionKey key) {
try {
ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
SocketChannel client = ssc.accept(); //来啦,目的是调用accept接受客户端 fd7
client.configureBlocking(false);
ByteBuffer buffer = ByteBuffer.allocate(8192); //前边讲过了
// 0.0 我类个去
//你看,调用了register
/*
select,poll:jvm里开辟一个数组 fd7 放进去
epoll: epoll_ctl(fd3,ADD,fd7,EPOLLIN
*/
client.register(selector, SelectionKey.OP_READ, buffer);
System.out.println("-------------------------------------------");
System.out.println("新客户端:" + client.getRemoteAddress());
System.out.println("-------------------------------------------");
} catch (IOException e) {
e.printStackTrace();
}
}
public void readHandler(SelectionKey key) {
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buffer = (ByteBuffer) key.attachment();
buffer.clear();
int read = 0;
try {
while (true) {
read = client.read(buffer);
if (read > 0) {
buffer.flip();
while (buffer.hasRemaining()) {
client.write(buffer);
}
buffer.clear();
} else if (read == 0) {
break;
} else {
client.close();
break;
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
SocketMultiplexingSingleThreadv1 service = new SocketMultiplexingSingleThreadv1();
service.start();
}
}
tips:
正如前面所言在java层面select、poll、epoll的代码是一样的,只是会调用不同的内核函数。
selector = Selector.open() 开辟一个空间用于存放文件描述符fd。select、poll会在jvm生成,epoll会调用OS kernel的 epoll_creat()函数,在内核开辟。
SocketChannel.register() 就是将文件描述符注册到开辟的空间里。
selector.select() 就是将jvm中的文件描述符通过一次系统调用传给内核让其帮我们循环返回有事件的fds(select、poll)或直接返回有事件的fds(epoll,调用的是epoll_wait());
当然selector不是只能有一个,完全可以用多个来进一步提高效率,这就像netty里的boss和worker。
其实io模型的升级依赖于内核的升级,如果内核不能非阻塞,不能帮我们做循环、不能基于事件通知就不会有nio了,那具体内核是如何实现这些功能的还得去看kernel的c/c++源码!!