1. 五种 IO 模型

1.1 阻塞 IO

定义:在内核将数据准备好之前,系统调⽤会⼀直等待,所有的套接字,默认都是阻塞⽅式。

阻塞 IO 是最常见的 IO 模型,如下图所示:

在这里插入图片描述

1.2 非阻塞 IO

定义:如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回 EWOULDBLOCK 错误码。

非阻塞 IO 往往需要程序员以循环的方式,反复尝试读写文件描述符,这个过程称为轮询。这对 CPU 来说是较大的浪费,一般只有特定场景下才使用。

如下图所示:

在这里插入图片描述

1.3 信号驱动 IO

定义:内核将数据准备好的时候,使用 SIGIO 信号通知应用程序进行 IO 操作。

如下图所示:

在这里插入图片描述

1.4 IO 多路转接

定义:虽然从流程图上看起来和阻塞 IO 类似,实际上最核心在于 IO 多路转接能够同时等待多个文件描述符的就绪状态。

如下图所示:

在这里插入图片描述

1.5 异步 IO

定义:由内核在数据拷贝完成时,通知应用程序。注意,它和信号驱动有所不同,信号驱动 IO 是告诉应用程序何时可以开始拷贝数据。

如下图所示:

在这里插入图片描述

1.6 总结

任何 IO 过程中,都包含两个步骤:

  • 第一步:等待
  • 第二步:拷贝
  • 而且在实际的应用场景中,【等待】消耗的时间往往都远远高于【拷贝】的时间,所以让 IO 更高效、最核心的办法就是让【等待】的时间尽量少。

2. 高级 IO 重要概念

2.1 同步通信 vs 异步通信

在 Linux 网络编程中,同步 / 异步 关注的是 消息通信机制(即调用方与内核、远程主机之间的交互方式)。

同步(Synchronous)

  • 发出调用后,在没有得到结果之前,调用不会返回。
  • 一旦返回,就能立即获得结果。
  • 换句话说:调用者必须 主动等待 被调用者的结果。

例如:

recv(sockfd, buf, len, 0);

如果没有数据,这个调用会阻塞,直到有数据到达。这是典型的同步通信。

异步(Asynchronous)

  • 发出调用后,调用立即返回,不会等待结果。
  • 当操作真正完成时,被调用方通过事件、通知或回调函数告知调用方。
  • 换句话说:调用者发出请求后继续做别的事,结果准备好后再通知它。

例如:

aio_read(&aiocb);

aio_read() 立即返回,读操作在后台完成后内核通过信号或回调通知用户,这是异步通信。

需要注意的是:同步通信 ≠ 进程 / 线程同步,这两个 “同步” 完全不是一个概念,千万不要混淆。

如下表所示:

分类 所在领域 含义 举例
同步通信 网络编程、I/O 调用是否等待结果返回 同步 I/O、异步 I/O
线程同步 并发控制 多线程间协调执行次序 pthread_mutex_lock()wait()/notify()

线程同步的本质:

  • 是进程或线程之间为了完成某种任务而建立的制约关系,
  • 目的是让它们在某些位置上协调工作顺序或安全访问临界资源。

举例:

  • 线程 A 必须等待线程 B 初始化完成后再执行(同步)。
  • 多个线程不能同时修改同一全局变量(互斥)。

2.2 阻塞 vs 非阻塞

这两个概念关注的是:当程序发起 I/O 调用后,如果结果暂时不可得,当前线程的状态。

阻塞调用(Blocking I/O):当请求的数据还没准备好时,调用会一直等待(阻塞)直到数据到来或操作完成。

特点:

  • 调用线程会被挂起,CPU 可切换去执行其他任务;
  • 一旦数据准备好或操作完成,函数返回;
  • 简单易用,但效率较低。

例如:

recv(sockfd, buf, len, 0);  // 默认是阻塞的

如果没有数据到达,这个调用就会一直等,不返回。

非阻塞调用(Non-blocking I/O):当请求的数据暂时不可得时,调用立即返回,不等待,通常返回错误码 EAGAINEWOULDBLOCK

特点:

  • 调用线程不会被挂起;
  • 程序需要自己 “轮询” 或配合 select/poll/epoll 检查状态;
  • 更复杂,但能实现高并发。

例如:

fcntl(sockfd, F_SETFL, O_NONBLOCK);  // 设置非阻塞
recv(sockfd, buf, len, 0);           // 立即返回,无数据则返回 -1, errno = EAGAIN

对比总结:

类型 调用行为 是否等待结果 返回时机 优点 缺点
阻塞 I/O 调用后等待数据 等待 数据准备好后返回 简单,逻辑清晰 线程被挂起,效率低
非阻塞 I/O 调用后立即返回 不等待 立即返回 高并发性能好 需要轮询,逻辑复杂

一句话:阻塞是 “等结果”,非阻塞是 “马上走”。

2.3 理解四者的关系

我们来举个例子,有这样一个场景设定:

  • 唐僧:要被吃的目标(任务目标)。
  • 妖怪:发起 I/O 请求的一方(相当于应用程序)。
  • 锅:内核空间(负责准备结果)。
  • 蒸唐僧的过程:数据准备的过程(I/O 操作)。

妖怪要吃唐僧(拿到结果),但唐僧还没蒸好(数据未就绪)。于是,不同类型的妖怪就出现了

一、同步阻塞妖怪

妖怪把唐僧丢进锅里后,坐在锅边一直等着唐僧蒸熟。

💬 妖怪自言自语:“不蒸好我不走!我要看着!”

📌 特点:

  • 调用后等待结果(同步)
  • 在等待过程中自己也闲着不动(阻塞)

👉 对应:同步阻塞 I/O(最传统)

  • 例如:recv() 等待数据到来。

二、同步非阻塞妖怪

妖怪把唐僧丢进锅里,蒸一会儿后掀开锅盖看看:“熟了吗?还没?那我再去看看。”

💬 妖怪不停轮询:“熟了没?熟了没?熟了没?”

📌 特点:

  • 仍然要自己来确认是否蒸好(同步)
  • 但每次检查不会傻等,检查不到就去干别的再回来(非阻塞)

👉 对应:同步 + 非阻塞 I/O

  • 例如:设置 O_NONBLOCKsocket + 轮询。

三、异步阻塞妖怪(很少见)

妖怪请了一个锅神(操作系统)帮忙蒸唐僧。妖怪说:“你蒸好后叫我!”,然后妖怪自己在一旁睡觉,不干别的事。

💬 锅神:“好嘞,熟了我叫你!”

📌 特点:

  • 发出请求后不用自己检查(异步)
  • 但仍然 “阻塞” 自己等待通知(阻塞)

👉 实际上这种情况不常见,但可以理解为:异步调用 + 被动等待回调。

四、异步非阻塞妖怪(高端妖)

妖怪让锅神蒸唐僧后,转身去抓猪八戒、编草帽、修洞府。

唐僧一蒸好,锅神主动通知:“妖大王,唐僧蒸熟啦!”

💬 妖怪:“好嘞!端上来!”

📌 特点:

  • 发出请求就走(异步)
  • 不会等待(非阻塞)
  • 锅神完成任务后主动通知(回调 / 信号)

👉 对应:真正的异步 I/O(如 aio_read

五、四者关系总结表

类型 等不等结果(同步 vs 异步) 等的方式(阻塞 vs 非阻塞) 妖怪行为 编程对应
同步阻塞 等结果 傻等 坐在锅边等唐僧蒸好 recv()
同步非阻塞 等结果 时不时看看 不停掀锅盖看熟没 O_NONBLOCK + 轮询
异步阻塞 不主动查结果 睡觉等锅神叫醒 一直睡着等通知 异步 + wait
异步非阻塞 不主动查结果 干别的事 让锅神蒸,熟了通知 aio_read() 回调

最后一句话总结

  • “同步 / 异步” 关注:谁来等结果(自己等 or 别人通知)。
  • “阻塞 / 非阻塞” 关注:等的方式(傻等 or 干别的)。

2.4 其他高级 IO

非阻塞 IO,纪录锁,系统 V 流机制,I/O 多路转接(也叫 I/O 多路复用),readv 和 writev 函数以及存储映射
IO(mmap),这些统称为高级 IO。

我们重点讨论的是 I/O 多路转接。

3. 非阻塞 IO

我们知道,IO 操作可以理解为【等待 + 数据拷贝】。

当程序在进行网络通信或本地文件读取时,底层(如内核或设备)可能暂时还没有准备好数据,因此程序就需要【等待】。

这种【等待】就意味着时间被消耗在了 IO 上,也就是程序员或用户承担了这段时间成本。

为了更高效地利用这段等待时间,不让程序白白闲着,各种 IO 模型(如阻塞、非阻塞、IO 多路复用等)便应运而生。

3.1 阻塞 IO

所谓 阻塞 IO,是指当程序发起 IO 操作(如读取数据)时,如果数据尚未准备好,那么 进程会被阻塞

被阻塞意味着程序暂时停止运行、进入睡眠状态,直到数据准备完成。

例如,当你调用 read() 读取文件时,如果文件缓冲区中没有数据,那么操作系统会将当前进程挂起,并将其加入该文件对应的 struct file 结构体中维护的等待队列。只有当数据准备好后,系统才会唤醒进程继续执行。

3.2 非阻塞 IO

非阻塞 IO 的行为正相反。

当我们将文件描述符设置为非阻塞模式后,程序在执行 read()recv() 时,如果底层数据尚未准备好,系统调用不会等待,而是 立刻返回 一个结果(通常是错误码,如 EAGAINEWOULDBLOCK)。

这样,程序就可以根据返回结果自行决定下一步操作,比如稍后再次尝试读取,而不会因为等待数据而被挂起。

这种【不会因为条件不满足而进入等待】的 IO 模型,就是我们所说的 非阻塞 IO

3.3 如何启用非阻塞 IO

要使用非阻塞 IO,我们需要显式地将文件描述符设置为非阻塞模式

因为默认情况下,所有文件描述符都是阻塞的。

通常我们会通过 fcntl() 函数来修改文件描述符的属性,将其设置为非阻塞模式。

3.4 fcntl

一个文件描述符,默认都是阻塞 IO,函数原型如下:

#include <unistd.h>
#include <fcntl.h>
 
int fcntl(int fd, int cmd, ... /* arg */ );

传入的 cmd 的值不同,后面追加的参数也不相同。

fcntl 函数有 5 种功能:

  • 复制一个现有的描述符(cmd = F_DUPFD)
  • 获得/设置文件描述符标记(cmd = F_GETFD 或 F_SETFD)
  • 获得/设置文件状态标记(cmd = F_GETFL 或 F_SETFL)
  • 获得/设置异步 I/O 所有权(cmd = F_GETOWN 或 F_SETOWN)
  • 获得/设置记录锁(cmd = F_GETLK、F_SETLK 或 F_SETLKW)

我们此处只是用第三种功能,获取/设置文件状态标记,就可以将一个文件描述符设置为非阻塞。

3.5 实现函数 SetNoBlock

基于 fcntl,我们实现一个 SetNoBlock 函数,将文件描述符设置为非阻塞。

#include <iostream>
#include <unistd.h>
#include <fcntl.h>
#include <cstring>
#include <cerrno>
#include <cstdio>
#include <vector>
#include <functional>

using namespace std;

void setNonBlock(int fd)
{
    int fl = fcntl(fd, F_GETFL);
    if (fl < 0) 
    {
        cerr << "fcntl : " << strerror(errno) << endl;
        return;    
    }
    fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}

使用 F_GETFL 将当前的文件描述符的属性取出来(这是一个位图)。

然后再使用 F_SETFL 将文件描述符设置回去,设置回去的同时,加上一个 O_NONBLOCK 参数。

3.6 阻塞的情况

我们现在编写主函数,在实验开始之前,我们先来看一个阻塞的例子:

int main()
{
    char buffer[1024];
    while (true)
    {
        printf(">>> ");
        fflush(stdout);
        ssize_t s = read(0, buffer, sizeof(buffer)-1);
        if (s > 0)
        {
            buffer[s-1] = 0;
            cout << "echo# " << buffer << endl;
        }
        else if (s == 0)
        {
            cout << "read end" << endl;
            break;
        }
        else
        {
            // 
        }
    }
    return 0;
}

结果如下:

在这里插入图片描述

3.7 轮询方式读取标准输入

接下来,我们把非阻塞函数 SetNoBlock 添加进去:

结果如下,可以看到,我输入我的,程序打印它自己的,我们互不相干!
在这里插入图片描述

意味着我们当前在进行读取的时候呢,我们并不会在 0 号文件描述符处卡住,然后 read 系统调用会立马返回。那么立马返回之后,我们此时可以在代码空闲的时候可以做其他的工作。

比如我们写一个小 demo,在 SetNoBlock 函数中添加下面的代码:

#include <iostream>
#include <unistd.h>
#include <fcntl.h>
#include <cstring>
#include <cerrno>
#include <cstdio>
#include <vector>
#include <functional>

using namespace std;

// 设置非阻塞的函数
void setNonBlock(int fd)
{
    int fl = fcntl(fd, F_GETFL);
    if (fl < 0) 
    {
        cerr << "fcntl : " << strerror(errno) << endl;
        return;    
    }
    fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}

void printLog()
{
    cout << "this is a log" << endl;
}

void download()
{
    cout << "this is a download" << endl;
}

void executeSql()
{
    cout << "this is a executeSql" << endl;
}

接着修改主函数:

#include "util.hpp"

using func_t = function<void ()>; // 设置回调函数

#define INIT(v) do{\
            cbs.push_back(printLog);\
            cbs.push_back(download);\
            cbs.push_back(executeSql);\
        } while(0)

#define EXEC_OTHER(cbs) do{\
            for (auto const &cb : cbs) cb();\
        } while(0)

int main()
{
    vector<func_t> cbs;
    INIT(cbs);

    setNonBlock(0); // 设置非阻塞
    char buffer[1024];
    while (true)
    {
        printf(">>> ");
        fflush(stdout);
        ssize_t s = read(0, buffer, sizeof(buffer)-1);
        if (s > 0)
        {
            buffer[s-1] = 0;
            cout << "echo# " << buffer << endl;
        }
        else if (s == 0)
        {
            cout << "read end" << endl;
            break;
        }
        else
        {
            // 
        }
        EXEC_OTHER(cbs);
        sleep(1);        
    }
    return 0;
}

结果如下,可以看到,我们自己封装了些接口,那么在空闲的时候,可以做这些其他的事情:

在这里插入图片描述

那么思考一下:

  • 当我不输入的时候,底层没有数据,算错误吗?当然不算错误!只不过以错误的形式返回了。
  • 那么我又如何区是真的错了?还是底层没有数据呢?很明显单纯靠返回值无法区分!

我们可以使用 man 2 read 查一下手册:

在这里插入图片描述

所以,我们修改一下主函数的代码:

#include "util.hpp"

using func_t = function<void ()>; // 设置回调函数

#define INIT(v) do{\
            cbs.push_back(printLog);\
        } while(0)

#define EXEC_OTHER(cbs) do{\
            for (auto const &cb : cbs) cb();\
        } while(0)

int main()
{
    vector<func_t> cbs;
    INIT(cbs);

    setNonBlock(0); // 设置非阻塞
    char buffer[1024];
    while (true)
    {
        ssize_t s = read(0, buffer, sizeof(buffer)-1);
        if (s > 0)
        {
            buffer[s-1] = 0;
            cout << "echo# " << buffer << endl;
        }
        else if (s == 0)
        {
            cout << "read end" << endl;
            break;
        }
        else
        {
            if (errno == EAGAIN || errno == EWOULDBLOCK) // 说明是非阻塞, 只是数据没有准备好
            {
                cout << "我没错, 只是没数据" << endl;
                EXEC_OTHER(cbs);
            }
            else if (errno == EINTR) // 此时说明是 中断的系统调用, 那么就继续
            {
                continue;
            }
            else // 除了上面两种, 其余全是真正的错误
            {
                cout << "return value : " << s << ", errno : " << strerror(errno) << endl; // 可以看到返回值为-1
                break;
            }
        }
        sleep(1);        
    }
    return 0;
}

结果如下:

在这里插入图片描述

Logo

欢迎加入我们的广州开发者社区,与优秀的开发者共同成长!

更多推荐