1 Socket网络编程回顾
1.1 Socket概述
Socket,套接字就是两台主机之间逻辑连接的端点。TCP/IP协议是传输层协议,主要解决数据如何在网络中传输,而HTTP是应用层协议,主要解决如何包装数据。Socket是通信的基石,是支持TCP/IP协议的网络通信的基本操作单元。它是网络通信过程中端点的抽象表示,包含进行网络通信必须的五种信息:连接使用的协议、本地主机的IP地址、本地进程的协议端口、远程主机的IP地址、远程进程的协议端口。
1.2 Socket整体流程
Socket编程主要涉及到客户端和服务端两个方面,首先是在服务器端创建一个服务器套接字(ServerSocket),并把它附加到一个端口上,服务器从这个端口监听连接。端口号的范围是0到65536,但是0到1024是为特权服务保留的端口号,可以选择任意一个当前没有被其他进程使用的端口。
客户端请求与服务器进行连接的时候,根据服务器的域名或者IP地址,加上端口号,打开一个套接字。当服务器接受连接后,服务器和客户端之间的通信就像输入输出流一样进行操作。
1.3 代码实现
ServerDemo
public class ServerDemo {
public static void main(String[] args) throws IOException {
// 1.创建一个线程池,如果有客户端连接就创建一个线程, 与之通信
ExecutorService executorService = Executors.newCachedThreadPool();
// 2.创建socket对象
ServerSocket serverSocket = new ServerSocket(9999);
System.out.println("服务器已启动!");
while (true) {
// 3.监听客户端
Socket socket = serverSocket.accept();
System.out.println("有客户端连接!");
// 4.开启新的线程处理
executorService.execute(new Runnable() {
@Override
public void run() {
handle(socket);
}
});
}
}
public static void handle(Socket socket) {
try (InputStream is = socket.getInputStream();
OutputStream os = socket.getOutputStream();){
//从连接中取出输入流来接收消息
byte[] b = new byte[1024];
int read = is.read(b);
System.out.println("客户端:" + new String(b, 0, read));
// 连接中取出输出流并回话
os.write("hello".getBytes(StandardCharsets.UTF_8));
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
ClientDemo
public class ClientDemo {
public static void main(String[] args) throws IOException {
while (true) {
// 1.创建socket对象
Socket socket = new Socket("127.0.0.1", 9999);
// 2.从连接中取出输出流并发消息
OutputStream os = socket.getOutputStream();
System.out.println("请输入:");
Scanner sc = new Scanner(System.in);
String msg = sc.nextLine();
os.write(msg.getBytes(StandardCharsets.UTF_8));
// 3.从连接中取出输入流并接收回话
InputStream is = socket.getInputStream();
byte[] b = new byte[1024];
int read = is.read(b);
System.out.println("ClientDemo :" + new String(b, 0, read).trim());
// 4.关闭
socket.close();
}
}
}
2 高并发IO的底层原理
2.1 IO读写的基础原理
为了避免用户进程直接操作内核,保证内核安全,操作系统将内存(虚拟内存)划分为两部分,一部分是内核空间(Kernel-Space),一部分是用户空间(User-Space)。 在Linux 系统中,内核模块运行在内核空间,对应的进程处于内核态;而用户程序运行在用户空间,对应的进程处于用户态。
操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内核空间,也有访问底层硬件设备的权限。内核空间总是驻留在内存中,它是为操作系统的内核保留的。
应用程序是不允许直接在内核空间区域进行读写,也是不容许直接调用内核代码定义的函数的。每个应用程序进程都有一个单独的用户空间,对应的进程处于用户态,用户态进程不能访问内核空间中的数据,也不能直接调用内核函数的,因此要进行系统调用的时候,就要将进程切换到内核态才能进行。
内核态进程可以执行任意命令,调用系统的一切资源,而用户态进程只能执行简单的运算,不能直接调用系统资源,那么用户态进程如何执行系统调用呢?答案如下:用户态进程必须通过系统接口(System Call),才能向内核发出指令,完成调用系统资源之类的操作。
用户程序进行IO的读写,依赖于底层的IO读写,基本上会用到底层的read&write两大系统调用。虽然在不同的操作系统中,read&write两大系统调用的名称和形式可能不完全一样,但是他们的基本功能是一样的。
操作系统层面的read系统调用,并不是直接从物理设备把数据读取到应用的内存中;write系统调用,也不是直接把数据写入到物理设备。上层应用无论是调用操作系统的read,还是调用操作系统的write,都会涉及缓冲区。具体来说,上层应用通过操作系统的read系统调用,是把数据从内核缓冲区复制到应用程序的进程缓冲区;上层应用通过操作系统的write系统调用,是把数据从应用程序的进程缓冲区复制到操作系统内核缓冲区。
简单来说,应用程序的IO操作,实际上不是物理设备级别的读写,而是缓存的复制。read&write两大系统调用,都不负责数据在内核缓冲区和物理设备(如磁盘、网卡等)之间的交换。这项底层的读写交换操作,是由操作系统内核(Kernel)来完成的。所以,应用程序中的IO操作,无论是对Socket的IO操作,还是对文件的IO操作,都属于上层应用的开发,它们的在输入(Input)和输出(Output)维度上的执行流程,都是类似的,都是在内核缓冲区和进程缓冲区之间的进行数据交换。
2.2 内核缓冲区与进程缓冲区
缓冲区的目的,是为了减少频繁地与设备之间的物理交换。计算机的外部物理设备与内存与CPU相比,有着非常大的差距,外部设备的直接读写,涉及操作系统的中断。发生系统中断时,需要保存之前的进程数据和状态等信息,而结束中断之后,还需要恢复之前的进程数据和状态等信息。为了减少底层系统的频繁中断所导致的时间损耗、性能损耗,于是出现了内核缓冲区。
有了内核缓冲区,操作系统会对内核缓冲区进行监控,等待缓冲区达到一定数量的时候,再进行IO设备的中断处理,集中执行物理设备的实际IO操作,通过这种机制来提升系统的性能。至于具体在什么时候执行系统中断(包括读中断、写中断),则由操作系统的内核来决定,应用程序不需要关心。
上层应用程序使用read系统调用时,仅仅把数据从内核缓冲区复制到上层应用的缓冲区(进程缓冲区);上层应用使用write系统调用时,仅仅把数据从应用的用户缓冲区复制到内核缓冲区中。
内核缓冲区与应用缓冲区在数量上也不同,在Linux系统中,操作系统内核只有一个内核缓冲区。而每个用户程序(进程)则有自己独立的缓冲区,叫做用户缓冲区或者进程缓冲区。Linux系统中的用户程序的IO读写程序,在大多数情况下,并没有进行实际的IO操作,而是在用户缓冲区和内核缓冲区之间直接进行数据的交换。
3 I/O模型
3.1 Linux网络I/O模型简介
Linux的内核将所有外部设备都看做一个文件来操作,对一个文件的读写操作会调用内核提供的系统命令,返回一个file descriptor(fd,文件描述符)。而对一个socket的读写也会有对应的描述符,称为socketfd(socket描述符),描述符就是一个数字,指向内核中的一个结构体(文件路径,数据区等一些属性)。
根据UNIX网络编程对I/O模型的分类,UNIX提供了5中I/O模型,如下:
3.1.1 阻塞I/O模型
最常用的I/O模型的就是阻塞I/O模型,缺省情形下,所有的操作符都是阻塞的。
以套接字接口为例来讲解此模型:在进程空间中调用recvfrom,其系统调用直到数据包到达且被复制到应用进程的缓冲区或者发送错误时才返回,在此期间一直会等待,进程从调用recvfrom开始到它返回的整段时间内都是被阻塞的,因此被称为阻塞I/O模型。如图所示:
3.1.2 非阻塞I/O模型
recvfrom从应用层到内核的时候,如果该缓冲区没有数据的话,就直接返回一个EWOULDBLOCK错误,一般都对非阻塞I/O模型来进行轮询检查这个状态,看内核是不是有数据到来,如图所示:
3.1.3 I/O复用模型
Linux提供select/poll,进程通过将一个或多个fd传递给select或poll系统调用,阻塞在select操作上,这样select/poll就可以帮我们侦测到多个fd是否处于就绪状态。select/poll是顺序扫描fd是否就绪,而且支持的fd数量有限,因此它的使用受到了一些制约。Linux还提供了epoll系统调用,epoll使用基于事件驱动方式代替顺序扫描,因此性能更高。当有fd就绪时,立即回调函数rollback。如图
3.1.4 信号驱动I/O模型
首先开启套接字信号驱动I/O功能,并通过系统调用sigaction执行一个信号处理函数(此系统调用立即返回,进程继续工作,它是非 阻塞的)。当数据准备就绪时,就为该进程生成一个SIGIO信号,通过信号回调通知应用程序调用recvfrom来读取数据,并通知主循环函数处理数据,如图:
3.1.5 异步I/O
告知内核启动某个操作,并让内核在整个操作完成后(包括将数据从内核复制到用户自己的缓冲区)通知我们。这种模型与信号驱动模型的主要区别是:信号驱动I/O由内核通知我们何时可以开始一个I/O操作;异步I/O模型由内核通知我们I/O操作何时已经完成。如图:
3.2 I/O多路复用技术
在I/O编程过程中,当需要 同时处理多个客户端接入请求时,可以利用多线程或者I/O多路复用技术进行处理。I/O多路复用技术通过把多个I/O的阻塞复用到同一个select的阻塞上,从而使得系统在单线程的情况下可以同时处理多个客户端请求。与传统的多线程/多进程模型比,I/O多路复用的最大优势是系统开销小,系统不需要创建新的额外进程或者线程,也不需要维护这些进程和线程的运行,降低了系统的维护工作量,节省了系统资源,I/O多路复用的主要应用场景如下:
- 服务器需要同时处理多个处于监听状态或者多个连接状态的套接字;
- 服务器需要同时处理多种网络协议的套接字
目前支持I/O多路复用的系统调用有select,pselect,poll,epoll,在Linux网络编程中,很长一段时间都使用select做轮询和网络事件通知,然而select的一些固有缺陷导致了它的应用受到了很大的限制,最终Linux不得不在新的内核版本中寻找select的替代方案,最终选择了epoll。epoll与select的原理比较类似, 为了克服select的缺点,epoll作了很多重大的改进,总结如下:
1.支持一个进程打开的socket描述符(FD)不受限制(仅受限于操作系统的最大文件句柄数)
select的最大缺陷就是单个进程所打开的FD是有一定限制的,它由FD_SETSIZE设置,默认值是1024。对于那些需要支持上万个TCP连接的大型服务器来说太少了。而epoll没有这个限制,它所支持的FD上限是操作系统的最大句柄数,这个数字远远大于1024。例如,在1GB内存的机器上大约是10万个句柄左右,具体的值可以通过命令
cat /proc/sys/fs/file-max
来查看,通常情况下这个值跟系统的内存关系比较大。
2.I/O效率不会随着FD数目的增加而线性下降
传统select/poll的另一个致命弱点,就是当你拥有一个很大的socket集合时,由于网络延迟或者链路空闲,任意时刻只有少部分的socket是“活跃”的,但是select/poll每次调用都会线性扫描全部的集合,导致效率线性下降。epoll不存在这个问题,它只会对“活跃”的socket进行操作-----这是因为在内核实现中,epoll是根据每个fd上面的callback函数实现。那么,只有“活跃”的socket才会主动调用callback函数,其他idle状态的socket不会。
3.使用mmap加速内核与用户空间的消息传递
无论是select,poll还是epoll都需要内核把FD消息通知给用户空间,如何避免不必要的内存复制就显得非常重要,epoll是通过内核和用户空间mmap同一块内存来实现的。
4.epoll的API更加简单
包括创建一个epoll描述符,添加监听事件,阻塞等待所监听的事件发生,关闭epoll描述符等。
3.3 Java中的I/O模型
Java 共支持 3 种网络编程模型/IO 模式:BIO(同步并阻塞)、NIO(同步非阻塞)、AIO(异步非阻塞)
阻塞与非阻塞
主要指的是访问IO的线程是否会阻塞(或处于等待)
线程访问资源,该资源是否准备就绪的一种处理方式
同步和异步
主要是指的数据的请求方式
同步和异步是指访问数据的一种机制
3.3.1 BIO(同步并阻塞)
Java BIO就是传统的 socket编程。
BIO(blocking I/O) : 同步阻塞,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,可以通过线程池机制改善(实现多个客户连接服务器)。
工作机制:
生活中的例子:
BIO问题分析:
- 每个请求都需要创建独立的线程,与对应的客户端进行数据 Read,业务处理,数据 Write
- 并发数较大时,需要创建大量线程来处理连接,系统资源占用较大
- 连接建立后,如果当前线程暂时没有数据可读,则线程就阻塞在 Read 操作上,造成线程资源浪费
3.3.2 NIO(同步非阻塞)
同步非阻塞,服务器实现模式为一个线程处理多个请求(连接),即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有 I/O 请求就进行处理
生活中的例子:
3.3.3 AIO(异步非阻塞)
AIO 引入异步通道的概念,采用了 Proactor 模式,简化了程序编写,有效的请求才启动线程,它的特点是先由操作系统完成后才通知服务端程序启动线程去处理,一般适用于连接数较多且连接时间较长的应用。
Proactor 模式是一个消息异步通知的设计模式,Proactor 通知的不是就绪事件,而是操作完成事
件,这也就是操作系统异步 IO 的主要模型。
生活中的例子:
3.3.4 BIO、NIO、AIO 适用场景分析
- BIO(同步并阻塞) 方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4以前的唯一选择,但程序简单易理解
- NIO(同步非阻塞) 方式适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,弹幕系统,服务器间通讯等。编程比较复杂,JDK1.4 开始支持
- AIO(异步非阻塞) 方式使用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分调用 OS 参与并发操作, 编程比较复杂,JDK7 开始支持。