在网络通信的过程中,应用程序可能接收数据包,也有可能发送数据包。应用程序接受发送数据包的大致流程如下:
#include
struct iovec { /* Scatter/gather array items */
void *iov_base; /* Starting address */
size_t iov_len; /* Number of bytes to transfer */
};
struct msghdr {
void *msg_name; /* optional address */
socklen_t msg_namelen; /* size of address */
struct iovec *msg_iov; /* scatter/gather array */
size_t msg_iovlen; /* # elements in msg_iov */
void *msg_control; /* ancillary data, see below */
size_t msg_controllen; /* ancillary data buffer len */
int msg_flags; /* flags on received message */
};
硬中断:由硬件产生,例如磁盘、网卡、键盘、时钟等,每个设备都有自己的硬件请求,基于硬件请求CPU可以将相应的请求发送到对应的硬件驱动程序上。硬中断可以直接中断CPU。
软中断:处理方式非常像硬中断,但是是由当前正在运行的进程产生的。不会直接中断CPU。
ksoftirqd线程:每个CPU都会绑定一个 ksoftirqd 线程专门处理软中断响应。
sk_buffer缓冲区:维护网络帧结构的双向链表,链表中的每个元素都是一个数据帧。
对上面的应用程序接收和发送数据的过程进一步抽象,以读取网卡中的数据为例:
其可以分为两个主要的阶段:
有了上面应用程序读取网卡数据抽象出来的两个过程,可以进一步理解阻塞和非阻塞的含义。
阻塞和非阻塞
阻塞:
用户线程发生系统调用会去读取 Socket 缓冲区的内容,如果此时 Socket 缓冲区没有数据,则会阻塞用户线程,直到 Socket 缓冲区有数据之后唤醒用户线程,用户线程负责将 Socket 缓冲区的内容拷贝到用户空间中,这个过程用户线程也是阻塞的。因此阻塞发生在第一、第二阶段。
非阻塞:
与阻塞相比,在第一阶段,如果此时 Socket 缓冲区没有数据,则会直接返回。如果 Socket 缓冲区有数据,则用户线程负责将 Socket 缓冲区的内容拷贝到用户空间。因此非阻塞的特点是在第一阶段不会等待,但是第二阶段仍然会等待。
同步和异步
同步:
在数据拷贝阶段,是由用户线程负责将 Socket 缓冲区的内容拷贝到用户空间。
异步:
在数据拷贝阶段,是由内核来负责将Socket缓冲区的内容拷贝到用户空间,接着通知用户线程IO操作已经完成。
异步需要操作系统内核的支持,做的比较好的是Windows系统,Linux系统还不是很完善。
因此可以这么理解成阻塞与非阻塞主要关注的是数据准备阶段,而同步与异步主要关注的是数据拷贝阶段。
是不是可以理解为什么没有异步阻塞这种模型了呢?
阻塞的意思意味着用户线程要去等待 Socket 缓冲区的数据,而异步的意思意味着数据从用户态拷贝到内核态不是由用户线程执行的,既然数据的拷贝不是由用户线程进行的,那么用户线程的等待就没有意义了。
在《UNIX网络编程》中一共介绍了5种IO模型:阻塞IO、非阻塞IO、IO多路复用、信号驱动IO、异步IO。其演变过程的原则是:如何用尽可能少的线程去处理更多的连接。
由上面可知,阻塞IO模型会在数据准备阶段和数据拷贝阶段用户线程都会进行等待。
先来看看非阻塞IO的读写过程:
public static void main(String[] args) throws Exception {
// 保留所有的Socket连接
LinkedList clients = new LinkedList<>();
ServerSocketChannel ss = ServerSocketChannel.open();
ss.bind(new InetSocketAddress(9090));
ss.configureBlocking(false); //设置为非阻塞IO模式
while (true) {
SocketChannel client = ss.accept(); //没有连接来则不会阻塞
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);
}
for (SocketChannel c : clients) {
// 遍历每个Socket连接,处理具体的读写事件
}
}
}
}
由于是非阻塞的,在上面的轮询各个 Socket 连接可以交由一个线程来做,处理具体的IO事件和业务的读写逻辑的时候可以交由线程池去处理。这样就可以使用少量的线程来处理较多的连接了。
非阻塞IO与阻塞IO相比已经在线程的数量的优化迈出巨大的一步了。但是在高并发的场景下仍然有个致命的问题就是:每次轮询判断是否有数据时都要发生一次系统调用,进行上下文的切换,随着并发量的增加,这个开销也是非常巨大的。
应用场景:C10K以下的场景。
再强调一遍,IO模型的核心是应用如何使用少量的线程来处理大量的连接。
上面的非阻塞IO提供了解决阻塞IO需要创建大量线程的问题,那么在高并发场景下存在大量系统调用的问题又该如何解决呢?于是就演变出IO多路复用这个IO模型。
那么如何去实现复用?
在非阻塞IO中需要在用户空间中轮询是否有数据,这样的每次轮询就会发生一次系统调用,那么换个方式,能不能将这个轮询的过程交由内核空间来完成,用户空间只需要一次调用,就能够获取所有连接的事件,这样的话只需要一次系统调用(这个系统调用就是多路复用器)就能找到所有有状态的连接了。
Linux提供了三种多路复用器:
这里的fd数组其实是一个Bitmap结构,该结构最多支持1024个位(由内核参数的 FD_SETSIZE 控制),因此 select 只支持最多处理 1024 个 Socket 连接。每一位对应的索引就是一个文件描述符fd。
在 Linux 中 Socket 也是一个文件,在 PCB 控制块(task_struct 结构体)中有一个属性files_struct *file s的结构体属性,它最终指向了一个保存进程中所有打开文件的打开文件表数组,该数组的元素是一个封装了文件信息的 file 结构体,打开文件表数组中的下标也就是常说的文件描述符fd。
注意:由于内核返回的文件描述符数组会修改到原来的状态,因此用户线程在每个处理完之后需要重新设置文件描述符的状态。
性能开销:
poll 的工作原理和 select 没有太大的区别,主要是在文件描述符数组的结构和文件描述符大小的限制上。其将 Bitmap 换成一个没有固定长度的链表,链表中的元素如下:
struct pollfd {
int fd; // 文件描述符
short events; // 需要监听的事件
short revents; // 实际发生的事件,由内核修改
}
性能开销方面和 select 是一样的。
Select 和 Poll 两者的问题:
Epoll 提供了解决 select 和 poll 性能瓶颈的方案,Epoll 会在内核中保存要监听的 Socket 集合和在适当的时候通知具体就绪的IO事件。
Epoll 这个多路复用器主要包括三个系统调用:
内核提供的一个创建 epoll 对象的系统调用,在用户进程调用 epoll_create 时,内核会创建一个eventpoll 的结构体,并且创建相对应的 file 对象与之相关联(也就是说 epoll 对象也是个文件,“一切皆文件”),同时将这个文件放入进程的打开文件表中。eventpoll 的定义如下:
struct eventpoll {
// 等待队列,阻塞在epoll上的线程会放在这里
wait_queue_head_t wq;
// 就绪队列,IO就绪的Socket连接会放在这里
struct list_head rdllist;
// 红黑树,用来监听所有的socket连接
struct rb_root rbr;
// 关联的文件对象
struct file *file;
}
当创建出来 epoll 的对象 eventpoll 后,可以利用 epoll_ctl 向 eventpoll 对象中添加要管理的 Socket连接。这个过程如下:
strict epitem {
// 指向所属的epoll对象
struct eventpoll *ep;
// 注册感兴趣的事件,也就是用户空间的epoll_event
struct epoll_event event;
// 指向epoll中的就绪队列
struct list_head rdllink;
// 指向epoll中的红黑树节点
struct rb_node rbn;
// 指向epitem所表示的Socket文件
struct epoll_filefd ffd;
}
边缘触发和水平触发是 Epoll 中的两个触发模式,从上面的执行流程来看,边缘触发和水平触发最关键的区别在于当 socket 接收缓冲区还有数据可读时,epoll_wait 是否会清空 rdllist。
边缘触发:
epoll_wait 获取到IO就绪的 Socket 后,不管 Socket 上是否还有数据可读,都会直接清空 rdllist。因此使用边缘触发模式需要一次性尽可能的将 socket 上的数据读取完毕,否则用户程序无法再次获得这个 socket 中的数据,直到该 socket 下次有数据到达被重新放入 rdllist。因此边缘触发只会从epoll_wait 中苏醒一次。
水平触发:
epoll_wait 获取到IO就绪的 Socket 后不会清空 rdllist,假如 Socket 中的数据只读取了一部分没有读取完毕,再次调用 epoll_wait(用户线程调用)会检查这些 Socket 中的接受缓冲区是否还有数据可读,如果有数据可读,则会将这些 socket 重新放回 rdllist 中。下次再调用 epoll_wait 时仍然可以获得这些没有读取完数据的 Socket。
总之:边缘触发模式的 epoll_wait 会清空就绪队列,水平触发模式的 epoll_wait 不会清空rdllist。
总结epoll的优化点
应用场景:各大主流网络框架用到的网络IO模型,可以解决C10K、C100K甚至是C1000K的问题。
用户线程通过系统调用 sigaction 函数发起一个IO请求,在对应的 Socket 注册一个信号回调,不阻塞用户线程,用户线程继续工作。当数据就绪的时候(放到Socket缓冲区),就会为该线程产生一个SIGIO信号,通过信号回调通知线程进行相关的IO操作。
信号驱动仍然是同步IO,因为在数据拷贝阶段仍然是需要用户线程自己拷贝的。
TCP很少会去使用信号驱动IO,因为其信号事件比较多;UDP只有一种信号事件,可以采用信号驱动IO。
如果信号驱动IO是通知用户线程数据已经到达了可以去拷贝了,那么异步IO则会将数据拷贝到用户空间。
异步IO的系统调用需要内核的支持,目前只有 Window 中的 IOCP 实现了非常成熟的异步IO机制,Linux 系统对异步IO机制的实现不够成熟,但是在5.1版本后引入了 io_uring 异步IO库,改善了一些性能。
上面的IO模型是从内核空间的角度进行的,重点在关注怎么高效的去处理IO事件;从用户空间角度的话就可以引出IO线程模型,这些不同的IO线程模型都是在讨论如何在多线程中分配工作,谁负责接收连接、谁负责响应读写、谁负责计算、谁负责发送和接收等等。Reactor模型就对这些分工做出了具体的划分。
常见的IO线程模型有 Reactor 和 Proactor。
利用NIO对IO线程进行不同的分工:
例如使用 epoll 来进行IO多路复用、监听IO就绪事件。
Reactor 模型是同步非阻塞网络模型,数据拷贝阶段仍然需要用户线程自己从内核拷贝到用户空间。而 Proactor 模型是异步的网络模型,数据拷贝阶段都不需要用户线程自己拷贝,而是由操作系统内核拷贝到用户空间之后通知用户线程去读取,因此其要使用异步IO的方式才能够实现。其工作模式如下:
【来自《小林coding》】
Netty支持三种Reactor模型,但是常用的是主从Reactor多线程模型。注意三种Reactor只是一种设计思想,具体实现不一定严格按照其来实现。Netty中的主从Reactor多线程模型如下:
EventLoopGroup eventGroup = new NioEventLoopGroup(1);
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(eventGroup);
指定线程数为1。
2. 配置单Reactor多线程
EventLoopGroup eventGroup = new NioEventLoopGroup();
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(eventGroup);
默认线程数为CPU*2。
3. 配置主从Reactor多线程
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGroup, workerGroup);
【参考】
https://mp.weixin.qq.com/s/zAh1yD5IfwuoYdrZ1tGf5Q