Linux IO模型
Linux中的五种I/O模型有:
- 阻塞I/O
- 非阻塞I/O
- I/O多路复用
- 信号驱动I/O
- 异步I/O
通常有同步(Sync)/异步(Async),阻塞(Block)/非阻塞(Unblock)四种调用方式。

阻塞I/O
这是最简单也是最常用的模型。当应用程序发起一个 I/O 操作(如recv())时,如果数据没有准备好,该调用会一直挂起,直到数据到达并被内核复制到用户空间,操作才返回。
linux中默认情况下所有的socket都是阻塞式。
/* ... */
// recv() 是一个阻塞调用 如果没有数据可读,程序会一直等待
ssize_t bytes_received = recv(sockfd, buffer, sizeof(buffer), 0);
当用户进程调用了recv()系统调用,kernel就开始了IO操作的两个阶段:
- 第一阶段:准备数据,对于网络IO来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的UDP包。这个时候kernel就要等待足够的数据到来。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。而在用户进程这边,整个进程会被阻塞。
- 第二阶段:数据拷贝,当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态。
blocking IO的特点就是在IO执行的两个阶段都被block了。
非阻塞I/O
非阻塞I/O允许程序在I/O操作未完成时立即返回,而不会被挂起。它一般通过轮询(Polling)来检查数据是否就绪。
/* ... */
// 将 socket 设为非阻塞模式
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);
while(true) {
    ssize_t bytes_received = recv(sockfd, buffer, sizeof(buffer), 0);
    if (bytes_received > 0) { // 成功接收
        break;
    } else if (bytes_received == -1) { // 未成功接收
        if (errno == EAGAIN || errno == EWOULDBLOCK) { // 表示数据未就绪
            sleep(1);
            continue;
        } else { // recevie error
            break;
        }
    }
}
非阻塞的recv()系统调用调用之后,进程并没有被阻塞,内核马上返回给进程,如果数据还没准备好,此时会返回一个error。进程在返回之后,可以干点别的事情,然后再发起recv系统调用。重复上面的过程,循环往复的进行recv系统调用。这个过程通常被称之为轮询。轮询检查内核数据,直到数据准备好,再拷贝数据到进程,进行数据处理。需要注意:拷贝数据整个过程,进程仍然是属于阻塞的状态。
nonblocking IO的特点是用户进程需要不断的主动询问kernel数据准备好了没有。
I/O多路复用
I/O多路复用允许单个线程同时监控多个socket的状态,当任何一个socket文件描述符就绪(可读、可写)时,内核会通知应用程序,从而避免了阻塞和轮询的低效。
Linux中提供了select、poll和epoll三种IO多路复用机制。
/* ... */
// 创建epoll实例,监听可读事件
int epoll_fd = epoll_create1(0);
struct epoll_event event;
event.events = EPOLLIN; // 监听可读事件
event.data.fd = server_sock;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_sock, &event);
struct epoll_event events[10];
while (true) {
    // epoll_wait阻塞直到有事件就绪
    int num_events = epoll_wait(epoll_fd, events, 10, -1);
    for (int i = 0; i < num_events; i++) {
        if (events[i].data.fd == server_sock) {
            // 新的连接
            client_sock = accept(server_sock, NULL, NULL);
            // 将client_sock设为非阻塞模式
            int flags = fcntl(client_sock, F_GETFL, 0);
            fcntl(client_sock, F_SETFL, flags | O_NONBLOCK);
            // 监听可读,使用边缘触发
            event.events = EPOLLIN | EPOLLET;
            event.data.fd = client_sock;
            epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_sock, &event);
            printf("新连接到来,fd=%d\n", client_sock);
        } else {
            // 有数据可读
            char buffer[1024];
            ssize_t bytes_read = recv(events[i].data.fd, buffer, sizeof(buffer), 0);
            if (bytes_read > 0) {
                printf("从 fd=%d 接收到数据: %s\n", events[i].data.fd, buffer);
            }
        }
    }
}
多路复用I/O需要使用两个system call(select 和 recv),而阻塞I/O只调用了一个system call (recv)。但是,用select的优势在于它可以同时处理多个connection。
所以,如果处理的连接数不是很高的话,使用select/epoll不一定比使用multi-threading+blocking IO性能更好,可能延迟还更大。(select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)
在IO多路复用模型中,对于每一个socket,一般都设置成为non-blocking,但是整个用户的进程其实是一直被block的。只不过进程是被select这个函数block,而不是被socket IO给block。所以IO多路复用是阻塞在select,epoll这样的系统调用之上,而没有阻塞在真正的I/O系统调用如recv之上。
与传统的多线程/多进程模型比,I/O多路复用的最大优势是系统开销小,系统不需要创建新的额外进程或者线程,也不需要维护这些进程和线程的运行,降底了系统的维护工作量,节省了系统资源。
信号驱动I/O
信号驱动I/O允许内核在I/O操作就绪时,向应用程序发送一个SIGIO信号。应用程序可以注册一个信号处理函数来处理这个信号。
int g_sockfd;
// 信号处理函数
void sigio_handler(int signo)
{
    if (signo == SIGIO) {
        printf("收到 SIGIO 信号,数据已就绪!\n");
        char buffer[1024];
        // 可以在这里调用非阻塞的 recv()
        ssize_t bytes_received = recv(g_sockfd, buffer, sizeof(buffer), 0);
        if (bytes_received > 0) {
            printf("在信号处理函数中接收到 %zd 字节数据: %s\n", bytes_received, buffer);
        }
    }
}
int main()
{
    // 注册信号处理函数
    signal(SIGIO, sigio_handler);
    // 将 socket的所有者设为当前进程
    fcntl(g_sockfd, F_SETOWN, getpid());
    // 开启信号驱动I/O
    int flags = fcntl(g_sockfd, F_GETFL, 0);
    fcntl(g_sockfd, F_SETFL, flags | FASYNC);
    printf("等待数据,程序不阻塞...\n");
    // 主循环可以做其他事情
    // process
    close(g_sockfd);
    return 0;
}
异步I/O
异步I/O与信号驱动I/O的主要区别在于,内核在数据准备好并被复制到用户空间后才通知应用程序。
异步I/O是真正意义上的非阻塞。应用程序发起请求后,完全不用等待,直到数据准备就绪并传输完成后才收到通知。
Linux提供了AIO库函数实现异步,但是用的很少。对于网络socket,原生AIO并没有得到很好的支持。
目前主流的异步IO库有libevent、libev、libuv(这些异步IO库也是基于多路复用实现)。