操作系统IO模型
Unix下有5种可用的IO模型
Blocking IO(阻塞IO)
最常见的IO模型,进行读写时,线程阻塞于IO调用,等待IO完成。
Nonblocking IO(非阻塞IO)
用户进程发起一个IO操作后,如果没有数据,内核返回一个EWOULDBLOCK错误。
相比于阻塞式的IO,非阻塞IO模型有两个主要的不同点:
- 用户进程在发起IO调用后会立即返回,因此用户进程可以去执行其他任务,
- 对于IO操作是否就绪,要求用户进程不停轮询。
IO multiplexing (IO多路复用)
在互联网业务中,网络IO是我们主要需要关注的IO场景。不管是阻塞式IO还是非阻塞式IO,都是一个线程负责一个文件描述符的读写,在这种情况下会造成以下问题:
- 服务器的最大线程数决定了这台服务器网络IO的并发数。
- 文件描述符时而可读,时而可写,时而空闲,大量线程会处于阻塞状态,浪费系统资源。
未了避免性能瓶颈和资源浪费,我们可以让多个文件描述符共用一个线程,也就是所谓的IO多路复用,具体的做法在于让IO阻塞在select,poll或epoll上,而不是阻塞在真正的IO系统调用上。
select
例子
// select API
/* According to POSIX.1-2001 */
#include <sys/select.h>
/* According to earlier standards */
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);
#include <stdio.h>
#include <stdlib.h>
#include <sys/select.h>
int
main(void)
{
fd_set rfds;
struct timeval tv;
int retval;
/* Watch stdin (fd 0) to see when it has input. */
FD_ZERO(&rfds);
FD_SET(0, &rfds);
/* Wait up to five seconds. */
tv.tv_sec = 5;
tv.tv_usec = 0;
retval = select(1, &rfds, NULL, NULL, &tv);
/* Don't rely on the value of tv now! */
if (retval == -1)
perror("select()");
else if (retval)
printf("Data is available now.\n");
/* FD_ISSET(0, &rfds) will be true. */
else
printf("No data within five seconds.\n");
exit(EXIT_SUCCESS);
}
select存在的问题
select 使用一个fd_set来存放需要监听的文件描述符:
- 每一个fd_set上注册的fd数量不能大于1024
- select 的返回值只提示有文件描述符就绪,具体哪个文件描述符就绪还需要遍历fd_set
- 每次调用select后,所有fd_set 都会被清空,所以调用select后需要将文件描述符重新加入到fd_set中
- select的入参中将fd分为三类,read,write和except, 往select传入fd_set的时候需要分类传入的,操作繁琐。
poll
poll的实现和select非常相似,只是描述fd集合的方式不同,poll使用pollfd结构而不是select的fd_set结构,其他的都差不多,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制。
epoll
例子
//用户数据载体
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
//fd装载入内核的载体
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
//三板斧api
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
#define MAX_EVENTS 10
struct epoll_event ev, events[MAX_EVENTS];
int listen_sock, conn_sock, nfds, epollfd;
/* Set up listening socket, 'listen_sock' (socket(),
bind(), listen()) */
epollfd = epoll_create(10);
if(epollfd == -1) {
perror("epoll_create");
exit(EXIT_FAILURE);
}
ev.events = EPOLLIN;
ev.data.fd = listen_sock;
if(epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) {
perror("epoll_ctl: listen_sock");
exit(EXIT_FAILURE);
}
for(;;) {
nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
if (nfds == -1) {
perror("epoll_pwait");
exit(EXIT_FAILURE);
}
for (n = 0; n < nfds; ++n) {
if (events[n].data.fd == listen_sock) {
//主监听socket有新连接
conn_sock = accept(listen_sock,
(struct sockaddr *) &local, &addrlen);
if (conn_sock == -1) {
perror("accept");
exit(EXIT_FAILURE);
}
setnonblocking(conn_sock);
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = conn_sock;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, conn_sock,
&ev) == -1) {
perror("epoll_ctl: conn_sock");
exit(EXIT_FAILURE);
}
} else {
//已建立连接的可读写句柄
do_use_fd(events[n].data.fd);
}
}
}
触发模式
epoll的有两种触发模式,ET(边缘触发)模式和LT(水平触发)模式
ET(边缘触发)
在ET模式下,只有文件文件描述符状态发生变更时才会传递事件。如果事件没有处理完,下次再调用epoll_wait的时候,不会再通知用户程序。
由于如果事件没处理完不会再通知,因此当文件描述符就绪时必须读写完所有数据,在这种情况下,如果某个socket源源不断收到非常多的数据,会导致其他socket饥饿。
解决scoket饥饿的一种办法是:解决办法是为每个已经准备好的描述符维护一个队列,这样程序就可以知道哪些描述符已经准备好了但是并没有被读取完,然后程序定时或定量的读取,如果读完则移除,直到队列为空,这样就保证了每个fd都被读到并且不会丢失数据。
LT(水平触发)
在LT模式下将事件传递给用户程序之后,如果没有被处理或者未处理完(如数据未完全读写),那么在下次调用时还会反馈给用户程序。
这种模式下,read事件操作没有太多的问题。但是写操作存在频繁通知的情况,因为socket空闲时发送缓冲区一般是不满的,调用epoll_wait时会一直通知写事件。
对于socket不停触发的问题,有两种解决方案:
- 需要向socket写数据时,将该socket加入到epoll等待可写事件,接收到socket可写事件后,调用write()或send()发送数据,当数据全部写完后, 将socket描述符移出epoll列表。
- 向socket写数据时直接调用send()发送,当send()返回错误码EAGAIN,才将socket加入到epoll,等待可写事件后再发送数据,全部数据发送完毕,再移出epoll模型,改进的做法相当于认为socket在大部分时候是可写的,不能写了再让epoll帮忙监控。(这个有点搞不太懂,一个是搞不清楚epoll什么情况下会返回,我估计我得写一个完整的过程)
两种触发方式的不同可以参考官方给出的这个例子
假设发生如下的场景:
1。 有一个管道,它的读文件描述符(rfd)被注册到某个epoll实例上
2。 管道通过写文件描述符写入2kb的数据
3, 对epoll_wait的调用完成(没有文件描述符就绪时会阻塞),返回就绪的读文件描述符
4, 从读文件描述符中读取了1kb读数据
5。 对epoll_wait进行调用
如果是ET模式,第五步将不会再通知该文件描述符可读;
如果是LT模式,第五步回通知文件描述符可读。
Signal Driven IO(信号驱动IO)
这种IO方式的主要思路是让内核在描述符就绪时发送SIGIO信号通知我们,主要过程如下:
- 通过sigaction系统调用安装一个信号处理函数。
- 当数据报准备好读取时,内核就为该进程产生一个SG信号。
- 在信号处理函数中调用 recvfroml读取数据报,并通知主循环数据已准备好待处理。
Asynchronous IO(异步IO)
这种IO模式的工作机制是:告知内核启动某个操作,并让内核在整个操作(包括将数据从内核复制到我们自己的缓冲区)完成后通知我们。这种模型与信号驱动模型的主要区别在于:信号驱动式IO是由内核通知我们何时可以启动一个IO操作,而异步IO模型是由内核通知我们I/O操作何时完成。
Windows 下通过 IOCP 实现了真正的异步 I/O,而在 Linux 系统下的 AIO 并不完善。
IO设计模式
在网络IO中,服务端对一个请求的处理流程可以简单抽象为"建立连接-->读取数据-->业务处理-->返回结果",如果整个流程都使用一个线程处理,那服务器处理请求的并发数就会受限于服务器的最大线程数。
使用IO多路复用并结合线程池,可以提高服务端的并发数量,基于这种思路,有两个网络IO设计模型:Reactor 和 Proactor。
Reactor
Reactor 模式有三种典型的实现方案:
- 单 Reactor ,单进程(线程)
- 单 Reactor ,多进程(线程)
- 多 Reactor, 多进程(线程)
具体选择进程还是线程,更多地是和编程语言及平台相关。例如,Java 语言一般使用线程(例如,Netty),C 语言使用进程和线程都可以。例如,Nginx 使用进程,Memcache 使用线程。
单 Reactor ,单进程(线程)
- Reactor负责监听事件
- 如果是链接建立事件,由 Acceptor 处理,Acceptor 接受连接并创建一个 Handler 来处理连接后续的各种事件。
- 如果不是连接建立事件,则 Reactor 会调用连接对应的 Handler(第 2 步中创建的 Handler)来进行响应。
- Handler 会完成 read-> 业务处理 ->send 的完整业务流程
优点:简单,单进程(线程),不需要线程间的同步和竞争。
缺点:如果Handler 在处理某个连接上的业务时,整个进程(线程)无法处理其他连接的事件,很容易导致性能瓶颈。
应用场景:单 Reactor 单进程只适用于业务处理非常快速的场景,比如Redis。
单 Reactor ,多进程(线程)
- 主线程中,Reactor 监控连接事件。
- 如果是连接建立的事件,则由 Acceptor 处理,Acceptor 接受连接并创建一个 Handler 来处理连接后续的各种事件。
- 如果不是连接建立事件,则 Reactor 会调用连接对应的 Handler(第 2 步中创建的 Handler)来进行响应。
- Handler 只负责响应事件,不进行业务处理;Handler 通过 read 读取到数据后,会发给 Processor 进行业务处理。
- Processor 会在独立的子线程中完成真正的业务处理,然后将响应结果发给主进程的 Handler 处理;Handler 收到响应后通过 send 将响应结果返回给 client。
优点:充分利用多核多 CPU 的处理能力
缺点:
- 多线程数据共享和访问比较复杂,例如,子线程完成业务处理后,要把结果传递给主线程的 Reactor 进行发送,这里涉及共享数据的互斥和保护机制。
- Reactor 承担所有事件的监听和响应,只在主线程中运行,瞬间高并发时会成为性能瓶颈。
多 Reactor, 多进程(线程)
- 父进程中 mainReactor 监控连接建立事件,收到事件后通过 Acceptor 接收,将新的连接分配给某个子进程。
- 子进程的 subReactor 将 mainReactor 分配的连接加入连接队列进行监听,并创建一个 Handler 用于处理连接的各种事件。
- 当有新的事件发生时,subReactor 会调用连接对应的 Handler(即第 2 步中创建的 Handler)来进行响应。
- Handler 完成 read→业务处理→send 的完整业务流程。
优点:
- 父进程和子进程的职责非常明确,父进程只负责接收新连接,子进程负责完成后续的业务处理。
- 父进程和子进程的交互很简单,父进程只需要把新连接传给子进程,子进程无须返回数据。
- 子进程之间是互相独立的,无须同步共享之类的处理(这里仅限于网络模型相关的 select、read、send 等无须同步共享,“业务处理”还是有可能需要同步共享的)
应用场景:Nginx,Memcache和Netty
Proactor
Reactor中的IO本质上是同步的,因为真正的 read 和 send 操作都需要用户进程同步操作(平时开发中所说的异步更多指的是非阻塞的意思),如果把 I/O 操作改为异步就能够进一步提升性能,这就是异步网络模型 Proactor。
异步 I/O 能够充分利用 DMA 特性,让 I/O 操作与计算重叠,但要实现真正的异步 I/O,操作系统需要做大量的工作。目前 Windows 下通过 IOCP 实现了真正的异步 I/O,而在 Linux 系统下的 AIO 并不完善,因此在 Linux 下实现高并发网络编程时都是以 Reactor 模式为主。所以即使 Boost.Asio 号称实现了 Proactor 模型,其实它在 Windows 下采用 IOCP,而在 Linux 下是用 Reactor 模式(采用 epoll)模拟出来的异步模型。
Java中的IO模型
BIO
在读取输入流或者写入输出流时,在读、写动作完成之前,线程会一直阻塞。Java中的IO操作大部分情况下是阻塞的。
NIO
Java中的NIO指的是一种高效的网络IO模型,基于Reactor模型,采用零拷贝技术,面向缓冲区进行数据的读写。 Java NIO 有三个核心组成部分:
- Channel
- Buffer
- Selector
可以简单将Selector理解为epoll ,channel理解为文件描述符,Buffer 是读写缓冲区。
AIO
Java中有提供AIO相关的类,但是由于linux并未实现真正的AIO,在性能上并没有明显的优势,使用AIO的场景较少。