01-Java 套接字编程进阶
前面 《Socket 网络编程:从基础到实践的全面解析(下)》 介绍的基本套接字编程仅适用于小规模的系统。
如果有大量的 client 同时访问 server,即使 server 端使用多线程技术,每个线程服务一个 client,仍然无法扩展到同时支持大量用户。
虽然可以使用线程池进一步优化 server 端,受限于硬件资源,服务端程序仍会遇到所谓的“10k 瓶颈”。
“10k 瓶颈”指的是一种现象,即当并发连接数超过大约10000个时,系统的性能和扩展性可能会受到限制。
这种现象可能出现在各种不同的应用和系统中,例如分布式数据库、Web服务器、消息代理等等。
I/O 多路复用是解决提高服务端吞吐量的技术之一。
从 Java 8 开始,NIO 中的 Selector 接口提供了对 I/O 多路复用的支持,主要用于解决非阻塞 I/O 中进程空转问题。
更多关于 I/O 多路复用的介绍,参考后面的相关知识扩展章节。
除了 Selector 外,NIO 还提供了 Channel 接口,特别是与 Socket 相关的 SocketChannel 和 ServerSocketChannel 实现。
另外,Buffer 也是 Java 8 NIO 提供的另外一个常用接口。
01.1-使用 Selector 实现 TcpEchoServer
在《Socket 网络编程:从基础到实践的全面解析(下)》 我介绍了如何使用 ServerSocket 创建一个 TcpEchoServer。
接下来,我将介绍如何使用 Selector 提供的 I/O 多路复用技术,重新实现 TcpEchoServer。
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
serverSocketChannel.bind(new InetSocketAddress(port));
System.out.println("Server listening on " + port);
Selector selector = Selector.open();
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (selector.select() > 0) {
Iterator iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (key.isAcceptable()) {
// 处理客户端的连接请求
ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
SocketChannel accepted = ssc.accept();
accepted.configureBlocking(false);
accepted.register(key.selector(), SelectionKey.OP_READ);
} else if (key.isReadable()) {
SocketChannel sc = (SocketChannel) key.channel();
// read client message
ByteBuffer allocated = ByteBuffer.allocate(1024);
int read = sc.read(allocated);
if (read == -1) {
// 说明 client socket 已关闭
System.out.println("Client " + sc.getRemoteAddress() + " disconnect");
sc.close();
break;
}
System.out.println(sc.getLocalAddress() + " received message: " + new String(allocated.array(), StandardCharsets.UTF_8));
// echo, write back
allocated.flip();
sc.write(allocated);
allocated.clear();
}
}
}
02-从进程角度看,Socket 到底是个什么?
new Socket()
使用的底层系统调用是 int socket(int domain, int type, int protocol);
1。
当系统调用 socket 成功时,返回的是一个文件描述符(fd);失败时,返回 -1。
在操作系统中,内核通过进程描述符(有些地方也称作进程控制块,PCB)来管理进程。
进程描述符在内核中通过数据结构 task_struct 表示。
task_struct 结构中,files 字段指向 files_struct 结构(表示系统打开文件表)。
files_struct 结构中,fd 字段指向当前进程打开的文件描述符列表。其中 0 表示标准输入,1 表示标准输出,2 表示标准错误输出。
socket 系统调用成功时返回的整型值,是 fd 列表中对应的文件描述符(这就将套接字与文件系统 VFS 关联起来)。
socketfs 是套接字的文件系统,是 VFS 中的一个特殊类型文件系统。
在 Linux 中,可以通过 ps -aux 查看进程的 pid。
获得 pid 后,可以查看 /proc/pid/fd来确定文件当前打开的文件描述符列表。或者,也可以通过lsof−p{pid}/fd 来确定文件当前打开的文件描述符列表。
或者,也可以通过 lsof -p pid/fd来确定文件当前打开的文件描述符列表。或者,也可以通过lsof−p{pid} 查看进程所有打开的文件信息。
samson@localhost:~$ ll /proc/10/fd/
total 0
dr-x------ 2 samson samson 0 Sep 28 15:47 ./
dr-xr-xr-x 7 samson samson 0 Sep 28 15:47 ../
lrwx------ 1 samson samson 0 Sep 28 15:47 0 -> /dev/tty1
lrwx------ 1 samson samson 0 Sep 28 15:47 1 -> /dev/tty1
lrwx------ 1 samson samson 0 Sep 28 15:47 2 -> /dev/tty1
lrwx------ 1 samson samson 0 Sep 28 15:47 255 -> /dev/tty1
samson@localhost:~$ lsof -p 10
COMMAND PID USER FD TYPE DEVICE SIZE NODE NAME
bash 10 samson cwd DIR 0,2 4096 844424930234815 /home/samson
bash 10 samson rtd DIR 0,2 4096 562949953522524 /
bash 10 samson txt REG 0,2 1113504 562949953522536 /bin/bash
bash 10 samson mem REG 0,0 132539 /lib/x86_64-linux-gnu/libnss_files-2.27.so (path dev=0,2, inode=562949953553851)
bash 10 samson 2u CHR 4,1 7036874418117270 /dev/tty1
strace -ff -o out java BIOServer # 查看进程的线程对内核进行了那些调用
02.1-通过写文件的方式进行网络 I/O
Bash 2.04+ 版本以后,默认支持 --enable-net-redirections,即允许 shell 通过重定向方式建立 socket 连接。
这就是 Bash 提供的 net-redirection 功能。
samson@localhost:~$ exec 3 /dev/tcp/www.baidu.com/80
samson@localhost:~$ echo -e "GET / HTTP/1.0n" >&3
samson@localhost:~$ cat 设备控制器,然后进行校验确保未发生错误。
设备控制器产生中断。
操作系统从设备控制器的寄存器中,读取数据到内存。
DMA(直接存储器访问,Direct Memory Access)如何读取数据?
CPU 对 DMA 控制器编程,因此 DMA 控制器知道将数据传输到什么地方。
DMA 对设备控制器(例如磁盘控制器)发出命令,将设备中的数据 -> 设备控制器中,并校验以确保无错误。
DMA 在总线上发送读命令,将设备控制器中的数据传输到内存中。(这里设备控制器不关心读信号来自 CPU 还是 DMA,对它来说都一样)
所有内容写到内存后,设备控制器通过总线应答 DMA,通知其数据传输完成。DMA 增加要使用的内存地址,较少字节数,如果此时字节数仍大于0,则重复2-3中的步骤。
当字节数为0时,DMA 产生中断。
DMA与零拷贝技术[1]
DMA 的优势在于,在设备控制器将数据传输到内核空间(内存)这段过程中,CPU 可以不用参与。
但是,I/O 的第三阶段,从内核空间到用户空间的复制,仍需要 CPU 参与。
有了这两节的背景知识后,我们来看下 Unix 中的5种 I/O 模型。
03.3-5种 I/O 模型
Unix 操作系统下支持5种不同的 I/O 模型:
第一种,阻塞式 I/O 模型。
在该模型下,应用进行 I/O 的第二、第三步骤,应用进程是阻塞的。
第二种,非阻塞式 I/O 模型。
在该模型下,应用进行 I/O 的第二步骤时,应用进程并不是阻塞的,而是轮询的方式来判断应用数据是否已拷贝到内核空间。
第三步骤时,应用进程仍然是阻塞的。
第三中,I/O 复用模型。(下面章节中详细介绍)
在此模型下,应用通过 select / poll 同时监控多个 I/O 设备的读写状态。
应用并不会阻塞在 I/O 的第二步骤上,但却会阻塞在 select / poll 的调用上,直到应用感兴趣的 I/O 设备准备就绪(即数据已拷贝到内核空间)。
第三步骤时,应用进程仍然是阻塞的。
和第一种模型相比,I/O 复用并不显得有什么优势,甚至稍有劣势。不过,I/O 复用的优势在于可以同时监控多个 I/O 设备,从而减轻操作系统的负担。
第四种,信号驱动式 I/O 模型。
在此模型下,I/O 的第二步中也不会阻塞应用进程。
应用进程通过 sigaction 系统调用,向操作系统安装一个信号及信号处理函数。
当数据拷贝到内核空间后,内核会为应用产生一个信号。
第三步骤时,应用进程仍然是阻塞的。
第五种,异步 I/O 模型。
此模型是真正意义上的异步,I/O 过程中的第二、第三步骤都不会阻塞应用。
只不过,目前 linux 对异步 I/O 的支持并不完美。
上述五种 I/O 模型的对比如下图所示:
03.4-I/O 复用(Multiplexing)
在阻塞式 I/O 模型中,操作系统将数据从设备控制器读取到内核空间,再从内核空间复制到用户空间。
发起 I/O 请求的应用进程被阻塞,直至数据被完全复制到用户空间。
在高并发场景时,会有大量的线程因等待 I/O 完成而被阻塞,浪费 CPU 资源。
而且,大量线程会导致频繁地上下文切换,造成额外的性能开销。
在非阻塞式 I/O 模型中,操作系统将数据从设备控制器读取到内核空间的过程中,发起 I/O 请求的进程是不会阻塞的,而是会通过轮询(poll)方式判断是否完成。
在高并发场景时,大量的线程不断轮询,会造成 CPU 空转,从而浪费硬件资源。
为解决上述两种模型的不足,I/O 多路复用模型被设计出来。
在 I/O 多路复用模型中,应用进程通过 selcet 或 poll 这两个函数调用来同时监控多个文件的状态,一旦其状态变得可读则立即通知对应的应用进程。
从而减少不必要的轮询、上下文切换等开销。在高并发场景,I/O 多路复用是提高服务端吞吐量的重要手段之一。
select() 的接口定义如下[1]:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds,
struct timeval *timeout);
其中:
- readfds,是一个文件描述符集合(fd),select 负责监控这些 fd 是否可读。
- writefds,是一个文件描述符集合(fd),select 负责监控这些 fd 是否可写。
- exceptfds,是一个文件描述符集合(fd),select 负责监控这些 fd 是否发生异常状况。
- nfds,是一个整型值,应当设置为 readfds/writefds/exceptfds 集合中的最大 fd + 1。
select() 实际上会检查在 [0, nfds) 范围中的 fd 是否可读、可写、有异常。
- timeout,是一个 timeval 结构体,表示 select() 由于等待 fd 就绪而阻塞的最长时间。
select() 方式有个明显的缺点,就是可以同时监控的 fd 的熟练受限,最大是 1024。针对并发特别高的场景,仍不适用。
poll() 的接口定义如下[2]:
int poll(struct pollfd fds[], nfds_t nfds, int timeout);
// pollfd
struct pollfd {
int fd;
short events;
short revents;
};
其中:
- fds,是一个 pollfd 结构体数组,其中的 fd 是关心的文件描述符,events 是感兴趣的事件,例如可读、可写等,revents 是发生在 fd 上的事件。
- nfds,是数组的长度,
- timeout,单位是毫秒,与 select 中的 timeout 作用类似。
epoll api[3] 与 poll 类似,可以同时监控一组文件描述符,并在文件描述符可用是通知应用进程。
只不过 epoll 并不是一个系统调用,而是一组系统调用的组合。
epoll 的核心是 epoll 实例,是内核空间的一个数据结构。
不过,从用户空间的角度看,这个数据接口可以看作是一个容器,包含两个列表:
- 关注列表,是一组 fd,由应用程序注册在 epoll 实例上,epoll 负责监控它们的状态。
- 就绪列表,是一组 fd,表示可以进行 I/O 的文件,是关注列表的子集。
epoll api 由下述系统调用组成:
- epoll_create,
int epoll_create(int size);
,负责在内核空间创建一个 epoll 实例。size 只是一个提示信息,从 linux 2.6.8 开始,这个参数就被忽略了。
- epoll_ctl,
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
,负责向关注列表中添加、修改、删除 fd;
epfd 是 epoll 实例,就是 epoll_crate 返回的值;
op 是 EPOLL_CTL_ADD/EPOLL_CTL_MOD/EPOLL_CTL_DEL 中的一个,表示添加、修改、删除;
fd 是要添加到关注列表的 fd;
event 包含 events 和 data,其中的 data 在 fd 就绪时返回。
- epoll_wait,它的功能与 select() 或 poll() 类似,会将当前应用阻塞在 epoll_wait() 上,直到 I/O 就绪或被中断或超时。