linux网络编程 – 五种IO模型

IO模型大体分为如下五种:

  • 阻塞IO模型
  • 非阻塞IO模型
  • IO多路复用模型
  • 信号驱动IO模型
  • 异步IO模型

阻塞IO模型

在阻塞IO模式下,当应用程序发起IO操作(比如读取文件或者网络通信),如果数据没有准备好或者无法立即处理,IO操作会阻塞当前线程或进程,直到数据准备好或者超时才返回结果。伪代码如下:

listenfd = socket();            // 创建一个socket
bind(listenfd);                 // 绑定
listen(listenfd);               // 监听
while(true){
    connfd = accept(listenfd);  //阻塞,建立连接
    int n = read(connfd, buffer);// 阻塞读取数据
    ···                         // 处理
    close(connfd);              // 关闭连接
}

采用阻塞IO模型,如上代码会在read出阻塞,直到客户端发来消息为止。这会导致的问题是,当在read阻塞,那么如果此时有新的客户端加入,那么无法及时响应。

img

非阻塞IO模型

在非阻塞IO模式下,应用程序发起IO操作后,即使数据没有准备好或无法立即处理,IO操作也会立即返回,不会阻塞当前线程或进程。需要通过轮询或者其他方式不断检查IO是否完成。示例代码如下:

listenfd = socket();            // 创建一个socket
bind(listenfd);                 // 绑定
listen(listenfd);               // 监听
set_non_blocking(listenfd);     // 设置为非阻塞模式
while(true){
    connfd = accept(listenfd);  // 阻塞尝试建立连接
    int n = read(connfd, buffer);// 尝试非阻塞读取数据,返回小于0的值,表示该读取无效
    if(n > 0){
        ···                     // 处理数据
    }
    close(connfd);              // 关闭连接
}

这样可以源源不断的建立新的连接,知道read有数据读入时,才会转去处理数据,如上的代码并不完美,因为当建立连接没有读取数据,之后就会关闭该连接,只是为了演示非阻塞IO的概念。

img

另辟蹊径 – thread

使用一种更好的方式,可以解决非阻塞IO既要求连接的建立并能阻塞执行read函数,又要求能源源不断的接受新的连接,最好的方式是使用线程机制,我们可以在每次连接建立之后,去开辟一个新的线程,来处理这个连接的read请求,示例代码如下:

listenfd = socket();            // 创建一个socket
bind(listenfd);                 // 绑定
listen(listenfd);               // 监听

while(true){
    connfd = accept(listenfd);  // 阻塞尝试建立连接
    thread_crate(recvThread, connfd);// 创建一个线程
}

void recvThread(connfd){
    int n = read(connfd, buffer);// 阻塞读取数据
    ···                         // 处理数据
    close(connfd);              // 关闭连接
}

这样既不会影响主线程不断接受连接,又不会影响每个连接的数据接收,不过这种方式也是存在缺点的,例如在存在大量客户端连接的情况下,创建大量的线程,会非常消耗资源,并且当CPU处理不过来也会阻塞一部分线程的执行。

IO多路复用模型

由于IO操作必须通过系统调用,由用户态切换到内核态去执行IO操作,如果频繁的从用户态切换到内核态,势必会造成非常大的切换开销,因此我们考虑引入IO多路复用技术。准确的来说,就是主线程只发起一次系统调用,然后由操作系统提供的IO管理机制去处理这些连接,然后在返回给用户态,这样就避免了大量的切换开销,同时由于只需要一个或多个线程去处理这些连接,也会节约大量内存。

在IO多路复用中,”多路”指的是多个IO通道(例如网络连接、文件描述符等),而”复用”则表示将这些IO通道汇集到一个地方进行管理和监控。(复用的另一种说法是复用系统调用,从原先非阻塞情况下,每个客户端都需要去调用系统调用去询问内核数据是否准备就绪,改变为了只执行一次系统调用)换句话说,IO多路复用是一种技术,允许程序同时监视和处理多个IO操作,而不必为每个IO操作创建一个独立的线程或进程。

也可以一句话解释:单线程(进程)同时管理若干个文件描述符的IO操作的一种方法。

IO多路复用的几种常见实现:

  • select
  • poll
  • epoll

使用select、poll、epoll等机制,通过一个进程或线程同时监听多个IO事件,实现在一个线程中处理多个IO操作。

selectpollepoll 都是用于实现IO多路复用的系统调用,但它们之间有一些区别。下面是它们的主要区别:

select

下面简要介绍一下 select 的实现原理:

  1. 文件描述符集合: 在调用 select 函数之前,应用程序需要通过 fd_set 数据结构来描述需要监视的文件描述符集合。fd_set 实际上是一个位图,每个文件描述符对应一个位,用于标识该文件描述符是否需要监视。
  2. 调用 select 函数: 应用程序调用 select 函数并传递 fd_set 结构,以及监视IO事件的超时时间。
  3. 内核处理: 当应用程序调用 select 函数时,内核会将 fd_set 结构拷贝到内核空间,并进行相应的事件监视。
  4. 事件监视: 内核会遍历需要监视的文件描述符集合,并检查每个文件描述符的状态,包括是否有数据可读、是否可以写入等。
  5. 返回结果: 当有IO事件发生或者超时时,内核会修改 fd_set 结构,标记哪些文件描述符发生了事件。然后将修改后的 fd_set 结构拷贝回用户空间,供应用程序处理。
  6. 应用程序处理: 应用程序根据 fd_set 结构中的标记来判断哪些文件描述符发生了事件,然后进行相应的IO操作处理。

需要注意的是,select 函数的效率较低,主要原因是每次调用 select 都需要将整个 fd_set 结构从用户空间拷贝到内核空间,然后再将修改后的结果拷贝回用户空间。这种复制操作在文件描述符数量较大时会影响性能。因此,在高并发、大规模IO操作的场景下,通常会选择使用更高效的 pollepoll 来代替 select

select的优缺点:

  • select 是最古老的IO多路复用机制之一,可跨平台使用。
  • 它使用一个 fd_set 的数据结构来管理需要监视的文件描述符,最大支持1024个文件描述符。
  • select 每次调用时都需要将需要监视的文件描述符集合传递给系统调用,然后系统调用会阻塞直到有IO事件发生或者超时。
  • 由于每次调用都要传递整个文件描述符集合,当文件描述符数量较大时,效率会降低。
  • select是通过线性遍历的方式去访问文件描述符集合,速度慢。

select函数的定义如下。

int select(
    int nfds,
    fd_set *readfds,
    fd_set *writefds,
    fd_set *exceptfds,
    struct timeval *timeout);
// nfds:监控的文件描述符集里最大文件描述符加1
// readfds:监控有读数据到达文件描述符集合,传入传出参数
// writefds:监控写数据到达文件描述符集合,传入传出参数
// exceptfds:监控异常发生达文件描述符集合, 传入传出参数
// timeout:定时阻塞监控时间,3种情况
//  1.NULL,永远等下去
//  2.设置timeval,等待固定时间
//  3.设置timeval里时间均为0,检查描述字后立即返回,轮询

服务端代码如下写法:

使用一个线程不断接受连接,并将socket文件描述符放入一个list中。

while(1) {
  connfd = accept(listenfd);
  fcntl(connfd, F_SETFL, O_NONBLOCK);
  fdlist.add(connfd);
}

使用另外一个线程,不断的将list交给select去处理

while(1) {
  // 把一堆文件描述符 list 传给 select 函数
  // 有已就绪的文件描述符就返回,nready 表示有多少个就绪的
  nready = select(list);
  ...
}

不过,当 select 函数返回后,用户依然需要遍历刚刚提交给操作系统的 list。

只不过,操作系统会将准备就绪的文件描述符做上标识,用户层将不会再有无意义的系统调用开销。

while(1) {
  nready = select(list);
  // 用户层依然要遍历,只不过少了很多无效的系统调用
  for(fd <-- fdlist) {
    if(fd != -1) {
      // 只读已就绪的文件描述符
      read(fd, buf);
      // 总共只有 nready 个已就绪描述符,不用过多遍历
      if(--nready == 0) break;
    }
  }
}

poll

他与select最显著的区别就是去掉了只能创建1024个连接的限制。

  • poll 是对 select 的改进,也可跨平台使用。
  • 它使用一个 pollfd 的数据结构来管理需要监视的文件描述符,没有数量限制。
  • poll 每次调用时只需要传递一个 pollfd 结构的数组,不需要传递整个文件描述符集合。
  • select 相比,poll 在处理大量文件描述符时效率更高。
int poll(struct pollfd *fds, nfds_tnfds, int timeout);

struct pollfd {
  intfd; /*文件描述符*/
  shortevents; /*监控的事件*/
  shortrevents; /*监控事件中满足条件返回的事件*/
};

epoll

epoll是目前最先进的IO管理机制,他相对于select or poll来说,他有如下优点:

  • epoll 是 Linux 特有的IO多路复用机制,不支持跨平台。
  • 它使用三个系统调用 epoll_createepoll_ctlepoll_wait 来管理需要监视的文件描述符。
  • 内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可。
  • 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。select和poll每次都会线性扫描整个待检测集合,集合越大速度越慢,epoll使用的是回调机制,效率高,处理效率也不会随着检测集合的变大而下降。
  • 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。
  • epoll 的设计更加高效和灵活,可以处理大量的文件描述符,它的存储不使用线性结构,转而使用红黑树来管理待检测集合的,因此在文件描述符数量增加时性能基本不受影响。
  • epoll 支持水平触发(LT,Level-Triggered)和边缘触发(ET,Edge-Triggered)两种模式,边缘触发模式只在有新数据到来时才触发事件。
    • 水平触发(Level-Triggered)是一种IO事件触发模式,用于描述IO多路复用中的一种行为方式。在水平触发模式下,当IO通道上有数据可读或者可写时,IO多路复用机制会通知应用程序,无论应用程序是否已经处理过这些数据。换句话说,只要IO通道上的状态发生变化(比如有新数据到达或者缓冲区可写),就会触发IO事件通知应用程序,即使应用程序没有及时处理这些数据。
    • 边缘触发模式下,只有当IO通道上的状态发生变化时,才会触发IO事件通知应用程序。也就是说,只有当有新数据到达或者缓冲区由不可写变为可写时,才会触发IO事件通知应用程序,之后如果应用程序没有及时处理这些数据,不会再次触发事件通知,直到下一次IO状态变化。

综上所述,epoll 是性能最好的IO多路复用机制,特别适合处理大量文件描述符的场景。而 selectpoll 则适用于一些较小规模的IO多路复用需求。示例代码如下:

epollfd = epoll_create();       // 创建epoll对象
listenfd = socket();            // 创建一个socket
bind(listenfd);                 // 绑定
listen(listenfd);               // 监听
epoll_ctl(epollfd, EPOLL_CTL_ADD, listenfd, EPOLLIN);   // 将listenfd加入epoll监听列表
while(true){
    events = epoll_wait(epollfd, MAX_EVENTS, timeout);  // 等待事件发生
    for(int i = 0; i < events; ++i){
        if(events[i].data.fd == listenfd){  // 有新连接
            connfd = accept(listenfd);      // 建立连接
            epoll_ctl(epollfd, EPOLL_CTL_ADD, connfd, EPOLLIN); // 将connfd加入epoll监听列表
        } else if(events[i].events & EPOLLIN){  // 有数据可读
            int n = read(events[i].data.fd, buffer);    // 读取数据
            ···                                 // 处理数据
            close(events[i].data.fd);               // 关闭连接
        }
    }
}

img

信号驱动IO模型

信号驱动IO模型使用信号通知来告知应用程序IO操作已完成,应用程序可以继续执行其他任务,不需要轮询等待IO完成。示例代码如下:

listenfd = socket();            // 创建一个socket
bind(listenfd);                 // 绑定
listen(listenfd);               // 监听
signal(SIGIO, signal_handler);  // 设置信号处理函数
fcntl(listenfd, F_SETOWN, getpid());    // 设置IO拥有者为当前进程
fcntl(listenfd, F_SETFL, O_ASYNC | O_NONBLOCK); // 设置异步非阻塞IO
while(true){
    pause();    // 等待信号
}

img

在信号驱动IO模型中,应用程序得到的是一个信号,表明现在可以执行IO操作(如读取或写入数据),但实际的IO操作需要应用程序自己去执行。这意味着,应用程序需要在接收到信号后,自己发起读取或写入数据的操作,而自己执行IO操作时,进程会被阻塞。

更通俗的例子是:想象你在一家餐厅点餐。在信号驱动IO模型中,你点餐后不需要坐着等待食物准备好。你可以回到你的座位上继续做其他事情,如聊天或看书。当厨师做好你的食物后,服务员会通知你(就像操作系统发送一个信号),告诉你食物准备好了,你可以去取餐了。在这个过程中,你在被通知前不需要一直等待,但你需要亲自去取餐。

异步IO模型

异步IO模型通过操作系统完成IO操作,并在IO完成后通知应用程序,应用程序不需要等待IO完成,可以继续执行其他任务。示例代码如下:

listenfd = socket();            // 创建一个socket
bind(listenfd);                 // 绑定
listen(listenfd);               // 监听
aiocb read_aiocb;
memset(&read_aiocb, 0, sizeof(struct aiocb));
read_aiocb.aio_fildes = listenfd;
read_aiocb.aio_buf = buffer;
read_aiocb.aio_nbytes = MAX_SIZE;
read_aiocb.aio_offset = 0;
aio_read(&read_aiocb);      // 发起异步读取操作
while(aio_error(&read_aiocb) == EINPROGRESS){   // 等待异步IO完成
    ···                     // 执行其他任务
}
int n = aio_return(&read_aiocb);    // 获取异步IO结果

以上是对五种IO模型的介绍和示例代码,可以帮助读者更好地理解每种模型的特点和应用场景。

img

在异步IO模型中,应用程序请求IO操作后,就可以继续执行其他任务,操作系统会自动完成整个IO操作(包括数据的读取或写入)。一旦IO操作完成,应用程序会收到一个通知,此时它可以直接使用IO操作的结果,而不需要自己进行实际的读写操作。

更通俗的例子是:同样是在餐厅点餐,你点完餐之后也可以做其他事情,但当食物准备好后,服务员不仅会通知你,还会直接把食物送到你的座位上。你无需亲自去取餐,整个过程完全由服务人员处理完毕。

IO模型的比较

img

在五种IO模型中,可以将它们分为同步IO模型和异步IO模型,具体如下:

同步IO模型

  1. 阻塞IO模型(Blocking IO): 在阻塞IO模型中,当应用程序发起IO操作时,如果数据没有准备好或者无法立即处理,IO操作会阻塞当前线程或进程,直到数据准备好或者超时才返回结果。
  2. 非阻塞IO模型(Non-blocking IO): 在非阻塞IO模型中,应用程序发起IO操作后,即使数据没有准备好或者无法立即处理,IO操作也会立即返回,不会阻塞当前线程或进程。应用程序需要周期性地检查IO操作的状态,并根据状态来决定下一步的操作。

异步IO模型

  1. IO多路复用模型(IO Multiplexing): IO多路复用是一种IO模型,允许应用程序同时监视和处理多个IO通道(例如网络连接、文件描述符),而不必为每个IO通道创建一个独立的线程或者进程。常见的IO多路复用系统调用包括 selectpollepoll
  2. 信号驱动IO模型(Signal-driven IO): 信号驱动IO是一种IO模型,通过信号机制来通知应用程序IO操作的完成状态。应用程序在发起IO操作后,可以继续执行其他任务,当IO操作完成时,操作系统会发送一个信号给应用程序,应用程序通过信号处理函数来处理IO完成事件。
  3. 异步IO模型(Asynchronous IO): 异步IO是一种IO模型,允许应用程序发起IO操作后立即返回,并继续执行其他任务,当IO操作完成时,操作系统会通知应用程序,并将IO操作的结果返回给应用程序。异步IO模型下,应用程序不需要主动等待IO操作完成,可以更高效地处理IO操作。

总体来说,阻塞IO模型和非阻塞IO模型属于同步IO模型,因为它们在进行IO操作时需要主动等待或者轮询IO状态。而IO多路复用模型、信号驱动IO模型和异步IO模型属于异步IO模型,因为它们在进行IO操作时可以继续执行其他任务,不需要主动等待IO操作的完成。

作者:WuQiling
文章链接:https://www.wqlblog.cn/linux网络编程-五种io模型/
文章采用 CC BY-NC-SA 4.0 协议进行许可,转载请遵循协议
暂无评论

发送评论 编辑评论


				
默认
贴吧
上一篇
下一篇